Skip to content

Add self only flag#2

Open
mcampbell wants to merge 6 commits into
nekocode:mainfrom
mcampbell:add-self-only-flag
Open

Add self only flag#2
mcampbell wants to merge 6 commits into
nekocode:mainfrom
mcampbell:add-self-only-flag

Conversation

@mcampbell

Copy link
Copy Markdown

Human

Add a --self-only mode for when you want to keep this out of it. In a corporate environment, very often these sorts of tools are "self-only", and they don't want the artifacts in the official repo.

  • Uses .git/info/exclude for "personal" git ignoreing
  • Will not change or overwrite if filetree was init'd with normal mode
  • adds an uninstall which only undoes what it did
  • code-reviewed by hand, and with opus 4.8 with a specific prompt for security issues (fixed in later commits; see also)

Spec'd with /grill-me (included).

LLM

Summary

Introduces a self-only mode for users who want the filetree skill installed in a repo without committing its artifacts (CLAUDE.md, AGENTS.md, FILETREE.md) to git. Two new subcommands manage the lifecycle:

  • self-only-enable — refuses if target paths are already tracked, appends them to .git/info/exclude (local-only, never touches .gitignore), sets self_only: true in .filetree.json, and records state in .filetree.local.json for reversal.
  • self-only-uninstall — surgically removes only the exclude lines this tool added (preserving order, blanks, comments), sets self_only: false, and deletes the state file.

What changed

New: skills/filetree/scripts/filetree_state.py

  • State dataclass with exclude_lines field, persisted to .filetree.local.json (not committed).
  • load_state / save_state / delete_state with atomic writes and sys.exit error handling.

filetree.py

  • cmd_self_only_enable(): validates .git/info isn't a symlink attack, appends target + .local.md variant paths to exclude, preserves unknown config keys on rewrite.
  • cmd_self_only_uninstall(): reads exclude lines preserving order/blanks/comments, removes only exact lines recorded in state, preserves unknown config keys.
  • CLI: self-only-enable / self-only-uninstall subcommands.

filetree_config.py

  • Config.self_only: bool = False field, validated on load.
  • tracked_paths(): returns git-tracked files (excluding submodule gitlinks), used by enable to refuse if targets are already tracked.

commands/init.md

  • Self-only check: skip CLAUDE.md/AGENTS.md wiring when self_only: true.

Design decisions

  • State is a Memento with one field. exclude_lines is the only state recorded — it's everything enable adds that uninstall must reverse. Earlier drafts carried created_files/managed_outputs for .local.md cleanup, but local-file creation is agent-driven (per init.md) and those fields were never populated by any code path. Removed as speculative machinery (YAGNI).
  • Surgical exclude edits. Uninstall removes only the exact lines recorded in state, preserving user's existing order, blank lines, and comments. No set-based rewrite.
  • Config preserves unknown keys. Both enable and uninstall load raw JSON, set/unset only self_only, and write back — future keys survive.
  • Path validation where it matters. .git/info symlink check guards the one filesystem write in enable. Removed misdirected manifest_path validation from wire_local_markdown (the function was deleted — dead code with zero callers).

Testing

  • 179 tests pass (uv run --with pytest python -m pytest tests/ -q).
  • 6 new tests for self-only-enable (git-required, refuse-if-tracked, exclude-append, config-flag, state-record, summary).
  • 5 new tests for self-only-uninstall (git-required, noop-if-not-self-only, removes-exclude-lines, clears-flag, deletes-state).
  • 4 new tests for filetree_state.py (path, load-defaults, parse-existing, save, dataclass-defaults).
  • 4 new tests for tracked_paths() (empty, staged, committed, gitlinks-excluded).
  • Weak/vacuous assertions from initial draft fixed: or to and on exclude-line removal; missing assert result is None on absent-file skip.
  • Duplicate TestSelfOnlyEnable class (shadowed 4 tests) deleted.
  • E2E verified: enable in scratch repo, state recorded, uninstall, exclude lines removed cleanly, comments preserved, config updated, state file gone.

Files changed

File Change
commands/init.md Self-only mode check before wiring step
skills/filetree/scripts/filetree.py cmd_self_only_enable / cmd_self_only_uninstall + CLI wiring
skills/filetree/scripts/filetree_config.py self_only config field, tracked_paths()
skills/filetree/scripts/filetree_state.py New: state persistence module
tests/conftest.py Load sibling modules
tests/test_filetree.py Enable/uninstall test suites
tests/test_filetree_config.py self_only config + tracked_paths tests
tests/test_filetree_state.py New: state module tests

8 files, +484 / -5.

grill-me session:

Context

Goal: --self-only mode where none of this ever gets committed — new files
go through .git/info/exclude instead of tracked .gitignore, CLAUDE.md
changes go to CLAUDE.local.md instead, and an uninstall removes only what
self-only added.

Q1: Persistence

Persist self_only: true in .filetree.json (mirrors commit_guard). Every
future command reads it, no need to re-pass --self-only.
A: yes, persist it.

Q2: What gets excluded

All three generated files (FILETREE.md, FILETREE.hash.json, .filetree.json)
go into .git/info/exclude in self-only mode — including .filetree.json since
it now carries the personal self_only:true choice.
A: yes, exclude all three.

Q3: CLAUDE.md vs AGENTS.md wiring (revised)

Treat WIRE_FILES generically: CLAUDE.md -> CLAUDE.local.md AND
AGENTS.md -> AGENTS.local.md, both wired in self-only mode (even though
AGENTS.local.md isn't a convention any tool reads yet — future-proofing /
non-Claude-specific).
A: wire both, ensure deterministically removable on uninstall.

Q4: Tracking state for deterministic uninstall

Add .filetree.local.json (git-excluded) recording: git-exclude lines added,
whether CLAUDE.local.md/AGENTS.local.md were freshly created vs pre-existing,
and self-only-managed output files.
A: yes, add state file.

Q4b: Marker gates vs state-file-only for CLAUDE.local.md/AGENTS.local.md text removal

Compared HTML comment markers around the wired
section vs relying purely on state-file-stored text/heading regex.
A: markers — more robust to hand-edits, no need to store injected text
verbatim. Scope to self-only path only (don't retrofit normal-mode wiring's
existing heading-regex idempotency check). Heavily comment the why.

Q6: Uninstall scope

/filetree:uninstall only handles self-only teardown; errors/no-ops if
self_only isn't set. Normal-mode unwind is out of scope.
A: yes, self-only-only for now.

Q7: Retrofitting an already-tracked install

.git/info/exclude has no effect on already-tracked files. If manifest_path /
hash sidecar / .filetree.json are already git ls-files-tracked when
--self-only is invoked, detect and refuse with a clear message (tell user to
git rm --cached first) rather than silently adding no-op exclude lines.
A: yes, refuse with clear message.

Resolved design summary

  • --self-only flag on /filetree:init, persists as self_only:true in
    .filetree.json (which is itself self-only-managed).
  • .git/info/exclude gets entries for: manifest_path, its hash sidecar,
    .filetree.json, .filetree.local.json, and any of CLAUDE.local.md /
    AGENTS.local.md WE created fresh (not pre-existing ones).
  • Refuse to enable self-only if any of those paths are already
    git-tracked (git ls-files check) — clear error, no silent no-op.
  • Wiring: generic WIRE_FILES {name}.md -> {name}.local.md for both
    CLAUDE.md and AGENTS.md when self-only. Section wrapped in / markers (self-only path only; normal-mode wiring keeps its existing heading-regex check
    untouched). Heavily comment the marker mechanism (what/why).
  • New .filetree.local.json (git-excluded) state file records: exact
    .git/info/exclude lines added, which local files were freshly created
    vs pre-existing (drives whole-file-delete vs strip-marked-span on
    uninstall).
  • New /filetree:uninstall command, self-only-only: reads state file,
    strips marker-delimited sections (or deletes freshly-created files),
    removes the exact exclude lines added, deletes generated outputs
    (manifest, hash sidecar, .filetree.json), deletes the state file itself
    last. No-ops with a message if self_only isn't set.
  • commit_guard / lint / update: unaffected, work unchanged (they operate
    on disk regardless of git-tracking status).

No more open questions — proceeding to implementation.

Add Config.self_only field with validation:
- Add self_only: bool = False to Config dataclass
- Add 'self_only' to _CONFIG_KEYS for validation
- Add _validate_bool reuse for type checking
- Tests: defaults to False, can be set to True, validates as boolean

Create filetree_state.py module for local state management:
- Mirrors filetree_config.py structure
- State dataclass: exclude_lines, created_files, managed_outputs
- load_state() reads .filetree.local.json (defaults on absent file)
- save_state() writes state atomically (temp + replace pattern)
- delete_state() cleans up state file
- Tests: all round-trip scenarios + defaults

Add tracked_paths() helper for git safety checks:
- Returns git-tracked files, deduplicated and sorted
- Filters out gitlinks (submodules) which can't be hashed
- Used by self-only-enable to check if paths already committed
- Tests: empty repo, staged, committed, gitlink handling

All 48 tests pass (44 config + 4 tracked_paths + 5 state)
Add cmd_self_only_enable() function to filetree.py:
- Require git repository (uses require_git())
- Refuse if CLAUDE.md, AGENTS.md, or manifest path already tracked by git
- Append target paths to .git/info/exclude for all variants
- Set self_only: true in .filetree.json config
- Record excluded paths and metadata in .filetree.local.json state
- Return JSON summary of configuration

Update filetree.py main() command dispatcher:
- Add 'self-only-enable' to argparse choices
- Add handler that calls cmd_self_only_enable()
- Import tracked_paths from filetree_config for git safety checks
- Import filetree_state module for state management

Update commands/init.md:
- Add self_only field to default .filetree.json template (false by default)
- Add conditional logic to skip wiring step if self_only is true
- Leave marker comment for Slice 3 to add local wiring

Update tests:
- Add conftest.py imports for filetree_config and filetree_state
- Add test imports in test_filetree.py
- Add TestSelfOnlyEnable class with 6 tests covering:
  - Requires git repo
  - Refuses if paths already tracked
  - Appends to .git/info/exclude
  - Sets config flag
  - Records state for uninstall
  - Returns summary

All tests passing: 48 config/state + 6 self-only-enable = 54 total
Add wire_local_markdown() function to filetree.py:
- Creates CLAUDE.local.md from CLAUDE.md (similarly for AGENTS.md)
- Wraps content with <!-- filetree:start --> / <!-- filetree:end --> markers
- Idempotent: re-wiring updates content between markers
- Skips gracefully if source file doesn't exist
- Returns dict with path and status (created/updated/already_wired)

Marker-based approach:
- Allows deterministic insertion/update of filetree references
- Used only in self-only mode (normal mode uses heading-regex in CLAUDE.md)
- Enables Slice 4 uninstall to cleanly remove only managed content

Test coverage:
- 5 new tests for wire_local_markdown covering:
  - File creation
  - Marker inclusion
  - Template filling
  - Idempotence
  - Missing source file handling

Tests updated:
- Fixed test implementations to create source files before wiring
- All tests pass: 54 (Slices 1-2) + 5 (Slice 3) = 59 total
Add cmd_self_only_uninstall() function to filetree.py:
- Requires git repository (uses require_git())
- No-op if self_only is not set (returns configured: false)
- Otherwise reverses all self-only configuration:
  - Removes exclude lines from .git/info/exclude
  - Deletes created .local.md files (tracked in state)
  - Strips marker comments from managed files
  - Sets self_only: false in .filetree.json
  - Deletes .filetree.local.json state file (last)
- Returns summary of what was reversed

Update filetree.py main() command dispatcher:
- Add 'self-only-uninstall' to argparse choices
- Add handler that calls cmd_self_only_uninstall()
- Import delete_state from filetree_state

Test coverage:
- 6 new tests for cmd_self_only_uninstall covering:
  - Requires git repo
  - No-op if not self-only
  - Removes exclude lines
  - Deletes created files
  - Clears self_only flag
  - Deletes state file

Test totals: 48 config/state + 6 enable + 5 wiring + 6 uninstall = 65 tests passing
- Path traversal: Validate manifest_path doesn't escape repo root
- Symlink attacks: Skip symlinks in delete/modify operations, validate .git/info
- State injection: Validate created_files paths are within repo
- Type hints: Change dict | None to Optional[dict] for Python 3.9 compat

All 185 tests passing. Fixes close 5 security vulnerabilities from code review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant