skillrepo 4.3.0 → 4.5.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 +160 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/init.mjs +60 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/config.mjs +6 -0
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
- package/src/test/lib/telemetry.test.mjs +289 -0
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit/integration tests for src/commands/list.mjs
|
|
2
|
+
* Unit/integration tests for src/commands/list.mjs.
|
|
3
|
+
*
|
|
4
|
+
* Originally covered the table render + --json basics (#679, PR2 of
|
|
5
|
+
* #646). Extended in #1555 with per-row drift state coverage:
|
|
6
|
+
* - All four states (current / stale / missing / edited)
|
|
7
|
+
* - Worst-state-wins rollup across detected vendors
|
|
8
|
+
* - Library footer (4 ETag/last-sync cases)
|
|
9
|
+
* - --json shape with new `state` + `placements[]` fields
|
|
3
10
|
*/
|
|
4
11
|
|
|
5
12
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
13
|
import assert from "node:assert/strict";
|
|
7
|
-
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
15
|
import { join } from "node:path";
|
|
9
16
|
import { tmpdir } from "node:os";
|
|
10
17
|
|
|
11
18
|
import { runList } from "../../commands/list.mjs";
|
|
12
19
|
import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
|
|
20
|
+
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
21
|
+
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
13
22
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
14
23
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
15
24
|
import {
|
|
@@ -176,3 +185,502 @@ describe("runList — error paths", () => {
|
|
|
176
185
|
);
|
|
177
186
|
});
|
|
178
187
|
});
|
|
188
|
+
|
|
189
|
+
// ── Drift state coverage (#1555) ────────────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// These tests exercise the orchestration in list.mjs: the data sources
|
|
192
|
+
// (library, .last-sync, detected agents, on-disk placements) come
|
|
193
|
+
// together in `augmentSkill` to produce per-row `state` and the JSON
|
|
194
|
+
// `placements[]` array. The lower-level state-classification and disk-
|
|
195
|
+
// walk logic has its own exhaustive unit tests in drift.test.mjs and
|
|
196
|
+
// placement-walk.test.mjs; here we verify the integration.
|
|
197
|
+
|
|
198
|
+
const SKILL_MD_BODY = (name) =>
|
|
199
|
+
`---\nname: ${name}\ndescription: ${name} skill\n---\n\n# ${name}\n`;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Force Claude Code to be the detected agent by setting CLAUDECODE
|
|
203
|
+
* (one of its registered env signals). Without forcing detection,
|
|
204
|
+
* the sandbox has no agents and every state is `missing`.
|
|
205
|
+
*/
|
|
206
|
+
function forceClaudeCodeDetected() {
|
|
207
|
+
process.env.CLAUDECODE = "1";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function unforceDetection() {
|
|
211
|
+
delete process.env.CLAUDECODE;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Seed a skill on disk at the claudeProject placement. Returns the
|
|
216
|
+
* SHAs computed from the seeded content — caller uses them when
|
|
217
|
+
* shaping the `.last-sync` baseline.
|
|
218
|
+
*/
|
|
219
|
+
function seedClaudeSkillOnDisk(skillName, files) {
|
|
220
|
+
const dir = join(process.cwd(), ".claude", "skills", skillName);
|
|
221
|
+
mkdirSync(dir, { recursive: true });
|
|
222
|
+
for (const file of files) {
|
|
223
|
+
const filePath = join(dir, ...file.path.split("/"));
|
|
224
|
+
mkdirSync(join(filePath, ".."), { recursive: true });
|
|
225
|
+
writeFileSync(filePath, file.content, "utf8");
|
|
226
|
+
}
|
|
227
|
+
return computeSkillShas(files);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Write a v2 .last-sync at the sandbox home. */
|
|
231
|
+
function seedLastSync({ etag = '"v1"', syncedAt = "2025-01-01T00:00:00Z", skills = {} }) {
|
|
232
|
+
const stateDir = join(process.env.HOME, ".claude", "skillrepo");
|
|
233
|
+
mkdirSync(stateDir, { recursive: true });
|
|
234
|
+
writeFileSync(
|
|
235
|
+
globalLastSyncPath(),
|
|
236
|
+
JSON.stringify({ schemaVersion: 2, etag, syncedAt, skills }, null, 2),
|
|
237
|
+
"utf8",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
describe("runList — drift state (#1555)", () => {
|
|
242
|
+
beforeEach(async () => {
|
|
243
|
+
await setup();
|
|
244
|
+
forceClaudeCodeDetected();
|
|
245
|
+
});
|
|
246
|
+
afterEach(async () => {
|
|
247
|
+
unforceDetection();
|
|
248
|
+
await teardown();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("renders OK when SHA matches baseline and version matches library", async () => {
|
|
252
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
|
|
253
|
+
const shas = seedClaudeSkillOnDisk("clean", files);
|
|
254
|
+
seedLastSync({
|
|
255
|
+
etag: '"v1"',
|
|
256
|
+
skills: {
|
|
257
|
+
"alice/clean": {
|
|
258
|
+
version: "1.0.0",
|
|
259
|
+
skillMdSha256: shas.skillMdSha256,
|
|
260
|
+
filesSha256: shas.filesSha256,
|
|
261
|
+
syncedAt: "x",
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
server.setEtag('"v1"');
|
|
266
|
+
server.setLibraryResponse({
|
|
267
|
+
skills: [makeSkill("alice", "clean", "1.0.0")],
|
|
268
|
+
removals: [],
|
|
269
|
+
syncedAt: "x",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
273
|
+
const out = stdout.text();
|
|
274
|
+
assert.match(out, /clean/);
|
|
275
|
+
// Non-TTY capture stream → ASCII tokens.
|
|
276
|
+
assert.match(out, /\bOK\b/);
|
|
277
|
+
assert.match(out, /library in sync/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("renders STALE when library version is newer than synced version", async () => {
|
|
281
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("aging") }];
|
|
282
|
+
const shas = seedClaudeSkillOnDisk("aging", files);
|
|
283
|
+
seedLastSync({
|
|
284
|
+
etag: '"v1"',
|
|
285
|
+
skills: {
|
|
286
|
+
"alice/aging": {
|
|
287
|
+
version: "1.0.0",
|
|
288
|
+
skillMdSha256: shas.skillMdSha256,
|
|
289
|
+
filesSha256: shas.filesSha256,
|
|
290
|
+
syncedAt: "x",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
server.setEtag('"v2"');
|
|
295
|
+
server.setLibraryResponse({
|
|
296
|
+
skills: [makeSkill("alice", "aging", "1.1.0")],
|
|
297
|
+
removals: [],
|
|
298
|
+
syncedAt: "x",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
302
|
+
const out = stdout.text();
|
|
303
|
+
assert.match(out, /\bSTALE\b/);
|
|
304
|
+
assert.match(out, /library has changed since last sync/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("renders EDIT when SHA differs from baseline but version matches", async () => {
|
|
308
|
+
// Seed the disk with one content, baseline with a DIFFERENT SHA —
|
|
309
|
+
// simulates the user hand-editing the SKILL.md after sync.
|
|
310
|
+
seedClaudeSkillOnDisk("touched", [
|
|
311
|
+
{ path: "SKILL.md", content: SKILL_MD_BODY("touched-modified") },
|
|
312
|
+
]);
|
|
313
|
+
// Baseline SHAs intentionally arbitrary; they won't match what's on
|
|
314
|
+
// disk because we used a different content string.
|
|
315
|
+
seedLastSync({
|
|
316
|
+
etag: '"v1"',
|
|
317
|
+
skills: {
|
|
318
|
+
"alice/touched": {
|
|
319
|
+
version: "1.0.0",
|
|
320
|
+
skillMdSha256: "f".repeat(64),
|
|
321
|
+
filesSha256: "e".repeat(64),
|
|
322
|
+
syncedAt: "x",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
server.setEtag('"v1"');
|
|
327
|
+
server.setLibraryResponse({
|
|
328
|
+
skills: [makeSkill("alice", "touched", "1.0.0")],
|
|
329
|
+
removals: [],
|
|
330
|
+
syncedAt: "x",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
334
|
+
const out = stdout.text();
|
|
335
|
+
assert.match(out, /\bEDIT\b/);
|
|
336
|
+
// ETag matches AND drift exists → footer must reflect that.
|
|
337
|
+
assert.match(out, /library in sync — but \d+ skill/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("renders MISS when skill is in library but no on-disk placement", async () => {
|
|
341
|
+
// No seedClaudeSkillOnDisk → no dir on disk.
|
|
342
|
+
seedLastSync({
|
|
343
|
+
etag: '"v1"',
|
|
344
|
+
skills: {
|
|
345
|
+
"alice/absent": {
|
|
346
|
+
version: "1.0.0",
|
|
347
|
+
skillMdSha256: "a".repeat(64),
|
|
348
|
+
filesSha256: "b".repeat(64),
|
|
349
|
+
syncedAt: "x",
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
server.setEtag('"v1"');
|
|
354
|
+
server.setLibraryResponse({
|
|
355
|
+
skills: [makeSkill("alice", "absent", "1.0.0")],
|
|
356
|
+
removals: [],
|
|
357
|
+
syncedAt: "x",
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
361
|
+
const out = stdout.text();
|
|
362
|
+
assert.match(out, /\bMISS\b/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("renders MISS when there is no .last-sync baseline (fresh install)", async () => {
|
|
366
|
+
// Seed on disk but NO baseline. Per the spec ("fresh install →
|
|
367
|
+
// missing everywhere") and computeSkillState's documented choice,
|
|
368
|
+
// we report missing rather than fabricating a `current` verdict.
|
|
369
|
+
seedClaudeSkillOnDisk("orphan", [
|
|
370
|
+
{ path: "SKILL.md", content: SKILL_MD_BODY("orphan") },
|
|
371
|
+
]);
|
|
372
|
+
server.setEtag('"v1"');
|
|
373
|
+
server.setLibraryResponse({
|
|
374
|
+
skills: [makeSkill("alice", "orphan", "1.0.0")],
|
|
375
|
+
removals: [],
|
|
376
|
+
syncedAt: "x",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
380
|
+
const out = stdout.text();
|
|
381
|
+
assert.match(out, /\bMISS\b/);
|
|
382
|
+
assert.match(out, /No sync history on this machine/);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("footer: ✓ library in sync when ETag matches and no drift", async () => {
|
|
386
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
|
|
387
|
+
const shas = seedClaudeSkillOnDisk("clean", files);
|
|
388
|
+
seedLastSync({
|
|
389
|
+
etag: '"match"',
|
|
390
|
+
skills: {
|
|
391
|
+
"alice/clean": {
|
|
392
|
+
version: "1.0.0",
|
|
393
|
+
skillMdSha256: shas.skillMdSha256,
|
|
394
|
+
filesSha256: shas.filesSha256,
|
|
395
|
+
syncedAt: "x",
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
server.setEtag('"match"');
|
|
400
|
+
server.setLibraryResponse({
|
|
401
|
+
skills: [makeSkill("alice", "clean", "1.0.0")],
|
|
402
|
+
removals: [],
|
|
403
|
+
syncedAt: "x",
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
407
|
+
assert.match(stdout.text(), /library in sync — local skills up to date/);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("footer: library has changed when ETag differs", async () => {
|
|
411
|
+
seedLastSync({ etag: '"old"', skills: {} });
|
|
412
|
+
server.setEtag('"new"');
|
|
413
|
+
server.setLibraryResponse({
|
|
414
|
+
skills: [makeSkill("alice", "any")],
|
|
415
|
+
removals: [],
|
|
416
|
+
syncedAt: "x",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
420
|
+
assert.match(stdout.text(), /library has changed since last sync/);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("--json includes state and placements per item; no library wrapper", async () => {
|
|
424
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("scripted") }];
|
|
425
|
+
const shas = seedClaudeSkillOnDisk("scripted", files);
|
|
426
|
+
seedLastSync({
|
|
427
|
+
etag: '"v1"',
|
|
428
|
+
skills: {
|
|
429
|
+
"alice/scripted": {
|
|
430
|
+
version: "1.0.0",
|
|
431
|
+
skillMdSha256: shas.skillMdSha256,
|
|
432
|
+
filesSha256: shas.filesSha256,
|
|
433
|
+
syncedAt: "x",
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
server.setEtag('"v1"');
|
|
438
|
+
server.setLibraryResponse({
|
|
439
|
+
skills: [makeSkill("alice", "scripted", "1.0.0")],
|
|
440
|
+
removals: [],
|
|
441
|
+
syncedAt: "x",
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
445
|
+
const json = JSON.parse(stdout.text());
|
|
446
|
+
// Bare array preserved.
|
|
447
|
+
assert.ok(Array.isArray(json));
|
|
448
|
+
assert.equal(json.length, 1);
|
|
449
|
+
const item = json[0];
|
|
450
|
+
assert.equal(item.owner, "alice");
|
|
451
|
+
assert.equal(item.name, "scripted");
|
|
452
|
+
assert.equal(item.version, "1.0.0");
|
|
453
|
+
// New fields.
|
|
454
|
+
assert.equal(item.state, "current");
|
|
455
|
+
assert.ok(Array.isArray(item.placements));
|
|
456
|
+
assert.equal(item.placements.length, 1);
|
|
457
|
+
assert.deepEqual(item.placements[0], {
|
|
458
|
+
vendor: "claudeCode",
|
|
459
|
+
scope: "project",
|
|
460
|
+
state: "current",
|
|
461
|
+
localVersion: "1.0.0",
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("--json does NOT include any library-level footer fields", async () => {
|
|
466
|
+
// Verify the JSON contract is purely the existing bare-array shape
|
|
467
|
+
// with additive per-item fields — no top-level `library`, no
|
|
468
|
+
// `syncStatus`, no `summary`. Existing consumers stay unchanged.
|
|
469
|
+
server.setEtag('"v1"');
|
|
470
|
+
server.setLibraryResponse({
|
|
471
|
+
skills: [makeSkill("alice", "x")],
|
|
472
|
+
removals: [],
|
|
473
|
+
syncedAt: "x",
|
|
474
|
+
});
|
|
475
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
476
|
+
const parsed = JSON.parse(stdout.text());
|
|
477
|
+
assert.ok(Array.isArray(parsed));
|
|
478
|
+
// No object wrapper, no extra top-level keys.
|
|
479
|
+
assert.equal(parsed.constructor, Array);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("renders worst-state-wins when multiple vendors are detected", async () => {
|
|
483
|
+
// claudeCode is detected via CLAUDECODE env var; force cursor too
|
|
484
|
+
// via its documented CURSOR_AGENT env signal. Seed the same skill
|
|
485
|
+
// in Claude's placement only — cursor will see it as MISSING and
|
|
486
|
+
// the rollup must therefore be MISSING (worst state wins).
|
|
487
|
+
process.env.CURSOR_AGENT = "1";
|
|
488
|
+
try {
|
|
489
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("split") }];
|
|
490
|
+
const shas = seedClaudeSkillOnDisk("split", files);
|
|
491
|
+
seedLastSync({
|
|
492
|
+
etag: '"v1"',
|
|
493
|
+
skills: {
|
|
494
|
+
"alice/split": {
|
|
495
|
+
version: "1.0.0",
|
|
496
|
+
skillMdSha256: shas.skillMdSha256,
|
|
497
|
+
filesSha256: shas.filesSha256,
|
|
498
|
+
syncedAt: "x",
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
server.setEtag('"v1"');
|
|
503
|
+
server.setLibraryResponse({
|
|
504
|
+
skills: [makeSkill("alice", "split", "1.0.0")],
|
|
505
|
+
removals: [],
|
|
506
|
+
syncedAt: "x",
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
510
|
+
const [item] = JSON.parse(stdout.text());
|
|
511
|
+
// Rollup is MISSING because cursor's placement is missing.
|
|
512
|
+
assert.equal(item.state, "missing");
|
|
513
|
+
// Both vendors appear in placements with their own state.
|
|
514
|
+
const claude = item.placements.find((p) => p.vendor === "claudeCode");
|
|
515
|
+
const cursor = item.placements.find((p) => p.vendor === "cursor");
|
|
516
|
+
assert.ok(claude, "claudeCode placement must be present");
|
|
517
|
+
assert.ok(cursor, "cursor placement must be present");
|
|
518
|
+
assert.equal(claude.state, "current");
|
|
519
|
+
assert.equal(cursor.state, "missing");
|
|
520
|
+
} finally {
|
|
521
|
+
delete process.env.CURSOR_AGENT;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("--json drift counter and human drift counter both reflect rollup", async () => {
|
|
526
|
+
// Two skills, one drifted, one clean. Verify the "1 needs
|
|
527
|
+
// attention" footer count + the JSON state field.
|
|
528
|
+
const cleanFiles = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
|
|
529
|
+
const cleanShas = seedClaudeSkillOnDisk("clean", cleanFiles);
|
|
530
|
+
const driftFiles = [{ path: "SKILL.md", content: SKILL_MD_BODY("drift") }];
|
|
531
|
+
seedClaudeSkillOnDisk("drift", driftFiles);
|
|
532
|
+
seedLastSync({
|
|
533
|
+
etag: '"v1"',
|
|
534
|
+
skills: {
|
|
535
|
+
"alice/clean": {
|
|
536
|
+
version: "1.0.0",
|
|
537
|
+
skillMdSha256: cleanShas.skillMdSha256,
|
|
538
|
+
filesSha256: cleanShas.filesSha256,
|
|
539
|
+
syncedAt: "x",
|
|
540
|
+
},
|
|
541
|
+
"alice/drift": {
|
|
542
|
+
version: "1.0.0",
|
|
543
|
+
// Wrong SHA → drift detected as `edited`.
|
|
544
|
+
skillMdSha256: "0".repeat(64),
|
|
545
|
+
filesSha256: "0".repeat(64),
|
|
546
|
+
syncedAt: "x",
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
server.setEtag('"v1"');
|
|
551
|
+
server.setLibraryResponse({
|
|
552
|
+
skills: [
|
|
553
|
+
makeSkill("alice", "clean", "1.0.0"),
|
|
554
|
+
makeSkill("alice", "drift", "1.0.0"),
|
|
555
|
+
],
|
|
556
|
+
removals: [],
|
|
557
|
+
syncedAt: "x",
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
561
|
+
const human = stdout.text();
|
|
562
|
+
assert.match(human, /2 skills in your library/);
|
|
563
|
+
assert.match(human, /1 needs attention/);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("no detected agents → all rows MISS, friendly message at top + no-sync footer", async () => {
|
|
567
|
+
// Don't force any agent detection — sandbox should have none.
|
|
568
|
+
// Critically, do NOT call seedLastSync here: writing the state
|
|
569
|
+
// file creates `~/.claude/skillrepo/`, and the existence of
|
|
570
|
+
// `~/.claude` is one of claudeCode's documented detection
|
|
571
|
+
// signals (`{ type: "home", value: ".claude" }`). The test
|
|
572
|
+
// would then see Claude Code as detected. The "no agents"
|
|
573
|
+
// scenario means: no .claude/ in HOME, no .agents/ anywhere,
|
|
574
|
+
// no env-var hints. seedLastSync's side effects defeat that.
|
|
575
|
+
unforceDetection();
|
|
576
|
+
server.setEtag('"v1"');
|
|
577
|
+
server.setLibraryResponse({
|
|
578
|
+
skills: [makeSkill("alice", "lonely", "1.0.0")],
|
|
579
|
+
removals: [],
|
|
580
|
+
syncedAt: "x",
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
584
|
+
const out = stdout.text();
|
|
585
|
+
assert.match(out, /No agents detected in this project/);
|
|
586
|
+
assert.match(out, /\bMISS\b/);
|
|
587
|
+
// Footer assertion (Phase 4 L4): without `.last-sync`, the
|
|
588
|
+
// "no sync history" footer must fire — same code path as the
|
|
589
|
+
// fresh-install test, but here we lock in that BOTH messages
|
|
590
|
+
// appear together so a regression that suppresses one but not
|
|
591
|
+
// the other is caught.
|
|
592
|
+
assert.match(out, /No sync history on this machine/);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("renders MISS when .last-sync exists but has no entry for this skill", async () => {
|
|
596
|
+
// Real-world case: a teammate added a new skill to the library
|
|
597
|
+
// between our last `update` and this `list`. The library
|
|
598
|
+
// response includes the new skill, our `.last-sync` does not.
|
|
599
|
+
// Per spec: "no baseline → missing." The user runs `update` to
|
|
600
|
+
// get the new skill.
|
|
601
|
+
//
|
|
602
|
+
// This locks in Phase 4 M2's gap: previous tests only covered
|
|
603
|
+
// the no-`.last-sync`-file case. The partial-map case shares
|
|
604
|
+
// the code path but exercises the per-skill lookup, not the
|
|
605
|
+
// file-existence check.
|
|
606
|
+
const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("known") }];
|
|
607
|
+
const shas = seedClaudeSkillOnDisk("known", files);
|
|
608
|
+
seedLastSync({
|
|
609
|
+
etag: '"v1"',
|
|
610
|
+
skills: {
|
|
611
|
+
// Only `known` is in the map. `new-skill` is not.
|
|
612
|
+
"alice/known": {
|
|
613
|
+
version: "1.0.0",
|
|
614
|
+
skillMdSha256: shas.skillMdSha256,
|
|
615
|
+
filesSha256: shas.filesSha256,
|
|
616
|
+
syncedAt: "x",
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
server.setEtag('"v1"');
|
|
621
|
+
server.setLibraryResponse({
|
|
622
|
+
skills: [
|
|
623
|
+
makeSkill("alice", "known", "1.0.0"),
|
|
624
|
+
makeSkill("alice", "new-skill", "0.1.0"),
|
|
625
|
+
],
|
|
626
|
+
removals: [],
|
|
627
|
+
syncedAt: "x",
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
631
|
+
const json = JSON.parse(stdout.text());
|
|
632
|
+
const known = json.find((s) => s.name === "known");
|
|
633
|
+
const newer = json.find((s) => s.name === "new-skill");
|
|
634
|
+
assert.equal(known.state, "current");
|
|
635
|
+
// `new-skill` has no baseline in the map → MISSING per the
|
|
636
|
+
// computeSkillState contract, even though it's a brand-new
|
|
637
|
+
// skill that's never been on disk.
|
|
638
|
+
assert.equal(newer.state, "missing");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("renders MISS when .last-sync entry has wrong-typed fields (validateBaselineShape guard)", async () => {
|
|
642
|
+
// PR #1566 removed the lookupSkill / getLastSyncSkillEntry
|
|
643
|
+
// helpers and replaced the per-entry shape guard with
|
|
644
|
+
// validateBaselineShape inside list.mjs. The removed helper tests
|
|
645
|
+
// covered partial / wrong-type entries; nothing replaced them
|
|
646
|
+
// at the list layer. This test re-establishes that property:
|
|
647
|
+
// a `.last-sync` file with a syntactically valid `skills` map
|
|
648
|
+
// but per-entry fields of the wrong type must NOT crash
|
|
649
|
+
// computeSkillState (e.g., via semver.gt receiving a number),
|
|
650
|
+
// and must render the affected skill as MISSING — the
|
|
651
|
+
// conservative verdict for cache state we cannot trust.
|
|
652
|
+
seedClaudeSkillOnDisk("weird", [
|
|
653
|
+
{ path: "SKILL.md", content: SKILL_MD_BODY("weird") },
|
|
654
|
+
]);
|
|
655
|
+
seedLastSync({
|
|
656
|
+
etag: '"v1"',
|
|
657
|
+
skills: {
|
|
658
|
+
// Per-entry shape is malformed: version is a number, SHAs
|
|
659
|
+
// are wrong types. readLastSync's top-level coercion does
|
|
660
|
+
// NOT touch per-entry shapes — that's validateBaselineShape's
|
|
661
|
+
// job.
|
|
662
|
+
"alice/weird": {
|
|
663
|
+
version: 42,
|
|
664
|
+
skillMdSha256: true,
|
|
665
|
+
filesSha256: null,
|
|
666
|
+
syncedAt: "x",
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
server.setEtag('"v1"');
|
|
671
|
+
server.setLibraryResponse({
|
|
672
|
+
skills: [makeSkill("alice", "weird", "1.0.0")],
|
|
673
|
+
removals: [],
|
|
674
|
+
syncedAt: "x",
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
678
|
+
const [item] = JSON.parse(stdout.text());
|
|
679
|
+
assert.equal(item.state, "missing");
|
|
680
|
+
// Sanity: no crash, output parses cleanly, the per-vendor
|
|
681
|
+
// placement also reports missing.
|
|
682
|
+
assert.equal(item.placements[0].state, "missing");
|
|
683
|
+
// `localVersion` should be null since the baseline was rejected.
|
|
684
|
+
assert.equal(item.placements[0].localVersion, null);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
@@ -206,6 +206,39 @@ describe("writeConfig", () => {
|
|
|
206
206
|
assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
// Telemetry opt-in/out flag persistence (#1539). The field is only
|
|
210
|
+
// written when the caller explicitly sets it (boolean) — omitting
|
|
211
|
+
// means "no preference recorded" and the telemetry module treats
|
|
212
|
+
// that as the opt-out-friendly default of enabled.
|
|
213
|
+
it("persists telemetry=true when explicitly set", () => {
|
|
214
|
+
writeConfig({
|
|
215
|
+
apiKey: "sk_live_abc",
|
|
216
|
+
serverUrl: "https://example.com",
|
|
217
|
+
telemetry: true,
|
|
218
|
+
});
|
|
219
|
+
const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
|
|
220
|
+
assert.equal(raw.telemetry, true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("persists telemetry=false when explicitly set", () => {
|
|
224
|
+
writeConfig({
|
|
225
|
+
apiKey: "sk_live_abc",
|
|
226
|
+
serverUrl: "https://example.com",
|
|
227
|
+
telemetry: false,
|
|
228
|
+
});
|
|
229
|
+
const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
|
|
230
|
+
assert.equal(raw.telemetry, false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does NOT write a telemetry field when omitted (preserves no-preference state)", () => {
|
|
234
|
+
writeConfig({
|
|
235
|
+
apiKey: "sk_live_abc",
|
|
236
|
+
serverUrl: "https://example.com",
|
|
237
|
+
});
|
|
238
|
+
const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
|
|
239
|
+
assert.equal("telemetry" in raw, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
209
242
|
it("throws validationError on missing apiKey", () => {
|
|
210
243
|
assert.throws(
|
|
211
244
|
() => writeConfig({ serverUrl: "https://example.com" }),
|