skillrepo 3.2.0 → 4.1.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 +137 -27
- 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-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- 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/removers/agent-hooks.mjs +83 -0
- 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 +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- 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/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/sync.test.mjs +157 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -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,32 +54,41 @@
|
|
|
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";
|
|
84
|
+
import { installCohortHooks } from "./init-cohort-hooks.mjs";
|
|
56
85
|
import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
|
|
57
86
|
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} from "../lib/prompt.mjs";
|
|
87
|
+
claudeSkillsProjectRoot,
|
|
88
|
+
agentsSkillsProjectRoot,
|
|
89
|
+
} from "../lib/paths.mjs";
|
|
90
|
+
import { promptWithBrowserOpen } from "../lib/prompt.mjs";
|
|
91
|
+
import { promptMultiSelect } from "../lib/prompt-multiselect.mjs";
|
|
62
92
|
import {
|
|
63
93
|
CliError,
|
|
64
94
|
authError,
|
|
@@ -67,6 +97,26 @@ import {
|
|
|
67
97
|
} from "../lib/errors.mjs";
|
|
68
98
|
import { cliAuthUrl } from "../lib/constants.mjs";
|
|
69
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Format the user-visible "configured X, Y" line for the init step-4
|
|
102
|
+
* success message. Resolves the vendor list to its placement targets,
|
|
103
|
+
* deduplicates via placementTargetsFor, and labels each with a
|
|
104
|
+
* readable path. Falls back to vendor display names if path resolution
|
|
105
|
+
* throws (e.g., a vendor was passed without a corresponding target —
|
|
106
|
+
* shouldn't happen post-validation, but the fallback prevents a
|
|
107
|
+
* step-4 success line from blowing up the rest of init).
|
|
108
|
+
*/
|
|
109
|
+
function describeVendors(vendors, global) {
|
|
110
|
+
try {
|
|
111
|
+
const targets = placementTargetsFor({ vendors, global: !!global });
|
|
112
|
+
return targets.map(describePlacementTarget).join(", ");
|
|
113
|
+
} catch {
|
|
114
|
+
return vendors
|
|
115
|
+
.map((v) => AGENT_REGISTRY.find((e) => e.key === v)?.displayName ?? v)
|
|
116
|
+
.join(", ");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
70
120
|
// Local print helpers that use the INJECTED stdout/stderr streams.
|
|
71
121
|
// The prompt.mjs `print*` helpers write to process.stdout directly,
|
|
72
122
|
// which collides with the stream-injection pattern used by every
|
|
@@ -155,6 +205,16 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
155
205
|
|
|
156
206
|
const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
|
|
157
207
|
|
|
208
|
+
// Capture detection state EARLY — before step 3 writes
|
|
209
|
+
// `~/.claude/skillrepo/config.json`, which would itself create
|
|
210
|
+
// `~/.claude/` and self-fire the Claude Code home-trace signal.
|
|
211
|
+
// Detection has to reflect what's on disk before init touches
|
|
212
|
+
// anything. The result feeds the step 4 picker; it's only used
|
|
213
|
+
// when the user did NOT pass `--agent` (otherwise the picker is
|
|
214
|
+
// skipped). Capturing unconditionally is cheap (a handful of
|
|
215
|
+
// `existsSync` probes) and keeps the code simple.
|
|
216
|
+
const earlyDetections = detectAgents();
|
|
217
|
+
|
|
158
218
|
// `--json` is for non-interactive consumers (CI scripts, programmatic
|
|
159
219
|
// callers). Without `--yes`, init would hang at step 5 (MCP merge
|
|
160
220
|
// per-vendor confirm prompt) waiting for stdin that no one is going
|
|
@@ -303,7 +363,10 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
303
363
|
// three entries they should add manually. The config is already
|
|
304
364
|
// saved at this point, so we don't abort init.
|
|
305
365
|
try {
|
|
306
|
-
const gitignoreResult = mergeGitignore(
|
|
366
|
+
const gitignoreResult = mergeGitignore({
|
|
367
|
+
vendors: flags.vendors ?? undefined,
|
|
368
|
+
global: flags.global,
|
|
369
|
+
});
|
|
307
370
|
if (gitignoreResult.action === "created") {
|
|
308
371
|
p.success(`.gitignore created with ${gitignoreResult.added.length} SkillRepo entries`);
|
|
309
372
|
} else if (gitignoreResult.action === "updated") {
|
|
@@ -316,61 +379,58 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
316
379
|
p.warning(
|
|
317
380
|
`Could not update .gitignore: ${err?.message ?? String(err)}. ` +
|
|
318
381
|
`Add these entries manually: .env.local, .claude/skills/, ` +
|
|
319
|
-
`.claude/settings.local.json`,
|
|
382
|
+
`.agents/skills/, .claude/settings.local.json`,
|
|
320
383
|
);
|
|
321
384
|
}
|
|
322
385
|
p.blank();
|
|
323
386
|
|
|
324
|
-
// ── Step 4:
|
|
325
|
-
p.step(4, 7, "
|
|
387
|
+
// ── Step 4: Detection + two-row picker (#1236) ──────────────
|
|
388
|
+
p.step(4, 7, "Configuring targets");
|
|
326
389
|
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
390
|
+
// `flags.vendors === []` is the `--agent none` sentinel from
|
|
391
|
+
// parseAgentList. The user explicitly opted out of placement, so
|
|
392
|
+
// skip the picker entirely. Steps 5-7 downstream check
|
|
393
|
+
// `vendors.length === 0` to skip MCP merge, session-sync, and the
|
|
394
|
+
// first library sync.
|
|
395
|
+
//
|
|
396
|
+
// Any other non-null `flags.vendors` means the user passed an
|
|
397
|
+
// explicit `--agent` list — also skip the picker.
|
|
332
398
|
let vendors;
|
|
333
|
-
if (flags.vendors) {
|
|
399
|
+
if (Array.isArray(flags.vendors) && flags.vendors.length === 0) {
|
|
334
400
|
vendors = flags.vendors;
|
|
335
|
-
p.success(
|
|
336
|
-
} else {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
for (const ide of ideList) {
|
|
343
|
-
if (ide.detected) p.success(ide.name);
|
|
344
|
-
}
|
|
345
|
-
if (detectedKeys.length === 0) {
|
|
346
|
-
// Print a copy-pasteable MCP config blob so users whose IDEs
|
|
347
|
-
// aren't supported for auto-detection can still wire up MCP
|
|
348
|
-
// manually. Then refuse so headless CI scenarios fail loudly
|
|
349
|
-
// unless the user opts in with --ide. This call makes
|
|
350
|
-
// printManualMcpInstructions live code — it was exported
|
|
351
|
-
// but unused after the initial PR3b draft.
|
|
352
|
-
const mcpUrl = `${serverUrl}/api/mcp`;
|
|
353
|
-
printManualMcpInstructions(mcpUrl, { stdout });
|
|
354
|
-
|
|
355
|
-
throw validationError("No IDEs detected in this directory.", {
|
|
356
|
-
hint:
|
|
357
|
-
"The JSON above is a copy-pasteable MCP config for any IDE. " +
|
|
358
|
-
"Or run init from inside a project with .claude/, .cursor/, .vscode/, " +
|
|
359
|
-
"or with --ide <vendor> (claude, cursor, windsurf, vscode).",
|
|
360
|
-
});
|
|
401
|
+
p.success("Skipping placement (--agent none)");
|
|
402
|
+
} else if (flags.vendors) {
|
|
403
|
+
vendors = flags.vendors;
|
|
404
|
+
if (vendors.length === 0) {
|
|
405
|
+
p.success("Skipping placement (--agent none).");
|
|
406
|
+
} else {
|
|
407
|
+
p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
|
|
361
408
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
409
|
+
} else {
|
|
410
|
+
vendors = await runDetectionPicker({
|
|
411
|
+
yes,
|
|
412
|
+
json: flags.json,
|
|
413
|
+
stdout,
|
|
414
|
+
detections: earlyDetections,
|
|
415
|
+
});
|
|
416
|
+
if (vendors.length === 0) {
|
|
417
|
+
p.success("Skipping placement (None selected).");
|
|
418
|
+
} else {
|
|
419
|
+
p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
|
|
372
420
|
}
|
|
373
|
-
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Validate the (vendor, --global) combination upfront. Catches cases
|
|
424
|
+
// like `--global --agent copilot` (Copilot has no personal scope)
|
|
425
|
+
// before any side-effectful work runs. Without this check, the
|
|
426
|
+
// validation error would fire from inside runSync and be swallowed
|
|
427
|
+
// by the partial-state try/catch below — exiting 0 with a warning
|
|
428
|
+
// when the user's invocation was structurally invalid.
|
|
429
|
+
if (Array.isArray(vendors) && vendors.length > 0) {
|
|
430
|
+
placementTargetsFor({
|
|
431
|
+
vendors: effectiveVendors({ vendors, global: flags.global }),
|
|
432
|
+
global: flags.global,
|
|
433
|
+
});
|
|
374
434
|
}
|
|
375
435
|
p.blank();
|
|
376
436
|
|
|
@@ -407,17 +467,41 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
407
467
|
}
|
|
408
468
|
p.blank();
|
|
409
469
|
|
|
410
|
-
// ── Step 6: Session sync (#884
|
|
470
|
+
// ── Step 6: Session sync (#884 Claude path + #1240 cohort path) ─
|
|
471
|
+
//
|
|
472
|
+
// Two sibling installers run here:
|
|
411
473
|
//
|
|
412
|
-
//
|
|
413
|
-
// `init-session-sync.mjs`.
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
474
|
+
// - Claude Code's SessionStart hook (#884) — `installSessionSyncHook`
|
|
475
|
+
// in `init-session-sync.mjs`. Has its own six-branch decision
|
|
476
|
+
// tree because Claude Code's hook needs an absolute-path command
|
|
477
|
+
// (Claude doesn't load PATH at hook time), which under `npx
|
|
478
|
+
// skillrepo init` requires offering to install skillrepo
|
|
479
|
+
// globally first.
|
|
480
|
+
// - Cohort SessionStart hooks (#1240) — `installCohortHooks` in
|
|
481
|
+
// `init-cohort-hooks.mjs`. Writes hooks for every selected vendor
|
|
482
|
+
// whose agent-registry entry has a non-null `agentHook` spec
|
|
483
|
+
// (Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). Uses
|
|
484
|
+
// `npx --yes skillrepo update --silent` as the universal hook
|
|
485
|
+
// command — no global install needed.
|
|
486
|
+
//
|
|
487
|
+
// The two flows are deliberately separate. Folding them into a single
|
|
488
|
+
// function would create irreducible branching by Phase 4 (#1241-1244)
|
|
489
|
+
// because Claude's binaryPath resolution has no analogue in the
|
|
490
|
+
// cohort path.
|
|
491
|
+
//
|
|
492
|
+
// `--no-session-sync` skips BOTH paths (semantics widened in #1240
|
|
493
|
+
// from "skip Claude" to "skip all auto-refresh hooks").
|
|
417
494
|
p.step(6, 7, "Session sync");
|
|
495
|
+
|
|
496
|
+
// Install the Claude Code SessionStart hook only when Claude Code
|
|
497
|
+
// is actually a target. Pre-#1249 this condition also included
|
|
498
|
+
// `Boolean(flags.global)` because bare `--global` historically
|
|
499
|
+
// routed to Claude; after #1249's effectiveVendors fix, `--global
|
|
500
|
+
// --agent windsurf` legitimately targets Windsurf only, and we
|
|
501
|
+
// must NOT silently add a Claude hook for users who never picked
|
|
502
|
+
// Claude. Manual E2E sweep caught this regression.
|
|
418
503
|
const claudeTargeted =
|
|
419
|
-
|
|
420
|
-
(Array.isArray(vendors) && vendors.includes("claudeCode"));
|
|
504
|
+
Array.isArray(vendors) && vendors.includes("claudeCode");
|
|
421
505
|
const sessionSync = await installSessionSyncHook({
|
|
422
506
|
noSessionSync,
|
|
423
507
|
claudeTargeted,
|
|
@@ -430,68 +514,103 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
430
514
|
const sessionSyncAction = sessionSync.action;
|
|
431
515
|
const sessionSyncPath = sessionSync.path;
|
|
432
516
|
const globalInstallActive = sessionSync.globalInstallActive;
|
|
517
|
+
|
|
518
|
+
// Cohort SessionStart hooks for every non-Claude selected vendor
|
|
519
|
+
// with an `agentHook` spec. Skipped under `--no-session-sync` AND
|
|
520
|
+
// when no eligible vendors are selected (e.g. `--agent claude` only,
|
|
521
|
+
// or `--agent none`). Per-vendor failures do not abort init —
|
|
522
|
+
// each result entry carries its own `action: "failed"` + `reason`.
|
|
523
|
+
const cohortHooksResult =
|
|
524
|
+
Array.isArray(vendors) && vendors.length > 0
|
|
525
|
+
? installCohortHooks({ noSessionSync, vendors, p })
|
|
526
|
+
: { hooks: [] };
|
|
433
527
|
p.blank();
|
|
434
528
|
|
|
435
529
|
// ── Step 7: First sync ───────────────────────────────────────
|
|
436
530
|
p.step(7, 7, "Pulling library");
|
|
437
531
|
let syncSummary;
|
|
438
532
|
let syncFailedReason = null;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
global: flags.global,
|
|
451
|
-
io: syncIo,
|
|
452
|
-
});
|
|
453
|
-
} catch (err) {
|
|
454
|
-
// A sync failure after the rest of init succeeded is a
|
|
455
|
-
// partial-state concern: config IS saved, MCP IS configured,
|
|
456
|
-
// and the access key IS validated — only the skill files
|
|
457
|
-
// haven't been fetched yet. The right recovery is `skillrepo
|
|
458
|
-
// update`, not a re-init. Surface the warning and exit 0 so
|
|
459
|
-
// the user sees the "run update later" message as actionable
|
|
460
|
-
// guidance rather than as a failed-command contradiction.
|
|
461
|
-
//
|
|
462
|
-
// Round-2 review caught the prior behavior (warn + unconditional
|
|
463
|
-
// rethrow) as an inconsistent contract: the warning told users
|
|
464
|
-
// to retry with `update`, but the non-zero exit code made it
|
|
465
|
-
// look like the whole init had failed.
|
|
466
|
-
p.warning(
|
|
467
|
-
`Config saved but first sync failed: ${err.message}. ` +
|
|
468
|
-
`Run \`skillrepo update\` later to retry.`,
|
|
469
|
-
);
|
|
470
|
-
syncFailedReason = err.message;
|
|
471
|
-
// Synthesize a zero-delta summary matching the SyncSummary
|
|
472
|
-
// typedef in sync.mjs (added, updated, removed, notModified,
|
|
473
|
-
// syncedAt). No `unchanged` or `failed` field — those were
|
|
474
|
-
// phantom fields the cross-review flagged; failure is signaled
|
|
475
|
-
// via `syncFailedReason` locally and via `sync.failureReason`
|
|
476
|
-
// in the --json output below.
|
|
533
|
+
// `--agent none` short-circuits the first sync: the user opted
|
|
534
|
+
// out of placement, and the credential write + gitignore + MCP
|
|
535
|
+
// bookkeeping that init also does are still complete. Calling
|
|
536
|
+
// runSync with an empty vendor list would throw inside
|
|
537
|
+
// placementTargetsFor (no vendors specified), and even if it
|
|
538
|
+
// didn't, fetching skill files we have nowhere to write is
|
|
539
|
+
// pure waste.
|
|
540
|
+
const skipFirstSync =
|
|
541
|
+
Array.isArray(vendors) && vendors.length === 0 && !flags.global;
|
|
542
|
+
if (skipFirstSync) {
|
|
543
|
+
p.success("Skipped first sync (--agent none).");
|
|
477
544
|
syncSummary = {
|
|
478
545
|
added: 0,
|
|
479
546
|
updated: 0,
|
|
480
547
|
removed: 0,
|
|
481
548
|
notModified: false,
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
// consumers: it looks like a legitimate "delta sync returned
|
|
487
|
-
// zero" signal. Using `null` makes the unknown-state
|
|
488
|
-
// explicit — any typed consumer must handle it separately
|
|
489
|
-
// from true/false. The always-present `sync.failureReason`
|
|
490
|
-
// field is still the authoritative "did the sync fail"
|
|
491
|
-
// indicator; fullSync is just additional context.
|
|
549
|
+
// Same null-fullSync rationale as the failure-recovery branch:
|
|
550
|
+
// the network call never ran, so reporting `false` would be a
|
|
551
|
+
// legitimate-looking "delta returned zero" lie. `null` makes
|
|
552
|
+
// the unknown-state explicit.
|
|
492
553
|
fullSync: null,
|
|
493
554
|
syncedAt: new Date().toISOString(),
|
|
494
555
|
};
|
|
556
|
+
} else {
|
|
557
|
+
try {
|
|
558
|
+
// In --json mode, suppress runSync's warning prints to stdout
|
|
559
|
+
// by passing a black-hole stream. (Its warnings go to stderr
|
|
560
|
+
// by default, which stays visible.)
|
|
561
|
+
const syncIo = flags.json
|
|
562
|
+
? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
|
|
563
|
+
: io;
|
|
564
|
+
syncSummary = await runSync({
|
|
565
|
+
serverUrl,
|
|
566
|
+
apiKey,
|
|
567
|
+
vendors: effectiveVendors({ vendors, global: flags.global }),
|
|
568
|
+
global: flags.global,
|
|
569
|
+
io: syncIo,
|
|
570
|
+
});
|
|
571
|
+
} catch (err) {
|
|
572
|
+
// A sync failure after the rest of init succeeded is a
|
|
573
|
+
// partial-state concern: config IS saved, MCP IS configured,
|
|
574
|
+
// and the access key IS validated — only the skill files
|
|
575
|
+
// haven't been fetched yet. The right recovery is `skillrepo
|
|
576
|
+
// update`, not a re-init. Surface the warning and exit 0 so
|
|
577
|
+
// the user sees the "run update later" message as actionable
|
|
578
|
+
// guidance rather than as a failed-command contradiction.
|
|
579
|
+
//
|
|
580
|
+
// Round-2 review caught the prior behavior (warn + unconditional
|
|
581
|
+
// rethrow) as an inconsistent contract: the warning told users
|
|
582
|
+
// to retry with `update`, but the non-zero exit code made it
|
|
583
|
+
// look like the whole init had failed.
|
|
584
|
+
p.warning(
|
|
585
|
+
`Config saved but first sync failed: ${err.message}. ` +
|
|
586
|
+
`Run \`skillrepo update\` later to retry.`,
|
|
587
|
+
);
|
|
588
|
+
syncFailedReason = err.message;
|
|
589
|
+
// Synthesize a zero-delta summary matching the SyncSummary
|
|
590
|
+
// typedef in sync.mjs (added, updated, removed, notModified,
|
|
591
|
+
// syncedAt). No `unchanged` or `failed` field — those were
|
|
592
|
+
// phantom fields the cross-review flagged; failure is signaled
|
|
593
|
+
// via `syncFailedReason` locally and via `sync.failureReason`
|
|
594
|
+
// in the --json output below.
|
|
595
|
+
syncSummary = {
|
|
596
|
+
added: 0,
|
|
597
|
+
updated: 0,
|
|
598
|
+
removed: 0,
|
|
599
|
+
notModified: false,
|
|
600
|
+
// On a synthesized failure summary we genuinely don't know
|
|
601
|
+
// whether the sync WOULD have been full or delta — the network
|
|
602
|
+
// call never completed. Architect review (v3.1.1) flagged that
|
|
603
|
+
// emitting `fullSync: false` here is misleading for --json
|
|
604
|
+
// consumers: it looks like a legitimate "delta sync returned
|
|
605
|
+
// zero" signal. Using `null` makes the unknown-state
|
|
606
|
+
// explicit — any typed consumer must handle it separately
|
|
607
|
+
// from true/false. The always-present `sync.failureReason`
|
|
608
|
+
// field is still the authoritative "did the sync fail"
|
|
609
|
+
// indicator; fullSync is just additional context.
|
|
610
|
+
fullSync: null,
|
|
611
|
+
syncedAt: new Date().toISOString(),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
495
614
|
}
|
|
496
615
|
|
|
497
616
|
const zeroDeltas =
|
|
@@ -547,9 +666,19 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
547
666
|
// Session-sync block. `action` is one of the
|
|
548
667
|
// `SessionSyncAction` enum values defined in
|
|
549
668
|
// `./session-sync-actions.mjs` (single source of truth).
|
|
669
|
+
// `cohortHooks` (added in #1240) reports per-vendor cohort
|
|
670
|
+
// SessionStart hook outcomes — empty array when no cohort
|
|
671
|
+
// vendor was selected or when --no-session-sync was passed.
|
|
550
672
|
sessionSync: {
|
|
551
673
|
action: sessionSyncAction,
|
|
552
674
|
path: sessionSyncPath,
|
|
675
|
+
cohortHooks: cohortHooksResult.hooks.map((h) => ({
|
|
676
|
+
vendorKey: h.vendorKey,
|
|
677
|
+
displayName: h.displayName,
|
|
678
|
+
path: h.path,
|
|
679
|
+
action: h.action,
|
|
680
|
+
...(h.reason ? { reason: h.reason } : {}),
|
|
681
|
+
})),
|
|
553
682
|
},
|
|
554
683
|
// Sync block always shows the counts (zeroed on failure)
|
|
555
684
|
// and adds `failureReason` when the first sync blew up —
|
|
@@ -609,10 +738,10 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
609
738
|
|
|
610
739
|
/**
|
|
611
740
|
* Parse init-specific flags. Uses resolveFlags for the common ones
|
|
612
|
-
* (--key/--url/--global/--
|
|
741
|
+
* (--key/--url/--global/--agent/--json) and pulls out --yes/-y + --force.
|
|
613
742
|
*/
|
|
614
743
|
function parseInitFlags(argv) {
|
|
615
|
-
// resolveFlags handles --key/--url/--global/--
|
|
744
|
+
// resolveFlags handles --key/--url/--global/--agent/--json and
|
|
616
745
|
// rejects unknown flags via its acceptPositional callback. We
|
|
617
746
|
// intercept --yes, --force, and --no-session-sync as "positional-
|
|
618
747
|
// shaped" flags via the callback so we don't need to pre-filter
|
|
@@ -654,3 +783,237 @@ function parseInitFlags(argv) {
|
|
|
654
783
|
|
|
655
784
|
return { flags, yes, force, noSessionSync };
|
|
656
785
|
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Build the picker rows from a detection result.
|
|
789
|
+
*
|
|
790
|
+
* Both Claude Code and the cohort row are pre-checked when ANY signal
|
|
791
|
+
* fires for that target. On a fresh clone with no detection, both
|
|
792
|
+
* rows are still pre-checked — the product's reason for existing is
|
|
793
|
+
* to set up skills, and defaulting to "do nothing" because env vars
|
|
794
|
+
* didn't fire is the bug we are fixing.
|
|
795
|
+
*
|
|
796
|
+
* @param {ReturnType<typeof detectAgents>} detections
|
|
797
|
+
* @returns {{ items: import("../lib/prompt-multiselect.mjs").MultiSelectItem[], claudeReason: string|null, cohortReasons: string[] }}
|
|
798
|
+
*/
|
|
799
|
+
function buildPickerItems(detections) {
|
|
800
|
+
const claudeDetection = detections.find((d) => d.key === "claudeCode");
|
|
801
|
+
const cohortDetections = detections.filter((d) =>
|
|
802
|
+
AGENTS_COHORT_KEYS.includes(d.key),
|
|
803
|
+
);
|
|
804
|
+
const cohortDetectedNames = cohortDetections
|
|
805
|
+
.filter((d) => d.detected)
|
|
806
|
+
.map((d) => d.displayName);
|
|
807
|
+
|
|
808
|
+
const claudeDetected = Boolean(claudeDetection?.detected);
|
|
809
|
+
const cohortDetected = cohortDetectedNames.length > 0;
|
|
810
|
+
|
|
811
|
+
// Idempotent re-run hint: if the target dir already exists with
|
|
812
|
+
// content, annotate the row.
|
|
813
|
+
const claudeAlreadyConfigured = directoryHasContent(claudeSkillsProjectRoot());
|
|
814
|
+
const cohortAlreadyConfigured = directoryHasContent(agentsSkillsProjectRoot());
|
|
815
|
+
|
|
816
|
+
const claudeHint = formatHint({
|
|
817
|
+
detectionReason: claudeDetected ? claudeDetection.reason : null,
|
|
818
|
+
pathLabel: ".claude/skills/",
|
|
819
|
+
alreadyConfigured: claudeAlreadyConfigured,
|
|
820
|
+
});
|
|
821
|
+
const cohortHint = formatHint({
|
|
822
|
+
detectionReason: cohortDetected ? formatCohortBrands(cohortDetectedNames) : null,
|
|
823
|
+
pathLabel: ".agents/skills/",
|
|
824
|
+
alreadyConfigured: cohortAlreadyConfigured,
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Three-state pre-check policy:
|
|
828
|
+
// 1. Detection fires for both targets → both rows pre-checked.
|
|
829
|
+
// 2. Detection fires for one target only → only that row pre-checked
|
|
830
|
+
// (the other stays empty so the picker nudges the user toward
|
|
831
|
+
// what we actually found).
|
|
832
|
+
// 3. No detection anywhere (fresh clone) → both rows pre-checked
|
|
833
|
+
// (the product's job is to set things up; defaulting to "do
|
|
834
|
+
// nothing" is the bug we are fixing).
|
|
835
|
+
const anySignal = claudeDetected || cohortDetected;
|
|
836
|
+
const claudePreChecked = anySignal ? claudeDetected : true;
|
|
837
|
+
const cohortPreChecked = anySignal ? cohortDetected : true;
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
claudeReason: claudeDetected ? claudeDetection.reason : null,
|
|
841
|
+
cohortReasons: cohortDetectedNames,
|
|
842
|
+
items: [
|
|
843
|
+
{
|
|
844
|
+
key: "claude",
|
|
845
|
+
label: "Claude Code",
|
|
846
|
+
hint: claudeHint,
|
|
847
|
+
preChecked: claudePreChecked,
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
key: "agents",
|
|
851
|
+
label: "Other agents",
|
|
852
|
+
hint: cohortHint,
|
|
853
|
+
preChecked: cohortPreChecked,
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
key: "none",
|
|
857
|
+
label: "None — I'll configure manually",
|
|
858
|
+
preChecked: false,
|
|
859
|
+
},
|
|
860
|
+
],
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Format the cohort detection reason. Up to 3 brand names listed,
|
|
866
|
+
* then "+N more" — keeps the picker hint readable when most cohort
|
|
867
|
+
* vendors fire at once.
|
|
868
|
+
*
|
|
869
|
+
* @param {string[]} names
|
|
870
|
+
*/
|
|
871
|
+
export function formatCohortBrands(names) {
|
|
872
|
+
if (names.length <= 3) return names.join(", ");
|
|
873
|
+
return `${names.slice(0, 3).join(", ")} +${names.length - 3} more`;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Build the per-row hint string. Combines the path label, the
|
|
878
|
+
* detection reason (or the "no signal" fallback), and the
|
|
879
|
+
* already-configured annotation when applicable.
|
|
880
|
+
*
|
|
881
|
+
* Exported for unit-test surface area (#1252 / QA round): the
|
|
882
|
+
* `(already configured — re-checking will refresh)` annotation has
|
|
883
|
+
* historically been only end-to-end-tested via the picker, which
|
|
884
|
+
* locks the wording to its rendering layer rather than to the data
|
|
885
|
+
* shape callers expect. Direct testing of this helper means a future
|
|
886
|
+
* refactor that splits picker rendering from row construction can't
|
|
887
|
+
* silently drop the annotation without breaking the targeted unit
|
|
888
|
+
* test.
|
|
889
|
+
*
|
|
890
|
+
* @param {object} args
|
|
891
|
+
* @param {string|null} args.detectionReason
|
|
892
|
+
* @param {string} args.pathLabel
|
|
893
|
+
* @param {boolean} args.alreadyConfigured
|
|
894
|
+
*/
|
|
895
|
+
export function formatHint({ detectionReason, pathLabel, alreadyConfigured }) {
|
|
896
|
+
const detectionPart = detectionReason
|
|
897
|
+
? `(detected: ${detectionReason})`
|
|
898
|
+
: "(not detected — leave checked if you use one)";
|
|
899
|
+
const configuredPart = alreadyConfigured
|
|
900
|
+
? " (already configured — re-checking will refresh)"
|
|
901
|
+
: "";
|
|
902
|
+
return `${pathLabel} ${detectionPart}${configuredPart}`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* `true` when `dir` exists and contains at least one entry. Used to
|
|
907
|
+
* annotate the picker rows with the idempotent "already configured"
|
|
908
|
+
* hint. We don't validate skill layout here — any content under the
|
|
909
|
+
* target dir means a prior run wrote there.
|
|
910
|
+
*
|
|
911
|
+
* Exported alongside `formatHint` for direct unit-test coverage of
|
|
912
|
+
* the idempotent re-run annotation path. See `formatHint` for the
|
|
913
|
+
* rationale.
|
|
914
|
+
*
|
|
915
|
+
* @param {string} dir
|
|
916
|
+
*/
|
|
917
|
+
export function directoryHasContent(dir) {
|
|
918
|
+
if (!existsSync(dir)) return false;
|
|
919
|
+
try {
|
|
920
|
+
const entries = readdirSync(dir);
|
|
921
|
+
return entries.length > 0;
|
|
922
|
+
} catch {
|
|
923
|
+
// Permission denied, race, etc. — treat as "no content" so the
|
|
924
|
+
// row is annotated as fresh. Annotation is informational; a
|
|
925
|
+
// false negative just shows the row without the "already
|
|
926
|
+
// configured" hint, which is harmless.
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Run the two-row picker and translate the user's selection into a
|
|
933
|
+
* vendors list. The output matches what `--agent` produces:
|
|
934
|
+
*
|
|
935
|
+
* - "claude" selected → ["claudeCode"]
|
|
936
|
+
* - "agents" selected → cohort keys
|
|
937
|
+
* - "claude" + "agents" selected → ["claudeCode", ...cohort]
|
|
938
|
+
* - "none" selected (alone) → []
|
|
939
|
+
* - "none" + anything else → rejected (validation error)
|
|
940
|
+
*
|
|
941
|
+
* Under `--yes`, the picker render is skipped: the pre-checked rows
|
|
942
|
+
* become the selection. With no detection, that means both default
|
|
943
|
+
* rows pre-checked → both targets configured. Spec rationale: writing
|
|
944
|
+
* a few KB the user didn't strictly need is trivial; CI running
|
|
945
|
+
* `init --yes` on a fresh clone and writing nothing is broken
|
|
946
|
+
* automation.
|
|
947
|
+
*
|
|
948
|
+
* @param {object} args
|
|
949
|
+
* @param {boolean} args.yes
|
|
950
|
+
* @param {boolean} args.json - True under --json: suppress the human
|
|
951
|
+
* explanatory header so stdout stays parseable.
|
|
952
|
+
* @param {NodeJS.WritableStream} args.stdout
|
|
953
|
+
* @param {ReturnType<typeof detectAgents>} args.detections - Captured
|
|
954
|
+
* BEFORE step 3 writes `~/.claude/skillrepo/config.json`
|
|
955
|
+
* (which would itself create `~/.claude/` and self-fire the
|
|
956
|
+
* Claude Code home-trace signal).
|
|
957
|
+
* @returns {Promise<string[]>}
|
|
958
|
+
*/
|
|
959
|
+
async function runDetectionPicker({ yes, json, stdout, detections }) {
|
|
960
|
+
const { items } = buildPickerItems(detections);
|
|
961
|
+
|
|
962
|
+
// Print the explanatory header before the picker (or before the
|
|
963
|
+
// pre-checked auto-select under --yes). Keeps the user grounded
|
|
964
|
+
// in WHAT will be written WHERE. Suppressed under --json so the
|
|
965
|
+
// final JSON blob is the only thing on stdout.
|
|
966
|
+
if (!json) {
|
|
967
|
+
stdout.write(
|
|
968
|
+
"\n This will write skills to your project under:\n" +
|
|
969
|
+
" .claude/skills/ (for Claude Code)\n" +
|
|
970
|
+
" .agents/skills/ (for Cursor, Windsurf, Gemini CLI, Codex CLI, " +
|
|
971
|
+
"Cline, Copilot, and others)\n" +
|
|
972
|
+
"\n" +
|
|
973
|
+
" Both paths are added to .gitignore — skills are a per-developer " +
|
|
974
|
+
"cache, not committed.\n\n",
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
let selectedKeys;
|
|
979
|
+
if (yes) {
|
|
980
|
+
// Auto-select: pre-checked rows become the selection.
|
|
981
|
+
selectedKeys = items
|
|
982
|
+
.filter((it) => it.preChecked)
|
|
983
|
+
.map((it) => it.key);
|
|
984
|
+
} else {
|
|
985
|
+
selectedKeys = await promptMultiSelect(
|
|
986
|
+
{ question: "Set up which targets?", items },
|
|
987
|
+
{ stdout },
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return translatePickerSelection(selectedKeys);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Map the picker's selected keys to the canonical vendor list.
|
|
996
|
+
* Exported for test surface area would be ideal but the function is
|
|
997
|
+
* an internal helper to `runDetectionPicker` — translation is
|
|
998
|
+
* exercised end-to-end via `init.test.mjs`.
|
|
999
|
+
*
|
|
1000
|
+
* @param {string[]} selected
|
|
1001
|
+
* @returns {string[]}
|
|
1002
|
+
*/
|
|
1003
|
+
function translatePickerSelection(selected) {
|
|
1004
|
+
const hasNone = selected.includes("none");
|
|
1005
|
+
const hasOther = selected.some((k) => k !== "none");
|
|
1006
|
+
if (hasNone && hasOther) {
|
|
1007
|
+
throw validationError(
|
|
1008
|
+
"Cannot mix 'None' with other targets in the picker.",
|
|
1009
|
+
{
|
|
1010
|
+
hint: "Pick either 'None' alone (skip placement) or one or both of the configured target rows.",
|
|
1011
|
+
},
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (hasNone) return [];
|
|
1015
|
+
const out = [];
|
|
1016
|
+
if (selected.includes("claude")) out.push("claudeCode");
|
|
1017
|
+
if (selected.includes("agents")) out.push(...AGENTS_COHORT_KEYS);
|
|
1018
|
+
return out;
|
|
1019
|
+
}
|