Skip to content

Commit dec6ed8

Browse files
committed
feat: symlink entire skills directory instead of individual skill entries
Change skills targets from symlink-contents to symlink type so that .claude/skills (and equivalent agent paths) becomes a single directory symlink pointing to .agents/skills instead of per-skill symlinks. This means new skills auto-appear without re-running sync, renames and deletes cannot leave stale entries, and the sync logic is simpler. Closes DALLAY-197
1 parent 7faee1d commit dec6ed8

12 files changed

Lines changed: 977 additions & 64 deletions

File tree

.agents/agentsync.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ type = "symlink"
7070
[agents.opencode.targets.skills]
7171
source = "skills"
7272
destination = ".opencode/skills"
73-
type = "symlink-contents"
73+
type = "symlink"
7474

7575
[agents.opencode.targets.commands]
7676
source = "command"
@@ -93,7 +93,7 @@ type = "symlink"
9393
[agents.copilot.targets.skills]
9494
source = "skills"
9595
destination = ".github/skills"
96-
type = "symlink-contents"
96+
type = "symlink"
9797

9898
[agents.copilot.targets.agents]
9999
source = "command"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Design: Directory-level skills symlink
2+
3+
## Technical Approach
4+
5+
Change the default sync type for skills targets from `symlink-contents` to `symlink` across all agent init templates and this repo's config. No new code paths, types, or flags — `SyncType::Symlink` already handles directory sources correctly on both Unix and Windows. The change is purely config-level: update TOML strings in `src/init.rs` and `.agents/agentsync.toml`, then update tests to assert a single directory symlink instead of per-entry symlinks.
6+
7+
This maps directly to the proposal's approach (Option 1 from exploration).
8+
9+
## Architecture Decisions
10+
11+
### Decision: Reuse existing `SyncType::Symlink` instead of a new variant
12+
13+
**Choice**: Use the existing `Symlink` sync type for directory sources
14+
**Alternatives considered**: New `SyncType::SymlinkDirectory` variant; `link_directory` boolean flag on `SymlinkContents`
15+
**Rationale**: `create_symlink()` (`src/linker.rs:344-463`) already handles directory sources — on Unix it calls `std::os::unix::fs::symlink` (line 442) which works for directories, and on Windows it dispatches to `std::os::windows::fs::symlink_dir` (line 448) when the source is a directory. Adding a new variant or flag would be pure overhead with no functional benefit. The `process_target()` dispatch (line 204-208) routes `SyncType::Symlink` correctly without any changes.
16+
17+
### Decision: Config-only change, no migration tooling
18+
19+
**Choice**: Change defaults for new projects; existing projects keep `symlink-contents` until manual update
20+
**Alternatives considered**: Auto-migration on sync; deprecation warnings for `symlink-contents`
21+
**Rationale**: `symlink-contents` remains valid for targets that use `pattern` filtering (commands, prompts, agents). Auto-migration would be risky and unnecessary — users can opt in by editing their config or re-running `agentsync init`. Running `agentsync sync --clean` handles the transition cleanly via existing logic.
22+
23+
### Decision: Accept `registry.json` exposure
24+
25+
**Choice**: Allow `registry.json` and other non-skill files in `.agents/skills/` to be visible through the directory symlink
26+
**Alternatives considered**: Adding a `.gitignore`-style filter for directory symlinks; documenting as a blocker
27+
**Rationale**: Agents already read the skills directory contents. `registry.json` is non-sensitive metadata. The simplicity of a single directory symlink outweighs the minor exposure.
28+
29+
## Data Flow
30+
31+
No change to data flow. The existing path through the linker remains identical:
32+
33+
```
34+
agentsync sync
35+
36+
37+
Linker::sync() → process_target()
38+
39+
├── SyncType::Symlink ──→ create_symlink()
40+
│ (now used for skills) │
41+
│ ├── dest is symlink? → check target, skip or update
42+
│ ├── dest is real dir? → backup to .bak.<timestamp>
43+
│ └── create symlink (unix: symlink, windows: symlink_dir)
44+
45+
└── SyncType::SymlinkContents ──→ create_symlinks_for_contents()
46+
(still used for commands, prompts, agents)
47+
```
48+
49+
The only difference: skills targets now take the `Symlink` branch instead of `SymlinkContents`.
50+
51+
## File Changes
52+
53+
| File | Action | Description |
54+
|------|--------|-------------|
55+
| `src/init.rs:73` | Modify | Claude skills target: `type = "symlink-contents"``type = "symlink"` |
56+
| `src/init.rs:109` | Modify | Codex skills target: `type = "symlink-contents"``type = "symlink"` |
57+
| `src/init.rs:126` | Modify | Gemini skills target: `type = "symlink-contents"``type = "symlink"` |
58+
| `src/init.rs:148` | Modify | OpenCode skills target: `type = "symlink-contents"``type = "symlink"` |
59+
| `.agents/agentsync.toml:73` | Modify | Repo's OpenCode skills: `type = "symlink-contents"``type = "symlink"` |
60+
| `.agents/agentsync.toml:96` | Modify | Repo's Copilot skills: `type = "symlink-contents"``type = "symlink"` |
61+
| `src/linker.rs` (tests) | Modify | Add `test_sync_symlink_directory_for_skills` unit test |
62+
| `tests/test_agent_adoption.rs` | Modify | Update 5 test functions: change skills targets from `symlink-contents` to `symlink`, update assertions from per-entry checks to directory symlink checks |
63+
64+
## Interfaces / Contracts
65+
66+
No new interfaces. The only contract change is in the TOML config schema, where skills targets use a different value for an existing field:
67+
68+
```toml
69+
# Before
70+
[agents.claude.targets.skills]
71+
source = "skills"
72+
destination = ".claude/skills"
73+
type = "symlink-contents"
74+
75+
# After
76+
[agents.claude.targets.skills]
77+
source = "skills"
78+
destination = ".claude/skills"
79+
type = "symlink"
80+
```
81+
82+
The `SyncType` enum, `TargetConfig` struct, and all Rust APIs remain unchanged.
83+
84+
## Testing Strategy
85+
86+
| Layer | What to Test | Approach |
87+
|-------|-------------|----------|
88+
| Unit | Directory symlink creation for skills source | New test `test_sync_symlink_directory_for_skills` in `src/linker.rs`: create a `.agents/skills/` dir with multiple skill subdirectories, configure `type = "symlink"`, sync, assert dest is a single symlink pointing to source dir (not individual entries inside a real dir) |
89+
| Unit | Clean removes directory symlink | Verify existing `SyncType::Symlink` clean test covers this, or add assertion that cleaning a directory symlink works (line 894-905 already handles it) |
90+
| Integration | Claude adoption with directory symlink | Update `test_adoption_claude_with_skills_and_commands` (line 117-163): change skills target to `"symlink"`, replace `assert_symlink_points_to(root, ".claude/skills/debugging", ...)` with `assert_symlink_points_to(root, ".claude/skills", "skills")` — a single directory symlink |
91+
| Integration | Gemini adoption with directory symlink | Update `test_adoption_gemini_with_skills_and_commands` (line 169-261): same pattern |
92+
| Integration | Codex adoption with directory symlink | Update `test_adoption_codex_skills_only` (line 266-298): same pattern |
93+
| Integration | Multi-agent adoption | Update `test_adoption_multi_agent_shared_skills` (line 303-424): change all three agents' skills targets, update all per-skill assertions to directory-level assertions |
94+
| Integration | Dry-run | Update `test_adoption_dry_run_no_side_effects` (line 430-469): change skills target to `"symlink"` |
95+
96+
### Test assertion pattern change
97+
98+
```rust
99+
// Before: assert individual symlinks inside a real directory
100+
assert_symlink_points_to(root, ".claude/skills/debugging", "debugging");
101+
assert_symlink_points_to(root, ".claude/skills/testing", "testing");
102+
103+
// After: assert the directory itself is a symlink to the source
104+
assert_symlink_points_to(root, ".claude/skills", "skills");
105+
// Then verify contents are accessible through the symlink
106+
assert!(root.join(".claude/skills/debugging").exists());
107+
assert!(root.join(".claude/skills/testing").exists());
108+
```
109+
110+
## Migration / Rollout
111+
112+
### New projects
113+
`agentsync init` emits `type = "symlink"` for skills targets. No action needed.
114+
115+
### Existing projects
116+
- Config still says `symlink-contents` → behavior is unchanged, no breakage
117+
- User updates config to `symlink` → next `agentsync sync` triggers backup logic:
118+
1. `create_symlink()` sees `.claude/skills/` is a real directory (not a symlink)
119+
2. Renames to `.claude/skills.bak.<timestamp>` (line 412-423)
120+
3. Creates single directory symlink `.claude/skills → ../../.agents/skills`
121+
- User runs `agentsync sync --clean` first → cleaner transition:
122+
1. `clean()` with old `SymlinkContents` config removes per-entry symlinks + empty dir (line 860-892)
123+
2. User updates config to `symlink`
124+
3. `sync()` creates the directory symlink fresh
125+
126+
### This repo
127+
Update `.agents/agentsync.toml`, run `pnpm run agents:sync:clean` to transition.
128+
129+
## Open Questions
130+
131+
- [x] Does `create_symlink` handle directory sources? → **Yes**, confirmed at lines 441-452
132+
- [x] Does clean handle `Symlink` type for directories? → **Yes**, `fs::remove_file` on a directory symlink works on both Unix and Windows (line 900)
133+
- [x] Does backup logic handle existing real directories at dest? → **Yes**, `fs::rename` at line 417
134+
- [ ] Should we add a note in CLI `--help` or docs about the `symlink` vs `symlink-contents` distinction for skills? → Non-blocking, can be a follow-up
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
## Exploration: Directory-level skills symlink
2+
3+
### Current State
4+
5+
**Skills sync uses `symlink-contents` today.** When a target has `type = "symlink-contents"`, the linker (`src/linker.rs:466-516`) iterates through every entry in the source directory and creates an individual symlink for each one inside the destination directory. For skills, this means:
6+
7+
```
8+
.agents/skills/pinned-tag/ → .claude/skills/pinned-tag (symlink)
9+
.agents/skills/rust/ → .claude/skills/rust (symlink)
10+
.agents/skills/registry.json → .claude/skills/registry.json (symlink)
11+
```
12+
13+
The destination directory (`.claude/skills/`) is created as a real directory by `ensure_directory()` (line 487), then each child gets its own symlink.
14+
15+
**A `symlink` type already exists** (`SyncType::Symlink`) that creates a single symlink from source to destination. It already handles directory sources — on Unix via `std::os::unix::fs::symlink` (line 442) and on Windows via `std::os::windows::fs::symlink_dir` (line 448). Changing the type from `symlink-contents` to `symlink` would produce:
16+
17+
```
18+
.claude/skills → ../../.agents/skills (single directory symlink)
19+
```
20+
21+
**Default configs are defined in two places:**
22+
1. `src/init.rs:70-73` — the default template emitted by `agentsync init`, which sets `type = "symlink-contents"` for skills across all agents (claude, codex, gemini, opencode, etc.)
23+
2. Each project's `.agents/agentsync.toml` — the user-facing config
24+
25+
**Clean logic differs by type:**
26+
- `SymlinkContents` clean (`linker.rs:860-893`): iterates entries inside dest dir, removes each symlink, then tries to `remove_dir` the now-empty directory
27+
- `Symlink` clean (`linker.rs:894-905`): removes the single symlink at the destination path
28+
29+
### Affected Areas
30+
31+
- `src/init.rs:70-73,106-109,123-126,145-148` — default config template for all agents with skills targets; change `type = "symlink-contents"` to `type = "symlink"` for skills targets
32+
- `src/linker.rs:204-215``process_target()` dispatch; no code change needed, already routes `Symlink` correctly
33+
- `src/linker.rs:344-463``create_symlink()`; already handles directory sources (no change needed)
34+
- `src/linker.rs:798-945``clean()`; `SyncType::Symlink` branch already handles removal (no change needed)
35+
- `src/linker.rs:2027-2102` — unit tests for `symlink-contents` skills; need parallel tests for `symlink` directory behavior
36+
- `tests/test_agent_adoption.rs` — integration tests that assert per-skill symlinks; must be updated for directory symlink
37+
- `.agents/agentsync.toml:70-73` — this repo's own config; change skills target type
38+
- `src/linker.rs:403-425` — backup logic for existing real directories at destination; already handles this via `fs::rename` to `.bak`
39+
40+
### Approaches
41+
42+
1. **Change default to `symlink` for skills targets** — Modify the init template and this repo's config to use `type = "symlink"` instead of `type = "symlink-contents"` for skills.
43+
- Pros: Simplest change; leverages existing `Symlink` code path; single symlink is cleaner, fewer filesystem entries, new skills in `.agents/skills/` appear instantly without re-running sync
44+
- Cons: Exposes `registry.json` and any non-skill files in `.agents/skills/` to the agent; existing projects using `symlink-contents` need manual config migration; `pattern` filter (e.g., `*.md`) no longer applies
45+
- Effort: Low
46+
47+
2. **New `symlink-directory` type** — Add a dedicated `SyncType` variant that always symlinks the source as a directory, with explicit semantics.
48+
- Pros: Clear intent; doesn't change `symlink` semantics for file sources; could add directory-specific validation
49+
- Cons: Over-engineered; `SyncType::Symlink` already handles directories; adds enum variant, serde, tests for no functional gain
50+
- Effort: Medium
51+
52+
3. **Keep `symlink-contents` but add a `link_directory` flag** — Add an optional boolean to `TargetConfig` that makes `symlink-contents` create a directory symlink instead of per-item links.
53+
- Pros: Backward-compatible config shape
54+
- Cons: Confusing — "symlink-contents" that doesn't symlink contents; adds complexity to an already-working code path
55+
- Effort: Medium
56+
57+
### Recommendation
58+
59+
**Approach 1: Change default to `symlink` for skills targets.** The existing `SyncType::Symlink` code path already handles directory sources correctly on both Unix and Windows. No new sync type or flag is needed. The change is:
60+
61+
1. Update init template in `src/init.rs` — change skills targets from `type = "symlink-contents"` to `type = "symlink"`
62+
2. Update this repo's `.agents/agentsync.toml` — same change
63+
3. Handle migration: when `clean()` runs on an old `symlink-contents` setup, the per-skill symlinks inside `.claude/skills/` get removed and the directory itself gets removed. Then `sync()` with the new `symlink` type creates the single directory symlink. The `--clean` flag already handles this transition.
64+
4. Update tests
65+
66+
Users with existing configs keep `symlink-contents` until they choose to change — it's purely a default change for new projects and explicit opt-in for existing ones.
67+
68+
### Risks
69+
70+
- **`registry.json` exposure**: A directory symlink exposes everything in `.agents/skills/`, including `registry.json`. This is likely acceptable since agents already see the skills directory contents, but worth noting.
71+
- **Existing project migration**: Users who run `agentsync sync` after updating the binary but before updating their config will see no change (their config still says `symlink-contents`). Only `agentsync init` on new projects or manual config edits are affected. No silent breakage.
72+
- **`pattern` filter becomes irrelevant**: With `symlink` type, the `pattern` field is ignored. Any config that relied on `pattern` to filter skills (e.g., `pattern = "*.md"`) would need to use `symlink-contents` explicitly. The default configs don't use `pattern` for skills, so this is low risk.
73+
- **Windows directory symlinks require elevated privileges**: On Windows, `symlink_dir` may require admin/developer mode. This is an existing limitation of `SyncType::Symlink` for directory sources, not new to this change. The `create_symlink` method already uses `symlink_dir` for directories.
74+
- **Clean then sync ordering**: If a user has `.claude/skills/` as a real directory with per-skill symlinks, running sync with the new `symlink` type will trigger the backup logic (line 403-425) which renames the existing directory to `.claude/skills.bak.<timestamp>`. This is safe but users should be aware.
75+
76+
### Ready for Proposal
77+
78+
Yes — the recommended approach (change default type from `symlink-contents` to `symlink` for skills targets) is well-scoped and low-risk. The proposal should cover:
79+
- Init template changes across all agents
80+
- This repo's config update
81+
- Test updates (unit + integration)
82+
- Documentation of the `registry.json` exposure consideration
83+
- Migration guidance for existing users (use `--clean` flag or manually update config)

0 commit comments

Comments
 (0)