skillrepo 3.1.0 → 3.1.1
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 +3 -1
- package/package.json +1 -1
- package/src/commands/init.mjs +52 -5
- package/src/lib/artifact-registry.mjs +43 -3
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +9 -10
- package/src/lib/mergers/session-hook.mjs +99 -19
- package/src/lib/platform.mjs +124 -0
- package/src/lib/sync.mjs +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +228 -15
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +25 -23
- package/src/test/commands/uninstall.test.mjs +20 -14
- package/src/test/commands/update.test.mjs +10 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +441 -11
- package/src/test/mergers/uninstall-settings.test.mjs +12 -1
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform conventions — single source of truth for OS-specific
|
|
3
|
+
* differences the CLI has to honor.
|
|
4
|
+
*
|
|
5
|
+
* The CLI runs on POSIX systems (macOS, Linux) and Windows. Most
|
|
6
|
+
* code paths are platform-neutral via Node built-ins (path.join,
|
|
7
|
+
* os.homedir, fs.rmSync, etc.) — but a handful of surfaces have
|
|
8
|
+
* real platform differences that can't be abstracted away at the
|
|
9
|
+
* Node level:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Binary-locator command**. POSIX provides `which`; Windows
|
|
12
|
+
* provides `where.exe`. `execFileSync` doesn't spawn a shell,
|
|
13
|
+
* so the literal name must exist on disk.
|
|
14
|
+
*
|
|
15
|
+
* 2. **Hook shell backstop suffix**. The SessionStart hook command
|
|
16
|
+
* relies on a shell-level fallback (`|| true`) to guarantee
|
|
17
|
+
* exit 0 even if the binary vanishes. POSIX shells support it;
|
|
18
|
+
* cmd.exe doesn't know the `true` builtin and would emit a
|
|
19
|
+
* confusing error. The `--session-hook` flag's exit-0 contract
|
|
20
|
+
* inside the Node process is the primary defense regardless of
|
|
21
|
+
* platform; the shell backstop is belt-and-suspenders that we
|
|
22
|
+
* lose on Windows.
|
|
23
|
+
*
|
|
24
|
+
* 3. **POSIX file permissions**. `chmodSync(0o600)` silently
|
|
25
|
+
* succeeds on Windows but doesn't produce the intended effect —
|
|
26
|
+
* Windows's ACL model doesn't map to the Unix mode bits. Any
|
|
27
|
+
* call meant to restrict permissions on credential files must
|
|
28
|
+
* be guarded so Windows users aren't misled into thinking their
|
|
29
|
+
* files are access-controlled when they aren't.
|
|
30
|
+
*
|
|
31
|
+
* 4. **Atomic directory replacement semantics**. POSIX's
|
|
32
|
+
* `renameSync` over an existing directory is atomic on the same
|
|
33
|
+
* filesystem — the swap is instantaneous from the perspective
|
|
34
|
+
* of any concurrent reader. Windows fails with EEXIST/EPERM if
|
|
35
|
+
* the target exists; the replacement must be done as a
|
|
36
|
+
* remove-then-rename sequence with a small window where the
|
|
37
|
+
* target is missing. Callers that write skills have to know
|
|
38
|
+
* which strategy applies so they can surface a meaningful
|
|
39
|
+
* recovery hint if the Windows path fails mid-sequence.
|
|
40
|
+
*
|
|
41
|
+
* This module exposes a single `platformConventions()` function that
|
|
42
|
+
* returns a frozen object with every platform-specific value the
|
|
43
|
+
* CLI needs. New platform-specific surfaces should be added here
|
|
44
|
+
* rather than spreading ad-hoc `platform() === "win32"` checks
|
|
45
|
+
* across the codebase. This is a convention, not an enforced rule —
|
|
46
|
+
* it's documentation plus a consumer pattern, not a linter.
|
|
47
|
+
*
|
|
48
|
+
* The `platform` parameter is an optional override for tests —
|
|
49
|
+
* production callers let it default to `os.platform()`.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { platform as osPlatform } from "node:os";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} PlatformConventions
|
|
56
|
+
* @property {"posix" | "windows"} family - High-level family name.
|
|
57
|
+
* @property {string} binaryLocator - Command used to resolve a
|
|
58
|
+
* binary's absolute path from PATH. `"which"` on POSIX,
|
|
59
|
+
* `"where"` on Windows.
|
|
60
|
+
* @property {string} hookShellSuffix - Suffix appended to hook
|
|
61
|
+
* commands to guarantee exit 0 at the shell level. `" || true"`
|
|
62
|
+
* on POSIX (appended to the base command), empty string on
|
|
63
|
+
* Windows (the `--session-hook` flag's exit-0 contract is
|
|
64
|
+
* the only defense).
|
|
65
|
+
* @property {boolean} supportsPosixPermissions - True when
|
|
66
|
+
* `chmodSync(mode)` produces the intended POSIX mode-bit
|
|
67
|
+
* effect. False on Windows, where the call nominally
|
|
68
|
+
* succeeds but doesn't restrict ACLs the way a 0600 bit
|
|
69
|
+
* would on Unix. Callers use this to skip chmod on
|
|
70
|
+
* Windows rather than leave misleading "perms applied"
|
|
71
|
+
* success paths that don't actually restrict access.
|
|
72
|
+
* @property {boolean} supportsAtomicDirectoryRename - True when
|
|
73
|
+
* `renameSync` over an existing directory is atomic.
|
|
74
|
+
* POSIX: true. Windows: false — callers must implement
|
|
75
|
+
* remove-then-rename, accepting the small non-atomic
|
|
76
|
+
* window where the target doesn't exist. The Windows
|
|
77
|
+
* code path MUST produce a recoverable failure state if
|
|
78
|
+
* the rename step fails (i.e. leave the `.tmp/` dir on
|
|
79
|
+
* disk so the user can rename it manually).
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
const POSIX = Object.freeze({
|
|
83
|
+
family: "posix",
|
|
84
|
+
binaryLocator: "which",
|
|
85
|
+
hookShellSuffix: " || true",
|
|
86
|
+
supportsPosixPermissions: true,
|
|
87
|
+
supportsAtomicDirectoryRename: true,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const WINDOWS = Object.freeze({
|
|
91
|
+
family: "windows",
|
|
92
|
+
binaryLocator: "where",
|
|
93
|
+
hookShellSuffix: "",
|
|
94
|
+
supportsPosixPermissions: false,
|
|
95
|
+
supportsAtomicDirectoryRename: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Return the platform-specific convention set for the current
|
|
100
|
+
* platform, or an override.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} [options]
|
|
103
|
+
* @param {NodeJS.Platform} [options.platform] - Override for testing.
|
|
104
|
+
* Production callers should let this default to the real
|
|
105
|
+
* runtime platform.
|
|
106
|
+
* @returns {PlatformConventions}
|
|
107
|
+
*/
|
|
108
|
+
export function platformConventions({ platform: platformOverride } = {}) {
|
|
109
|
+
const plat = platformOverride ?? osPlatform();
|
|
110
|
+
return plat === "win32" ? WINDOWS : POSIX;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True if the current (or overridden) platform is Windows.
|
|
115
|
+
* Convenience wrapper — prefer `platformConventions().family ===
|
|
116
|
+
* "windows"` in call sites that already hold a conventions object.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} [options]
|
|
119
|
+
* @param {NodeJS.Platform} [options.platform]
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isWindows({ platform: platformOverride } = {}) {
|
|
123
|
+
return platformConventions({ platform: platformOverride }).family === "windows";
|
|
124
|
+
}
|
package/src/lib/sync.mjs
CHANGED
|
@@ -27,6 +27,22 @@
|
|
|
27
27
|
* @property {number} updated - Skills overwritten on disk
|
|
28
28
|
* @property {number} removed - Tombstones applied
|
|
29
29
|
* @property {boolean} notModified - True if 304 short-circuit fired
|
|
30
|
+
* @property {boolean | null} fullSync - True if this was a full (non-delta)
|
|
31
|
+
* sync — i.e. no prior `.last-sync` state
|
|
32
|
+
* existed so no `since` was sent. False if
|
|
33
|
+
* this was a delta sync. `null` only ever
|
|
34
|
+
* appears in SYNTHESIZED failure summaries
|
|
35
|
+
* (e.g. `init.mjs` when runSync threw) to
|
|
36
|
+
* mark the state as genuinely unknown —
|
|
37
|
+
* consumers should never see `null` from
|
|
38
|
+
* runSync itself. A full sync returning
|
|
39
|
+
* zero skills genuinely means "empty
|
|
40
|
+
* library"; a delta sync returning zero
|
|
41
|
+
* means "nothing changed since last sync".
|
|
42
|
+
* Consumers must distinguish these to
|
|
43
|
+
* render accurate user messages (see
|
|
44
|
+
* init.mjs step 7).
|
|
45
|
+
* @property {string} [syncedAt]
|
|
30
46
|
* @property {string} syncedAt - ISO timestamp from the server response
|
|
31
47
|
*
|
|
32
48
|
* @typedef {Object} SyncStateFile
|
|
@@ -182,6 +198,14 @@ export async function runSync(options) {
|
|
|
182
198
|
if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
|
|
183
199
|
if (lastSync?.syncedAt) opts.since = lastSync.syncedAt;
|
|
184
200
|
|
|
201
|
+
// Track whether this is a full or delta sync BEFORE the network
|
|
202
|
+
// call, for the returned summary's `fullSync` field. A "full" sync
|
|
203
|
+
// is one where no `since` was sent — which is exactly when no
|
|
204
|
+
// prior last-sync state existed. The distinction matters to
|
|
205
|
+
// consumers (init.mjs) that need to tell "empty library" from
|
|
206
|
+
// "nothing changed since last sync" in the zero-counters case.
|
|
207
|
+
const fullSync = !lastSync?.syncedAt;
|
|
208
|
+
|
|
185
209
|
const result = await getLibrary(serverUrl, apiKey, opts);
|
|
186
210
|
|
|
187
211
|
// Step 4: 304 short-circuit
|
|
@@ -191,6 +215,7 @@ export async function runSync(options) {
|
|
|
191
215
|
updated: 0,
|
|
192
216
|
removed: 0,
|
|
193
217
|
notModified: true,
|
|
218
|
+
fullSync,
|
|
194
219
|
syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
|
|
195
220
|
};
|
|
196
221
|
}
|
|
@@ -201,6 +226,7 @@ export async function runSync(options) {
|
|
|
201
226
|
updated: 0,
|
|
202
227
|
removed: 0,
|
|
203
228
|
notModified: false,
|
|
229
|
+
fullSync,
|
|
204
230
|
syncedAt: result.syncedAt,
|
|
205
231
|
};
|
|
206
232
|
|
|
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
|
13
13
|
import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
|
|
14
14
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
15
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
|
+
import {
|
|
17
|
+
captureHome,
|
|
18
|
+
setSandboxHome,
|
|
19
|
+
restoreHome,
|
|
20
|
+
} from "../helpers/sandbox-home.mjs";
|
|
16
21
|
|
|
17
22
|
let sandbox;
|
|
18
23
|
let server;
|
|
19
24
|
let serverUrl;
|
|
20
25
|
let originalCwd;
|
|
21
|
-
|
|
26
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
27
|
+
let originalHomeEnv;
|
|
22
28
|
let stdout;
|
|
23
29
|
const VALID_KEY = "sk_live_test";
|
|
24
30
|
|
|
@@ -46,9 +52,9 @@ async function setup() {
|
|
|
46
52
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
47
53
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
48
54
|
originalCwd = process.cwd();
|
|
49
|
-
|
|
55
|
+
originalHomeEnv = captureHome();
|
|
50
56
|
process.chdir(join(sandbox, "project"));
|
|
51
|
-
|
|
57
|
+
setSandboxHome(join(sandbox, "home"));
|
|
52
58
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
53
59
|
|
|
54
60
|
server = createMockServer({});
|
|
@@ -61,7 +67,7 @@ async function setup() {
|
|
|
61
67
|
async function teardown() {
|
|
62
68
|
if (server) await server.stop();
|
|
63
69
|
process.chdir(originalCwd);
|
|
64
|
-
|
|
70
|
+
restoreHome(originalHomeEnv);
|
|
65
71
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
66
72
|
server = null;
|
|
67
73
|
}
|
|
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
|
13
13
|
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
14
14
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
15
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
|
+
import {
|
|
17
|
+
captureHome,
|
|
18
|
+
setSandboxHome,
|
|
19
|
+
restoreHome,
|
|
20
|
+
} from "../helpers/sandbox-home.mjs";
|
|
16
21
|
|
|
17
22
|
let sandbox;
|
|
18
23
|
let server;
|
|
19
24
|
let serverUrl;
|
|
20
25
|
let originalCwd;
|
|
21
|
-
|
|
26
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
27
|
+
let originalHomeEnv;
|
|
22
28
|
let stdout;
|
|
23
29
|
const VALID_KEY = "sk_live_test";
|
|
24
30
|
|
|
@@ -53,9 +59,9 @@ async function setup() {
|
|
|
53
59
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
54
60
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
55
61
|
originalCwd = process.cwd();
|
|
56
|
-
|
|
62
|
+
originalHomeEnv = captureHome();
|
|
57
63
|
process.chdir(join(sandbox, "project"));
|
|
58
|
-
|
|
64
|
+
setSandboxHome(join(sandbox, "home"));
|
|
59
65
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
60
66
|
delete process.env.SKILLREPO_URL;
|
|
61
67
|
|
|
@@ -69,7 +75,7 @@ async function setup() {
|
|
|
69
75
|
async function teardown() {
|
|
70
76
|
if (server) await server.stop();
|
|
71
77
|
process.chdir(originalCwd);
|
|
72
|
-
|
|
78
|
+
restoreHome(originalHomeEnv);
|
|
73
79
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
74
80
|
server = null;
|
|
75
81
|
}
|
|
@@ -20,12 +20,18 @@ import { readConfig } from "../../lib/config.mjs";
|
|
|
20
20
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
21
21
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
22
22
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
23
|
+
import {
|
|
24
|
+
captureHome,
|
|
25
|
+
setSandboxHome,
|
|
26
|
+
restoreHome,
|
|
27
|
+
} from "../helpers/sandbox-home.mjs";
|
|
23
28
|
|
|
24
29
|
let sandbox;
|
|
25
30
|
let server;
|
|
26
31
|
let serverUrl;
|
|
27
32
|
let originalCwd;
|
|
28
|
-
|
|
33
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
34
|
+
let originalHomeEnv;
|
|
29
35
|
let stdout;
|
|
30
36
|
let stderr;
|
|
31
37
|
const VALID_KEY = "sk_live_init_test";
|
|
@@ -38,9 +44,9 @@ async function setup() {
|
|
|
38
44
|
mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
|
|
39
45
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
40
46
|
originalCwd = process.cwd();
|
|
41
|
-
|
|
47
|
+
originalHomeEnv = captureHome();
|
|
42
48
|
process.chdir(join(sandbox, "project"));
|
|
43
|
-
|
|
49
|
+
setSandboxHome(join(sandbox, "home"));
|
|
44
50
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
45
51
|
delete process.env.SKILLREPO_URL;
|
|
46
52
|
|
|
@@ -55,7 +61,7 @@ async function setup() {
|
|
|
55
61
|
async function teardown() {
|
|
56
62
|
if (server) await server.stop();
|
|
57
63
|
process.chdir(originalCwd);
|
|
58
|
-
|
|
64
|
+
restoreHome(originalHomeEnv);
|
|
59
65
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
60
66
|
server = null;
|
|
61
67
|
}
|
|
@@ -499,25 +505,29 @@ describe("runInit — stale-key handling", () => {
|
|
|
499
505
|
// init tests verify the ORCHESTRATION: --yes path, --no-session-sync
|
|
500
506
|
// opt-out, --json output shape, and the non-fatal disk-error path.
|
|
501
507
|
|
|
502
|
-
import { chmodSync as _chmodSync } from "node:fs";
|
|
503
508
|
import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
509
|
+
import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
|
|
510
|
+
|
|
511
|
+
/** @type {ReturnType<typeof installShim> | undefined} */
|
|
512
|
+
let _shimHandle;
|
|
504
513
|
|
|
505
514
|
async function setupWithShim() {
|
|
506
515
|
await setup();
|
|
507
|
-
// Drop a
|
|
508
|
-
//
|
|
509
|
-
// global install.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
+
// Drop a cross-platform `skillrepo` shim into HOME/bin so the
|
|
517
|
+
// CLI's binary resolver (which/where) finds it rather than
|
|
518
|
+
// depending on whether the dev/CI machine has a global install.
|
|
519
|
+
_shimHandle = installShim(process.env.HOME);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function teardownWithShim() {
|
|
523
|
+
uninstallShim(_shimHandle);
|
|
524
|
+
_shimHandle = undefined;
|
|
525
|
+
await teardown();
|
|
516
526
|
}
|
|
517
527
|
|
|
518
528
|
describe("runInit — session sync (#884)", () => {
|
|
519
529
|
beforeEach(setupWithShim);
|
|
520
|
-
afterEach(
|
|
530
|
+
afterEach(teardownWithShim);
|
|
521
531
|
|
|
522
532
|
it("--yes installs the hook at step 6 by default", async () => {
|
|
523
533
|
// INTENT: the architect-designed default for --yes mode is
|
|
@@ -695,3 +705,206 @@ describe("runInit — session sync (#884)", () => {
|
|
|
695
705
|
assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
|
|
696
706
|
});
|
|
697
707
|
});
|
|
708
|
+
|
|
709
|
+
// ── v3.1.1 patch fixes: init UX bugs surfaced by real-world npx use ──
|
|
710
|
+
//
|
|
711
|
+
// Real-user `npx skillrepo@latest init` session surfaced four bugs in
|
|
712
|
+
// the v3.1.0 init output. The tests below lock each fix as a
|
|
713
|
+
// behavioral contract:
|
|
714
|
+
//
|
|
715
|
+
// 1. "Next steps" hardcoded `skillrepo` even under npx — would fail
|
|
716
|
+
// with "command not found" for users without a global install.
|
|
717
|
+
// 2. Session-sync hook installed under npx with a cache-temporary
|
|
718
|
+
// path that breaks on cache eviction. (Covered in
|
|
719
|
+
// session-hook.test.mjs via isNpxInvocation guard test.)
|
|
720
|
+
// 3. Step-7 zero-delta message conflated "empty library" with
|
|
721
|
+
// "nothing changed since last sync" — lied to users who had
|
|
722
|
+
// synced skills but no server-side changes since.
|
|
723
|
+
// 4. `--verbose` rejected as Unknown argument by resolveFlags.
|
|
724
|
+
// (Covered in cli-config.test.mjs.)
|
|
725
|
+
|
|
726
|
+
describe("runInit — v3.1.1 zero-delta message (bug 3)", () => {
|
|
727
|
+
beforeEach(setup);
|
|
728
|
+
afterEach(teardown);
|
|
729
|
+
|
|
730
|
+
it("reports 'Library is up to date' when a delta sync returns zero changes", async () => {
|
|
731
|
+
// Reproduces the real-user bug: a user with an existing
|
|
732
|
+
// .last-sync from a prior v3.0.0 session runs `skillrepo init`
|
|
733
|
+
// again. Init's step 7 sends `since=<prior syncedAt>`. Server
|
|
734
|
+
// returns empty skills[] because nothing changed server-side.
|
|
735
|
+
// Counters (added/updated/removed) all zero.
|
|
736
|
+
//
|
|
737
|
+
// Before the fix: init printed "No skills in library yet"
|
|
738
|
+
// regardless of whether the library was empty or fully synced.
|
|
739
|
+
// After: init distinguishes via the new `fullSync` field in
|
|
740
|
+
// SyncSummary and prints "Library is up to date (no changes
|
|
741
|
+
// since last sync)" for the delta case.
|
|
742
|
+
//
|
|
743
|
+
// Pre-seed a .last-sync file simulating the prior sync state.
|
|
744
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
745
|
+
writeFileSync(
|
|
746
|
+
join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
|
|
747
|
+
JSON.stringify({
|
|
748
|
+
schemaVersion: 1,
|
|
749
|
+
etag: '"old-v300-format-etag"',
|
|
750
|
+
syncedAt: "2026-04-15T12:00:00.000Z",
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
// Server returns empty skills[] (no changes since last sync).
|
|
755
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
|
|
756
|
+
|
|
757
|
+
await runInit(
|
|
758
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
759
|
+
{ stdout, stderr },
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const out = stdout.text();
|
|
763
|
+
assert.match(
|
|
764
|
+
out,
|
|
765
|
+
/Library is up to date/,
|
|
766
|
+
"zero-delta on delta sync must report 'up to date', not 'no skills in library yet'",
|
|
767
|
+
);
|
|
768
|
+
assert.doesNotMatch(
|
|
769
|
+
out,
|
|
770
|
+
/No skills in library yet/,
|
|
771
|
+
"the misleading 'no skills' message must NOT fire when .last-sync existed",
|
|
772
|
+
);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("still reports 'No skills in library yet' on a true first-run full sync with zero skills", async () => {
|
|
776
|
+
// The other side of bug 3: when there's NO prior .last-sync and
|
|
777
|
+
// the server returns zero skills, the library IS genuinely
|
|
778
|
+
// empty. The message should still say so.
|
|
779
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
|
|
780
|
+
|
|
781
|
+
await runInit(
|
|
782
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
783
|
+
{ stdout, stderr },
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const out = stdout.text();
|
|
787
|
+
assert.match(out, /No skills in library yet/);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("304 Not Modified reports 'Library is up to date' (neutral phrasing)", async () => {
|
|
791
|
+
// 304 path: server returned "nothing changed" at the HTTP
|
|
792
|
+
// level. Definitively up-to-date regardless of whether library
|
|
793
|
+
// is populated or empty.
|
|
794
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
795
|
+
writeFileSync(
|
|
796
|
+
join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
|
|
797
|
+
JSON.stringify({
|
|
798
|
+
schemaVersion: 1,
|
|
799
|
+
etag: '"match-me"',
|
|
800
|
+
syncedAt: "2026-04-15T12:00:00.000Z",
|
|
801
|
+
}),
|
|
802
|
+
);
|
|
803
|
+
server.setEtag('"match-me"');
|
|
804
|
+
|
|
805
|
+
await runInit(
|
|
806
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
807
|
+
{ stdout, stderr },
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
assert.match(stdout.text(), /Library is up to date/);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
|
|
815
|
+
// Process-state isolation: the tests below depend on controlling
|
|
816
|
+
// isNpxInvocation()'s outputs. The test host's shell environment
|
|
817
|
+
// may have `_` set to some launching command (the enclosing `npm
|
|
818
|
+
// run check`, `node --test`, etc.). beforeEach force-clears the
|
|
819
|
+
// two isNpxInvocation signals (argv[1] and `_`) plus the
|
|
820
|
+
// now-unused `npm_command` — the latter is cleared defensively
|
|
821
|
+
// because older versions of this codebase treated it as an npx
|
|
822
|
+
// signal, and belt-and-suspenders cleanup costs nothing.
|
|
823
|
+
let originalArgv;
|
|
824
|
+
let originalNpmCommand;
|
|
825
|
+
let originalUnderscore;
|
|
826
|
+
|
|
827
|
+
beforeEach(async () => {
|
|
828
|
+
await setup();
|
|
829
|
+
originalArgv = process.argv;
|
|
830
|
+
originalNpmCommand = process.env.npm_command;
|
|
831
|
+
originalUnderscore = process.env._;
|
|
832
|
+
// Non-npx state:
|
|
833
|
+
process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
|
|
834
|
+
delete process.env.npm_command;
|
|
835
|
+
delete process.env._;
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
afterEach(async () => {
|
|
839
|
+
process.argv = originalArgv;
|
|
840
|
+
if (originalNpmCommand === undefined) delete process.env.npm_command;
|
|
841
|
+
else process.env.npm_command = originalNpmCommand;
|
|
842
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
843
|
+
else process.env._ = originalUnderscore;
|
|
844
|
+
await teardown();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("shows bare `skillrepo` prefix when running from a stable install", async () => {
|
|
848
|
+
// With all npx signals cleared (beforeEach above), isNpxInvocation
|
|
849
|
+
// returns false and the bare prefix is used in the Next Steps
|
|
850
|
+
// block.
|
|
851
|
+
//
|
|
852
|
+
// NOTE ON REGEX: "npm install -g skillrepo" appears in TWO places
|
|
853
|
+
// that look alike but come from different code paths:
|
|
854
|
+
//
|
|
855
|
+
// 1. Step 6/7 session-sync skipped message (when the binary
|
|
856
|
+
// can't be resolved — in the test environment, `which
|
|
857
|
+
// skillrepo` doesn't find anything real).
|
|
858
|
+
// 2. Next Steps tip (only shown under npx).
|
|
859
|
+
//
|
|
860
|
+
// We want to assert only #2 is absent. Match the EXACT "Tip:"
|
|
861
|
+
// prefix to scope the assertion to Next Steps.
|
|
862
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
863
|
+
await runInit(
|
|
864
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
865
|
+
{ stdout, stderr },
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const out = stdout.text();
|
|
869
|
+
// The commands in Next Steps are bare, not prefixed.
|
|
870
|
+
assert.match(out, /^ +• skillrepo list/m);
|
|
871
|
+
assert.doesNotMatch(
|
|
872
|
+
out,
|
|
873
|
+
/npx skillrepo list/,
|
|
874
|
+
"bare install must NOT show the npx-prefixed hints",
|
|
875
|
+
);
|
|
876
|
+
// The "Tip: …" line appears only under npx.
|
|
877
|
+
assert.doesNotMatch(
|
|
878
|
+
out,
|
|
879
|
+
/Tip: `npm install -g skillrepo`/,
|
|
880
|
+
"bare install must NOT show the npx-mode global-install tip",
|
|
881
|
+
);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("shows `npx skillrepo` prefix and global-install tip under npx invocation", async () => {
|
|
885
|
+
// Simulate npx by stuffing argv[1] with an _npx cache path.
|
|
886
|
+
// isNpxInvocation() returns true and the output changes shape.
|
|
887
|
+
process.argv = [
|
|
888
|
+
"/usr/local/bin/node",
|
|
889
|
+
"/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
|
|
890
|
+
];
|
|
891
|
+
|
|
892
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
893
|
+
await runInit(
|
|
894
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
895
|
+
{ stdout, stderr },
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
const out = stdout.text();
|
|
899
|
+
assert.match(
|
|
900
|
+
out,
|
|
901
|
+
/npx skillrepo list/,
|
|
902
|
+
"npx invocation must prefix commands with `npx`",
|
|
903
|
+
);
|
|
904
|
+
assert.match(
|
|
905
|
+
out,
|
|
906
|
+
/Tip: `npm install -g skillrepo`/,
|
|
907
|
+
"npx invocation must show the Next-Steps global-install tip",
|
|
908
|
+
);
|
|
909
|
+
});
|
|
910
|
+
});
|
|
@@ -12,12 +12,18 @@ import { runList } from "../../commands/list.mjs";
|
|
|
12
12
|
import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
|
|
13
13
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
14
14
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
15
|
+
import {
|
|
16
|
+
captureHome,
|
|
17
|
+
setSandboxHome,
|
|
18
|
+
restoreHome,
|
|
19
|
+
} from "../helpers/sandbox-home.mjs";
|
|
15
20
|
|
|
16
21
|
let sandbox;
|
|
17
22
|
let server;
|
|
18
23
|
let serverUrl;
|
|
19
24
|
let originalCwd;
|
|
20
|
-
|
|
25
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
26
|
+
let originalHomeEnv;
|
|
21
27
|
let stdout;
|
|
22
28
|
const VALID_KEY = "sk_live_test";
|
|
23
29
|
|
|
@@ -37,9 +43,9 @@ async function setup() {
|
|
|
37
43
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
38
44
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
39
45
|
originalCwd = process.cwd();
|
|
40
|
-
|
|
46
|
+
originalHomeEnv = captureHome();
|
|
41
47
|
process.chdir(join(sandbox, "project"));
|
|
42
|
-
|
|
48
|
+
setSandboxHome(join(sandbox, "home"));
|
|
43
49
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
44
50
|
|
|
45
51
|
server = createMockServer({});
|
|
@@ -52,7 +58,7 @@ async function setup() {
|
|
|
52
58
|
async function teardown() {
|
|
53
59
|
if (server) await server.stop();
|
|
54
60
|
process.chdir(originalCwd);
|
|
55
|
-
|
|
61
|
+
restoreHome(originalHomeEnv);
|
|
56
62
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
57
63
|
server = null;
|
|
58
64
|
}
|
|
@@ -13,12 +13,18 @@ import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
|
13
13
|
import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
|
|
14
14
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
15
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
|
+
import {
|
|
17
|
+
captureHome,
|
|
18
|
+
setSandboxHome,
|
|
19
|
+
restoreHome,
|
|
20
|
+
} from "../helpers/sandbox-home.mjs";
|
|
16
21
|
|
|
17
22
|
let sandbox;
|
|
18
23
|
let server;
|
|
19
24
|
let serverUrl;
|
|
20
25
|
let originalCwd;
|
|
21
|
-
|
|
26
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
27
|
+
let originalHomeEnv;
|
|
22
28
|
let stdout;
|
|
23
29
|
const VALID_KEY = "sk_live_test";
|
|
24
30
|
|
|
@@ -46,9 +52,9 @@ async function setup() {
|
|
|
46
52
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
47
53
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
48
54
|
originalCwd = process.cwd();
|
|
49
|
-
|
|
55
|
+
originalHomeEnv = captureHome();
|
|
50
56
|
process.chdir(join(sandbox, "project"));
|
|
51
|
-
|
|
57
|
+
setSandboxHome(join(sandbox, "home"));
|
|
52
58
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
53
59
|
|
|
54
60
|
server = createMockServer({});
|
|
@@ -61,7 +67,7 @@ async function setup() {
|
|
|
61
67
|
async function teardown() {
|
|
62
68
|
if (server) await server.stop();
|
|
63
69
|
process.chdir(originalCwd);
|
|
64
|
-
|
|
70
|
+
restoreHome(originalHomeEnv);
|
|
65
71
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
66
72
|
server = null;
|
|
67
73
|
}
|
|
@@ -12,12 +12,18 @@ import { runSearch } from "../../commands/search.mjs";
|
|
|
12
12
|
import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
|
|
13
13
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
14
14
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
15
|
+
import {
|
|
16
|
+
captureHome,
|
|
17
|
+
setSandboxHome,
|
|
18
|
+
restoreHome,
|
|
19
|
+
} from "../helpers/sandbox-home.mjs";
|
|
15
20
|
|
|
16
21
|
let sandbox;
|
|
17
22
|
let server;
|
|
18
23
|
let serverUrl;
|
|
19
24
|
let originalCwd;
|
|
20
|
-
|
|
25
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
26
|
+
let originalHomeEnv;
|
|
21
27
|
let stdout;
|
|
22
28
|
let stderr;
|
|
23
29
|
const VALID_KEY = "sk_live_test";
|
|
@@ -42,9 +48,9 @@ async function setup() {
|
|
|
42
48
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
43
49
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
44
50
|
originalCwd = process.cwd();
|
|
45
|
-
|
|
51
|
+
originalHomeEnv = captureHome();
|
|
46
52
|
process.chdir(join(sandbox, "project"));
|
|
47
|
-
|
|
53
|
+
setSandboxHome(join(sandbox, "home"));
|
|
48
54
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
49
55
|
|
|
50
56
|
server = createMockServer({});
|
|
@@ -58,7 +64,7 @@ async function setup() {
|
|
|
58
64
|
async function teardown() {
|
|
59
65
|
if (server) await server.stop();
|
|
60
66
|
process.chdir(originalCwd);
|
|
61
|
-
|
|
67
|
+
restoreHome(originalHomeEnv);
|
|
62
68
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
63
69
|
server = null;
|
|
64
70
|
}
|