@stigmer/cli 3.12.1 → 3.12.2
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/commands/push.d.ts.map +1 -1
- package/commands/push.js +57 -0
- package/commands/push.js.map +1 -1
- package/commands/setup.d.ts.map +1 -1
- package/commands/setup.js +7 -3
- package/commands/setup.js.map +1 -1
- package/local/daemon/launch.js +2 -6
- package/local/daemon/launch.js.map +1 -1
- package/local/llm-config.d.ts +0 -6
- package/local/llm-config.d.ts.map +1 -1
- package/local/llm-config.js +8 -14
- package/local/llm-config.js.map +1 -1
- package/local/setup/wizard.d.ts +4 -5
- package/local/setup/wizard.d.ts.map +1 -1
- package/local/setup/wizard.js +7 -9
- package/local/setup/wizard.js.map +1 -1
- package/local/state/startup-config.d.ts +5 -4
- package/local/state/startup-config.d.ts.map +1 -1
- package/local/state/startup-config.js +6 -3
- package/local/state/startup-config.js.map +1 -1
- package/local/status.d.ts.map +1 -1
- package/local/status.js +5 -6
- package/local/status.js.map +1 -1
- package/package.json +5 -5
- package/resources/skill.d.ts +38 -0
- package/resources/skill.d.ts.map +1 -1
- package/resources/skill.js +84 -2
- package/resources/skill.js.map +1 -1
- package/src/commands/push.ts +74 -0
- package/src/commands/setup.ts +7 -4
- package/src/local/daemon/launch.ts +1 -6
- package/src/local/llm-config.test.ts +11 -14
- package/src/local/llm-config.ts +8 -15
- package/src/local/setup/wizard.test.ts +2 -3
- package/src/local/setup/wizard.ts +7 -9
- package/src/local/state/startup-config.ts +11 -7
- package/src/local/state/state.test.ts +23 -3
- package/src/local/status.test.ts +2 -2
- package/src/local/status.ts +5 -6
- package/src/resources/skill.test.ts +119 -5
- package/src/resources/skill.ts +108 -2
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Unit tests for the skill packaging layer: SKILL.md frontmatter parsing, the
|
|
2
2
|
// ignore-filtered zip walk, dry-run analysis, and byte/hash formatting.
|
|
3
3
|
|
|
4
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
8
8
|
import type { Stigmer } from "@stigmer/sdk";
|
|
9
|
-
import { unzipSync } from "fflate";
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { unzipSync, zipSync } from "fflate";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
11
|
import { classify, ExitCode } from "../errors/index.js";
|
|
12
12
|
import {
|
|
13
13
|
analyzeDryRun,
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
parseSkillMetadata,
|
|
18
18
|
parseVisibility,
|
|
19
19
|
pushSkill,
|
|
20
|
+
pushSkillFromArchive,
|
|
21
|
+
readSkillArchive,
|
|
20
22
|
shortHash,
|
|
21
23
|
} from "./skill.js";
|
|
22
24
|
|
|
@@ -113,12 +115,17 @@ describe("parseVisibility", () => {
|
|
|
113
115
|
|
|
114
116
|
// A minimal fake of the SDK surface pushSkill touches: skill.push + skill.updateVisibility.
|
|
115
117
|
function fakeClient(pushMeta: { id?: string; visibility?: ApiResourceVisibility }) {
|
|
116
|
-
const calls = {
|
|
118
|
+
const calls = {
|
|
119
|
+
push: 0,
|
|
120
|
+
pushedArtifacts: [] as Uint8Array[],
|
|
121
|
+
updateVisibility: [] as Array<{ resourceId: string; visibility: ApiResourceVisibility }>,
|
|
122
|
+
};
|
|
117
123
|
const skillMessage = { metadata: { ...pushMeta } };
|
|
118
124
|
const client = {
|
|
119
125
|
skill: {
|
|
120
|
-
async push() {
|
|
126
|
+
async push(request?: { artifact?: Uint8Array }) {
|
|
121
127
|
calls.push++;
|
|
128
|
+
if (request?.artifact !== undefined) calls.pushedArtifacts.push(request.artifact);
|
|
122
129
|
return skillMessage;
|
|
123
130
|
},
|
|
124
131
|
async updateVisibility(input: { resourceId: string; visibility: ApiResourceVisibility }) {
|
|
@@ -199,6 +206,113 @@ describe("createSkillZip", () => {
|
|
|
199
206
|
});
|
|
200
207
|
});
|
|
201
208
|
|
|
209
|
+
describe("createSkillZip determinism", () => {
|
|
210
|
+
// Version identity is the server-side SHA-256 of the zip bytes, so identical
|
|
211
|
+
// content must produce identical bytes no matter when or where it was
|
|
212
|
+
// checked out (stigmer/stigmer#671). Distinct source mtimes model the fresh
|
|
213
|
+
// CI checkout; distinct wall-clock runs are inherent to running twice.
|
|
214
|
+
it("produces byte-identical zips for identical content across mtimes and wall-clock time", () => {
|
|
215
|
+
const otherDir = mkdtempSync(join(tmpdir(), "skill-test-b-"));
|
|
216
|
+
vi.useFakeTimers();
|
|
217
|
+
try {
|
|
218
|
+
for (const d of [dir, otherDir]) {
|
|
219
|
+
writeFileSync(join(d, "SKILL.md"), SKILL_MD);
|
|
220
|
+
mkdirSync(join(d, "references"));
|
|
221
|
+
writeFileSync(join(d, "references", "guide.md"), "# Guide\n");
|
|
222
|
+
}
|
|
223
|
+
// Backdate one copy: same content, different filesystem timestamps
|
|
224
|
+
// (models a fresh CI checkout).
|
|
225
|
+
const past = new Date("2020-06-15T12:00:00Z");
|
|
226
|
+
utimesSync(join(otherDir, "SKILL.md"), past, past);
|
|
227
|
+
utimesSync(join(otherDir, "references", "guide.md"), past, past);
|
|
228
|
+
|
|
229
|
+
// Advance the clock between runs: without a pinned mtime, fflate stamps
|
|
230
|
+
// zip-creation time into every entry (DOS 2-second granularity), so two
|
|
231
|
+
// pushes minutes apart would differ even from the same directory.
|
|
232
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
233
|
+
const a = createSkillZip(dir, NO_IGNORE).bytes;
|
|
234
|
+
vi.setSystemTime(new Date("2026-01-01T00:05:00Z"));
|
|
235
|
+
const b = createSkillZip(otherDir, NO_IGNORE).bytes;
|
|
236
|
+
expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true);
|
|
237
|
+
} finally {
|
|
238
|
+
vi.useRealTimers();
|
|
239
|
+
rmSync(otherDir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("changes bytes when content changes", () => {
|
|
244
|
+
writeFileSync(join(dir, "SKILL.md"), SKILL_MD);
|
|
245
|
+
const before = createSkillZip(dir, NO_IGNORE).bytes;
|
|
246
|
+
writeFileSync(join(dir, "extra.md"), "new content\n");
|
|
247
|
+
const after = createSkillZip(dir, NO_IGNORE).bytes;
|
|
248
|
+
expect(Buffer.from(before).equals(Buffer.from(after))).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("readSkillArchive", () => {
|
|
253
|
+
function writeArchive(files: Record<string, string>): string {
|
|
254
|
+
const zipped = zipSync(
|
|
255
|
+
Object.fromEntries(Object.entries(files).map(([p, c]) => [p, new TextEncoder().encode(c)])),
|
|
256
|
+
);
|
|
257
|
+
const archivePath = join(dir, "skill.zip");
|
|
258
|
+
writeFileSync(archivePath, zipped);
|
|
259
|
+
return archivePath;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
it("accepts an archive with a root SKILL.md and reports entry stats", () => {
|
|
263
|
+
const archivePath = writeArchive({
|
|
264
|
+
"SKILL.md": SKILL_MD,
|
|
265
|
+
"references/guide.md": "# Guide\n",
|
|
266
|
+
});
|
|
267
|
+
const archive = readSkillArchive(archivePath);
|
|
268
|
+
expect(archive.meta.name).toBe("my-skill");
|
|
269
|
+
expect(archive.fileCount).toBe(2);
|
|
270
|
+
expect(archive.totalSize).toBeGreaterThan(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("rejects an archive whose SKILL.md is only nested (root-only contract, DD-018)", () => {
|
|
274
|
+
const archivePath = writeArchive({ "my-skill/SKILL.md": SKILL_MD });
|
|
275
|
+
expect(() => readSkillArchive(archivePath)).toThrow(/root of/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("rejects a file that is not a ZIP archive", () => {
|
|
279
|
+
const archivePath = join(dir, "not-a-zip.zip");
|
|
280
|
+
writeFileSync(archivePath, "plain text");
|
|
281
|
+
expect(() => readSkillArchive(archivePath)).toThrow(/not a valid ZIP/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("rejects a missing file with a usage error", () => {
|
|
285
|
+
const err = (() => {
|
|
286
|
+
try {
|
|
287
|
+
readSkillArchive(join(dir, "absent.zip"));
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return e;
|
|
290
|
+
}
|
|
291
|
+
})();
|
|
292
|
+
expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("pushSkillFromArchive", () => {
|
|
297
|
+
it("uploads the archive bytes untouched (checksum parity) and applies declared visibility", async () => {
|
|
298
|
+
const zipped = zipSync({
|
|
299
|
+
"SKILL.md": new TextEncoder().encode("---\nname: my-skill\nvisibility: public\n---\n# S\n"),
|
|
300
|
+
});
|
|
301
|
+
const archivePath = join(dir, "skill.zip");
|
|
302
|
+
writeFileSync(archivePath, zipped);
|
|
303
|
+
const { client, calls } = fakeClient({ id: "skill-123", visibility: ApiResourceVisibility.visibility_private });
|
|
304
|
+
|
|
305
|
+
const result = await pushSkillFromArchive(client, archivePath, "stigmer", "", "release v1.2.3");
|
|
306
|
+
|
|
307
|
+
expect(calls.push).toBe(1);
|
|
308
|
+
expect(Buffer.from(calls.pushedArtifacts[0]).equals(Buffer.from(zipped))).toBe(true);
|
|
309
|
+
expect(calls.updateVisibility).toEqual([
|
|
310
|
+
{ resourceId: "skill-123", visibility: ApiResourceVisibility.visibility_public },
|
|
311
|
+
]);
|
|
312
|
+
expect(result.skillName).toBe("my-skill");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
202
316
|
describe("analyzeDryRun", () => {
|
|
203
317
|
it("reports counts and pattern sources without producing bytes", () => {
|
|
204
318
|
writeFileSync(join(dir, "SKILL.md"), SKILL_MD);
|
package/src/resources/skill.ts
CHANGED
|
@@ -14,13 +14,24 @@ import { GitProvenanceSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1
|
|
|
14
14
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
15
15
|
import { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
16
16
|
import type { Stigmer } from "@stigmer/sdk";
|
|
17
|
-
import { zipSync } from "fflate";
|
|
17
|
+
import { strFromU8, unzipSync, zipSync } from "fflate";
|
|
18
18
|
import { parse as parseYaml } from "yaml";
|
|
19
19
|
import { UsageError } from "../errors/index.js";
|
|
20
20
|
import { getGitBranchName, getGitCommit, getGitRemoteUrl, getGitRepoRoot } from "./git.js";
|
|
21
21
|
import { createMatcher, REASON_TEXT, type Reason } from "./ignore/index.js";
|
|
22
22
|
|
|
23
23
|
export const SKILL_FILE = "SKILL.md";
|
|
24
|
+
|
|
25
|
+
// A skill version's identity is the server-side SHA-256 of the uploaded zip
|
|
26
|
+
// bytes, so packaging must be a pure function of content — otherwise
|
|
27
|
+
// re-pushing unchanged content registers a new version and the server's
|
|
28
|
+
// unchanged-content no-op never fires (stigmer/stigmer#671). fflate stamps
|
|
29
|
+
// zip-creation time into every entry when no mtime is given; pinning the DOS
|
|
30
|
+
// epoch (the earliest representable zip timestamp) removes the only
|
|
31
|
+
// byte-level variance. Local-field Date construction is deliberate: DOS
|
|
32
|
+
// timestamps store wall-clock fields, so this encodes identically in every
|
|
33
|
+
// timezone.
|
|
34
|
+
const DETERMINISTIC_ZIP_MTIME = new Date(1980, 0, 1);
|
|
24
35
|
// Kebab-case, optionally scoped with dot-separated namespaces (e.g.
|
|
25
36
|
// "platform.planton-architecture"). Every segment must be alphanumeric, so no
|
|
26
37
|
// leading/trailing/consecutive separators. The derived slug renders dots as hyphens.
|
|
@@ -102,7 +113,16 @@ export function parseSkillMetadata(dir: string): SkillMetadata {
|
|
|
102
113
|
} catch (err) {
|
|
103
114
|
throw new Error(`failed to read ${SKILL_FILE}: ${(err as Error).message}`);
|
|
104
115
|
}
|
|
116
|
+
return parseSkillMetadataContent(content);
|
|
117
|
+
}
|
|
105
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Parse SKILL.md content directly — the shared core behind the directory
|
|
121
|
+
* path (`parseSkillMetadata`) and the pre-packaged archive path
|
|
122
|
+
* (`pushSkillFromArchive`), where the content comes out of a zip entry
|
|
123
|
+
* rather than the filesystem.
|
|
124
|
+
*/
|
|
125
|
+
export function parseSkillMetadataContent(content: string): SkillMetadata {
|
|
106
126
|
const frontmatter = extractFrontmatter(content);
|
|
107
127
|
const parsed = (parseYaml(frontmatter) ?? {}) as Record<string, unknown>;
|
|
108
128
|
const name = typeof parsed.name === "string" ? parsed.name : "";
|
|
@@ -233,7 +253,7 @@ export function createSkillZip(
|
|
|
233
253
|
};
|
|
234
254
|
walk(dir, "");
|
|
235
255
|
|
|
236
|
-
const bytes = zipSync(files, { level: 6 });
|
|
256
|
+
const bytes = zipSync(files, { level: 6, mtime: DETERMINISTIC_ZIP_MTIME });
|
|
237
257
|
return { bytes, stats };
|
|
238
258
|
}
|
|
239
259
|
|
|
@@ -373,6 +393,92 @@ export async function pushSkillFromClone(
|
|
|
373
393
|
return toResult(applied, name, params.message, stats.totalSize, visibility);
|
|
374
394
|
}
|
|
375
395
|
|
|
396
|
+
/** A validated pre-packaged skill archive (`--archive`), ready to upload. */
|
|
397
|
+
export interface SkillArchive {
|
|
398
|
+
/** The archive file's exact bytes — uploaded untouched. */
|
|
399
|
+
readonly bytes: Uint8Array;
|
|
400
|
+
/** Metadata parsed from the archive's root SKILL.md. */
|
|
401
|
+
readonly meta: SkillMetadata;
|
|
402
|
+
/** Number of file entries (directory markers excluded). */
|
|
403
|
+
readonly fileCount: number;
|
|
404
|
+
/** Total uncompressed size of all file entries, in bytes. */
|
|
405
|
+
readonly totalSize: number;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Read and validate a pre-packaged skill archive.
|
|
410
|
+
*
|
|
411
|
+
* Client-side validation is deliberately minimal — root SKILL.md present and
|
|
412
|
+
* frontmatter parses (the same contract the console's upload preview checks);
|
|
413
|
+
* the server remains the authoritative validator. The unzip filter inflates
|
|
414
|
+
* ONLY SKILL.md: entry metadata is enough for the count/size summary, and
|
|
415
|
+
* validation must not pay for decompressing a large artifact.
|
|
416
|
+
*/
|
|
417
|
+
export function readSkillArchive(archivePath: string): SkillArchive {
|
|
418
|
+
let raw: Buffer;
|
|
419
|
+
try {
|
|
420
|
+
raw = readFileSync(archivePath);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
throw new UsageError(`failed to read archive ${archivePath}: ${(err as Error).message}`);
|
|
423
|
+
}
|
|
424
|
+
const bytes = new Uint8Array(raw);
|
|
425
|
+
|
|
426
|
+
let fileCount = 0;
|
|
427
|
+
let totalSize = 0;
|
|
428
|
+
let unzipped: Record<string, Uint8Array>;
|
|
429
|
+
try {
|
|
430
|
+
unzipped = unzipSync(bytes, {
|
|
431
|
+
filter: (info) => {
|
|
432
|
+
if (!info.name.endsWith("/")) {
|
|
433
|
+
fileCount++;
|
|
434
|
+
totalSize += info.originalSize;
|
|
435
|
+
}
|
|
436
|
+
return info.name === SKILL_FILE;
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
} catch (err) {
|
|
440
|
+
throw new UsageError(`${archivePath} is not a valid ZIP archive: ${(err as Error).message}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const skillMd = unzipped[SKILL_FILE];
|
|
444
|
+
if (skillMd === undefined) {
|
|
445
|
+
throw new UsageError(
|
|
446
|
+
`${SKILL_FILE} not found at the root of ${archivePath}\n\n` +
|
|
447
|
+
`A skill archive must contain ${SKILL_FILE} at its root (not inside a directory) defining the skill interface`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { bytes, meta: parseSkillMetadataContent(strFromU8(skillMd)), fileCount, totalSize };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Push a pre-packaged skill archive as-is (`--archive`).
|
|
456
|
+
*
|
|
457
|
+
* The bytes are uploaded untouched, so the engine's version hash is the
|
|
458
|
+
* SHA-256 of the file on disk — release pipelines that publish checksums get
|
|
459
|
+
* engine version identities that match them (stigmer/stigmer#671). Git
|
|
460
|
+
* provenance is deliberately omitted: the archive was built elsewhere, so the
|
|
461
|
+
* local checkout says nothing about the artifact's origin.
|
|
462
|
+
*/
|
|
463
|
+
export async function pushSkillFromArchive(
|
|
464
|
+
client: Stigmer,
|
|
465
|
+
archivePath: string,
|
|
466
|
+
org: string,
|
|
467
|
+
tag: string,
|
|
468
|
+
message: string,
|
|
469
|
+
): Promise<PushResult> {
|
|
470
|
+
const archive = readSkillArchive(archivePath);
|
|
471
|
+
const request = create(PushSkillRequestSchema, {
|
|
472
|
+
org,
|
|
473
|
+
artifact: archive.bytes,
|
|
474
|
+
tag: tag === "" ? "latest" : tag,
|
|
475
|
+
message,
|
|
476
|
+
});
|
|
477
|
+
const response = await client.skill.push(request);
|
|
478
|
+
const applied = await applyDeclaredVisibility(client, response, archive.meta.visibility);
|
|
479
|
+
return toResult(applied, archive.meta.name, message, archive.totalSize, archive.meta.visibility);
|
|
480
|
+
}
|
|
481
|
+
|
|
376
482
|
/**
|
|
377
483
|
* Apply the SKILL.md-declared visibility after a push. The push RPC carries
|
|
378
484
|
* artifact + provenance but not access level (visibility is metadata, not part
|