skillrepo 3.2.0 → 4.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.
Files changed (39) hide show
  1. package/README.md +90 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-session-sync.mjs +1 -1
  7. package/src/commands/init.mjs +435 -111
  8. package/src/commands/list.mjs +1 -1
  9. package/src/commands/remove.mjs +10 -2
  10. package/src/commands/uninstall.mjs +1 -1
  11. package/src/commands/update.mjs +15 -3
  12. package/src/lib/agent-registry.mjs +215 -0
  13. package/src/lib/cli-config.mjs +146 -44
  14. package/src/lib/detect-agents.mjs +112 -0
  15. package/src/lib/file-write.mjs +162 -77
  16. package/src/lib/mcp-merge.mjs +17 -36
  17. package/src/lib/mergers/gitignore.mjs +55 -28
  18. package/src/lib/paths.mjs +27 -25
  19. package/src/lib/prompt-multiselect.mjs +324 -0
  20. package/src/lib/sync.mjs +18 -19
  21. package/src/test/commands/add.test.mjs +18 -3
  22. package/src/test/commands/init-picker.test.mjs +144 -0
  23. package/src/test/commands/init.test.mjs +228 -42
  24. package/src/test/commands/remove.test.mjs +4 -1
  25. package/src/test/commands/update.test.mjs +13 -3
  26. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  27. package/src/test/e2e/cli-commands.test.mjs +39 -13
  28. package/src/test/integration/file-write.integration.test.mjs +31 -10
  29. package/src/test/lib/agent-registry.test.mjs +215 -0
  30. package/src/test/lib/cli-config.test.mjs +222 -38
  31. package/src/test/lib/detect-agents.test.mjs +336 -0
  32. package/src/test/lib/file-write-placement.test.mjs +264 -0
  33. package/src/test/lib/file-write.test.mjs +231 -30
  34. package/src/test/lib/mcp-merge.test.mjs +23 -15
  35. package/src/test/lib/paths.test.mjs +53 -17
  36. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  37. package/src/test/lib/sync.test.mjs +157 -0
  38. package/src/lib/detect-ides.mjs +0 -44
  39. package/src/test/detect-ides.test.mjs +0 -65
@@ -32,13 +32,17 @@
32
32
  * - 403 scope → scopeError (via http.mjs) with write-key hint
33
33
  * - 401 → authError
34
34
  *
35
- * Flags: --global / --ide / --json / --key / --url
35
+ * Flags: --global / --agent / --json / --key / --url
36
36
  * Positional: <@owner/name>
37
37
  */
38
38
 
39
39
  import { removeSkillFromLibrary } from "../lib/http.mjs";
40
40
  import { removeSkillDir, cleanupOrphans } from "../lib/file-write.mjs";
41
- import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
41
+ import {
42
+ resolveFlags,
43
+ effectiveVendors,
44
+ requireVendorTargets,
45
+ } from "../lib/cli-config.mjs";
42
46
  import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
43
47
  import { validationError } from "../lib/errors.mjs";
44
48
 
@@ -75,6 +79,10 @@ export async function runRemove(argv, io = {}) {
75
79
 
76
80
  const { owner, name } = parseIdentifier(identifier);
77
81
  const vendors = effectiveVendors(flags);
82
+ // `remove` deletes specific files from disk — `--agent none` means
83
+ // "no targets to delete from", which is a no-op the user almost
84
+ // certainly didn't intend. Reject early.
85
+ requireVendorTargets(vendors, "remove");
78
86
 
79
87
  // Pre-flight: clean any .old/ orphans from a prior crashed write.
80
88
  // `remove` isn't writing new files, but it IS deleting, and the
@@ -430,7 +430,7 @@ function renderPreviewLine(descriptor, result) {
430
430
  function parseUninstallFlags(argv) {
431
431
  let dryRun = false;
432
432
  let yes = false;
433
- // Reuse resolveFlags for the standard --global / --json / --ide /
433
+ // Reuse resolveFlags for the standard --global / --json / --agent /
434
434
  // --key / --url shape. resolveFlags ignores unknown flags when an
435
435
  // acceptPositional callback is provided that can consume them —
436
436
  // the callback pattern matches init's own parsing.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Flags (parsed by the shared `resolveFlags` helper):
9
9
  * --global Write to ~/.claude/skills/ instead of project-local
10
- * --ide <list> Comma-separated vendor list
10
+ * --agent <list> Comma-separated agent target list
11
11
  * --json Print summary as JSON
12
12
  * --key <key> Override config-file access key
13
13
  * --url <url> Override config-file server URL
@@ -26,7 +26,11 @@
26
26
  */
27
27
 
28
28
  import { runSync } from "../lib/sync.mjs";
29
- import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
29
+ import {
30
+ resolveFlags,
31
+ effectiveVendors,
32
+ requireVendorTargets,
33
+ } from "../lib/cli-config.mjs";
30
34
 
31
35
  /**
32
36
  * Run `update`.
@@ -70,7 +74,7 @@ export async function runUpdate(argv, io = {}) {
70
74
  // fires before `skillrepo init` has run — a real first-run
71
75
  // scenario, not synthetic)
72
76
  // - `validationError` on unknown flags
73
- // - `validationError` from parseVendorList on a malformed --ide
77
+ // - `validationError` from parseAgentList on a malformed --agent
74
78
  //
75
79
  // All three happen INSIDE resolveFlags, before our try/catch block
76
80
  // could see them if we called it after. The only robust answer is
@@ -98,6 +102,13 @@ export async function runUpdate(argv, io = {}) {
98
102
  },
99
103
  });
100
104
  const vendors = effectiveVendors(flags);
105
+ // `--agent none` makes `update` a no-op — the user opted out of
106
+ // placement, so there's nowhere to write the synced library.
107
+ // Reject in session-hook mode too: the contract is "exit 0 on
108
+ // any error", but the requireVendorTargets throw is a typed
109
+ // validation error and the outer try/catch maps it to the
110
+ // documented one-line failure message.
111
+ requireVendorTargets(vendors, "update");
101
112
 
102
113
  const summary = await runSync({
103
114
  serverUrl: flags.serverUrl,
@@ -143,6 +154,7 @@ export async function runUpdate(argv, io = {}) {
143
154
  // when tests inject one.
144
155
  const flags = resolveFlags(argv);
145
156
  const vendors = effectiveVendors(flags);
157
+ requireVendorTargets(vendors, "update");
146
158
 
147
159
  const summary = await runSync({
148
160
  serverUrl: flags.serverUrl,
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Single source of truth for the agent vendors the CLI supports (#1234).
3
+ *
4
+ * Every agent the CLI knows about is declared here exactly once. Path
5
+ * resolvers (file-write.mjs), flag parsers (cli-config.mjs), MCP merge
6
+ * coordinators (mcp-merge.mjs), gitignore management (mergers/gitignore.mjs),
7
+ * detection probes (detect-agents.mjs), and orphan sweeps all derive their
8
+ * vendor lists from this registry.
9
+ *
10
+ * Adding a vendor: append a frozen entry. Renaming a vendor: change `key`
11
+ * and put the old key in `aliases` so prior CLI invocations keep working.
12
+ *
13
+ * The two-target placement model (per the verified vendor matrix):
14
+ * - Claude Code uses Anthropic's documented `.claude/skills/` path.
15
+ * - Every other supported agent shares `.agents/skills/` for project
16
+ * scope and (with the exception of Windsurf and Copilot) for personal
17
+ * scope. Windsurf has a vendor-specific personal path under
18
+ * `~/.codeium/windsurf/skills/`. Copilot has no personal scope.
19
+ *
20
+ * Detection signals (`detectionSignals`) are the primary-source-verified
21
+ * fingerprints `detect-agents.mjs` probes when picking which agents have
22
+ * footprint on this machine / in this project. Three signal types:
23
+ *
24
+ * - `env` — an environment variable the agent sets in shells/sub-processes
25
+ * it spawns. Truthy presence in `process.env` indicates an active session.
26
+ * NOTE: only assert env vars the vendor's own docs document as set BY the
27
+ * agent in spawned shells. Variables the agent merely *reads* (e.g.
28
+ * `CODEX_HOME` for Codex's config dir) are config inputs, not detection
29
+ * signals — listing them here would produce false positives.
30
+ * - `home` — a HOME-relative path the agent creates on first run. Used as
31
+ * a "this user has installed the agent" proxy when no env var is
32
+ * documented.
33
+ * - `project` — a CWD-relative dotfile / dotdir the user keeps in the
34
+ * repo to drive agent behavior. Used as the "configured in this repo"
35
+ * signal.
36
+ *
37
+ * Detection is registry-driven: adding a new signal is a registry edit,
38
+ * no detect-agents.mjs change required.
39
+ *
40
+ * @typedef {"claudeProject" | "claudeGlobal" | "agentsProject" | "agentsGlobal" | "windsurfGlobal"} PlacementTarget
41
+ *
42
+ * @typedef {Object} DetectionSignal
43
+ * @property {"env"|"home"|"project"} type
44
+ * @property {string} value - Env var name, HOME-relative path, or
45
+ * project (CWD)-relative path. Use forward slashes; resolution is
46
+ * via `node:path.join` so Windows separators are produced
47
+ * automatically at probe time.
48
+ *
49
+ * @typedef {Object} AgentEntry
50
+ * @property {string} key - Canonical key. Stored in config files.
51
+ * @property {string} displayName - User-facing name (used in prompts and summaries).
52
+ * @property {string[]} aliases - Alternate names accepted on the command line.
53
+ * @property {PlacementTarget} projectTarget - Where project-scope writes land.
54
+ * @property {PlacementTarget|null} globalTarget - Where personal-scope writes land, or null if the vendor has no personal scope.
55
+ * @property {boolean} hasMcp - True if the CLI has a per-vendor MCP merger; false for file-only vendors (cohort vendors without a documented MCP config path).
56
+ * @property {readonly DetectionSignal[]} detectionSignals - Fingerprints that mark this agent as present.
57
+ */
58
+
59
+ /** @type {readonly AgentEntry[]} */
60
+ export const AGENT_REGISTRY = Object.freeze([
61
+ Object.freeze({
62
+ key: "claudeCode",
63
+ displayName: "Claude Code",
64
+ // `claude` is the canonical short token (also recognized
65
+ // explicitly by parseAgentList for the target-shortcut role).
66
+ // `claude-code` is the kebab-case form some users reach for by
67
+ // habit; accepting it as a silent alias matches the spec's
68
+ // "vendor names accepted silently as courtesy aliases" rule.
69
+ aliases: Object.freeze(["claude", "claude-code"]),
70
+ projectTarget: "claudeProject",
71
+ globalTarget: "claudeGlobal",
72
+ hasMcp: true,
73
+ detectionSignals: Object.freeze([
74
+ Object.freeze({ type: "env", value: "CLAUDECODE" }),
75
+ Object.freeze({ type: "home", value: ".claude" }),
76
+ Object.freeze({ type: "project", value: ".claude" }),
77
+ ]),
78
+ }),
79
+ Object.freeze({
80
+ key: "cursor",
81
+ displayName: "Cursor",
82
+ aliases: Object.freeze([]),
83
+ projectTarget: "agentsProject",
84
+ globalTarget: "agentsGlobal",
85
+ hasMcp: true,
86
+ detectionSignals: Object.freeze([
87
+ Object.freeze({ type: "env", value: "CURSOR_AGENT" }),
88
+ // CURSOR_CLI is the documented fallback when CURSOR_AGENT is
89
+ // not set in older Cursor builds; both fire under the same
90
+ // OR semantics so listing both costs nothing.
91
+ Object.freeze({ type: "env", value: "CURSOR_CLI" }),
92
+ Object.freeze({ type: "home", value: ".cursor" }),
93
+ Object.freeze({ type: "project", value: ".cursor" }),
94
+ ]),
95
+ }),
96
+ Object.freeze({
97
+ key: "windsurf",
98
+ displayName: "Windsurf",
99
+ aliases: Object.freeze([]),
100
+ projectTarget: "agentsProject",
101
+ globalTarget: "windsurfGlobal",
102
+ hasMcp: true,
103
+ detectionSignals: Object.freeze([
104
+ // No documented active-session env var (verified absent in
105
+ // Windsurf docs 2026-05). HOME trace under the Codeium prefix
106
+ // is the strongest available signal.
107
+ Object.freeze({ type: "home", value: ".codeium/windsurf" }),
108
+ Object.freeze({ type: "project", value: ".windsurf" }),
109
+ ]),
110
+ }),
111
+ Object.freeze({
112
+ key: "gemini",
113
+ displayName: "Gemini CLI",
114
+ aliases: Object.freeze([]),
115
+ projectTarget: "agentsProject",
116
+ globalTarget: "agentsGlobal",
117
+ hasMcp: false,
118
+ detectionSignals: Object.freeze([
119
+ Object.freeze({ type: "env", value: "GEMINI_CLI" }),
120
+ Object.freeze({ type: "home", value: ".gemini" }),
121
+ Object.freeze({ type: "project", value: ".gemini" }),
122
+ ]),
123
+ }),
124
+ Object.freeze({
125
+ key: "codex",
126
+ displayName: "Codex CLI",
127
+ aliases: Object.freeze([]),
128
+ projectTarget: "agentsProject",
129
+ globalTarget: "agentsGlobal",
130
+ hasMcp: false,
131
+ detectionSignals: Object.freeze([
132
+ // Codex has no documented active-session env var. CODEX_HOME
133
+ // is a config var Codex *reads*, not one it sets — listing it
134
+ // would produce false positives for users who simply have the
135
+ // var pointed elsewhere. Detection is HOME + project only.
136
+ Object.freeze({ type: "home", value: ".codex" }),
137
+ Object.freeze({ type: "project", value: ".codex" }),
138
+ ]),
139
+ }),
140
+ Object.freeze({
141
+ key: "cline",
142
+ displayName: "Cline",
143
+ aliases: Object.freeze([]),
144
+ projectTarget: "agentsProject",
145
+ globalTarget: "agentsGlobal",
146
+ hasMcp: false,
147
+ detectionSignals: Object.freeze([
148
+ // Cline v3.24+ sets CLINE_ACTIVE="true" in spawned shells.
149
+ // Detection treats any truthy value as the signal.
150
+ Object.freeze({ type: "env", value: "CLINE_ACTIVE" }),
151
+ Object.freeze({ type: "home", value: ".cline" }),
152
+ Object.freeze({ type: "project", value: ".cline" }),
153
+ ]),
154
+ }),
155
+ Object.freeze({
156
+ key: "copilot",
157
+ displayName: "VS Code + Copilot",
158
+ aliases: Object.freeze(["vscode"]),
159
+ projectTarget: "agentsProject",
160
+ globalTarget: null,
161
+ hasMcp: true,
162
+ detectionSignals: Object.freeze([
163
+ // Copilot has no documented active-session env var. The HOME
164
+ // path is the directory the Copilot CLI / VS Code Copilot
165
+ // extension creates on first run; the project path is the
166
+ // documented Agent Skills location for VS Code + Copilot.
167
+ Object.freeze({ type: "home", value: ".copilot" }),
168
+ Object.freeze({ type: "project", value: ".github/skills" }),
169
+ ]),
170
+ }),
171
+ ]);
172
+
173
+ /**
174
+ * Canonical keys of every cohort vendor — every registry entry whose
175
+ * project-scope writes land in `.agents/skills/`. Derived from the
176
+ * registry so a future cohort addition flows through every consumer
177
+ * without touching their code. Used by `cli-config.mjs` to expand the
178
+ * `--agent agents` token, and by `init.mjs` to translate the
179
+ * "Other agents" picker row to a vendor list.
180
+ *
181
+ * @type {readonly string[]}
182
+ */
183
+ export const AGENTS_COHORT_KEYS = Object.freeze(
184
+ AGENT_REGISTRY.filter((entry) => entry.projectTarget === "agentsProject").map(
185
+ (entry) => entry.key,
186
+ ),
187
+ );
188
+
189
+ /**
190
+ * Look up a registry entry by its canonical key. Returns `undefined`
191
+ * for unknown keys — callers should treat that as a programming error.
192
+ *
193
+ * @param {string} key
194
+ * @returns {AgentEntry | undefined}
195
+ */
196
+ export function getAgentByKey(key) {
197
+ return AGENT_REGISTRY.find((entry) => entry.key === key);
198
+ }
199
+
200
+ /**
201
+ * Resolve an alias (or canonical key) to its canonical key. Returns
202
+ * `null` for inputs that match neither a canonical key nor any
203
+ * declared alias. The function is case-sensitive — alias normalization
204
+ * is the caller's responsibility.
205
+ *
206
+ * @param {string} alias
207
+ * @returns {string | null}
208
+ */
209
+ export function getAgentByAlias(alias) {
210
+ for (const entry of AGENT_REGISTRY) {
211
+ if (entry.key === alias) return entry.key;
212
+ if (entry.aliases.includes(alias)) return entry.key;
213
+ }
214
+ return null;
215
+ }
@@ -2,7 +2,7 @@
2
2
  * Shared credential + flag resolution for command modules.
3
3
  *
4
4
  * Every command needs to:
5
- * 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
5
+ * 1. Resolve `--key`/`--url`/`--agent`/`--global`/`--json` flags
6
6
  * 2. Fall back to ~/.claude/skillrepo/config.json
7
7
  * 3. Fall back to SKILLREPO_ACCESS_KEY / SKILLREPO_URL env vars
8
8
  * 4. Hard-error with an actionable hint pointing at `init` if no
@@ -20,17 +20,28 @@
20
20
  import { existsSync, readFileSync } from "node:fs";
21
21
 
22
22
  import { globalConfigPath } from "./paths.mjs";
23
+ import {
24
+ AGENT_REGISTRY,
25
+ AGENTS_COHORT_KEYS,
26
+ getAgentByAlias,
27
+ } from "./agent-registry.mjs";
23
28
  import { authError, validationError } from "./errors.mjs";
24
29
 
25
- const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
26
- const VENDOR_ALIASES = { claude: "claudeCode" };
30
+ const ALL_VENDOR_KEYS = AGENT_REGISTRY.map((entry) => entry.key);
31
+
32
+ // Sentinel returned by parseAgentList for `--agent none`. It is the
33
+ // empty array literally — every consumer reads `flags.vendors.length`
34
+ // to detect "no placement writes." Frozen so a downstream mutation
35
+ // (e.g. `.push`) cannot turn one user's `none` into another's surprise.
36
+ const NO_VENDORS = Object.freeze([]);
27
37
 
28
38
  /**
29
39
  * @typedef {Object} ResolvedFlags
30
40
  * @property {string} serverUrl
31
41
  * @property {string} apiKey
32
42
  * @property {boolean} global
33
- * @property {string[]|null} vendors - null = use the default
43
+ * @property {string[]|null} vendors - null = use the default;
44
+ * empty array = `--agent none` (caller skips placement)
34
45
  * @property {boolean} json
35
46
  */
36
47
 
@@ -78,8 +89,22 @@ export function resolveFlags(argv, opts = {}) {
78
89
 
79
90
  if (arg === "--global") {
80
91
  flagState.global = true;
81
- } else if (arg === "--ide" && argv[i + 1] !== undefined) {
82
- flagState.vendors = parseVendorList(argv[++i]);
92
+ } else if (arg === "--agent") {
93
+ if (argv[i + 1] === undefined) {
94
+ throw validationError(
95
+ "Missing value for --agent. Pass a comma-separated list (e.g., --agent claude,agents).",
96
+ );
97
+ }
98
+ flagState.vendors = parseAgentList(argv[++i]);
99
+ } else if (arg === "--ide") {
100
+ throw validationError(
101
+ "--ide was renamed to --agent in v3.0.0.",
102
+ {
103
+ hint:
104
+ "Replace --ide with --agent (e.g., --agent claude). " +
105
+ "Run the command with --help for valid options.",
106
+ },
107
+ );
83
108
  } else if (arg === "--json") {
84
109
  flagState.json = true;
85
110
  } else if ((arg === "--key" || arg === "-k") && argv[i + 1] !== undefined) {
@@ -172,62 +197,139 @@ export function resolveFlags(argv, opts = {}) {
172
197
  }
173
198
 
174
199
  /**
175
- * Compute the vendor list to pass to file-write / sync. Defaults to
176
- * `["claudeCode"]` when neither `--ide` nor `--global` is provided —
177
- * the v3.0.0 CLI deliberately does NOT silently fall back to
178
- * `[claudeCode, cursor]` like v2.0.0 did. The user opts in.
200
+ * Compute the vendor list to pass to file-write / sync.
201
+ *
202
+ * Resolution order:
203
+ * 1. Explicit `--agent` (including the `--agent none` sentinel `[]`)
204
+ * always wins. Both flags propagate together, so `--global
205
+ * --agent windsurf` correctly resolves to `["windsurf"]` and
206
+ * placementTargetsFor maps that to `windsurfGlobal`.
207
+ * 2. No `--agent` defaults to `["claudeCode"]`, regardless of
208
+ * `--global`. The v3.0.0 CLI deliberately does NOT silently
209
+ * fall back to `[claudeCode, cursor]` like v2.0.0 did.
210
+ *
211
+ * The empty-array `--agent none` sentinel is returned verbatim —
212
+ * callers detect "no placement" via `vendors.length === 0`. The
213
+ * default-fallback branch uses an explicit null/undefined check so
214
+ * the empty array survives.
179
215
  */
180
216
  export function effectiveVendors(flags) {
181
- if (flags.global) return undefined; // global mode ignores vendors
182
- if (flags.vendors) return flags.vendors;
183
- return ["claudeCode"];
217
+ if (flags.vendors === null || flags.vendors === undefined) {
218
+ return ["claudeCode"];
219
+ }
220
+ return flags.vendors;
221
+ }
222
+
223
+ /**
224
+ * Reject `--agent none` for commands that have no meaningful behavior
225
+ * without a placement target. `init` and `update` accept `--agent none`
226
+ * (init still writes config + gitignore; update is a no-op). Every
227
+ * other write/read-write command (`add`, `get`, `remove`, `uninstall`)
228
+ * exists to put or remove specific skill files on disk — calling them
229
+ * with no targets is a user error, not a degenerate-but-valid case.
230
+ *
231
+ * Pass the resolved `vendors` array (the output of `effectiveVendors`).
232
+ * `undefined` (the `--global` case) is allowed because `--global`
233
+ * implies the Claude Code personal directory; only an empty array is
234
+ * rejected.
235
+ *
236
+ * @param {string[] | undefined} vendors
237
+ * @param {string} commandName - For the error message.
238
+ */
239
+ export function requireVendorTargets(vendors, commandName) {
240
+ if (Array.isArray(vendors) && vendors.length === 0) {
241
+ throw validationError(
242
+ `--agent none has no effect on \`skillrepo ${commandName}\` ` +
243
+ `because the command writes or deletes specific skill files.`,
244
+ {
245
+ hint:
246
+ "Use --agent claude or --agent agents (or a specific vendor) to " +
247
+ "target a placement, or drop --agent to use the default.",
248
+ },
249
+ );
250
+ }
184
251
  }
185
252
 
186
253
  // ── Internals ──────────────────────────────────────────────────────────
187
254
 
188
- function parseVendorList(raw) {
255
+ /**
256
+ * Parse the raw `--agent <list>` argument into a deduplicated array
257
+ * of canonical vendor keys, OR the empty array sentinel for `none`.
258
+ *
259
+ * Token semantics (each comma-separated token):
260
+ *
261
+ * - `none` → empty array (no placement writes). Must be the only
262
+ * token — mixing with any other token is rejected.
263
+ * - `claude` → `["claudeCode"]` (the Claude Code target shortcut).
264
+ * - `agents` → every cohort vendor whose project writes land in
265
+ * `.agents/skills/`, derived from the registry.
266
+ * - canonical key (e.g., `claudeCode`, `cursor`) → that key.
267
+ * - alias declared on a registry entry (e.g., `claude-code`,
268
+ * `vscode`) → its canonical key.
269
+ * - anything else → validation error.
270
+ *
271
+ * `all` is NOT a valid token — use `claude,agents` for full coverage.
272
+ */
273
+ function parseAgentList(raw) {
189
274
  if (typeof raw !== "string") {
190
- throw validationError(`--ide expects a comma-separated list, got: ${raw}`);
275
+ throw validationError(`--agent expects a comma-separated list, got: ${raw}`);
191
276
  }
192
277
 
193
- // First pass: collect tokens, normalize aliases, detect `all`.
194
278
  const pieces = raw.split(",").map((p) => p.trim()).filter(Boolean);
195
- const normalized = pieces.map((v) => VENDOR_ALIASES[v] ?? v);
196
- const hasAll = normalized.includes("all");
197
- const otherVendors = normalized.filter((v) => v !== "all");
198
-
199
- // Defense against confusing input: `--ide cursor,all` is ambiguous
200
- // — the user might think it means "cursor first, then everything"
201
- // or "all minus duplicates". Both interpretations are wrong because
202
- // `all` is the full set. Reject the combination so the user is
203
- // forced to pick one.
204
- if (hasAll && otherVendors.length > 0) {
205
- throw validationError(
206
- `--ide cannot mix "all" with other vendors: "${raw}"`,
207
- { hint: "Use --ide all OR --ide claude,cursor,... — not both." },
208
- );
279
+ if (pieces.length === 0) {
280
+ throw validationError("--agent list is empty.", {
281
+ hint: "Pass one or more of: claude, agents, none, or a vendor name.",
282
+ });
209
283
  }
210
284
 
211
- if (hasAll) {
212
- return ["claudeCode", "cursor", "windsurf", "vscode"];
285
+ // `none` is mutually exclusive with every other token. Mixing
286
+ // `--agent claude,none` is ambiguous — does the user want Claude
287
+ // or no placement? Reject so they pick one.
288
+ if (pieces.includes("none")) {
289
+ if (pieces.length > 1) {
290
+ throw validationError(
291
+ `--agent cannot mix "none" with other tokens: "${raw}"`,
292
+ { hint: "Pass --agent none alone, or list specific targets without none." },
293
+ );
294
+ }
295
+ return NO_VENDORS;
213
296
  }
214
297
 
215
- if (normalized.length === 0) {
216
- throw validationError("--ide list is empty.");
298
+ const expanded = [];
299
+ for (const token of pieces) {
300
+ if (token === "claude") {
301
+ expanded.push("claudeCode");
302
+ continue;
303
+ }
304
+ if (token === "agents") {
305
+ for (const key of AGENTS_COHORT_KEYS) {
306
+ expanded.push(key);
307
+ }
308
+ continue;
309
+ }
310
+ const canonical = getAgentByAlias(token);
311
+ if (canonical !== null) {
312
+ expanded.push(canonical);
313
+ continue;
314
+ }
315
+ throw validationError(`Unknown --agent target: "${token}"`, {
316
+ hint:
317
+ "Use one of: claude, agents, none, or a canonical vendor key " +
318
+ `(${ALL_VENDOR_KEYS.join(", ")}).`,
319
+ });
217
320
  }
218
321
 
219
- // Validate each vendor token
220
- for (const resolved of normalized) {
221
- if (!VALID_VENDORS.has(resolved)) {
222
- // Find the original (un-aliased) form for the error message
223
- const original = pieces[normalized.indexOf(resolved)];
224
- throw validationError(`Unknown --ide vendor: "${original}"`, {
225
- hint: "Use claudeCode (or 'claude'), cursor, windsurf, vscode, or 'all'.",
226
- });
322
+ // Dedupe while preserving first-seen order — matters for tests and
323
+ // for downstream consumers that iterate the array deterministically.
324
+ const seen = new Set();
325
+ const result = [];
326
+ for (const key of expanded) {
327
+ if (!seen.has(key)) {
328
+ seen.add(key);
329
+ result.push(key);
227
330
  }
228
331
  }
229
-
230
- return normalized;
332
+ return result;
231
333
  }
232
334
 
233
335
  function readGlobalConfig() {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Multi-signal agent detection (#1236, Phase 3 of #876).
3
+ *
4
+ * Replaces the single-signal `detect-ides.mjs` module. Detection is
5
+ * registry-driven: every probe is described as a `DetectionSignal` on
6
+ * the corresponding `AGENT_REGISTRY` entry, and this module just
7
+ * iterates the registry and runs the platform-correct probe for each
8
+ * signal type. Adding a new signal is a registry edit, no change here.
9
+ *
10
+ * Three signal types:
11
+ *
12
+ * - `env` — `process.env[value]` is truthy. Indicates an active
13
+ * agent session (the agent itself sets the var in the
14
+ * shells it spawns).
15
+ * - `home` — `~/<value>` exists on disk. Indicates the agent has
16
+ * been installed for this user (most agents create the
17
+ * directory on first run).
18
+ * - `project` — `<cwd>/<value>` exists on disk. Indicates the agent
19
+ * is configured for this repo (dotdir / dotfile).
20
+ *
21
+ * For each agent, `detected` is the OR of all signal probes. The first
22
+ * signal that fires (priority order: env > home > project) becomes the
23
+ * human-readable `reason`. Reasons are formatted to match the picker UX:
24
+ *
25
+ * - env signal: `"CLAUDECODE=1"`
26
+ * - home signal: `"~/.claude/"` or `"~/.codeium/windsurf/"`
27
+ * - project signal: `".claude/"` or `".github/skills/"`
28
+ * - none: `null` (the picker translates this to
29
+ * `"(no signal — opt in if you use one)"`)
30
+ *
31
+ * Cross-platform: paths use `node:path.join(homedir(), …)` and
32
+ * `join(process.cwd(), …)`. On Windows, `homedir()` reads
33
+ * `USERPROFILE`; the test sandbox helper sets both HOME and USERPROFILE
34
+ * so probes are sandbox-isolated on every platform.
35
+ */
36
+
37
+ import { existsSync } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { join } from "node:path";
40
+
41
+ import { AGENT_REGISTRY } from "./agent-registry.mjs";
42
+
43
+ /**
44
+ * @typedef {Object} AgentDetection
45
+ * @property {string} key - Canonical agent key.
46
+ * @property {string} displayName - Brand name (used in picker hints).
47
+ * @property {boolean} detected - True if any signal fired.
48
+ * @property {string|null} reason - Short human-readable label of the
49
+ * first signal that fired, or `null` when nothing fired.
50
+ */
51
+
52
+ /**
53
+ * Probe one detection signal against the live process state and disk.
54
+ *
55
+ * Pure helper. Returns the formatted reason string when the signal
56
+ * fires, or `null` when it does not.
57
+ *
58
+ * @param {import("./agent-registry.mjs").DetectionSignal} signal
59
+ * @returns {string | null}
60
+ */
61
+ function probeSignal(signal) {
62
+ if (signal.type === "env") {
63
+ const raw = process.env[signal.value];
64
+ if (raw === undefined || raw === "") return null;
65
+ // Env vars in shell environments are always strings. Truthy
66
+ // values include "1", "true", "yes", etc — Cline's documented
67
+ // value is the literal string "true". We accept any non-empty
68
+ // value rather than a stricter truthy-string match because the
69
+ // signal's purpose is "the agent set this var on me" and any
70
+ // value the agent chose to set qualifies.
71
+ return `${signal.value}=${raw}`;
72
+ }
73
+ if (signal.type === "home") {
74
+ const path = join(homedir(), signal.value);
75
+ if (!existsSync(path)) return null;
76
+ return `~/${signal.value}/`;
77
+ }
78
+ if (signal.type === "project") {
79
+ const path = join(process.cwd(), signal.value);
80
+ if (!existsSync(path)) return null;
81
+ return `${signal.value}/`;
82
+ }
83
+ // Unknown type — defensive guard. Callers should never see this
84
+ // because the registry's frozen entries are typed.
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Detect every agent in the registry against the current process and
90
+ * working directory. Returns one `AgentDetection` per registry entry,
91
+ * in registry order (Claude Code first, then the cohort).
92
+ *
93
+ * @returns {AgentDetection[]}
94
+ */
95
+ export function detectAgents() {
96
+ return AGENT_REGISTRY.map((entry) => {
97
+ let reason = null;
98
+ for (const signal of entry.detectionSignals) {
99
+ const fired = probeSignal(signal);
100
+ if (fired !== null) {
101
+ reason = fired;
102
+ break;
103
+ }
104
+ }
105
+ return {
106
+ key: entry.key,
107
+ displayName: entry.displayName,
108
+ detected: reason !== null,
109
+ reason,
110
+ };
111
+ });
112
+ }