@hurst05/dotagents 0.1.3

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/cli.js +1171 -0
  4. package/package.json +43 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ian Nuttall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ <div align="center">
2
+ <strong>dotagents</strong>
3
+ <br />
4
+ <em>One canonical .agents folder that powers all your AI tools.</em>
5
+
6
+ <br /><br />
7
+ <em>
8
+ Simple setup • One source of truth • Safe to re-run anytime
9
+ </em>
10
+ </div>
11
+
12
+ ## Quick Start
13
+
14
+ Requirements: Node.js 18+ and npm.
15
+
16
+ Run the guided CLI:
17
+ ```bash
18
+ npx @hurst05/dotagents
19
+ ```
20
+
21
+ Choose a workspace (Global home or Project folder), select the clients you want to manage, and follow the prompts. You can run it again anytime to repair links or undo changes.
22
+
23
+ Global home affects all projects. Project folder only affects the current directory you run dotagents from.
24
+
25
+ ## What it does
26
+
27
+ - Keeps `.agents` as the source of truth.
28
+ - Creates symlinks for Claude, Codex, Factory, Cursor, OpenCode, and Gemini (based on your selection).
29
+ - Always creates a backup before any overwrite so changes are reversible.
30
+
31
+ ## Where it links (global scope)
32
+
33
+ `.agents/CLAUDE.md` → `~/.claude/CLAUDE.md` (if present)
34
+
35
+ `.agents/AGENTS.md` → `~/.claude/CLAUDE.md` (fallback when no CLAUDE.md)
36
+
37
+ `.agents/GEMINI.md` → `~/.gemini/GEMINI.md` (if present)
38
+
39
+ `.agents/AGENTS.md` → `~/.gemini/GEMINI.md` (fallback when no GEMINI.md)
40
+
41
+ `.agents/commands` → `~/.claude/commands`
42
+
43
+ `.agents/commands` → `~/.factory/commands`
44
+
45
+ `.agents/commands` → `~/.cursor/commands`
46
+
47
+ `.agents/commands` → `~/.config/opencode/commands`
48
+
49
+ `.agents/commands` → `~/.gemini/commands`
50
+
51
+ `.agents/hooks` → `~/.claude/hooks`
52
+
53
+ `.agents/hooks` → `~/.factory/hooks`
54
+
55
+ `.agents/AGENTS.md` → `~/.factory/AGENTS.md`
56
+
57
+ `.agents/AGENTS.md` → `~/.codex/AGENTS.md`
58
+
59
+ `.agents/AGENTS.md` → `~/.config/opencode/AGENTS.md`
60
+
61
+ `.agents/skills` → `~/.claude/skills`
62
+
63
+ `.agents/skills` → `~/.factory/skills`
64
+
65
+ `.agents/skills` → `~/.gemini/config/skills` (Antigravity global scope)
66
+
67
+ Project scope links only commands/hooks/skills into the project’s client folders (no AGENTS/CLAUDE/GEMINI rules).
68
+
69
+ ## Development
70
+
71
+ Run the CLI in dev mode:
72
+ ```bash
73
+ npm run dev
74
+ ```
75
+
76
+ Type-check:
77
+ ```bash
78
+ npm run type-check
79
+ ```
80
+
81
+ Run tests:
82
+ ```bash
83
+ npm test
84
+ ```
85
+
86
+ Build the CLI:
87
+ ```bash
88
+ npm run build
89
+ ```
90
+
91
+ Install dependencies with `npm install`. To use an unpublished local checkout
92
+ from other project folders, run `npm run build && npm link` here, then run
93
+ `dotagents` from the target project.
94
+
95
+ To publish a public scoped release, sign in with `npm login`, update the version,
96
+ then run `npm publish --access public`. The publish hook runs tests, type-checking,
97
+ and the production build automatically.
98
+
99
+ ## Notes
100
+
101
+ - Cursor reads `.agents/skills` natively at both user and project scope. dotagents only links `.agents/commands` → `.cursor/commands`.
102
+ - OpenCode uses `~/.config/opencode/AGENTS.md` and prefers AGENTS.md over CLAUDE.md when both exist.
103
+ - Codex custom prompts are deprecated, so dotagents no longer creates `.codex/prompts` links. Existing prompts are still scanned during migration.
104
+ - Codex, Gemini CLI, OpenCode, and GitHub Copilot discover project-level `.agents/skills` natively, so dotagents does not create redundant project links for them.
105
+ - Google Antigravity uses `.agents/skills` inside an active workspace, but global skills live in `~/.gemini/config/skills`; global scope links that folder to the canonical `~/.agents/skills`.
106
+ - Skills require a valid `SKILL.md` with `name` + `description` frontmatter.
107
+ - Claude prompt precedence: if `.agents/CLAUDE.md` exists, it links to `.claude/CLAUDE.md`. Otherwise `.agents/AGENTS.md` is used. After adding or removing `.agents/CLAUDE.md`, re-run dotagents and apply/repair links to update the symlink. Factory/Codex always link to `.agents/AGENTS.md`.
108
+ - Gemini context file precedence: if `.agents/GEMINI.md` exists, it links to `.gemini/GEMINI.md`. Otherwise `.agents/AGENTS.md` is used. After adding or removing `.agents/GEMINI.md`, re-run dotagents and apply/repair links to update the symlink.
109
+ - Project scope creates `.agents` plus client folders for commands/hooks/skills only. Rule files (`AGENTS.md`/`CLAUDE.md`/`GEMINI.md`) are left to the repo root so you can manage them explicitly.
110
+ - Backups are stored under `.agents/backup/<timestamp>` and can be restored via “Undo last change.”
111
+
112
+ ## License
113
+
114
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,1171 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.tsx
4
+ import path13 from "path";
5
+ import chalk from "chalk";
6
+ import { intro, outro, select, confirm, note, spinner, isCancel, cancel, multiselect } from "@clack/prompts";
7
+
8
+ // src/core/plan.ts
9
+ import fs2 from "fs";
10
+ import path5 from "path";
11
+
12
+ // src/core/mappings.ts
13
+ import path3 from "path";
14
+
15
+ // src/core/paths.ts
16
+ import os from "os";
17
+ import path from "path";
18
+ function resolveRoots(opts) {
19
+ const homeDir = opts.homeDir || os.homedir();
20
+ const projectRoot = path.resolve(opts.projectRoot || process.cwd());
21
+ if (opts.scope === "global") {
22
+ return {
23
+ canonicalRoot: path.join(homeDir, ".agents"),
24
+ claudeRoot: path.join(homeDir, ".claude"),
25
+ factoryRoot: path.join(homeDir, ".factory"),
26
+ codexRoot: path.join(homeDir, ".codex"),
27
+ cursorRoot: path.join(homeDir, ".cursor"),
28
+ opencodeRoot: path.join(homeDir, ".config", "opencode"),
29
+ opencodeConfigRoot: path.join(homeDir, ".config", "opencode"),
30
+ geminiRoot: path.join(homeDir, ".gemini"),
31
+ githubRoot: path.join(projectRoot, ".github"),
32
+ copilotRoot: path.join(homeDir, ".copilot"),
33
+ ampcodeConfigRoot: path.join(homeDir, ".config", "amp"),
34
+ projectRoot,
35
+ homeDir
36
+ };
37
+ }
38
+ return {
39
+ canonicalRoot: path.join(projectRoot, ".agents"),
40
+ claudeRoot: path.join(projectRoot, ".claude"),
41
+ factoryRoot: path.join(projectRoot, ".factory"),
42
+ codexRoot: path.join(projectRoot, ".codex"),
43
+ cursorRoot: path.join(projectRoot, ".cursor"),
44
+ opencodeRoot: path.join(projectRoot, ".opencode"),
45
+ opencodeConfigRoot: path.join(homeDir, ".config", "opencode"),
46
+ geminiRoot: path.join(projectRoot, ".gemini"),
47
+ githubRoot: path.join(projectRoot, ".github"),
48
+ copilotRoot: path.join(homeDir, ".copilot"),
49
+ ampcodeConfigRoot: path.join(homeDir, ".config", "amp"),
50
+ projectRoot,
51
+ homeDir
52
+ };
53
+ }
54
+
55
+ // src/utils/fs.ts
56
+ import fs from "fs";
57
+ import path2 from "path";
58
+ async function pathExists(p) {
59
+ try {
60
+ await fs.promises.lstat(p);
61
+ return true;
62
+ } catch (err) {
63
+ if (err && err.code === "ENOENT") return false;
64
+ throw err;
65
+ }
66
+ }
67
+ async function ensureDir(dir) {
68
+ await fs.promises.mkdir(dir, { recursive: true });
69
+ }
70
+ async function ensureFile(filePath, content) {
71
+ await ensureDir(path2.dirname(filePath));
72
+ await fs.promises.writeFile(filePath, content, "utf8");
73
+ }
74
+ async function readText(filePath) {
75
+ return await fs.promises.readFile(filePath, "utf8");
76
+ }
77
+ async function copyFile(src, dest, force = false) {
78
+ if (!force && await pathExists(dest)) return "skipped";
79
+ await ensureDir(path2.dirname(dest));
80
+ await fs.promises.copyFile(src, dest);
81
+ return "written";
82
+ }
83
+ async function copyDir(src, dest, force = false) {
84
+ if (!force && await pathExists(dest)) return "skipped";
85
+ await ensureDir(path2.dirname(dest));
86
+ await fs.promises.cp(src, dest, { recursive: true, force });
87
+ return "written";
88
+ }
89
+ async function removePath(target) {
90
+ if (!await pathExists(target)) return;
91
+ const stat = await fs.promises.lstat(target);
92
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
93
+ await fs.promises.rm(target, { recursive: true, force: true });
94
+ } else {
95
+ await fs.promises.unlink(target);
96
+ }
97
+ }
98
+ async function listDirs(dir) {
99
+ try {
100
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
101
+ return entries.filter((e) => e.isDirectory()).map((e) => path2.join(dir, e.name));
102
+ } catch (err) {
103
+ if (err && err.code === "ENOENT") return [];
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ // src/core/mappings.ts
109
+ async function getMappings(opts) {
110
+ const roots = resolveRoots(opts);
111
+ const canonical = roots.canonicalRoot;
112
+ const claudeOverride = path3.join(canonical, "CLAUDE.md");
113
+ const geminiOverride = path3.join(canonical, "GEMINI.md");
114
+ const agentsFallback = path3.join(canonical, "AGENTS.md");
115
+ const claudeSource = await pathExists(claudeOverride) ? claudeOverride : agentsFallback;
116
+ const geminiSource = await pathExists(geminiOverride) ? geminiOverride : agentsFallback;
117
+ const clients = new Set(opts.clients ?? ["claude", "factory", "codex", "cursor", "opencode", "gemini", "github", "ampcode"]);
118
+ const mappings = [];
119
+ const includeAgentFiles = opts.scope === "global";
120
+ if (includeAgentFiles && clients.has("claude")) {
121
+ mappings.push({
122
+ name: "claude-md",
123
+ source: claudeSource,
124
+ targets: [path3.join(roots.claudeRoot, "CLAUDE.md")],
125
+ kind: "file"
126
+ });
127
+ }
128
+ if (includeAgentFiles && clients.has("gemini")) {
129
+ mappings.push({
130
+ name: "gemini-md",
131
+ source: geminiSource,
132
+ targets: [path3.join(roots.geminiRoot, "GEMINI.md")],
133
+ kind: "file"
134
+ });
135
+ }
136
+ if (includeAgentFiles) {
137
+ const agentTargets = [
138
+ clients.has("factory") ? path3.join(roots.factoryRoot, "AGENTS.md") : null,
139
+ clients.has("codex") ? path3.join(roots.codexRoot, "AGENTS.md") : null,
140
+ clients.has("opencode") ? path3.join(roots.opencodeConfigRoot, "AGENTS.md") : null,
141
+ clients.has("ampcode") ? path3.join(roots.ampcodeConfigRoot, "AGENTS.md") : null
142
+ ].filter(Boolean);
143
+ if (agentTargets.length > 0) {
144
+ mappings.push({
145
+ name: "agents-md",
146
+ source: agentsFallback,
147
+ targets: agentTargets,
148
+ kind: "file"
149
+ });
150
+ }
151
+ }
152
+ mappings.push(
153
+ {
154
+ name: "commands",
155
+ source: path3.join(canonical, "commands"),
156
+ targets: [
157
+ clients.has("claude") ? path3.join(roots.claudeRoot, "commands") : null,
158
+ clients.has("factory") ? path3.join(roots.factoryRoot, "commands") : null,
159
+ clients.has("opencode") ? path3.join(roots.opencodeRoot, "commands") : null,
160
+ clients.has("cursor") ? path3.join(roots.cursorRoot, "commands") : null,
161
+ clients.has("gemini") ? path3.join(roots.geminiRoot, "commands") : null
162
+ ].filter(Boolean),
163
+ kind: "dir"
164
+ },
165
+ {
166
+ name: "hooks",
167
+ source: path3.join(canonical, "hooks"),
168
+ targets: [
169
+ clients.has("claude") ? path3.join(roots.claudeRoot, "hooks") : null,
170
+ clients.has("factory") ? path3.join(roots.factoryRoot, "hooks") : null
171
+ ].filter(Boolean),
172
+ kind: "dir"
173
+ },
174
+ {
175
+ name: "skills",
176
+ source: path3.join(canonical, "skills"),
177
+ targets: [
178
+ clients.has("claude") ? path3.join(roots.claudeRoot, "skills") : null,
179
+ clients.has("factory") ? path3.join(roots.factoryRoot, "skills") : null,
180
+ clients.has("gemini") && opts.scope === "global" ? path3.join(roots.geminiRoot, "config", "skills") : null
181
+ ].filter(Boolean),
182
+ kind: "dir"
183
+ }
184
+ );
185
+ return mappings;
186
+ }
187
+
188
+ // src/core/link-target.ts
189
+ import path4 from "path";
190
+ function getLinkTarget(source, target, kind) {
191
+ const resolvedSource = path4.resolve(source);
192
+ if (process.platform === "win32" && kind === "dir") {
193
+ return { link: resolvedSource, isRelative: false };
194
+ }
195
+ const relative = path4.relative(path4.dirname(target), resolvedSource);
196
+ if (!relative || path4.isAbsolute(relative)) {
197
+ return { link: resolvedSource, isRelative: false };
198
+ }
199
+ const resolvedRelative = path4.resolve(path4.dirname(target), relative);
200
+ if (resolvedRelative !== resolvedSource) {
201
+ return { link: resolvedSource, isRelative: false };
202
+ }
203
+ return { link: relative, isRelative: true };
204
+ }
205
+
206
+ // src/core/plan.ts
207
+ async function getLinkTargetAbsolute(targetPath) {
208
+ try {
209
+ const link = await fs2.promises.readlink(targetPath);
210
+ if (!path5.isAbsolute(link)) {
211
+ return path5.resolve(path5.dirname(targetPath), link);
212
+ }
213
+ return link;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ async function ensureSourceTask(source, kind) {
219
+ const exists = await pathExists(source);
220
+ if (!exists) return [{ type: "ensure-source", path: source, kind }];
221
+ const stat = await fs2.promises.lstat(source);
222
+ if (kind === "file" && stat.isDirectory()) {
223
+ return [{ type: "conflict", source, target: source, reason: "Expected file but found directory", kind }];
224
+ }
225
+ if (kind === "dir" && !stat.isDirectory()) {
226
+ return [{ type: "conflict", source, target: source, reason: "Expected directory but found file", kind }];
227
+ }
228
+ return [];
229
+ }
230
+ async function analyzeTarget(source, target, kind, opts) {
231
+ const exists = await pathExists(target);
232
+ if (!exists) return { type: "link", source, target, kind };
233
+ const stat = await fs2.promises.lstat(target);
234
+ if (stat.isSymbolicLink()) {
235
+ const rawLink = await fs2.promises.readlink(target);
236
+ const resolved = await getLinkTargetAbsolute(target);
237
+ if (resolved && path5.resolve(resolved) === path5.resolve(source)) {
238
+ const desired = getLinkTarget(source, target, kind);
239
+ if (desired.isRelative && path5.isAbsolute(rawLink)) {
240
+ return { type: "link", source, target, kind, replaceSymlink: true };
241
+ }
242
+ return { type: "noop", source, target };
243
+ }
244
+ if (resolved && opts?.relinkableSources) {
245
+ const relinkable = new Set(opts.relinkableSources.map((p) => path5.resolve(p)));
246
+ if (relinkable.has(path5.resolve(resolved))) {
247
+ return { type: "link", source, target, kind, replaceSymlink: true };
248
+ }
249
+ }
250
+ const detail = resolved ? `Symlink points elsewhere: ${resolved}` : "Symlink points elsewhere";
251
+ return { type: "conflict", source, target, reason: detail, kind };
252
+ }
253
+ const targetKind = stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "path";
254
+ return { type: "conflict", source, target, reason: `Target exists and is not a symlink (${targetKind})`, kind };
255
+ }
256
+ async function buildLinkPlan(opts) {
257
+ const mappings = await getMappings(opts);
258
+ const tasks = [];
259
+ for (const mapping of mappings) {
260
+ tasks.push(...await ensureSourceTask(mapping.source, mapping.kind));
261
+ let relinkableSources;
262
+ if (mapping.name === "claude-md") {
263
+ relinkableSources = [
264
+ path5.join(path5.dirname(mapping.source), "AGENTS.md"),
265
+ path5.join(path5.dirname(mapping.source), "CLAUDE.md")
266
+ ];
267
+ } else if (mapping.name === "gemini-md") {
268
+ relinkableSources = [
269
+ path5.join(path5.dirname(mapping.source), "AGENTS.md"),
270
+ path5.join(path5.dirname(mapping.source), "GEMINI.md")
271
+ ];
272
+ }
273
+ for (const target of mapping.targets) {
274
+ tasks.push(await analyzeTarget(mapping.source, target, mapping.kind, { relinkableSources }));
275
+ }
276
+ }
277
+ const conflicts = tasks.filter((t) => t.type === "conflict");
278
+ const changes = tasks.filter((t) => t.type === "link" || t.type === "ensure-source");
279
+ return { tasks, conflicts, changes };
280
+ }
281
+
282
+ // src/core/status.ts
283
+ import fs3 from "fs";
284
+ import path6 from "path";
285
+ async function resolveLinkTarget(targetPath) {
286
+ try {
287
+ const link = await fs3.promises.readlink(targetPath);
288
+ if (!path6.isAbsolute(link)) return path6.resolve(path6.dirname(targetPath), link);
289
+ return link;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+ async function getLinkStatus(opts) {
295
+ const mappings = await getMappings(opts);
296
+ const statuses = [];
297
+ for (const mapping of mappings) {
298
+ const targets = [];
299
+ for (const target of mapping.targets) {
300
+ const exists = await pathExists(target);
301
+ if (!exists) {
302
+ targets.push({ path: target, status: "missing" });
303
+ continue;
304
+ }
305
+ const stat = await fs3.promises.lstat(target);
306
+ if (stat.isSymbolicLink()) {
307
+ const resolved = await resolveLinkTarget(target);
308
+ if (resolved && path6.resolve(resolved) === path6.resolve(mapping.source)) {
309
+ targets.push({ path: target, status: "linked" });
310
+ } else {
311
+ targets.push({ path: target, status: "conflict" });
312
+ }
313
+ continue;
314
+ }
315
+ targets.push({ path: target, status: "conflict" });
316
+ }
317
+ statuses.push({ name: mapping.name, source: mapping.source, targets });
318
+ }
319
+ return statuses;
320
+ }
321
+
322
+ // src/core/migrate.ts
323
+ import fs6 from "fs";
324
+ import path10 from "path";
325
+
326
+ // src/core/skills.ts
327
+ import path7 from "path";
328
+ import matter from "gray-matter";
329
+ var NAME_RE = /^[a-z0-9-]{1,64}$/;
330
+ async function parseSkillFile(skillFile) {
331
+ const raw = await readText(skillFile);
332
+ const parsed = matter(raw);
333
+ const data = parsed.data;
334
+ const name = String(data?.name || "").trim();
335
+ const description = String(data?.description || "").trim();
336
+ if (!name || !description) {
337
+ throw new Error(`SKILL.md missing required frontmatter fields (name, description): ${skillFile}`);
338
+ }
339
+ if (!NAME_RE.test(name)) {
340
+ throw new Error(`Skill name must be lowercase letters, numbers, and hyphens (max 64): ${name}`);
341
+ }
342
+ return {
343
+ name,
344
+ description,
345
+ allowedTools: Array.isArray(data?.["allowed-tools"]) ? data["allowed-tools"] : void 0,
346
+ model: typeof data?.model === "string" ? data.model : void 0
347
+ };
348
+ }
349
+ async function isSkillDir(dir) {
350
+ return await pathExists(path7.join(dir, "SKILL.md"));
351
+ }
352
+ async function findSkillDirs(root) {
353
+ const direct = await isSkillDir(root);
354
+ if (direct) return [root];
355
+ const skillsDir = path7.join(root, "skills");
356
+ const skillsDirExists = await pathExists(skillsDir);
357
+ if (skillsDirExists) {
358
+ const children2 = await listDirs(skillsDir);
359
+ const matches2 = [];
360
+ for (const child of children2) {
361
+ if (await isSkillDir(child)) matches2.push(child);
362
+ }
363
+ if (matches2.length) return matches2;
364
+ }
365
+ const children = await listDirs(root);
366
+ const matches = [];
367
+ for (const child of children) {
368
+ if (await isSkillDir(child)) matches.push(child);
369
+ }
370
+ return matches;
371
+ }
372
+
373
+ // src/core/apply.ts
374
+ import fs5 from "fs";
375
+ import path9 from "path";
376
+
377
+ // src/core/backup.ts
378
+ import fs4 from "fs";
379
+ import path8 from "path";
380
+ var MANIFEST_NAME = "manifest.json";
381
+ function backupPathFor(target, backupRoot) {
382
+ const root = path8.parse(target).root || path8.sep;
383
+ const rel = path8.relative(root, target);
384
+ return path8.join(backupRoot, rel);
385
+ }
386
+ function hasParentPath(target, seen) {
387
+ const resolved = path8.resolve(target);
388
+ for (const parent of seen) {
389
+ if (resolved === parent) return true;
390
+ if (resolved.startsWith(parent + path8.sep)) return true;
391
+ }
392
+ return false;
393
+ }
394
+ async function backupSymlink(target, dest) {
395
+ const link = await fs4.promises.readlink(target);
396
+ await ensureDir(path8.dirname(dest));
397
+ await fs4.promises.symlink(link, dest);
398
+ await fs4.promises.unlink(target);
399
+ }
400
+ async function backupPathImpl(target, dest, kind) {
401
+ await ensureDir(path8.dirname(dest));
402
+ try {
403
+ await fs4.promises.rename(target, dest);
404
+ return;
405
+ } catch (err) {
406
+ if (err?.code !== "EXDEV") throw err;
407
+ }
408
+ if (kind === "dir") {
409
+ await copyDir(target, dest, true);
410
+ } else {
411
+ await copyFile(target, dest, true);
412
+ }
413
+ await removePath(target);
414
+ }
415
+ async function createBackupSession(opts) {
416
+ const timestamp = opts.timestamp || (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
417
+ const dir = path8.join(opts.canonicalRoot, "backup", timestamp);
418
+ await ensureDir(dir);
419
+ return {
420
+ dir,
421
+ manifestPath: path8.join(dir, MANIFEST_NAME),
422
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
423
+ scope: opts.scope,
424
+ operation: opts.operation,
425
+ entries: [],
426
+ _seen: /* @__PURE__ */ new Set()
427
+ };
428
+ }
429
+ async function backupPath(target, session) {
430
+ if (!await pathExists(target)) return false;
431
+ const resolved = path8.resolve(target);
432
+ if (hasParentPath(resolved, session._seen)) return false;
433
+ const stat = await fs4.promises.lstat(target);
434
+ const kind = stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "dir" : "file";
435
+ const dest = backupPathFor(target, session.dir);
436
+ if (stat.isSymbolicLink()) {
437
+ await backupSymlink(target, dest);
438
+ } else {
439
+ await backupPathImpl(target, dest, kind);
440
+ }
441
+ session.entries.push({ originalPath: resolved, backupPath: dest, kind, action: "backup" });
442
+ session._seen.add(resolved);
443
+ return true;
444
+ }
445
+ function recordCreatedPath(target, kind, session) {
446
+ const resolved = path8.resolve(target);
447
+ if (hasParentPath(resolved, session._seen)) return;
448
+ session.entries.push({ originalPath: resolved, kind, action: "create" });
449
+ session._seen.add(resolved);
450
+ }
451
+ async function finalizeBackup(session) {
452
+ const manifest = {
453
+ version: 1,
454
+ createdAt: session.createdAt,
455
+ scope: session.scope,
456
+ operation: session.operation,
457
+ entries: session.entries
458
+ };
459
+ await fs4.promises.writeFile(session.manifestPath, JSON.stringify(manifest, null, 2), "utf8");
460
+ }
461
+ async function loadBackupManifest(dir) {
462
+ const manifestPath = path8.join(dir, MANIFEST_NAME);
463
+ if (!await pathExists(manifestPath)) return null;
464
+ const raw = await fs4.promises.readFile(manifestPath, "utf8");
465
+ return JSON.parse(raw);
466
+ }
467
+
468
+ // src/core/apply.ts
469
+ var DEFAULT_AGENTS = `# AGENTS
470
+
471
+ Add shared agent instructions here.
472
+ `;
473
+ async function createSource(task) {
474
+ if (task.kind === "dir") {
475
+ await ensureDir(task.path);
476
+ return;
477
+ }
478
+ await ensureFile(task.path, DEFAULT_AGENTS);
479
+ }
480
+ async function createLink(source, target, kind, overwrite, backup) {
481
+ const linkTarget = getLinkTarget(source, target, kind);
482
+ if (await pathExists(target)) {
483
+ if (!overwrite) return { created: false, backedUp: false };
484
+ const backedUp = backup ? await backupPath(target, backup) : false;
485
+ if (await pathExists(target)) await removePath(target);
486
+ await ensureDir(path9.dirname(target));
487
+ const type2 = kind === "dir" ? "junction" : "file";
488
+ await fs5.promises.symlink(linkTarget.link, target, type2);
489
+ return { created: true, backedUp };
490
+ }
491
+ await ensureDir(path9.dirname(target));
492
+ const type = kind === "dir" ? "junction" : "file";
493
+ await fs5.promises.symlink(linkTarget.link, target, type);
494
+ return { created: true, backedUp: false };
495
+ }
496
+ async function applyLinkPlan(plan, opts) {
497
+ const force = !!opts?.force;
498
+ const backup = opts.backup;
499
+ if (!backup) throw new Error("Backup session required.");
500
+ let applied = 0;
501
+ let skipped = 0;
502
+ let conflicts = 0;
503
+ let backedUp = 0;
504
+ for (const task of plan.tasks) {
505
+ if (task.type === "conflict") {
506
+ conflicts += 1;
507
+ if (force && task.target !== task.source && task.kind) {
508
+ const result = await createLink(task.source, task.target, task.kind, true, backup);
509
+ if (result.backedUp) backedUp += 1;
510
+ applied += 1;
511
+ }
512
+ continue;
513
+ }
514
+ if (task.type === "noop") {
515
+ skipped += 1;
516
+ continue;
517
+ }
518
+ if (task.type === "ensure-source") {
519
+ await createSource(task);
520
+ recordCreatedPath(task.path, task.kind, backup);
521
+ applied += 1;
522
+ continue;
523
+ }
524
+ if (task.type === "link") {
525
+ const before = await pathExists(task.target);
526
+ let useForce = force;
527
+ if (!useForce && task.replaceSymlink && before) {
528
+ try {
529
+ const stat = await fs5.promises.lstat(task.target);
530
+ if (stat.isSymbolicLink()) useForce = true;
531
+ } catch {
532
+ }
533
+ }
534
+ const result = await createLink(task.source, task.target, task.kind, useForce, backup);
535
+ if (result.backedUp) backedUp += 1;
536
+ if (!before && result.created) {
537
+ recordCreatedPath(task.target, "symlink", backup);
538
+ }
539
+ if (before && !useForce) skipped += 1;
540
+ else applied += 1;
541
+ }
542
+ }
543
+ return { applied, skipped, conflicts, backupDir: backup.dir, backedUp };
544
+ }
545
+
546
+ // src/core/migrate.ts
547
+ async function isSymlink(p) {
548
+ try {
549
+ const stat = await fs6.promises.lstat(p);
550
+ return stat.isSymbolicLink();
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
555
+ async function listFiles(dir) {
556
+ try {
557
+ const entries = await fs6.promises.readdir(dir, { withFileTypes: true });
558
+ return entries.filter((e) => e.isFile()).map((e) => path10.join(dir, e.name));
559
+ } catch {
560
+ return [];
561
+ }
562
+ }
563
+ function conflictLabel(targetPath, canonicalRoot) {
564
+ if (targetPath.startsWith(canonicalRoot)) {
565
+ const rel = path10.relative(canonicalRoot, targetPath);
566
+ return rel || path10.basename(targetPath);
567
+ }
568
+ return path10.basename(targetPath);
569
+ }
570
+ async function scanMigration(opts) {
571
+ const roots = resolveRoots(opts);
572
+ const canonicalRoot = roots.canonicalRoot;
573
+ const candidatesByTarget = /* @__PURE__ */ new Map();
574
+ const clients = new Set(opts.clients ?? ["claude", "factory", "codex", "cursor", "opencode", "gemini", "github", "ampcode"]);
575
+ const includeAgentFiles = opts.scope === "global";
576
+ const canonicalCommands = path10.join(canonicalRoot, "commands");
577
+ const canonicalHooks = path10.join(canonicalRoot, "hooks");
578
+ const canonicalSkills = path10.join(canonicalRoot, "skills");
579
+ const canonicalAgents = path10.join(canonicalRoot, "AGENTS.md");
580
+ const canonicalClaude = path10.join(canonicalRoot, "CLAUDE.md");
581
+ const opencodeSkillsRoot = opts.scope === "global" ? roots.opencodeConfigRoot : roots.opencodeRoot;
582
+ const sources = {
583
+ commands: [
584
+ clients.has("claude") ? { label: "Claude commands", dir: path10.join(roots.claudeRoot, "commands") } : null,
585
+ clients.has("factory") ? { label: "Factory commands", dir: path10.join(roots.factoryRoot, "commands") } : null,
586
+ clients.has("codex") ? { label: "Codex prompts", dir: path10.join(roots.codexRoot, "prompts") } : null,
587
+ clients.has("cursor") ? { label: "Cursor commands", dir: path10.join(roots.cursorRoot, "commands") } : null,
588
+ clients.has("opencode") ? { label: "OpenCode commands", dir: path10.join(roots.opencodeRoot, "commands") } : null,
589
+ clients.has("gemini") ? { label: "Gemini commands", dir: path10.join(roots.geminiRoot, "commands") } : null
590
+ ].filter(Boolean),
591
+ hooks: [
592
+ clients.has("claude") ? { label: "Claude hooks", dir: path10.join(roots.claudeRoot, "hooks") } : null,
593
+ clients.has("factory") ? { label: "Factory hooks", dir: path10.join(roots.factoryRoot, "hooks") } : null
594
+ ].filter(Boolean),
595
+ skills: [
596
+ clients.has("claude") ? { label: "Claude skills", dir: path10.join(roots.claudeRoot, "skills") } : null,
597
+ clients.has("factory") ? { label: "Factory skills", dir: path10.join(roots.factoryRoot, "skills") } : null,
598
+ clients.has("codex") ? { label: "Codex skills", dir: path10.join(roots.codexRoot, "skills") } : null,
599
+ clients.has("cursor") ? { label: "Cursor skills", dir: path10.join(roots.cursorRoot, "skills") } : null,
600
+ clients.has("opencode") ? { label: "OpenCode skills", dir: path10.join(opencodeSkillsRoot, "skills") } : null,
601
+ clients.has("gemini") && opts.scope === "global" ? { label: "Gemini global skills", dir: path10.join(roots.geminiRoot, "config", "skills") } : null,
602
+ clients.has("gemini") ? { label: "Gemini legacy skills", dir: path10.join(roots.geminiRoot, "skills") } : null,
603
+ // GitHub uses .github/skills for project scope and ~/.copilot/skills for global scope.
604
+ clients.has("github") ? opts.scope === "global" ? { label: "GitHub Copilot skills", dir: path10.join(roots.copilotRoot, "skills") } : { label: "GitHub skills", dir: path10.join(roots.githubRoot, "skills") } : null
605
+ ].filter(Boolean),
606
+ agents: includeAgentFiles ? [
607
+ clients.has("claude") ? { label: "Claude AGENTS.md", file: path10.join(roots.claudeRoot, "AGENTS.md") } : null,
608
+ clients.has("factory") ? { label: "Factory AGENTS.md", file: path10.join(roots.factoryRoot, "AGENTS.md") } : null,
609
+ clients.has("codex") ? { label: "Codex AGENTS.md", file: path10.join(roots.codexRoot, "AGENTS.md") } : null,
610
+ clients.has("opencode") ? { label: "OpenCode AGENTS.md", file: path10.join(roots.opencodeConfigRoot, "AGENTS.md") } : null,
611
+ clients.has("ampcode") ? { label: "Ampcode AGENTS.md", file: path10.join(roots.ampcodeConfigRoot, "AGENTS.md") } : null
612
+ ].filter(Boolean) : [],
613
+ claude: includeAgentFiles ? [
614
+ clients.has("claude") ? { label: "Claude CLAUDE.md", file: path10.join(roots.claudeRoot, "CLAUDE.md") } : null
615
+ ].filter(Boolean) : [],
616
+ gemini: includeAgentFiles ? [
617
+ clients.has("gemini") ? { label: "Gemini GEMINI.md", file: path10.join(roots.geminiRoot, "GEMINI.md") } : null
618
+ ].filter(Boolean) : []
619
+ };
620
+ const addCandidate = (candidate) => {
621
+ const list = candidatesByTarget.get(candidate.targetPath) || [];
622
+ list.push(candidate);
623
+ candidatesByTarget.set(candidate.targetPath, list);
624
+ };
625
+ for (const src of sources.commands) {
626
+ if (!await pathExists(src.dir) || await isSymlink(src.dir)) continue;
627
+ const files = await listFiles(src.dir);
628
+ for (const file of files) {
629
+ const targetPath = path10.join(canonicalCommands, path10.basename(file));
630
+ addCandidate({ label: src.label, targetPath, kind: "file", action: "copy", sourcePath: file });
631
+ }
632
+ }
633
+ for (const src of sources.hooks) {
634
+ if (!await pathExists(src.dir) || await isSymlink(src.dir)) continue;
635
+ const files = await listFiles(src.dir);
636
+ for (const file of files) {
637
+ const targetPath = path10.join(canonicalHooks, path10.basename(file));
638
+ addCandidate({ label: src.label, targetPath, kind: "file", action: "copy", sourcePath: file });
639
+ }
640
+ }
641
+ for (const src of sources.skills) {
642
+ if (!await pathExists(src.dir) || await isSymlink(src.dir)) continue;
643
+ const skillDirs = await findSkillDirs(src.dir);
644
+ for (const dir of skillDirs) {
645
+ try {
646
+ const meta = await parseSkillFile(path10.join(dir, "SKILL.md"));
647
+ const targetPath = path10.join(canonicalSkills, meta.name);
648
+ addCandidate({ label: src.label, targetPath, kind: "dir", action: "copy", sourcePath: dir });
649
+ } catch {
650
+ }
651
+ }
652
+ }
653
+ for (const src of sources.agents) {
654
+ if (!await pathExists(src.file) || await isSymlink(src.file)) continue;
655
+ addCandidate({ label: src.label, targetPath: canonicalAgents, kind: "file", action: "copy", sourcePath: src.file });
656
+ }
657
+ for (const src of sources.claude) {
658
+ if (!await pathExists(src.file) || await isSymlink(src.file)) continue;
659
+ addCandidate({ label: src.label, targetPath: canonicalClaude, kind: "file", action: "copy", sourcePath: src.file });
660
+ }
661
+ const canonicalGemini = path10.join(canonicalRoot, "GEMINI.md");
662
+ for (const src of sources.gemini) {
663
+ if (!await pathExists(src.file) || await isSymlink(src.file)) continue;
664
+ addCandidate({ label: src.label, targetPath: canonicalGemini, kind: "file", action: "copy", sourcePath: src.file });
665
+ }
666
+ const auto = [];
667
+ const conflicts = [];
668
+ for (const [targetPath, list] of candidatesByTarget.entries()) {
669
+ const canonicalExists = await pathExists(targetPath);
670
+ if (canonicalExists) {
671
+ let kind = "file";
672
+ try {
673
+ const stat = await fs6.promises.lstat(targetPath);
674
+ kind = stat.isDirectory() ? "dir" : "file";
675
+ } catch {
676
+ }
677
+ list.unshift({
678
+ label: "Keep existing (.agents)",
679
+ targetPath,
680
+ kind,
681
+ action: "keep"
682
+ });
683
+ }
684
+ if (list.length === 1 && !canonicalExists) {
685
+ const only = list[0];
686
+ if (only) auto.push(only);
687
+ continue;
688
+ }
689
+ conflicts.push({
690
+ label: conflictLabel(targetPath, canonicalRoot),
691
+ targetPath,
692
+ candidates: list
693
+ });
694
+ }
695
+ return { auto, conflicts, canonicalRoot };
696
+ }
697
+ async function applyMigration(plan, selections, opts) {
698
+ const backup = opts.backup;
699
+ if (!backup) throw new Error("Backup session required.");
700
+ let copied = 0;
701
+ let skipped = 0;
702
+ const copyCandidate = async (candidate) => {
703
+ if (candidate.action !== "copy" || !candidate.sourcePath) return false;
704
+ const existed = await pathExists(candidate.targetPath);
705
+ if (existed) {
706
+ await backupPath(candidate.targetPath, backup);
707
+ } else {
708
+ recordCreatedPath(candidate.targetPath, candidate.kind === "dir" ? "dir" : "file", backup);
709
+ }
710
+ if (candidate.kind === "file") {
711
+ await copyFile(candidate.sourcePath, candidate.targetPath, true);
712
+ } else {
713
+ await copyDir(candidate.sourcePath, candidate.targetPath, true);
714
+ }
715
+ return true;
716
+ };
717
+ for (const candidate of plan.auto) {
718
+ if (await copyCandidate(candidate)) copied += 1;
719
+ else skipped += 1;
720
+ }
721
+ for (const conflict of plan.conflicts) {
722
+ const choice = selections.get(conflict.targetPath);
723
+ if (!choice || choice.action !== "copy") {
724
+ skipped += 1;
725
+ continue;
726
+ }
727
+ if (await copyCandidate(choice)) copied += 1;
728
+ else skipped += 1;
729
+ }
730
+ const linkPlan = await buildLinkPlan(opts);
731
+ const linkResult = await applyLinkPlan(linkPlan, { force: !!opts.forceLinks, backup });
732
+ return {
733
+ copied,
734
+ skipped,
735
+ backupDir: backup.dir,
736
+ links: {
737
+ applied: linkResult.applied,
738
+ skipped: linkResult.skipped,
739
+ conflicts: linkResult.conflicts,
740
+ backedUp: linkResult.backedUp
741
+ }
742
+ };
743
+ }
744
+
745
+ // src/core/undo.ts
746
+ import fs7 from "fs";
747
+ import path11 from "path";
748
+ async function listBackupDirs(canonicalRoot) {
749
+ const root = path11.join(canonicalRoot, "backup");
750
+ if (!await pathExists(root)) return [];
751
+ const entries = await fs7.promises.readdir(root, { withFileTypes: true });
752
+ return entries.filter((e) => e.isDirectory()).map((e) => path11.join(root, e.name));
753
+ }
754
+ async function findLatestBackup(canonicalRoot) {
755
+ const dirs = await listBackupDirs(canonicalRoot);
756
+ const manifests = [];
757
+ for (const dir of dirs) {
758
+ const manifest = await loadBackupManifest(dir);
759
+ if (manifest) manifests.push({ dir, manifest });
760
+ }
761
+ if (!manifests.length) return null;
762
+ manifests.sort((a, b) => a.manifest.createdAt.localeCompare(b.manifest.createdAt));
763
+ return manifests[manifests.length - 1] || null;
764
+ }
765
+ async function restoreSymlink(entry) {
766
+ if (!entry.backupPath) return;
767
+ const link = await fs7.promises.readlink(entry.backupPath);
768
+ await ensureDir(path11.dirname(entry.originalPath));
769
+ await fs7.promises.symlink(link, entry.originalPath);
770
+ await fs7.promises.unlink(entry.backupPath);
771
+ }
772
+ async function restorePath(entry) {
773
+ if (entry.action === "create") {
774
+ await removePath(entry.originalPath);
775
+ return;
776
+ }
777
+ if (!entry.backupPath) return;
778
+ if (entry.kind === "symlink") {
779
+ await restoreSymlink(entry);
780
+ return;
781
+ }
782
+ await ensureDir(path11.dirname(entry.originalPath));
783
+ try {
784
+ await fs7.promises.rename(entry.backupPath, entry.originalPath);
785
+ return;
786
+ } catch (err) {
787
+ if (err?.code !== "EXDEV") throw err;
788
+ }
789
+ if (entry.kind === "dir") {
790
+ await fs7.promises.cp(entry.backupPath, entry.originalPath, { recursive: true, force: true });
791
+ } else {
792
+ await fs7.promises.copyFile(entry.backupPath, entry.originalPath);
793
+ }
794
+ await removePath(entry.backupPath);
795
+ }
796
+ async function backupExistingOriginals(entries, session) {
797
+ for (const entry of entries) {
798
+ if (!await pathExists(entry.originalPath)) continue;
799
+ await backupPath(entry.originalPath, session);
800
+ }
801
+ }
802
+ async function undoLastChange(opts) {
803
+ const roots = resolveRoots(opts);
804
+ const latest = await findLatestBackup(roots.canonicalRoot);
805
+ if (!latest) throw new Error("No backups found to undo.");
806
+ const undoSession = await createBackupSession({
807
+ canonicalRoot: roots.canonicalRoot,
808
+ scope: opts.scope,
809
+ operation: "undo"
810
+ });
811
+ const entries = latest.manifest.entries || [];
812
+ await backupExistingOriginals(entries, undoSession);
813
+ let restored = 0;
814
+ let restoredBackups = 0;
815
+ let removedCreated = 0;
816
+ let removedSymlinks = 0;
817
+ for (const entry of entries) {
818
+ if (entry.action === "create") {
819
+ if (await pathExists(entry.originalPath)) {
820
+ await restorePath(entry);
821
+ restored += 1;
822
+ removedCreated += 1;
823
+ if (entry.kind === "symlink") removedSymlinks += 1;
824
+ }
825
+ continue;
826
+ }
827
+ if (!entry.backupPath) continue;
828
+ if (!await pathExists(entry.backupPath)) continue;
829
+ await restorePath(entry);
830
+ restored += 1;
831
+ restoredBackups += 1;
832
+ }
833
+ await finalizeBackup(undoSession);
834
+ return { restored, restoredBackups, removedCreated, removedSymlinks, backupDir: undoSession.dir, undoneDir: latest.dir };
835
+ }
836
+
837
+ // src/core/preflight.ts
838
+ import fs8 from "fs";
839
+ import path12 from "path";
840
+ async function needsLinkBackup(task, forceLinks) {
841
+ if (task.type === "conflict") return forceLinks && task.target !== task.source;
842
+ if (task.type !== "link") return false;
843
+ if (!await pathExists(task.target)) return false;
844
+ if (forceLinks) return true;
845
+ if (!task.replaceSymlink) return false;
846
+ try {
847
+ const stat = await fs8.promises.lstat(task.target);
848
+ return stat.isSymbolicLink();
849
+ } catch {
850
+ return false;
851
+ }
852
+ }
853
+ function collectMigrationCandidates(plan, selections) {
854
+ const candidates = [...plan.auto];
855
+ for (const conflict of plan.conflicts) {
856
+ const choice = selections.get(conflict.targetPath);
857
+ if (choice && choice.action === "copy") candidates.push(choice);
858
+ }
859
+ return candidates;
860
+ }
861
+ async function preflightBackup(opts) {
862
+ const targets = /* @__PURE__ */ new Set();
863
+ for (const task of opts.linkPlan.tasks) {
864
+ if ((task.type === "conflict" || task.type === "link") && await needsLinkBackup(task, opts.forceLinks)) {
865
+ targets.add(path12.resolve(task.target));
866
+ }
867
+ }
868
+ const migrationCandidates = collectMigrationCandidates(opts.migratePlan, opts.selections);
869
+ for (const candidate of migrationCandidates) {
870
+ if (!candidate.sourcePath) continue;
871
+ if (await pathExists(candidate.targetPath)) {
872
+ targets.add(path12.resolve(candidate.targetPath));
873
+ }
874
+ }
875
+ for (const target of targets) {
876
+ const exists = await pathExists(target);
877
+ if (!exists) continue;
878
+ const stat = await fs8.promises.lstat(target);
879
+ if (stat.isSymbolicLink()) {
880
+ await fs8.promises.readlink(target);
881
+ }
882
+ const dest = backupPathFor(target, opts.backup.dir);
883
+ await ensureDir(path12.dirname(dest));
884
+ await fs8.promises.access(path12.dirname(dest), fs8.constants.W_OK);
885
+ }
886
+ return { targets: targets.size };
887
+ }
888
+
889
+ // src/cli.tsx
890
+ var appTitle = "dotagents";
891
+ function exitCancelled() {
892
+ cancel("Cancelled");
893
+ process.exit(0);
894
+ }
895
+ function mergeAgentStatus(items) {
896
+ const claudeEntry = items.find((s) => s.name === "claude-md") || null;
897
+ const geminiEntry = items.find((s) => s.name === "gemini-md") || null;
898
+ const agentsEntry = items.find((s) => s.name === "agents-md") || null;
899
+ if (!claudeEntry && !agentsEntry && !geminiEntry) return items;
900
+ const merged = {
901
+ name: "agents-md",
902
+ source: claudeEntry?.source || geminiEntry?.source || agentsEntry?.source || "",
903
+ targets: [
904
+ ...claudeEntry?.targets || [],
905
+ ...geminiEntry?.targets || [],
906
+ ...agentsEntry?.targets || []
907
+ ]
908
+ };
909
+ const withoutAgents = items.filter((s) => s.name !== "claude-md" && s.name !== "gemini-md" && s.name !== "agents-md");
910
+ return [merged, ...withoutAgents];
911
+ }
912
+ function displayName(entry) {
913
+ if (entry.name === "agents-md") {
914
+ const sourceFile = path13.basename(entry.source);
915
+ if (sourceFile === "CLAUDE.md") return "AGENTS.md (Claude override)";
916
+ if (sourceFile === "GEMINI.md") return "AGENTS.md (Gemini override)";
917
+ return "AGENTS.md";
918
+ }
919
+ return entry.name;
920
+ }
921
+ function buildStatusSummary(status) {
922
+ return status.map((s) => {
923
+ const linked = s.targets.filter((t) => t.status === "linked").length;
924
+ const missing = s.targets.filter((t) => t.status === "missing").length;
925
+ const conflict = s.targets.filter((t) => t.status === "conflict").length;
926
+ return { name: displayName(s), linked, missing, conflict };
927
+ });
928
+ }
929
+ function formatSummaryTable(rows) {
930
+ const header = { name: "Section", conflict: "Conflicts", missing: "Need link", linked: "Linked" };
931
+ const width = {
932
+ name: Math.max(header.name.length, ...rows.map((r) => r.name.length)),
933
+ conflict: Math.max(header.conflict.length, ...rows.map((r) => String(r.conflict).length)),
934
+ missing: Math.max(header.missing.length, ...rows.map((r) => String(r.missing).length)),
935
+ linked: Math.max(header.linked.length, ...rows.map((r) => String(r.linked).length))
936
+ };
937
+ const pad = (value, len) => value.padEnd(len, " ");
938
+ const lines = [
939
+ `${pad(header.name, width.name)} ${pad(header.conflict, width.conflict)} ${pad(header.missing, width.missing)} ${pad(header.linked, width.linked)}`,
940
+ ...rows.map((r) => `${pad(r.name, width.name)} ${pad(String(r.conflict), width.conflict)} ${pad(String(r.missing), width.missing)} ${pad(String(r.linked), width.linked)}`)
941
+ ];
942
+ return lines;
943
+ }
944
+ function renderStatusLines(status, conflictReasons) {
945
+ const lines = [];
946
+ for (const entry of status) {
947
+ lines.push(chalk.cyan(displayName(entry)));
948
+ for (const target of entry.targets) {
949
+ const icon = target.status === "linked" ? chalk.green("\u2713") : target.status === "missing" ? chalk.yellow("\u2022") : chalk.red("\u26A0");
950
+ const reason = target.status === "conflict" ? conflictReasons.get(target.path) : void 0;
951
+ lines.push(` ${icon} ${target.path}${reason ? chalk.dim(` \u2014 ${reason}`) : ""}`);
952
+ }
953
+ }
954
+ return lines;
955
+ }
956
+ async function selectScope() {
957
+ const scope = await select({
958
+ message: "Choose a workspace",
959
+ options: [
960
+ { label: "Global (~/.agents)", value: "global" },
961
+ { label: "Project (.agents)", value: "project" },
962
+ { label: "Exit", value: "exit" }
963
+ ]
964
+ });
965
+ if (isCancel(scope)) exitCancelled();
966
+ if (scope === "exit") {
967
+ outro("Bye");
968
+ process.exit(0);
969
+ }
970
+ return scope;
971
+ }
972
+ function scopeLabel(scope) {
973
+ return scope === "global" ? "Global (~/.agents)" : "Project (.agents)";
974
+ }
975
+ function pluralize(count, singular, plural) {
976
+ return count === 1 ? singular : plural || `${singular}s`;
977
+ }
978
+ function formatCount(count, singular, plural) {
979
+ return `${count} ${pluralize(count, singular, plural)}`;
980
+ }
981
+ async function selectClients() {
982
+ const options = [
983
+ { label: "Claude", value: "claude" },
984
+ { label: "Factory", value: "factory" },
985
+ { label: "Codex", value: "codex" },
986
+ { label: "Cursor", value: "cursor" },
987
+ { label: "OpenCode", value: "opencode" },
988
+ { label: "Gemini", value: "gemini" },
989
+ { label: "GitHub", value: "github" },
990
+ { label: "Ampcode", value: "ampcode" }
991
+ ];
992
+ const selected = await multiselect({
993
+ message: "Select clients to manage",
994
+ options: options.map((opt) => ({ label: opt.label, value: opt.value })),
995
+ initialValues: options.map((opt) => opt.value),
996
+ required: true
997
+ });
998
+ if (isCancel(selected)) exitCancelled();
999
+ return selected;
1000
+ }
1001
+ function formatClients(clients) {
1002
+ const names = {
1003
+ claude: "Claude",
1004
+ factory: "Factory",
1005
+ codex: "Codex",
1006
+ cursor: "Cursor",
1007
+ opencode: "OpenCode",
1008
+ gemini: "Gemini",
1009
+ github: "GitHub",
1010
+ ampcode: "Ampcode"
1011
+ };
1012
+ return clients.map((c) => names[c]).join(", ");
1013
+ }
1014
+ async function showStatus(scope, clients, status, planConflicts) {
1015
+ const conflicts = new Map(planConflicts.map((c) => [c.target, c.reason]));
1016
+ const lines = renderStatusLines(mergeAgentStatus(status), conflicts);
1017
+ note(lines.join("\n"), `Status \xB7 ${scopeLabel(scope)} \xB7 ${formatClients(clients)}`);
1018
+ }
1019
+ async function resolveMigrationConflicts(plan) {
1020
+ const selections = /* @__PURE__ */ new Map();
1021
+ for (let i = 0; i < plan.conflicts.length; i += 1) {
1022
+ const conflict = plan.conflicts[i];
1023
+ if (!conflict) continue;
1024
+ const choice = await select({
1025
+ message: `Resolve migration conflict ${i + 1} of ${plan.conflicts.length}: ${conflict.label}`,
1026
+ options: conflict.candidates.map((c) => ({ label: c.label, value: c }))
1027
+ });
1028
+ if (isCancel(choice)) return null;
1029
+ selections.set(conflict.targetPath, choice);
1030
+ }
1031
+ return selections;
1032
+ }
1033
+ async function runChange(scope, clients) {
1034
+ const spin = spinner();
1035
+ spin.start("Scanning current setup...");
1036
+ const roots = resolveRoots({ scope });
1037
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1038
+ const migrate = await scanMigration({ scope, clients });
1039
+ const link = await buildLinkPlan({ scope, clients });
1040
+ const backupDir = path13.join(roots.canonicalRoot, "backup", timestamp);
1041
+ spin.stop("Scan complete");
1042
+ const planSummary = [
1043
+ `Migration: ${migrate.auto.length} auto \xB7 ${migrate.conflicts.length} conflicts (choose sources)`,
1044
+ `Links: ${link.changes.length} changes \xB7 ${link.conflicts.length} conflicts (existing files/dirs)`,
1045
+ `Backup: ${backupDir}`,
1046
+ 'Undo: Use "Undo last change" after this completes.'
1047
+ ].join("\n");
1048
+ note(planSummary, "Plan summary");
1049
+ let overwriteConflicts = true;
1050
+ if (link.conflicts.length > 0) {
1051
+ const choice = await select({
1052
+ message: "Apply changes",
1053
+ options: [
1054
+ { label: "Apply changes + overwrite conflicts", value: "force" },
1055
+ { label: "Apply changes (leave conflicts)", value: "skip" },
1056
+ { label: "Back", value: "back" }
1057
+ ]
1058
+ });
1059
+ if (isCancel(choice)) return;
1060
+ if (choice === "back") return;
1061
+ overwriteConflicts = choice === "force";
1062
+ } else {
1063
+ const ok = await confirm({ message: "Apply changes now?" });
1064
+ if (isCancel(ok) || !ok) return;
1065
+ }
1066
+ let selections = /* @__PURE__ */ new Map();
1067
+ if (migrate.conflicts.length > 0) {
1068
+ const resolved = await resolveMigrationConflicts(migrate);
1069
+ if (!resolved) return;
1070
+ selections = resolved;
1071
+ }
1072
+ const applySpinner = spinner();
1073
+ applySpinner.start("Applying changes...");
1074
+ try {
1075
+ const backup = await createBackupSession({
1076
+ canonicalRoot: roots.canonicalRoot,
1077
+ scope,
1078
+ operation: "change-to-agents",
1079
+ timestamp
1080
+ });
1081
+ await preflightBackup({
1082
+ backup,
1083
+ linkPlan: link,
1084
+ migratePlan: migrate,
1085
+ selections,
1086
+ forceLinks: overwriteConflicts
1087
+ });
1088
+ const result = await applyMigration(migrate, selections, { scope, clients, backup, forceLinks: overwriteConflicts });
1089
+ await finalizeBackup(backup);
1090
+ const migrationSummary = `Migrated ${formatCount(result.copied, "item")}`;
1091
+ const linkSummary = `Linked ${formatCount(result.links.applied, "path")}`;
1092
+ const conflictSummary = result.links.conflicts > 0 ? overwriteConflicts ? `overwrote ${formatCount(result.links.conflicts, "conflict")}` : `left ${formatCount(result.links.conflicts, "conflict")} untouched` : "";
1093
+ const pieces = [migrationSummary, linkSummary];
1094
+ if (conflictSummary) pieces.push(conflictSummary);
1095
+ applySpinner.stop(`${pieces.join(" \xB7 ")}. Backup: ${result.backupDir}`);
1096
+ } catch (err) {
1097
+ applySpinner.stop("Change failed");
1098
+ note(String(err?.message || err), "Error");
1099
+ }
1100
+ }
1101
+ async function run() {
1102
+ intro(chalk.cyan(appTitle));
1103
+ const scope = await selectScope();
1104
+ let clients = await selectClients();
1105
+ while (true) {
1106
+ const status = mergeAgentStatus(await getLinkStatus({ scope, clients }));
1107
+ const plan = await buildLinkPlan({ scope, clients });
1108
+ const conflicts = plan.conflicts.length || 0;
1109
+ const changes = plan.changes.length || 0;
1110
+ const summary = buildStatusSummary(status);
1111
+ const summaryLines = formatSummaryTable(summary);
1112
+ note([
1113
+ `Scope: ${scopeLabel(scope)}`,
1114
+ `Clients: ${formatClients(clients)}`,
1115
+ `Pending changes: ${changes} \xB7 Conflicts: ${conflicts}`,
1116
+ ...summaryLines
1117
+ ].join("\n"), "Overview");
1118
+ const options = [];
1119
+ if (changes > 0 || conflicts > 0) {
1120
+ const label = conflicts > 0 && changes === 0 ? `Resolve ${conflicts} ${pluralize(conflicts, "conflict")}` : `Apply ${changes} ${pluralize(changes, "change")}`;
1121
+ options.push({ label, value: "change" });
1122
+ }
1123
+ options.push({ label: "View status", value: "status" });
1124
+ options.push({ label: "Change clients", value: "clients" });
1125
+ options.push({ label: "Undo last change", value: "undo" });
1126
+ options.push({ label: "Exit", value: "exit" });
1127
+ const action = await select({ message: "Choose an action", options });
1128
+ if (isCancel(action)) exitCancelled();
1129
+ if (action === "exit") break;
1130
+ if (action === "status") {
1131
+ await showStatus(scope, clients, status, plan.conflicts);
1132
+ continue;
1133
+ }
1134
+ if (action === "clients") {
1135
+ clients = await selectClients();
1136
+ continue;
1137
+ }
1138
+ if (action === "undo") {
1139
+ const spin = spinner();
1140
+ spin.start("Undoing last change...");
1141
+ try {
1142
+ const result = await undoLastChange({ scope });
1143
+ const restoredSummary = `Restored ${formatCount(result.restoredBackups, "backup")}`;
1144
+ const removedSummary = `Removed ${formatCount(result.removedCreated, "created path")}`;
1145
+ const symlinkSummary = result.removedSymlinks > 0 ? `${formatCount(result.removedSymlinks, "symlink")} removed` : "";
1146
+ let totalSummary = "";
1147
+ if (result.restoredBackups === 0 && result.removedCreated === 0) {
1148
+ totalSummary = "Nothing to undo.";
1149
+ } else if (result.restoredBackups === 0) {
1150
+ totalSummary = [removedSummary, symlinkSummary, "No backups to restore."].filter(Boolean).join(" \xB7 ");
1151
+ } else {
1152
+ totalSummary = [restoredSummary, removedSummary, symlinkSummary].filter(Boolean).join(" \xB7 ");
1153
+ }
1154
+ spin.stop(`${totalSummary} Reverted: ${result.undoneDir}`);
1155
+ note(`Undo backup: ${result.backupDir}`, "Undo log");
1156
+ } catch (err) {
1157
+ spin.stop("Undo failed");
1158
+ note(String(err?.message || err), "Error");
1159
+ }
1160
+ continue;
1161
+ }
1162
+ if (action === "change") {
1163
+ await runChange(scope, clients);
1164
+ }
1165
+ }
1166
+ outro("Bye");
1167
+ }
1168
+ run().catch((err) => {
1169
+ note(String(err?.message || err), "Fatal error");
1170
+ process.exit(1);
1171
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@hurst05/dotagents",
3
+ "version": "0.1.3",
4
+ "description": "Canonical .agents manager with symlinks for popular AI tools",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "dotagents": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "dev": "tsx src/cli.tsx",
17
+ "build": "tsup src/cli.tsx --format esm --platform node --out-dir dist --clean",
18
+ "type-check": "tsc --noEmit",
19
+ "test": "vitest run",
20
+ "prepublishOnly": "npm test && npm run type-check && npm run build"
21
+ },
22
+ "dependencies": {
23
+ "@clack/prompts": "^0.7.0",
24
+ "chalk": "^5.4.1",
25
+ "gray-matter": "^4.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.10.2",
29
+ "tsup": "^8.5.0",
30
+ "tsx": "^4.20.3",
31
+ "typescript": "^5.9.3",
32
+ "vitest": "^3.2.4"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "overrides": {
38
+ "esbuild": "^0.28.1"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }