skillrepo 3.1.5 → 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.
- package/README.md +108 -33
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +446 -112
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +1 -1
- package/src/commands/update.mjs +15 -3
- package/src/lib/agent-registry.mjs +215 -0
- package/src/lib/browser-open.mjs +136 -0
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/constants.mjs +69 -0
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/http.mjs +17 -3
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/prompt.mjs +81 -5
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +228 -42
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +13 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/browser-open.test.mjs +187 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/constants.test.mjs +93 -0
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/http.test.mjs +63 -0
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/prompt.test.mjs +154 -0
- package/src/test/lib/sync.test.mjs +157 -0
- package/src/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
package/src/commands/init.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `skillrepo init` (#673) — PR3b rewrite, v3.1.0 session-sync (#884)
|
|
2
|
+
* `skillrepo init` (#673) — PR3b rewrite, v3.1.0 session-sync (#884),
|
|
3
|
+
* Phase 3 detection picker (#1236).
|
|
3
4
|
*
|
|
4
5
|
* First-run command. Replaces the v2.0.0 init that consumed the
|
|
5
6
|
* deprecated `/api/v1/setup` endpoint and wrote hook-delivery
|
|
@@ -8,12 +9,30 @@
|
|
|
8
9
|
* 1. Collect credentials (--key | env var | interactive prompt)
|
|
9
10
|
* 2. Validate the key via POST /api/v1/auth/validate
|
|
10
11
|
* 3. Write ~/.claude/skillrepo/config.json via config.mjs
|
|
11
|
-
* 4.
|
|
12
|
+
* 4. Multi-signal agent detection + two-row picker (#1236)
|
|
12
13
|
* 5. Run MCP auto-merge for detected IDEs (user-confirmed unless --yes)
|
|
13
14
|
* 6. Install Claude Code SessionStart hook (v3.1.0 #884)
|
|
14
15
|
* 7. Run the first library sync via sync.mjs
|
|
15
16
|
* 8. Print summary
|
|
16
17
|
*
|
|
18
|
+
* Step 4 details (#1236):
|
|
19
|
+
*
|
|
20
|
+
* - When `--agent` is passed (including `--agent none`), the picker
|
|
21
|
+
* is skipped entirely and the explicit list is used.
|
|
22
|
+
* - Otherwise, multi-signal detection runs against the registry.
|
|
23
|
+
* Each row of the picker (Claude Code / Other agents) is
|
|
24
|
+
* pre-checked when its target's signal fires. Fresh clone with no
|
|
25
|
+
* detection at all → both rows pre-checked anyway (the product's
|
|
26
|
+
* job is to set things up; defaulting to "do nothing" is the bug
|
|
27
|
+
* we are fixing). When detection fires for one target only, the
|
|
28
|
+
* other row is left unchecked so the picker nudges the user toward
|
|
29
|
+
* what was found.
|
|
30
|
+
* - `--yes` skips the render and uses the pre-checked rows. With
|
|
31
|
+
* no signals, that means BOTH targets — running `init --yes` on
|
|
32
|
+
* a fresh clone and writing nothing is broken automation.
|
|
33
|
+
* - The "None" row produces vendors = `[]`, the same sentinel
|
|
34
|
+
* `--agent none` returns. Mutually exclusive with the other rows.
|
|
35
|
+
*
|
|
17
36
|
* Key differences from v2.0.0:
|
|
18
37
|
*
|
|
19
38
|
* - Uses /api/v1/auth/validate (not /api/v1/setup)
|
|
@@ -21,8 +40,10 @@
|
|
|
21
40
|
* - MCP merge is interactive with per-vendor prompts
|
|
22
41
|
* - NO rules-delivery files (.claude/rules/, .claude/skillrepo*.md) —
|
|
23
42
|
* those died with the hooks in #835
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
43
|
+
* - The Phase-3 picker REPLACED the old "no agent targets detected
|
|
44
|
+
* → refuse" branch. Fresh clones now configure both default
|
|
45
|
+
* targets; the user can still opt out via the "None" row or
|
|
46
|
+
* `--agent none`.
|
|
26
47
|
* - Idempotent: re-running with a valid existing config re-runs
|
|
27
48
|
* detection + MCP merge prompts + sync, but does NOT re-prompt
|
|
28
49
|
* for a key. A stale (401) key triggers automatic re-prompt.
|
|
@@ -33,37 +54,67 @@
|
|
|
33
54
|
* Flags:
|
|
34
55
|
* --key/-k <key> Access key (overrides config + env)
|
|
35
56
|
* --url/-u <url> SkillRepo server URL (overrides config + env)
|
|
36
|
-
* --yes/-y Non-interactive: skip all prompts (MCP merge +
|
|
57
|
+
* --yes/-y Non-interactive: skip all prompts (MCP merge + picker)
|
|
37
58
|
* --force Re-prompt for a new key even if config exists and is valid
|
|
38
59
|
* --global Run first sync in global mode (~/.claude/skills/)
|
|
39
|
-
* --
|
|
60
|
+
* --agent <list> Override detected agent targets (comma-separated)
|
|
40
61
|
* --json JSON summary output
|
|
41
62
|
*/
|
|
42
63
|
|
|
64
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
65
|
+
|
|
43
66
|
import { validateAccessKey } from "../lib/http.mjs";
|
|
44
|
-
import {
|
|
67
|
+
import { detectAgents } from "../lib/detect-agents.mjs";
|
|
68
|
+
import { AGENT_REGISTRY, AGENTS_COHORT_KEYS } from "../lib/agent-registry.mjs";
|
|
45
69
|
import { readConfig, writeConfig } from "../lib/config.mjs";
|
|
46
70
|
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
47
71
|
import {
|
|
48
72
|
detectTransientRunner,
|
|
49
73
|
globalInstallCommandFor,
|
|
50
74
|
} from "../lib/transient-runners.mjs";
|
|
51
|
-
import { mergeMcpForVendors
|
|
75
|
+
import { mergeMcpForVendors } from "../lib/mcp-merge.mjs";
|
|
52
76
|
import { runSync } from "../lib/sync.mjs";
|
|
77
|
+
import {
|
|
78
|
+
placementTargetsFor,
|
|
79
|
+
describePlacementTarget,
|
|
80
|
+
} from "../lib/file-write.mjs";
|
|
53
81
|
import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
|
|
54
82
|
import { mergeGitignore } from "../lib/mergers/gitignore.mjs";
|
|
55
83
|
import { installSessionSyncHook } from "./init-session-sync.mjs";
|
|
56
84
|
import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
|
|
57
85
|
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} from "../lib/
|
|
86
|
+
claudeSkillsProjectRoot,
|
|
87
|
+
agentsSkillsProjectRoot,
|
|
88
|
+
} from "../lib/paths.mjs";
|
|
89
|
+
import { promptWithBrowserOpen } from "../lib/prompt.mjs";
|
|
90
|
+
import { promptMultiSelect } from "../lib/prompt-multiselect.mjs";
|
|
61
91
|
import {
|
|
62
92
|
CliError,
|
|
63
93
|
authError,
|
|
64
94
|
validationError,
|
|
65
95
|
EXIT_AUTH,
|
|
66
96
|
} from "../lib/errors.mjs";
|
|
97
|
+
import { cliAuthUrl } from "../lib/constants.mjs";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Format the user-visible "configured X, Y" line for the init step-4
|
|
101
|
+
* success message. Resolves the vendor list to its placement targets,
|
|
102
|
+
* deduplicates via placementTargetsFor, and labels each with a
|
|
103
|
+
* readable path. Falls back to vendor display names if path resolution
|
|
104
|
+
* throws (e.g., a vendor was passed without a corresponding target —
|
|
105
|
+
* shouldn't happen post-validation, but the fallback prevents a
|
|
106
|
+
* step-4 success line from blowing up the rest of init).
|
|
107
|
+
*/
|
|
108
|
+
function describeVendors(vendors, global) {
|
|
109
|
+
try {
|
|
110
|
+
const targets = placementTargetsFor({ vendors, global: !!global });
|
|
111
|
+
return targets.map(describePlacementTarget).join(", ");
|
|
112
|
+
} catch {
|
|
113
|
+
return vendors
|
|
114
|
+
.map((v) => AGENT_REGISTRY.find((e) => e.key === v)?.displayName ?? v)
|
|
115
|
+
.join(", ");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
67
118
|
|
|
68
119
|
// Local print helpers that use the INJECTED stdout/stderr streams.
|
|
69
120
|
// The prompt.mjs `print*` helpers write to process.stdout directly,
|
|
@@ -153,6 +204,16 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
153
204
|
|
|
154
205
|
const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
|
|
155
206
|
|
|
207
|
+
// Capture detection state EARLY — before step 3 writes
|
|
208
|
+
// `~/.claude/skillrepo/config.json`, which would itself create
|
|
209
|
+
// `~/.claude/` and self-fire the Claude Code home-trace signal.
|
|
210
|
+
// Detection has to reflect what's on disk before init touches
|
|
211
|
+
// anything. The result feeds the step 4 picker; it's only used
|
|
212
|
+
// when the user did NOT pass `--agent` (otherwise the picker is
|
|
213
|
+
// skipped). Capturing unconditionally is cheap (a handful of
|
|
214
|
+
// `existsSync` probes) and keeps the code simple.
|
|
215
|
+
const earlyDetections = detectAgents();
|
|
216
|
+
|
|
156
217
|
// `--json` is for non-interactive consumers (CI scripts, programmatic
|
|
157
218
|
// callers). Without `--yes`, init would hang at step 5 (MCP merge
|
|
158
219
|
// per-vendor confirm prompt) waiting for stdin that no one is going
|
|
@@ -204,7 +265,10 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
204
265
|
hint: "Pass --key sk_live_... or set SKILLREPO_ACCESS_KEY.",
|
|
205
266
|
});
|
|
206
267
|
}
|
|
207
|
-
apiKey = await
|
|
268
|
+
apiKey = await promptWithBrowserOpen(
|
|
269
|
+
cliAuthUrl(serverUrl),
|
|
270
|
+
"Enter your access key (sk_live_...)",
|
|
271
|
+
);
|
|
208
272
|
}
|
|
209
273
|
|
|
210
274
|
// Trim whitespace from the key before validating. Pasting an API
|
|
@@ -241,7 +305,12 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
241
305
|
!yes
|
|
242
306
|
) {
|
|
243
307
|
p.warning("Existing config has an invalid key. Re-prompting for a new one.");
|
|
244
|
-
apiKey = (
|
|
308
|
+
apiKey = (
|
|
309
|
+
await promptWithBrowserOpen(
|
|
310
|
+
cliAuthUrl(serverUrl),
|
|
311
|
+
"Enter your access key (sk_live_...)",
|
|
312
|
+
)
|
|
313
|
+
).trim();
|
|
245
314
|
if (!apiKey || !apiKey.startsWith("sk_live_")) {
|
|
246
315
|
throw validationError("Invalid access key format.");
|
|
247
316
|
}
|
|
@@ -293,7 +362,10 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
293
362
|
// three entries they should add manually. The config is already
|
|
294
363
|
// saved at this point, so we don't abort init.
|
|
295
364
|
try {
|
|
296
|
-
const gitignoreResult = mergeGitignore(
|
|
365
|
+
const gitignoreResult = mergeGitignore({
|
|
366
|
+
vendors: flags.vendors ?? undefined,
|
|
367
|
+
global: flags.global,
|
|
368
|
+
});
|
|
297
369
|
if (gitignoreResult.action === "created") {
|
|
298
370
|
p.success(`.gitignore created with ${gitignoreResult.added.length} SkillRepo entries`);
|
|
299
371
|
} else if (gitignoreResult.action === "updated") {
|
|
@@ -306,61 +378,58 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
306
378
|
p.warning(
|
|
307
379
|
`Could not update .gitignore: ${err?.message ?? String(err)}. ` +
|
|
308
380
|
`Add these entries manually: .env.local, .claude/skills/, ` +
|
|
309
|
-
`.claude/settings.local.json`,
|
|
381
|
+
`.agents/skills/, .claude/settings.local.json`,
|
|
310
382
|
);
|
|
311
383
|
}
|
|
312
384
|
p.blank();
|
|
313
385
|
|
|
314
|
-
// ── Step 4:
|
|
315
|
-
p.step(4, 7, "
|
|
386
|
+
// ── Step 4: Detection + two-row picker (#1236) ──────────────
|
|
387
|
+
p.step(4, 7, "Configuring targets");
|
|
316
388
|
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
389
|
+
// `flags.vendors === []` is the `--agent none` sentinel from
|
|
390
|
+
// parseAgentList. The user explicitly opted out of placement, so
|
|
391
|
+
// skip the picker entirely. Steps 5-7 downstream check
|
|
392
|
+
// `vendors.length === 0` to skip MCP merge, session-sync, and the
|
|
393
|
+
// first library sync.
|
|
394
|
+
//
|
|
395
|
+
// Any other non-null `flags.vendors` means the user passed an
|
|
396
|
+
// explicit `--agent` list — also skip the picker.
|
|
322
397
|
let vendors;
|
|
323
|
-
if (flags.vendors) {
|
|
398
|
+
if (Array.isArray(flags.vendors) && flags.vendors.length === 0) {
|
|
324
399
|
vendors = flags.vendors;
|
|
325
|
-
p.success(
|
|
326
|
-
} else {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
for (const ide of ideList) {
|
|
333
|
-
if (ide.detected) p.success(ide.name);
|
|
334
|
-
}
|
|
335
|
-
if (detectedKeys.length === 0) {
|
|
336
|
-
// Print a copy-pasteable MCP config blob so users whose IDEs
|
|
337
|
-
// aren't supported for auto-detection can still wire up MCP
|
|
338
|
-
// manually. Then refuse so headless CI scenarios fail loudly
|
|
339
|
-
// unless the user opts in with --ide. This call makes
|
|
340
|
-
// printManualMcpInstructions live code — it was exported
|
|
341
|
-
// but unused after the initial PR3b draft.
|
|
342
|
-
const mcpUrl = `${serverUrl}/api/mcp`;
|
|
343
|
-
printManualMcpInstructions(mcpUrl, { stdout });
|
|
344
|
-
|
|
345
|
-
throw validationError("No IDEs detected in this directory.", {
|
|
346
|
-
hint:
|
|
347
|
-
"The JSON above is a copy-pasteable MCP config for any IDE. " +
|
|
348
|
-
"Or run init from inside a project with .claude/, .cursor/, .vscode/, " +
|
|
349
|
-
"or with --ide <vendor> (claude, cursor, windsurf, vscode).",
|
|
350
|
-
});
|
|
400
|
+
p.success("Skipping placement (--agent none)");
|
|
401
|
+
} else if (flags.vendors) {
|
|
402
|
+
vendors = flags.vendors;
|
|
403
|
+
if (vendors.length === 0) {
|
|
404
|
+
p.success("Skipping placement (--agent none).");
|
|
405
|
+
} else {
|
|
406
|
+
p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
|
|
351
407
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
408
|
+
} else {
|
|
409
|
+
vendors = await runDetectionPicker({
|
|
410
|
+
yes,
|
|
411
|
+
json: flags.json,
|
|
412
|
+
stdout,
|
|
413
|
+
detections: earlyDetections,
|
|
414
|
+
});
|
|
415
|
+
if (vendors.length === 0) {
|
|
416
|
+
p.success("Skipping placement (None selected).");
|
|
417
|
+
} else {
|
|
418
|
+
p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
|
|
362
419
|
}
|
|
363
|
-
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Validate the (vendor, --global) combination upfront. Catches cases
|
|
423
|
+
// like `--global --agent copilot` (Copilot has no personal scope)
|
|
424
|
+
// before any side-effectful work runs. Without this check, the
|
|
425
|
+
// validation error would fire from inside runSync and be swallowed
|
|
426
|
+
// by the partial-state try/catch below — exiting 0 with a warning
|
|
427
|
+
// when the user's invocation was structurally invalid.
|
|
428
|
+
if (Array.isArray(vendors) && vendors.length > 0) {
|
|
429
|
+
placementTargetsFor({
|
|
430
|
+
vendors: effectiveVendors({ vendors, global: flags.global }),
|
|
431
|
+
global: flags.global,
|
|
432
|
+
});
|
|
364
433
|
}
|
|
365
434
|
p.blank();
|
|
366
435
|
|
|
@@ -405,9 +474,15 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
405
474
|
// because the hook's `skillrepo update` calls expect the config
|
|
406
475
|
// to already be written (step 3).
|
|
407
476
|
p.step(6, 7, "Session sync");
|
|
477
|
+
// Install the Claude Code SessionStart hook only when Claude Code
|
|
478
|
+
// is actually a target. Pre-#1249 this condition also included
|
|
479
|
+
// `Boolean(flags.global)` because bare `--global` historically
|
|
480
|
+
// routed to Claude; after #1249's effectiveVendors fix, `--global
|
|
481
|
+
// --agent windsurf` legitimately targets Windsurf only, and we
|
|
482
|
+
// must NOT silently add a Claude hook for users who never picked
|
|
483
|
+
// Claude. Manual E2E sweep caught this regression.
|
|
408
484
|
const claudeTargeted =
|
|
409
|
-
|
|
410
|
-
(Array.isArray(vendors) && vendors.includes("claudeCode"));
|
|
485
|
+
Array.isArray(vendors) && vendors.includes("claudeCode");
|
|
411
486
|
const sessionSync = await installSessionSyncHook({
|
|
412
487
|
noSessionSync,
|
|
413
488
|
claudeTargeted,
|
|
@@ -426,62 +501,87 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
426
501
|
p.step(7, 7, "Pulling library");
|
|
427
502
|
let syncSummary;
|
|
428
503
|
let syncFailedReason = null;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
global: flags.global,
|
|
441
|
-
io: syncIo,
|
|
442
|
-
});
|
|
443
|
-
} catch (err) {
|
|
444
|
-
// A sync failure after the rest of init succeeded is a
|
|
445
|
-
// partial-state concern: config IS saved, MCP IS configured,
|
|
446
|
-
// and the access key IS validated — only the skill files
|
|
447
|
-
// haven't been fetched yet. The right recovery is `skillrepo
|
|
448
|
-
// update`, not a re-init. Surface the warning and exit 0 so
|
|
449
|
-
// the user sees the "run update later" message as actionable
|
|
450
|
-
// guidance rather than as a failed-command contradiction.
|
|
451
|
-
//
|
|
452
|
-
// Round-2 review caught the prior behavior (warn + unconditional
|
|
453
|
-
// rethrow) as an inconsistent contract: the warning told users
|
|
454
|
-
// to retry with `update`, but the non-zero exit code made it
|
|
455
|
-
// look like the whole init had failed.
|
|
456
|
-
p.warning(
|
|
457
|
-
`Config saved but first sync failed: ${err.message}. ` +
|
|
458
|
-
`Run \`skillrepo update\` later to retry.`,
|
|
459
|
-
);
|
|
460
|
-
syncFailedReason = err.message;
|
|
461
|
-
// Synthesize a zero-delta summary matching the SyncSummary
|
|
462
|
-
// typedef in sync.mjs (added, updated, removed, notModified,
|
|
463
|
-
// syncedAt). No `unchanged` or `failed` field — those were
|
|
464
|
-
// phantom fields the cross-review flagged; failure is signaled
|
|
465
|
-
// via `syncFailedReason` locally and via `sync.failureReason`
|
|
466
|
-
// in the --json output below.
|
|
504
|
+
// `--agent none` short-circuits the first sync: the user opted
|
|
505
|
+
// out of placement, and the credential write + gitignore + MCP
|
|
506
|
+
// bookkeeping that init also does are still complete. Calling
|
|
507
|
+
// runSync with an empty vendor list would throw inside
|
|
508
|
+
// placementTargetsFor (no vendors specified), and even if it
|
|
509
|
+
// didn't, fetching skill files we have nowhere to write is
|
|
510
|
+
// pure waste.
|
|
511
|
+
const skipFirstSync =
|
|
512
|
+
Array.isArray(vendors) && vendors.length === 0 && !flags.global;
|
|
513
|
+
if (skipFirstSync) {
|
|
514
|
+
p.success("Skipped first sync (--agent none).");
|
|
467
515
|
syncSummary = {
|
|
468
516
|
added: 0,
|
|
469
517
|
updated: 0,
|
|
470
518
|
removed: 0,
|
|
471
519
|
notModified: false,
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
// consumers: it looks like a legitimate "delta sync returned
|
|
477
|
-
// zero" signal. Using `null` makes the unknown-state
|
|
478
|
-
// explicit — any typed consumer must handle it separately
|
|
479
|
-
// from true/false. The always-present `sync.failureReason`
|
|
480
|
-
// field is still the authoritative "did the sync fail"
|
|
481
|
-
// indicator; fullSync is just additional context.
|
|
520
|
+
// Same null-fullSync rationale as the failure-recovery branch:
|
|
521
|
+
// the network call never ran, so reporting `false` would be a
|
|
522
|
+
// legitimate-looking "delta returned zero" lie. `null` makes
|
|
523
|
+
// the unknown-state explicit.
|
|
482
524
|
fullSync: null,
|
|
483
525
|
syncedAt: new Date().toISOString(),
|
|
484
526
|
};
|
|
527
|
+
} else {
|
|
528
|
+
try {
|
|
529
|
+
// In --json mode, suppress runSync's warning prints to stdout
|
|
530
|
+
// by passing a black-hole stream. (Its warnings go to stderr
|
|
531
|
+
// by default, which stays visible.)
|
|
532
|
+
const syncIo = flags.json
|
|
533
|
+
? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
|
|
534
|
+
: io;
|
|
535
|
+
syncSummary = await runSync({
|
|
536
|
+
serverUrl,
|
|
537
|
+
apiKey,
|
|
538
|
+
vendors: effectiveVendors({ vendors, global: flags.global }),
|
|
539
|
+
global: flags.global,
|
|
540
|
+
io: syncIo,
|
|
541
|
+
});
|
|
542
|
+
} catch (err) {
|
|
543
|
+
// A sync failure after the rest of init succeeded is a
|
|
544
|
+
// partial-state concern: config IS saved, MCP IS configured,
|
|
545
|
+
// and the access key IS validated — only the skill files
|
|
546
|
+
// haven't been fetched yet. The right recovery is `skillrepo
|
|
547
|
+
// update`, not a re-init. Surface the warning and exit 0 so
|
|
548
|
+
// the user sees the "run update later" message as actionable
|
|
549
|
+
// guidance rather than as a failed-command contradiction.
|
|
550
|
+
//
|
|
551
|
+
// Round-2 review caught the prior behavior (warn + unconditional
|
|
552
|
+
// rethrow) as an inconsistent contract: the warning told users
|
|
553
|
+
// to retry with `update`, but the non-zero exit code made it
|
|
554
|
+
// look like the whole init had failed.
|
|
555
|
+
p.warning(
|
|
556
|
+
`Config saved but first sync failed: ${err.message}. ` +
|
|
557
|
+
`Run \`skillrepo update\` later to retry.`,
|
|
558
|
+
);
|
|
559
|
+
syncFailedReason = err.message;
|
|
560
|
+
// Synthesize a zero-delta summary matching the SyncSummary
|
|
561
|
+
// typedef in sync.mjs (added, updated, removed, notModified,
|
|
562
|
+
// syncedAt). No `unchanged` or `failed` field — those were
|
|
563
|
+
// phantom fields the cross-review flagged; failure is signaled
|
|
564
|
+
// via `syncFailedReason` locally and via `sync.failureReason`
|
|
565
|
+
// in the --json output below.
|
|
566
|
+
syncSummary = {
|
|
567
|
+
added: 0,
|
|
568
|
+
updated: 0,
|
|
569
|
+
removed: 0,
|
|
570
|
+
notModified: false,
|
|
571
|
+
// On a synthesized failure summary we genuinely don't know
|
|
572
|
+
// whether the sync WOULD have been full or delta — the network
|
|
573
|
+
// call never completed. Architect review (v3.1.1) flagged that
|
|
574
|
+
// emitting `fullSync: false` here is misleading for --json
|
|
575
|
+
// consumers: it looks like a legitimate "delta sync returned
|
|
576
|
+
// zero" signal. Using `null` makes the unknown-state
|
|
577
|
+
// explicit — any typed consumer must handle it separately
|
|
578
|
+
// from true/false. The always-present `sync.failureReason`
|
|
579
|
+
// field is still the authoritative "did the sync fail"
|
|
580
|
+
// indicator; fullSync is just additional context.
|
|
581
|
+
fullSync: null,
|
|
582
|
+
syncedAt: new Date().toISOString(),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
485
585
|
}
|
|
486
586
|
|
|
487
587
|
const zeroDeltas =
|
|
@@ -599,10 +699,10 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
599
699
|
|
|
600
700
|
/**
|
|
601
701
|
* Parse init-specific flags. Uses resolveFlags for the common ones
|
|
602
|
-
* (--key/--url/--global/--
|
|
702
|
+
* (--key/--url/--global/--agent/--json) and pulls out --yes/-y + --force.
|
|
603
703
|
*/
|
|
604
704
|
function parseInitFlags(argv) {
|
|
605
|
-
// resolveFlags handles --key/--url/--global/--
|
|
705
|
+
// resolveFlags handles --key/--url/--global/--agent/--json and
|
|
606
706
|
// rejects unknown flags via its acceptPositional callback. We
|
|
607
707
|
// intercept --yes, --force, and --no-session-sync as "positional-
|
|
608
708
|
// shaped" flags via the callback so we don't need to pre-filter
|
|
@@ -644,3 +744,237 @@ function parseInitFlags(argv) {
|
|
|
644
744
|
|
|
645
745
|
return { flags, yes, force, noSessionSync };
|
|
646
746
|
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Build the picker rows from a detection result.
|
|
750
|
+
*
|
|
751
|
+
* Both Claude Code and the cohort row are pre-checked when ANY signal
|
|
752
|
+
* fires for that target. On a fresh clone with no detection, both
|
|
753
|
+
* rows are still pre-checked — the product's reason for existing is
|
|
754
|
+
* to set up skills, and defaulting to "do nothing" because env vars
|
|
755
|
+
* didn't fire is the bug we are fixing.
|
|
756
|
+
*
|
|
757
|
+
* @param {ReturnType<typeof detectAgents>} detections
|
|
758
|
+
* @returns {{ items: import("../lib/prompt-multiselect.mjs").MultiSelectItem[], claudeReason: string|null, cohortReasons: string[] }}
|
|
759
|
+
*/
|
|
760
|
+
function buildPickerItems(detections) {
|
|
761
|
+
const claudeDetection = detections.find((d) => d.key === "claudeCode");
|
|
762
|
+
const cohortDetections = detections.filter((d) =>
|
|
763
|
+
AGENTS_COHORT_KEYS.includes(d.key),
|
|
764
|
+
);
|
|
765
|
+
const cohortDetectedNames = cohortDetections
|
|
766
|
+
.filter((d) => d.detected)
|
|
767
|
+
.map((d) => d.displayName);
|
|
768
|
+
|
|
769
|
+
const claudeDetected = Boolean(claudeDetection?.detected);
|
|
770
|
+
const cohortDetected = cohortDetectedNames.length > 0;
|
|
771
|
+
|
|
772
|
+
// Idempotent re-run hint: if the target dir already exists with
|
|
773
|
+
// content, annotate the row.
|
|
774
|
+
const claudeAlreadyConfigured = directoryHasContent(claudeSkillsProjectRoot());
|
|
775
|
+
const cohortAlreadyConfigured = directoryHasContent(agentsSkillsProjectRoot());
|
|
776
|
+
|
|
777
|
+
const claudeHint = formatHint({
|
|
778
|
+
detectionReason: claudeDetected ? claudeDetection.reason : null,
|
|
779
|
+
pathLabel: ".claude/skills/",
|
|
780
|
+
alreadyConfigured: claudeAlreadyConfigured,
|
|
781
|
+
});
|
|
782
|
+
const cohortHint = formatHint({
|
|
783
|
+
detectionReason: cohortDetected ? formatCohortBrands(cohortDetectedNames) : null,
|
|
784
|
+
pathLabel: ".agents/skills/",
|
|
785
|
+
alreadyConfigured: cohortAlreadyConfigured,
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// Three-state pre-check policy:
|
|
789
|
+
// 1. Detection fires for both targets → both rows pre-checked.
|
|
790
|
+
// 2. Detection fires for one target only → only that row pre-checked
|
|
791
|
+
// (the other stays empty so the picker nudges the user toward
|
|
792
|
+
// what we actually found).
|
|
793
|
+
// 3. No detection anywhere (fresh clone) → both rows pre-checked
|
|
794
|
+
// (the product's job is to set things up; defaulting to "do
|
|
795
|
+
// nothing" is the bug we are fixing).
|
|
796
|
+
const anySignal = claudeDetected || cohortDetected;
|
|
797
|
+
const claudePreChecked = anySignal ? claudeDetected : true;
|
|
798
|
+
const cohortPreChecked = anySignal ? cohortDetected : true;
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
claudeReason: claudeDetected ? claudeDetection.reason : null,
|
|
802
|
+
cohortReasons: cohortDetectedNames,
|
|
803
|
+
items: [
|
|
804
|
+
{
|
|
805
|
+
key: "claude",
|
|
806
|
+
label: "Claude Code",
|
|
807
|
+
hint: claudeHint,
|
|
808
|
+
preChecked: claudePreChecked,
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
key: "agents",
|
|
812
|
+
label: "Other agents",
|
|
813
|
+
hint: cohortHint,
|
|
814
|
+
preChecked: cohortPreChecked,
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
key: "none",
|
|
818
|
+
label: "None — I'll configure manually",
|
|
819
|
+
preChecked: false,
|
|
820
|
+
},
|
|
821
|
+
],
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Format the cohort detection reason. Up to 3 brand names listed,
|
|
827
|
+
* then "+N more" — keeps the picker hint readable when most cohort
|
|
828
|
+
* vendors fire at once.
|
|
829
|
+
*
|
|
830
|
+
* @param {string[]} names
|
|
831
|
+
*/
|
|
832
|
+
export function formatCohortBrands(names) {
|
|
833
|
+
if (names.length <= 3) return names.join(", ");
|
|
834
|
+
return `${names.slice(0, 3).join(", ")} +${names.length - 3} more`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Build the per-row hint string. Combines the path label, the
|
|
839
|
+
* detection reason (or the "no signal" fallback), and the
|
|
840
|
+
* already-configured annotation when applicable.
|
|
841
|
+
*
|
|
842
|
+
* Exported for unit-test surface area (#1252 / QA round): the
|
|
843
|
+
* `(already configured — re-checking will refresh)` annotation has
|
|
844
|
+
* historically been only end-to-end-tested via the picker, which
|
|
845
|
+
* locks the wording to its rendering layer rather than to the data
|
|
846
|
+
* shape callers expect. Direct testing of this helper means a future
|
|
847
|
+
* refactor that splits picker rendering from row construction can't
|
|
848
|
+
* silently drop the annotation without breaking the targeted unit
|
|
849
|
+
* test.
|
|
850
|
+
*
|
|
851
|
+
* @param {object} args
|
|
852
|
+
* @param {string|null} args.detectionReason
|
|
853
|
+
* @param {string} args.pathLabel
|
|
854
|
+
* @param {boolean} args.alreadyConfigured
|
|
855
|
+
*/
|
|
856
|
+
export function formatHint({ detectionReason, pathLabel, alreadyConfigured }) {
|
|
857
|
+
const detectionPart = detectionReason
|
|
858
|
+
? `(detected: ${detectionReason})`
|
|
859
|
+
: "(not detected — leave checked if you use one)";
|
|
860
|
+
const configuredPart = alreadyConfigured
|
|
861
|
+
? " (already configured — re-checking will refresh)"
|
|
862
|
+
: "";
|
|
863
|
+
return `${pathLabel} ${detectionPart}${configuredPart}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* `true` when `dir` exists and contains at least one entry. Used to
|
|
868
|
+
* annotate the picker rows with the idempotent "already configured"
|
|
869
|
+
* hint. We don't validate skill layout here — any content under the
|
|
870
|
+
* target dir means a prior run wrote there.
|
|
871
|
+
*
|
|
872
|
+
* Exported alongside `formatHint` for direct unit-test coverage of
|
|
873
|
+
* the idempotent re-run annotation path. See `formatHint` for the
|
|
874
|
+
* rationale.
|
|
875
|
+
*
|
|
876
|
+
* @param {string} dir
|
|
877
|
+
*/
|
|
878
|
+
export function directoryHasContent(dir) {
|
|
879
|
+
if (!existsSync(dir)) return false;
|
|
880
|
+
try {
|
|
881
|
+
const entries = readdirSync(dir);
|
|
882
|
+
return entries.length > 0;
|
|
883
|
+
} catch {
|
|
884
|
+
// Permission denied, race, etc. — treat as "no content" so the
|
|
885
|
+
// row is annotated as fresh. Annotation is informational; a
|
|
886
|
+
// false negative just shows the row without the "already
|
|
887
|
+
// configured" hint, which is harmless.
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Run the two-row picker and translate the user's selection into a
|
|
894
|
+
* vendors list. The output matches what `--agent` produces:
|
|
895
|
+
*
|
|
896
|
+
* - "claude" selected → ["claudeCode"]
|
|
897
|
+
* - "agents" selected → cohort keys
|
|
898
|
+
* - "claude" + "agents" selected → ["claudeCode", ...cohort]
|
|
899
|
+
* - "none" selected (alone) → []
|
|
900
|
+
* - "none" + anything else → rejected (validation error)
|
|
901
|
+
*
|
|
902
|
+
* Under `--yes`, the picker render is skipped: the pre-checked rows
|
|
903
|
+
* become the selection. With no detection, that means both default
|
|
904
|
+
* rows pre-checked → both targets configured. Spec rationale: writing
|
|
905
|
+
* a few KB the user didn't strictly need is trivial; CI running
|
|
906
|
+
* `init --yes` on a fresh clone and writing nothing is broken
|
|
907
|
+
* automation.
|
|
908
|
+
*
|
|
909
|
+
* @param {object} args
|
|
910
|
+
* @param {boolean} args.yes
|
|
911
|
+
* @param {boolean} args.json - True under --json: suppress the human
|
|
912
|
+
* explanatory header so stdout stays parseable.
|
|
913
|
+
* @param {NodeJS.WritableStream} args.stdout
|
|
914
|
+
* @param {ReturnType<typeof detectAgents>} args.detections - Captured
|
|
915
|
+
* BEFORE step 3 writes `~/.claude/skillrepo/config.json`
|
|
916
|
+
* (which would itself create `~/.claude/` and self-fire the
|
|
917
|
+
* Claude Code home-trace signal).
|
|
918
|
+
* @returns {Promise<string[]>}
|
|
919
|
+
*/
|
|
920
|
+
async function runDetectionPicker({ yes, json, stdout, detections }) {
|
|
921
|
+
const { items } = buildPickerItems(detections);
|
|
922
|
+
|
|
923
|
+
// Print the explanatory header before the picker (or before the
|
|
924
|
+
// pre-checked auto-select under --yes). Keeps the user grounded
|
|
925
|
+
// in WHAT will be written WHERE. Suppressed under --json so the
|
|
926
|
+
// final JSON blob is the only thing on stdout.
|
|
927
|
+
if (!json) {
|
|
928
|
+
stdout.write(
|
|
929
|
+
"\n This will write skills to your project under:\n" +
|
|
930
|
+
" .claude/skills/ (for Claude Code)\n" +
|
|
931
|
+
" .agents/skills/ (for Cursor, Windsurf, Gemini CLI, Codex CLI, " +
|
|
932
|
+
"Cline, Copilot, and others)\n" +
|
|
933
|
+
"\n" +
|
|
934
|
+
" Both paths are added to .gitignore — skills are a per-developer " +
|
|
935
|
+
"cache, not committed.\n\n",
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let selectedKeys;
|
|
940
|
+
if (yes) {
|
|
941
|
+
// Auto-select: pre-checked rows become the selection.
|
|
942
|
+
selectedKeys = items
|
|
943
|
+
.filter((it) => it.preChecked)
|
|
944
|
+
.map((it) => it.key);
|
|
945
|
+
} else {
|
|
946
|
+
selectedKeys = await promptMultiSelect(
|
|
947
|
+
{ question: "Set up which targets?", items },
|
|
948
|
+
{ stdout },
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return translatePickerSelection(selectedKeys);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Map the picker's selected keys to the canonical vendor list.
|
|
957
|
+
* Exported for test surface area would be ideal but the function is
|
|
958
|
+
* an internal helper to `runDetectionPicker` — translation is
|
|
959
|
+
* exercised end-to-end via `init.test.mjs`.
|
|
960
|
+
*
|
|
961
|
+
* @param {string[]} selected
|
|
962
|
+
* @returns {string[]}
|
|
963
|
+
*/
|
|
964
|
+
function translatePickerSelection(selected) {
|
|
965
|
+
const hasNone = selected.includes("none");
|
|
966
|
+
const hasOther = selected.some((k) => k !== "none");
|
|
967
|
+
if (hasNone && hasOther) {
|
|
968
|
+
throw validationError(
|
|
969
|
+
"Cannot mix 'None' with other targets in the picker.",
|
|
970
|
+
{
|
|
971
|
+
hint: "Pick either 'None' alone (skip placement) or one or both of the configured target rows.",
|
|
972
|
+
},
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (hasNone) return [];
|
|
976
|
+
const out = [];
|
|
977
|
+
if (selected.includes("claude")) out.push("claudeCode");
|
|
978
|
+
if (selected.includes("agents")) out.push(...AGENTS_COHORT_KEYS);
|
|
979
|
+
return out;
|
|
980
|
+
}
|