@borasta/agentlink 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +250 -0
- package/bin/ag.js +2 -0
- package/dist/chunk-IYLBLNXA.js +533 -0
- package/dist/cli.js +78 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +38 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# agentlink
|
|
2
|
+
|
|
3
|
+
Manage symlinks from a single `.ai` folder to multiple agentic IDE configurations. Write your AI instructions once, link them everywhere.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Every agentic IDE (Claude Code, Cursor, Codex, OpenCode, Windsurf, ...) expects its configuration in a different location. `agentlink` lets you keep a **single source of truth** in `.ai/` and automatically creates granular symlinks to each IDE's expected paths.
|
|
8
|
+
|
|
9
|
+
- **Granular linking** -- each file (skill, agent, command, root doc) is linked individually, not as a bulk folder copy.
|
|
10
|
+
- **Incremental sync** -- file hashes are tracked so re-running the command is a no-op when nothing changed. Safe for git hooks (e.g. husky).
|
|
11
|
+
- **Stale link cleanup** -- renamed or deleted sources automatically remove their old symlinks on the next sync.
|
|
12
|
+
- **Extensible** -- built-in adapters for the major IDEs, plus support for custom targets via the programmatic API.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Global install (recommended)
|
|
18
|
+
npm install -g agentlink
|
|
19
|
+
|
|
20
|
+
# Or run without installing
|
|
21
|
+
npx agentlink <command>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
After installing globally, both `agentlink` and `ag` are available as CLI commands.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 1. Initialize the .ai directory
|
|
30
|
+
ag init
|
|
31
|
+
|
|
32
|
+
# 2. Add your content to .ai/
|
|
33
|
+
# (see "Recommended .ai structure" below)
|
|
34
|
+
|
|
35
|
+
# 3. Sync to your IDE
|
|
36
|
+
ag claude # creates symlinks for Claude Code
|
|
37
|
+
ag cursor # creates symlinks for Cursor
|
|
38
|
+
ag codex # creates symlinks for Codex
|
|
39
|
+
|
|
40
|
+
# 4. Check health
|
|
41
|
+
ag doctor
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---------|-------------|
|
|
48
|
+
| `ag init` | Create the `.ai` source directory with the recommended structure |
|
|
49
|
+
| `ag <target>` | Full sync: detect changes, clean stale links, create new ones |
|
|
50
|
+
| `ag sync <target>` | Same as `ag <target>` (explicit form) |
|
|
51
|
+
| `ag unlink <target>` | Remove all managed symlinks for a target |
|
|
52
|
+
| `ag doctor` | Validate all managed symlinks (healthy, broken, missing) |
|
|
53
|
+
|
|
54
|
+
### Global Options
|
|
55
|
+
|
|
56
|
+
| Option | Description |
|
|
57
|
+
|--------|-------------|
|
|
58
|
+
| `-s, --source <path>` | Use a custom source folder instead of `.ai` |
|
|
59
|
+
| `-f, --force` | Force sync even if no changes detected (sync only) |
|
|
60
|
+
|
|
61
|
+
### Available Targets
|
|
62
|
+
|
|
63
|
+
Each adapter only links the categories that the IDE actually supports, based on official documentation.
|
|
64
|
+
|
|
65
|
+
| Target | IDE | Root doc | Skills | Agents | Commands |
|
|
66
|
+
|--------|-----|----------|--------|--------|----------|
|
|
67
|
+
| `claude` | Claude Code | `CLAUDE.md` | `.claude/skills/` | `.claude/agents/` | `.claude/commands/` |
|
|
68
|
+
| `cursor` | Cursor | `.cursor/rules/AGENTS.md` | `.cursor/skills/` | -- | -- |
|
|
69
|
+
| `codex` | Codex CLI | `AGENTS.md` | -- | -- | -- |
|
|
70
|
+
| `opencode` | OpenCode | `AGENTS.md` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/commands/` |
|
|
71
|
+
| `windsurf` | Windsurf | `.windsurf/rules/AGENTS.md` | -- | -- | -- |
|
|
72
|
+
|
|
73
|
+
Sources: [Claude Code docs](https://docs.claude.com/en/docs/claude-code/plugins), [Cursor docs](https://cursor.com/docs/context/rules), [Codex docs](https://developers.openai.com/codex/guides/agents-md/), [OpenCode docs](https://open-code.ai/docs/en/config), [Windsurf docs](https://windsurf.com/editor/directory).
|
|
74
|
+
|
|
75
|
+
## Recommended `.ai` Structure
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
.ai/
|
|
79
|
+
├── AGENTS.md # Main agent instructions (root doc)
|
|
80
|
+
├── skills/ # Reusable skill definitions
|
|
81
|
+
│ ├── coding-standards/ # Each skill is a directory
|
|
82
|
+
│ │ ├── SKILL.md # with SKILL.md as entrypoint
|
|
83
|
+
│ │ └── examples/ # and optional supporting files
|
|
84
|
+
│ │ └── sample.md
|
|
85
|
+
│ ├── testing/
|
|
86
|
+
│ │ └── SKILL.md
|
|
87
|
+
│ └── frontend-react/
|
|
88
|
+
│ ├── SKILL.md
|
|
89
|
+
│ └── reference.md
|
|
90
|
+
├── agents/ # Custom agent definitions
|
|
91
|
+
│ ├── code-reviewer.md
|
|
92
|
+
│ └── researcher.md
|
|
93
|
+
├── commands/ # Custom slash commands
|
|
94
|
+
│ ├── deploy.md
|
|
95
|
+
│ └── test-suite.md
|
|
96
|
+
└── .agentlink-state.json # Auto-generated sync state (do not edit)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### What goes where
|
|
100
|
+
|
|
101
|
+
| Folder | Purpose | Example content |
|
|
102
|
+
|--------|---------|-----------------|
|
|
103
|
+
| `AGENTS.md` | Main instructions for all AI agents | Project description, coding standards, architecture overview |
|
|
104
|
+
| `skills/` | Reusable knowledge/skills that agents can reference | Each skill is a directory with a `SKILL.md` entrypoint and optional supporting files |
|
|
105
|
+
| `agents/` | Definitions for specialized agents | Code reviewer instructions, research agent prompts (YAML frontmatter + markdown) |
|
|
106
|
+
| `commands/` | Custom slash commands the agent can execute | Deployment scripts, test runners, migration helpers |
|
|
107
|
+
|
|
108
|
+
Nested folders within any category are fully supported. For example, `.ai/skills/coding-standards/SKILL.md` will be linked to `.claude/skills/coding-standards/SKILL.md` (for the Claude target).
|
|
109
|
+
|
|
110
|
+
> **Note:** Not every IDE supports every category. For example, Codex and Windsurf only use the root doc (`AGENTS.md`). When you sync to a target that doesn't support a category, those files are simply not linked -- no error, no noise.
|
|
111
|
+
|
|
112
|
+
## How Linking Works
|
|
113
|
+
|
|
114
|
+
When you run `ag claude`, `agentlink`:
|
|
115
|
+
|
|
116
|
+
1. **Scans** `.ai/` to discover all files (root docs, skills, agents, commands).
|
|
117
|
+
2. **Computes** the expected symlink mapping using the Claude adapter.
|
|
118
|
+
3. **Checks** if anything changed since the last sync (via SHA-256 hashes stored in `.agentlink-state.json`).
|
|
119
|
+
4. **Removes** stale symlinks from previous syncs that no longer have a source file.
|
|
120
|
+
5. **Creates** new symlinks using relative paths.
|
|
121
|
+
|
|
122
|
+
### Example: Claude Code mapping
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
.ai/AGENTS.md → CLAUDE.md
|
|
126
|
+
.ai/skills/coding-standards/SKILL.md → .claude/skills/coding-standards/SKILL.md
|
|
127
|
+
.ai/skills/coding-standards/examples/ → .claude/skills/coding-standards/examples/
|
|
128
|
+
.ai/agents/reviewer.md → .claude/agents/reviewer.md
|
|
129
|
+
.ai/commands/deploy.md → .claude/commands/deploy.md
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Example: Cursor mapping
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
.ai/AGENTS.md → .cursor/rules/AGENTS.md
|
|
136
|
+
.ai/skills/coding-standards/SKILL.md → .cursor/skills/coding-standards/SKILL.md
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
(Cursor does not support agents or commands, so those are skipped.)
|
|
140
|
+
|
|
141
|
+
### Example: Codex mapping
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
.ai/AGENTS.md → AGENTS.md
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
(Codex reads `AGENTS.md` at the project root. Skills, agents, and commands are not supported.)
|
|
148
|
+
|
|
149
|
+
## Custom Source Folder
|
|
150
|
+
|
|
151
|
+
If you prefer a different folder name instead of `.ai`:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
ag init --source .my-ai
|
|
155
|
+
ag claude --source .my-ai
|
|
156
|
+
ag doctor --source .my-ai
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Using with Git Hooks
|
|
160
|
+
|
|
161
|
+
Since `agentlink` is incremental (no-op when nothing changed), it is safe to run on every commit:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"husky": {
|
|
166
|
+
"hooks": {
|
|
167
|
+
"pre-commit": "ag claude && ag cursor"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Programmatic API
|
|
174
|
+
|
|
175
|
+
`agentlink` is also usable as a library:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import {
|
|
179
|
+
AdapterRegistry,
|
|
180
|
+
syncCommand,
|
|
181
|
+
nodeFs,
|
|
182
|
+
} from 'agentlink';
|
|
183
|
+
|
|
184
|
+
const registry = new AdapterRegistry();
|
|
185
|
+
|
|
186
|
+
// Register a custom adapter
|
|
187
|
+
registry.register({
|
|
188
|
+
id: 'my-ide',
|
|
189
|
+
name: 'My IDE',
|
|
190
|
+
description: 'Custom IDE adapter',
|
|
191
|
+
rootDocs: [{ source: 'AGENTS.md', target: '.myide/instructions.md' }],
|
|
192
|
+
skills: { dir: '.myide/skills' },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const result = await syncCommand(
|
|
196
|
+
'my-ide',
|
|
197
|
+
{ source: '.ai' },
|
|
198
|
+
process.cwd(),
|
|
199
|
+
nodeFs,
|
|
200
|
+
registry,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
console.log(result);
|
|
204
|
+
// { skipped: false, created: 3, removed: 0, unchanged: 0, errors: [] }
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Troubleshooting
|
|
208
|
+
|
|
209
|
+
### Symlink permission errors on Windows
|
|
210
|
+
|
|
211
|
+
Windows requires developer mode or elevated permissions to create symlinks. Enable developer mode in Settings > Update & Security > For developers.
|
|
212
|
+
|
|
213
|
+
### Target exists and is not a symlink
|
|
214
|
+
|
|
215
|
+
If a target file already exists as a regular file (not a symlink), `agentlink` will skip it and report an error. Remove or rename the file manually, then re-run.
|
|
216
|
+
|
|
217
|
+
### Stale symlinks after renaming
|
|
218
|
+
|
|
219
|
+
`agentlink` automatically cleans up stale symlinks when you rename or delete source files. Just run `ag <target>` again -- the old symlink will be removed and the new one created.
|
|
220
|
+
|
|
221
|
+
### Category not linked for my IDE
|
|
222
|
+
|
|
223
|
+
Each adapter only links the categories that the IDE officially supports. If you run `ag codex` and your `.ai/skills/` folder isn't linked, that's expected -- Codex only reads `AGENTS.md`. Check the [target table](#available-targets) to see what each IDE supports.
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
git clone <repo-url>
|
|
229
|
+
cd agentlink
|
|
230
|
+
npm install
|
|
231
|
+
|
|
232
|
+
# Run unit tests
|
|
233
|
+
npm test
|
|
234
|
+
|
|
235
|
+
# Run e2e tests
|
|
236
|
+
npm run test:e2e
|
|
237
|
+
|
|
238
|
+
# Run all tests
|
|
239
|
+
npm run test:all
|
|
240
|
+
|
|
241
|
+
# Build
|
|
242
|
+
npm run build
|
|
243
|
+
|
|
244
|
+
# Type check
|
|
245
|
+
npm run lint
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT
|
package/bin/ag.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// src/core/fs.ts
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as nodePath from "path";
|
|
4
|
+
var nodeFs = {
|
|
5
|
+
readFile: (p) => fs.readFile(p, "utf-8"),
|
|
6
|
+
writeFile: async (p, c) => {
|
|
7
|
+
await fs.mkdir(nodePath.dirname(p), { recursive: true });
|
|
8
|
+
await fs.writeFile(p, c, "utf-8");
|
|
9
|
+
},
|
|
10
|
+
readdir: (p) => fs.readdir(p),
|
|
11
|
+
lstat: (p) => fs.lstat(p),
|
|
12
|
+
symlink: (t, p) => fs.symlink(t, p),
|
|
13
|
+
readlink: (p) => fs.readlink(p),
|
|
14
|
+
unlink: (p) => fs.unlink(p),
|
|
15
|
+
mkdir: (p) => fs.mkdir(p, { recursive: true }).then(() => {
|
|
16
|
+
}),
|
|
17
|
+
exists: async (p) => {
|
|
18
|
+
try {
|
|
19
|
+
await fs.lstat(p);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/adapters/builtins/claude.ts
|
|
28
|
+
var claudeAdapter = {
|
|
29
|
+
id: "claude",
|
|
30
|
+
name: "Claude Code",
|
|
31
|
+
description: "Anthropic Claude Code CLI",
|
|
32
|
+
rootDocs: [{ source: "AGENTS.md", target: "CLAUDE.md" }],
|
|
33
|
+
skills: { dir: ".claude/skills" },
|
|
34
|
+
agents: { dir: ".claude/agents" },
|
|
35
|
+
commands: { dir: ".claude/commands" }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/adapters/builtins/cursor.ts
|
|
39
|
+
var cursorAdapter = {
|
|
40
|
+
id: "cursor",
|
|
41
|
+
name: "Cursor",
|
|
42
|
+
description: "Cursor AI IDE",
|
|
43
|
+
rootDocs: [{ source: "AGENTS.md", target: ".cursor/rules/AGENTS.md" }],
|
|
44
|
+
skills: { dir: ".cursor/skills" }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/adapters/builtins/codex.ts
|
|
48
|
+
var codexAdapter = {
|
|
49
|
+
id: "codex",
|
|
50
|
+
name: "Codex",
|
|
51
|
+
description: "OpenAI Codex CLI",
|
|
52
|
+
rootDocs: [{ source: "AGENTS.md", target: "AGENTS.md" }]
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/adapters/builtins/opencode.ts
|
|
56
|
+
var opencodeAdapter = {
|
|
57
|
+
id: "opencode",
|
|
58
|
+
name: "OpenCode",
|
|
59
|
+
description: "OpenCode AI editor",
|
|
60
|
+
rootDocs: [{ source: "AGENTS.md", target: "AGENTS.md" }],
|
|
61
|
+
skills: { dir: ".opencode/skills" },
|
|
62
|
+
agents: { dir: ".opencode/agents" },
|
|
63
|
+
commands: { dir: ".opencode/commands" }
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/adapters/builtins/windsurf.ts
|
|
67
|
+
var windsurfAdapter = {
|
|
68
|
+
id: "windsurf",
|
|
69
|
+
name: "Windsurf",
|
|
70
|
+
description: "Codeium Windsurf IDE",
|
|
71
|
+
rootDocs: [{ source: "AGENTS.md", target: ".windsurf/rules/AGENTS.md" }]
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// src/adapters/builtins/index.ts
|
|
75
|
+
var builtinAdapters = [
|
|
76
|
+
claudeAdapter,
|
|
77
|
+
cursorAdapter,
|
|
78
|
+
codexAdapter,
|
|
79
|
+
opencodeAdapter,
|
|
80
|
+
windsurfAdapter
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// src/adapters/registry.ts
|
|
84
|
+
var AdapterRegistry = class {
|
|
85
|
+
adapters = /* @__PURE__ */ new Map();
|
|
86
|
+
constructor() {
|
|
87
|
+
for (const adapter of builtinAdapters) {
|
|
88
|
+
this.register(adapter);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
register(adapter) {
|
|
92
|
+
this.adapters.set(adapter.id, adapter);
|
|
93
|
+
}
|
|
94
|
+
get(id) {
|
|
95
|
+
return this.adapters.get(id);
|
|
96
|
+
}
|
|
97
|
+
has(id) {
|
|
98
|
+
return this.adapters.has(id);
|
|
99
|
+
}
|
|
100
|
+
list() {
|
|
101
|
+
return Array.from(this.adapters.values());
|
|
102
|
+
}
|
|
103
|
+
ids() {
|
|
104
|
+
return Array.from(this.adapters.keys());
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/core/scaffold.ts
|
|
109
|
+
import * as path from "path";
|
|
110
|
+
var DEFAULT_AGENTS_MD = `# Project Agents
|
|
111
|
+
|
|
112
|
+
Define your AI agent instructions here.
|
|
113
|
+
`;
|
|
114
|
+
var CATEGORIES = ["skills", "agents", "commands"];
|
|
115
|
+
async function scaffoldSourceDir(fs2, sourceDir) {
|
|
116
|
+
const created = [];
|
|
117
|
+
if (!await fs2.exists(sourceDir)) {
|
|
118
|
+
await fs2.mkdir(sourceDir);
|
|
119
|
+
created.push(sourceDir);
|
|
120
|
+
}
|
|
121
|
+
const agentsPath = path.join(sourceDir, "AGENTS.md");
|
|
122
|
+
if (!await fs2.exists(agentsPath)) {
|
|
123
|
+
await fs2.writeFile(agentsPath, DEFAULT_AGENTS_MD);
|
|
124
|
+
created.push(agentsPath);
|
|
125
|
+
}
|
|
126
|
+
for (const cat of CATEGORIES) {
|
|
127
|
+
const catDir = path.join(sourceDir, cat);
|
|
128
|
+
if (!await fs2.exists(catDir)) {
|
|
129
|
+
await fs2.mkdir(catDir);
|
|
130
|
+
created.push(catDir);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { created };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/commands/init.ts
|
|
137
|
+
import * as path2 from "path";
|
|
138
|
+
async function initCommand(options, projectRoot, fs2) {
|
|
139
|
+
const sourceDir = path2.resolve(projectRoot, options.source);
|
|
140
|
+
const result = await scaffoldSourceDir(fs2, sourceDir);
|
|
141
|
+
if (result.created.length === 0) {
|
|
142
|
+
console.log(`Source directory already initialized: ${options.source}`);
|
|
143
|
+
} else {
|
|
144
|
+
console.log(`Initialized ${options.source} with:`);
|
|
145
|
+
for (const item of result.created) {
|
|
146
|
+
console.log(` + ${path2.relative(projectRoot, item)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/core/scanner.ts
|
|
153
|
+
import * as path3 from "path";
|
|
154
|
+
async function listFilesRecursive(fs2, dirPath, prefix = "") {
|
|
155
|
+
if (!await fs2.exists(dirPath)) return [];
|
|
156
|
+
const entries = await fs2.readdir(dirPath);
|
|
157
|
+
const files = [];
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (entry.startsWith(".")) continue;
|
|
160
|
+
const fullPath = path3.join(dirPath, entry);
|
|
161
|
+
const relativePath = prefix ? path3.join(prefix, entry) : entry;
|
|
162
|
+
const stat = await fs2.lstat(fullPath);
|
|
163
|
+
if (stat.isFile()) {
|
|
164
|
+
files.push(relativePath);
|
|
165
|
+
} else if (stat.isDirectory()) {
|
|
166
|
+
const nested = await listFilesRecursive(fs2, fullPath, relativePath);
|
|
167
|
+
files.push(...nested);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return files.sort();
|
|
171
|
+
}
|
|
172
|
+
async function scanSourceDir(fs2, sourceDir) {
|
|
173
|
+
if (!await fs2.exists(sourceDir)) {
|
|
174
|
+
throw new Error(`Source directory not found: ${sourceDir}`);
|
|
175
|
+
}
|
|
176
|
+
const rootEntries = await fs2.readdir(sourceDir);
|
|
177
|
+
const rootDocs = [];
|
|
178
|
+
for (const entry of rootEntries) {
|
|
179
|
+
if (entry.startsWith(".")) continue;
|
|
180
|
+
const fullPath = path3.join(sourceDir, entry);
|
|
181
|
+
const stat = await fs2.lstat(fullPath);
|
|
182
|
+
if (stat.isFile()) {
|
|
183
|
+
rootDocs.push(entry);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
rootDocs: rootDocs.sort(),
|
|
188
|
+
skills: await listFilesRecursive(fs2, path3.join(sourceDir, "skills")),
|
|
189
|
+
agents: await listFilesRecursive(fs2, path3.join(sourceDir, "agents")),
|
|
190
|
+
commands: await listFilesRecursive(fs2, path3.join(sourceDir, "commands"))
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/core/mapper.ts
|
|
195
|
+
import * as path4 from "path";
|
|
196
|
+
var CATEGORY_KEYS = ["skills", "agents", "commands"];
|
|
197
|
+
function computeMappings(projectRoot, sourceDir, items, adapter) {
|
|
198
|
+
const mappings = [];
|
|
199
|
+
for (const docMap of adapter.rootDocs) {
|
|
200
|
+
if (items.rootDocs.includes(docMap.source)) {
|
|
201
|
+
mappings.push({
|
|
202
|
+
source: path4.resolve(sourceDir, docMap.source),
|
|
203
|
+
target: path4.resolve(projectRoot, docMap.target),
|
|
204
|
+
category: "rootDocs"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const key of CATEGORY_KEYS) {
|
|
209
|
+
const catTarget = adapter[key];
|
|
210
|
+
if (!catTarget) continue;
|
|
211
|
+
const files = items[key];
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const targetName = catTarget.transform?.(file) ?? file;
|
|
214
|
+
mappings.push({
|
|
215
|
+
source: path4.resolve(sourceDir, key, file),
|
|
216
|
+
target: path4.resolve(projectRoot, catTarget.dir, targetName),
|
|
217
|
+
category: key
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return mappings;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/core/linker.ts
|
|
225
|
+
import * as path5 from "path";
|
|
226
|
+
async function createSymlinks(fs2, mappings) {
|
|
227
|
+
const created = [];
|
|
228
|
+
const errors = [];
|
|
229
|
+
for (const mapping of mappings) {
|
|
230
|
+
try {
|
|
231
|
+
const targetDir = path5.dirname(mapping.target);
|
|
232
|
+
await fs2.mkdir(targetDir);
|
|
233
|
+
if (await fs2.exists(mapping.target)) {
|
|
234
|
+
const stat = await fs2.lstat(mapping.target);
|
|
235
|
+
if (stat.isSymbolicLink()) {
|
|
236
|
+
const existingTarget = await fs2.readlink(mapping.target);
|
|
237
|
+
const resolved = path5.resolve(
|
|
238
|
+
path5.dirname(mapping.target),
|
|
239
|
+
existingTarget
|
|
240
|
+
);
|
|
241
|
+
if (resolved === mapping.source) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
await fs2.unlink(mapping.target);
|
|
245
|
+
} else {
|
|
246
|
+
errors.push({
|
|
247
|
+
path: mapping.target,
|
|
248
|
+
message: "Target exists and is not a symlink, skipping"
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const relSource = path5.relative(targetDir, mapping.source);
|
|
254
|
+
await fs2.symlink(relSource, mapping.target);
|
|
255
|
+
created.push(mapping.target);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
258
|
+
errors.push({ path: mapping.target, message });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { created, errors };
|
|
262
|
+
}
|
|
263
|
+
async function removeSymlinks(fs2, targets) {
|
|
264
|
+
const removed = [];
|
|
265
|
+
const errors = [];
|
|
266
|
+
for (const target of targets) {
|
|
267
|
+
try {
|
|
268
|
+
if (!await fs2.exists(target)) continue;
|
|
269
|
+
const stat = await fs2.lstat(target);
|
|
270
|
+
if (stat.isSymbolicLink()) {
|
|
271
|
+
await fs2.unlink(target);
|
|
272
|
+
removed.push(target);
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
276
|
+
errors.push({ path: target, message });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { removed, errors };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/core/reconciler.ts
|
|
283
|
+
import * as path6 from "path";
|
|
284
|
+
async function reconcileStaleLinks(fs2, projectRoot, previousMappings, currentMappings) {
|
|
285
|
+
const currentTargets = new Set(
|
|
286
|
+
currentMappings.map((m) => path6.relative(projectRoot, m.target))
|
|
287
|
+
);
|
|
288
|
+
const removed = [];
|
|
289
|
+
for (const prev of previousMappings) {
|
|
290
|
+
if (!currentTargets.has(prev.target)) {
|
|
291
|
+
const absTarget = path6.resolve(projectRoot, prev.target);
|
|
292
|
+
try {
|
|
293
|
+
if (await fs2.exists(absTarget)) {
|
|
294
|
+
const stat = await fs2.lstat(absTarget);
|
|
295
|
+
if (stat.isSymbolicLink()) {
|
|
296
|
+
await fs2.unlink(absTarget);
|
|
297
|
+
removed.push(prev.target);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { removed };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/core/state.ts
|
|
308
|
+
import * as crypto from "crypto";
|
|
309
|
+
import * as path7 from "path";
|
|
310
|
+
var STATE_VERSION = 1;
|
|
311
|
+
async function loadState(fs2, statePath) {
|
|
312
|
+
if (!await fs2.exists(statePath)) {
|
|
313
|
+
return { version: STATE_VERSION, targets: {} };
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const content = await fs2.readFile(statePath);
|
|
317
|
+
return JSON.parse(content);
|
|
318
|
+
} catch {
|
|
319
|
+
return { version: STATE_VERSION, targets: {} };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function saveState(fs2, statePath, state) {
|
|
323
|
+
await fs2.writeFile(statePath, JSON.stringify(state, null, 2));
|
|
324
|
+
}
|
|
325
|
+
async function hashFile(fs2, filePath) {
|
|
326
|
+
const content = await fs2.readFile(filePath);
|
|
327
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
328
|
+
}
|
|
329
|
+
async function computeStoredMappings(fs2, projectRoot, mappings) {
|
|
330
|
+
const stored = [];
|
|
331
|
+
for (const m of mappings) {
|
|
332
|
+
const hash = await hashFile(fs2, m.source);
|
|
333
|
+
stored.push({
|
|
334
|
+
source: path7.relative(projectRoot, m.source),
|
|
335
|
+
target: path7.relative(projectRoot, m.target),
|
|
336
|
+
sourceHash: hash
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return stored;
|
|
340
|
+
}
|
|
341
|
+
function hasChanges(previous, current) {
|
|
342
|
+
if (previous.length !== current.length) return true;
|
|
343
|
+
const prevMap = /* @__PURE__ */ new Map();
|
|
344
|
+
for (const m of previous) {
|
|
345
|
+
prevMap.set(`${m.source}::${m.target}`, m.sourceHash);
|
|
346
|
+
}
|
|
347
|
+
for (const m of current) {
|
|
348
|
+
const key = `${m.source}::${m.target}`;
|
|
349
|
+
if (prevMap.get(key) !== m.sourceHash) return true;
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/commands/sync.ts
|
|
355
|
+
import * as path8 from "path";
|
|
356
|
+
async function syncCommand(target, options, projectRoot, fs2, registry) {
|
|
357
|
+
const adapter = registry.get(target);
|
|
358
|
+
if (!adapter) {
|
|
359
|
+
const available = registry.ids().join(", ");
|
|
360
|
+
throw new Error(`Unknown target "${target}". Available: ${available}`);
|
|
361
|
+
}
|
|
362
|
+
const sourceDir = path8.resolve(projectRoot, options.source);
|
|
363
|
+
const statePath = path8.join(sourceDir, ".agentlink-state.json");
|
|
364
|
+
const items = await scanSourceDir(fs2, sourceDir);
|
|
365
|
+
const mappings = computeMappings(projectRoot, sourceDir, items, adapter);
|
|
366
|
+
const state = await loadState(fs2, statePath);
|
|
367
|
+
const prevTarget = state.targets[target];
|
|
368
|
+
const currentStored = await computeStoredMappings(
|
|
369
|
+
fs2,
|
|
370
|
+
projectRoot,
|
|
371
|
+
mappings
|
|
372
|
+
);
|
|
373
|
+
if (!options.force && prevTarget && !hasChanges(prevTarget.mappings, currentStored)) {
|
|
374
|
+
return {
|
|
375
|
+
skipped: true,
|
|
376
|
+
created: 0,
|
|
377
|
+
removed: 0,
|
|
378
|
+
unchanged: mappings.length,
|
|
379
|
+
errors: []
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const { removed } = await reconcileStaleLinks(
|
|
383
|
+
fs2,
|
|
384
|
+
projectRoot,
|
|
385
|
+
prevTarget?.mappings ?? [],
|
|
386
|
+
mappings
|
|
387
|
+
);
|
|
388
|
+
const linkResult = await createSymlinks(fs2, mappings);
|
|
389
|
+
state.targets[target] = {
|
|
390
|
+
lastSync: (/* @__PURE__ */ new Date()).toISOString(),
|
|
391
|
+
mappings: currentStored
|
|
392
|
+
};
|
|
393
|
+
await saveState(fs2, statePath, state);
|
|
394
|
+
return {
|
|
395
|
+
skipped: false,
|
|
396
|
+
created: linkResult.created.length,
|
|
397
|
+
removed: removed.length,
|
|
398
|
+
unchanged: mappings.length - linkResult.created.length,
|
|
399
|
+
errors: linkResult.errors
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/commands/unlink.ts
|
|
404
|
+
import * as path9 from "path";
|
|
405
|
+
async function unlinkCommand(target, options, projectRoot, fs2, registry) {
|
|
406
|
+
if (!registry.has(target)) {
|
|
407
|
+
const available = registry.ids().join(", ");
|
|
408
|
+
throw new Error(`Unknown target "${target}". Available: ${available}`);
|
|
409
|
+
}
|
|
410
|
+
const sourceDir = path9.resolve(projectRoot, options.source);
|
|
411
|
+
const statePath = path9.join(sourceDir, ".agentlink-state.json");
|
|
412
|
+
const state = await loadState(fs2, statePath);
|
|
413
|
+
const targetState = state.targets[target];
|
|
414
|
+
if (!targetState || targetState.mappings.length === 0) {
|
|
415
|
+
console.log(`No managed symlinks found for "${target}".`);
|
|
416
|
+
return { removed: 0, errors: 0 };
|
|
417
|
+
}
|
|
418
|
+
const absPaths = targetState.mappings.map(
|
|
419
|
+
(m) => path9.resolve(projectRoot, m.target)
|
|
420
|
+
);
|
|
421
|
+
const { removed, errors } = await removeSymlinks(fs2, absPaths);
|
|
422
|
+
delete state.targets[target];
|
|
423
|
+
await saveState(fs2, statePath, state);
|
|
424
|
+
console.log(`Removed ${removed.length} symlink(s) for "${target}".`);
|
|
425
|
+
if (errors.length > 0) {
|
|
426
|
+
for (const e of errors) {
|
|
427
|
+
console.error(` Error: ${e.path} - ${e.message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { removed: removed.length, errors: errors.length };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/commands/doctor.ts
|
|
434
|
+
import * as path10 from "path";
|
|
435
|
+
async function doctorCommand(options, projectRoot, fs2, _registry) {
|
|
436
|
+
const sourceDir = path10.resolve(projectRoot, options.source);
|
|
437
|
+
const statePath = path10.join(sourceDir, ".agentlink-state.json");
|
|
438
|
+
const report = {
|
|
439
|
+
sourceExists: await fs2.exists(sourceDir),
|
|
440
|
+
stateExists: await fs2.exists(statePath),
|
|
441
|
+
targets: {}
|
|
442
|
+
};
|
|
443
|
+
if (!report.sourceExists) {
|
|
444
|
+
console.log(`Source directory not found: ${options.source}`);
|
|
445
|
+
console.log('Run "ag init" to create it.');
|
|
446
|
+
return report;
|
|
447
|
+
}
|
|
448
|
+
if (!report.stateExists) {
|
|
449
|
+
console.log('No sync state found. Run "ag <target>" to create symlinks.');
|
|
450
|
+
return report;
|
|
451
|
+
}
|
|
452
|
+
const state = await loadState(fs2, statePath);
|
|
453
|
+
for (const [targetId, targetState] of Object.entries(state.targets)) {
|
|
454
|
+
const targetReport = {
|
|
455
|
+
total: targetState.mappings.length,
|
|
456
|
+
healthy: 0,
|
|
457
|
+
broken: 0,
|
|
458
|
+
missing: 0,
|
|
459
|
+
issues: []
|
|
460
|
+
};
|
|
461
|
+
for (const mapping of targetState.mappings) {
|
|
462
|
+
const absTarget = path10.resolve(projectRoot, mapping.target);
|
|
463
|
+
const absSource = path10.resolve(projectRoot, mapping.source);
|
|
464
|
+
if (!await fs2.exists(absTarget)) {
|
|
465
|
+
targetReport.missing++;
|
|
466
|
+
targetReport.issues.push(`Missing symlink: ${mapping.target}`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const stat = await fs2.lstat(absTarget);
|
|
471
|
+
if (!stat.isSymbolicLink()) {
|
|
472
|
+
targetReport.broken++;
|
|
473
|
+
targetReport.issues.push(`Not a symlink: ${mapping.target}`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const linkTarget = await fs2.readlink(absTarget);
|
|
477
|
+
const resolved = path10.resolve(
|
|
478
|
+
path10.dirname(absTarget),
|
|
479
|
+
linkTarget
|
|
480
|
+
);
|
|
481
|
+
if (resolved !== absSource) {
|
|
482
|
+
targetReport.broken++;
|
|
483
|
+
targetReport.issues.push(
|
|
484
|
+
`Wrong target: ${mapping.target} -> ${linkTarget} (expected source: ${mapping.source})`
|
|
485
|
+
);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!await fs2.exists(absSource)) {
|
|
489
|
+
targetReport.broken++;
|
|
490
|
+
targetReport.issues.push(
|
|
491
|
+
`Dangling symlink: ${mapping.target} (source missing: ${mapping.source})`
|
|
492
|
+
);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
targetReport.healthy++;
|
|
496
|
+
} catch {
|
|
497
|
+
targetReport.broken++;
|
|
498
|
+
targetReport.issues.push(`Error checking: ${mapping.target}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
report.targets[targetId] = targetReport;
|
|
502
|
+
}
|
|
503
|
+
for (const [targetId, tr] of Object.entries(report.targets)) {
|
|
504
|
+
console.log(`
|
|
505
|
+
[${targetId}] ${tr.healthy}/${tr.total} healthy`);
|
|
506
|
+
if (tr.missing > 0) console.log(` ${tr.missing} missing`);
|
|
507
|
+
if (tr.broken > 0) console.log(` ${tr.broken} broken`);
|
|
508
|
+
for (const issue of tr.issues) {
|
|
509
|
+
console.log(` - ${issue}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return report;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export {
|
|
516
|
+
nodeFs,
|
|
517
|
+
builtinAdapters,
|
|
518
|
+
AdapterRegistry,
|
|
519
|
+
scaffoldSourceDir,
|
|
520
|
+
initCommand,
|
|
521
|
+
scanSourceDir,
|
|
522
|
+
computeMappings,
|
|
523
|
+
createSymlinks,
|
|
524
|
+
removeSymlinks,
|
|
525
|
+
reconcileStaleLinks,
|
|
526
|
+
loadState,
|
|
527
|
+
saveState,
|
|
528
|
+
computeStoredMappings,
|
|
529
|
+
hasChanges,
|
|
530
|
+
syncCommand,
|
|
531
|
+
unlinkCommand,
|
|
532
|
+
doctorCommand
|
|
533
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AdapterRegistry,
|
|
3
|
+
doctorCommand,
|
|
4
|
+
initCommand,
|
|
5
|
+
nodeFs,
|
|
6
|
+
syncCommand,
|
|
7
|
+
unlinkCommand
|
|
8
|
+
} from "./chunk-IYLBLNXA.js";
|
|
9
|
+
|
|
10
|
+
// src/cli.ts
|
|
11
|
+
import { Command } from "commander";
|
|
12
|
+
var KNOWN_COMMANDS = ["init", "sync", "unlink", "doctor", "help"];
|
|
13
|
+
var firstArg = process.argv[2];
|
|
14
|
+
if (firstArg && !firstArg.startsWith("-") && !KNOWN_COMMANDS.includes(firstArg)) {
|
|
15
|
+
process.argv.splice(2, 0, "sync");
|
|
16
|
+
}
|
|
17
|
+
var registry = new AdapterRegistry();
|
|
18
|
+
var projectRoot = process.cwd();
|
|
19
|
+
var program = new Command();
|
|
20
|
+
program.name("ag").description(
|
|
21
|
+
`Manage symlinks from .ai to agentic IDE configurations.
|
|
22
|
+
|
|
23
|
+
Available targets: ${registry.ids().join(", ")}`
|
|
24
|
+
).version("0.1.0");
|
|
25
|
+
program.command("init").description("Initialize the source directory structure").option("-s, --source <path>", "Source directory name", ".ai").action(async (opts) => {
|
|
26
|
+
try {
|
|
27
|
+
await initCommand(opts, projectRoot, nodeFs);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
program.command("sync <target>").description(
|
|
34
|
+
"Sync symlinks for a target IDE (detect changes, clean stale, relink)"
|
|
35
|
+
).option("-s, --source <path>", "Source directory name", ".ai").option("-f, --force", "Force sync even if no changes detected").action(async (target, opts) => {
|
|
36
|
+
try {
|
|
37
|
+
const result = await syncCommand(
|
|
38
|
+
target,
|
|
39
|
+
opts,
|
|
40
|
+
projectRoot,
|
|
41
|
+
nodeFs,
|
|
42
|
+
registry
|
|
43
|
+
);
|
|
44
|
+
if (result.skipped) {
|
|
45
|
+
console.log("Already up to date.");
|
|
46
|
+
} else {
|
|
47
|
+
console.log(
|
|
48
|
+
`Sync complete: ${result.created} created, ${result.removed} removed, ${result.unchanged} unchanged.`
|
|
49
|
+
);
|
|
50
|
+
if (result.errors.length > 0) {
|
|
51
|
+
for (const e of result.errors) {
|
|
52
|
+
console.error(` Error: ${e.path} - ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
program.command("unlink <target>").description("Remove all managed symlinks for a target IDE").option("-s, --source <path>", "Source directory name", ".ai").action(async (target, opts) => {
|
|
63
|
+
try {
|
|
64
|
+
await unlinkCommand(target, opts, projectRoot, nodeFs, registry);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
program.command("doctor").description("Check health of managed symlinks").option("-s, --source <path>", "Source directory name", ".ai").action(async (opts) => {
|
|
71
|
+
try {
|
|
72
|
+
await doctorCommand(opts, projectRoot, nodeFs, registry);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
program.parse();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
interface RootDocMapping {
|
|
2
|
+
source: string;
|
|
3
|
+
target: string;
|
|
4
|
+
}
|
|
5
|
+
interface CategoryTarget {
|
|
6
|
+
dir: string;
|
|
7
|
+
transform?: (filename: string) => string;
|
|
8
|
+
}
|
|
9
|
+
interface IdeAdapter {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
rootDocs: RootDocMapping[];
|
|
14
|
+
skills?: CategoryTarget;
|
|
15
|
+
agents?: CategoryTarget;
|
|
16
|
+
commands?: CategoryTarget;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface LinkMapping {
|
|
20
|
+
source: string;
|
|
21
|
+
target: string;
|
|
22
|
+
category: string;
|
|
23
|
+
}
|
|
24
|
+
interface SourceItems {
|
|
25
|
+
rootDocs: string[];
|
|
26
|
+
skills: string[];
|
|
27
|
+
agents: string[];
|
|
28
|
+
commands: string[];
|
|
29
|
+
}
|
|
30
|
+
interface StoredMapping {
|
|
31
|
+
source: string;
|
|
32
|
+
target: string;
|
|
33
|
+
sourceHash: string;
|
|
34
|
+
}
|
|
35
|
+
interface TargetState {
|
|
36
|
+
lastSync: string;
|
|
37
|
+
mappings: StoredMapping[];
|
|
38
|
+
}
|
|
39
|
+
interface SyncState {
|
|
40
|
+
version: number;
|
|
41
|
+
targets: Record<string, TargetState>;
|
|
42
|
+
}
|
|
43
|
+
interface SyncResult {
|
|
44
|
+
skipped: boolean;
|
|
45
|
+
created: number;
|
|
46
|
+
removed: number;
|
|
47
|
+
unchanged: number;
|
|
48
|
+
errors: Array<{
|
|
49
|
+
path: string;
|
|
50
|
+
message: string;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FsStats {
|
|
55
|
+
isFile(): boolean;
|
|
56
|
+
isDirectory(): boolean;
|
|
57
|
+
isSymbolicLink(): boolean;
|
|
58
|
+
}
|
|
59
|
+
interface FileSystem {
|
|
60
|
+
readFile(filePath: string): Promise<string>;
|
|
61
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
62
|
+
readdir(dirPath: string): Promise<string[]>;
|
|
63
|
+
lstat(filePath: string): Promise<FsStats>;
|
|
64
|
+
symlink(target: string, linkPath: string): Promise<void>;
|
|
65
|
+
readlink(linkPath: string): Promise<string>;
|
|
66
|
+
unlink(filePath: string): Promise<void>;
|
|
67
|
+
mkdir(dirPath: string): Promise<void>;
|
|
68
|
+
exists(filePath: string): Promise<boolean>;
|
|
69
|
+
}
|
|
70
|
+
declare const nodeFs: FileSystem;
|
|
71
|
+
|
|
72
|
+
declare class AdapterRegistry {
|
|
73
|
+
private adapters;
|
|
74
|
+
constructor();
|
|
75
|
+
register(adapter: IdeAdapter): void;
|
|
76
|
+
get(id: string): IdeAdapter | undefined;
|
|
77
|
+
has(id: string): boolean;
|
|
78
|
+
list(): IdeAdapter[];
|
|
79
|
+
ids(): string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
declare const builtinAdapters: IdeAdapter[];
|
|
83
|
+
|
|
84
|
+
declare function scanSourceDir(fs: FileSystem, sourceDir: string): Promise<SourceItems>;
|
|
85
|
+
|
|
86
|
+
declare function computeMappings(projectRoot: string, sourceDir: string, items: SourceItems, adapter: IdeAdapter): LinkMapping[];
|
|
87
|
+
|
|
88
|
+
interface LinkResult {
|
|
89
|
+
created: string[];
|
|
90
|
+
errors: Array<{
|
|
91
|
+
path: string;
|
|
92
|
+
message: string;
|
|
93
|
+
}>;
|
|
94
|
+
}
|
|
95
|
+
declare function createSymlinks(fs: FileSystem, mappings: LinkMapping[]): Promise<LinkResult>;
|
|
96
|
+
declare function removeSymlinks(fs: FileSystem, targets: string[]): Promise<{
|
|
97
|
+
removed: string[];
|
|
98
|
+
errors: Array<{
|
|
99
|
+
path: string;
|
|
100
|
+
message: string;
|
|
101
|
+
}>;
|
|
102
|
+
}>;
|
|
103
|
+
|
|
104
|
+
declare function reconcileStaleLinks(fs: FileSystem, projectRoot: string, previousMappings: StoredMapping[], currentMappings: LinkMapping[]): Promise<{
|
|
105
|
+
removed: string[];
|
|
106
|
+
}>;
|
|
107
|
+
|
|
108
|
+
declare function loadState(fs: FileSystem, statePath: string): Promise<SyncState>;
|
|
109
|
+
declare function saveState(fs: FileSystem, statePath: string, state: SyncState): Promise<void>;
|
|
110
|
+
declare function computeStoredMappings(fs: FileSystem, projectRoot: string, mappings: LinkMapping[]): Promise<StoredMapping[]>;
|
|
111
|
+
declare function hasChanges(previous: StoredMapping[], current: StoredMapping[]): boolean;
|
|
112
|
+
|
|
113
|
+
declare function scaffoldSourceDir(fs: FileSystem, sourceDir: string): Promise<{
|
|
114
|
+
created: string[];
|
|
115
|
+
}>;
|
|
116
|
+
|
|
117
|
+
interface SyncOptions {
|
|
118
|
+
source: string;
|
|
119
|
+
force?: boolean;
|
|
120
|
+
}
|
|
121
|
+
declare function syncCommand(target: string, options: SyncOptions, projectRoot: string, fs: FileSystem, registry: AdapterRegistry): Promise<SyncResult>;
|
|
122
|
+
|
|
123
|
+
interface InitOptions {
|
|
124
|
+
source: string;
|
|
125
|
+
}
|
|
126
|
+
declare function initCommand(options: InitOptions, projectRoot: string, fs: FileSystem): Promise<{
|
|
127
|
+
created: string[];
|
|
128
|
+
}>;
|
|
129
|
+
|
|
130
|
+
interface UnlinkOptions {
|
|
131
|
+
source: string;
|
|
132
|
+
}
|
|
133
|
+
declare function unlinkCommand(target: string, options: UnlinkOptions, projectRoot: string, fs: FileSystem, registry: AdapterRegistry): Promise<{
|
|
134
|
+
removed: number;
|
|
135
|
+
errors: number;
|
|
136
|
+
}>;
|
|
137
|
+
|
|
138
|
+
interface DoctorOptions {
|
|
139
|
+
source: string;
|
|
140
|
+
}
|
|
141
|
+
interface TargetReport {
|
|
142
|
+
total: number;
|
|
143
|
+
healthy: number;
|
|
144
|
+
broken: number;
|
|
145
|
+
missing: number;
|
|
146
|
+
issues: string[];
|
|
147
|
+
}
|
|
148
|
+
interface DoctorReport {
|
|
149
|
+
sourceExists: boolean;
|
|
150
|
+
stateExists: boolean;
|
|
151
|
+
targets: Record<string, TargetReport>;
|
|
152
|
+
}
|
|
153
|
+
declare function doctorCommand(options: DoctorOptions, projectRoot: string, fs: FileSystem, _registry: AdapterRegistry): Promise<DoctorReport>;
|
|
154
|
+
|
|
155
|
+
export { AdapterRegistry, type CategoryTarget, type FileSystem, type FsStats, type IdeAdapter, type LinkMapping, type RootDocMapping, type SourceItems, type StoredMapping, type SyncResult, type SyncState, type TargetState, builtinAdapters, computeMappings, computeStoredMappings, createSymlinks, doctorCommand, hasChanges, initCommand, loadState, nodeFs, reconcileStaleLinks, removeSymlinks, saveState, scaffoldSourceDir, scanSourceDir, syncCommand, unlinkCommand };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AdapterRegistry,
|
|
3
|
+
builtinAdapters,
|
|
4
|
+
computeMappings,
|
|
5
|
+
computeStoredMappings,
|
|
6
|
+
createSymlinks,
|
|
7
|
+
doctorCommand,
|
|
8
|
+
hasChanges,
|
|
9
|
+
initCommand,
|
|
10
|
+
loadState,
|
|
11
|
+
nodeFs,
|
|
12
|
+
reconcileStaleLinks,
|
|
13
|
+
removeSymlinks,
|
|
14
|
+
saveState,
|
|
15
|
+
scaffoldSourceDir,
|
|
16
|
+
scanSourceDir,
|
|
17
|
+
syncCommand,
|
|
18
|
+
unlinkCommand
|
|
19
|
+
} from "./chunk-IYLBLNXA.js";
|
|
20
|
+
export {
|
|
21
|
+
AdapterRegistry,
|
|
22
|
+
builtinAdapters,
|
|
23
|
+
computeMappings,
|
|
24
|
+
computeStoredMappings,
|
|
25
|
+
createSymlinks,
|
|
26
|
+
doctorCommand,
|
|
27
|
+
hasChanges,
|
|
28
|
+
initCommand,
|
|
29
|
+
loadState,
|
|
30
|
+
nodeFs,
|
|
31
|
+
reconcileStaleLinks,
|
|
32
|
+
removeSymlinks,
|
|
33
|
+
saveState,
|
|
34
|
+
scaffoldSourceDir,
|
|
35
|
+
scanSourceDir,
|
|
36
|
+
syncCommand,
|
|
37
|
+
unlinkCommand
|
|
38
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@borasta/agentlink",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Manage symlinks from a canonical .ai folder to multiple agentic IDE configurations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"agentlink": "./bin/ag.js",
|
|
10
|
+
"ag": "./bin/ag.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"bin"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"dev": "tsup --watch",
|
|
19
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --config jest.config.ts",
|
|
20
|
+
"test:watch": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --config jest.config.ts --watch",
|
|
21
|
+
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --config jest.e2e.config.ts",
|
|
22
|
+
"test:all": "npm run test && npm run test:e2e",
|
|
23
|
+
"lint": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai",
|
|
31
|
+
"symlink",
|
|
32
|
+
"ide",
|
|
33
|
+
"cursor",
|
|
34
|
+
"claude",
|
|
35
|
+
"codex",
|
|
36
|
+
"windsurf",
|
|
37
|
+
"opencode",
|
|
38
|
+
"agentlink"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"commander": "^13.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/jest": "^30.0.0",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"jest": "^30.2.0",
|
|
48
|
+
"ts-jest": "^29.4.6",
|
|
49
|
+
"tsup": "^8.0.0",
|
|
50
|
+
"typescript": "^5.7.0"
|
|
51
|
+
}
|
|
52
|
+
}
|