@stigmer/cli 3.12.1 → 3.12.3
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 +48 -0
- package/resources/skill.d.ts.map +1 -1
- package/resources/skill.js +144 -5
- 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 +197 -5
- package/src/resources/skill.ts +177 -5
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// Local agent execution is Anthropic-only (the native runner only constructs
|
|
8
8
|
// Anthropic clients and the registry's native entries are Anthropic models),
|
|
9
9
|
// so the interactive flow is a single API-key prompt rather than a provider
|
|
10
|
-
// menu.
|
|
11
|
-
// owns the execution default, and
|
|
12
|
-
//
|
|
10
|
+
// menu. There is no model concept here at all: the platform model registry
|
|
11
|
+
// owns the execution default, and per-run overrides belong to `stigmer run
|
|
12
|
+
// --model` (oss#314 removed the dead setup-level pin).
|
|
13
13
|
|
|
14
14
|
import type { Config } from "../../config/config.js";
|
|
15
15
|
import { type LlmSettings, setLlm } from "../llm-config.js";
|
|
@@ -27,19 +27,17 @@ export type ProviderChoice = (typeof PROVIDER_CHOICES)[number];
|
|
|
27
27
|
|
|
28
28
|
export interface SelectionInputs {
|
|
29
29
|
apiKey?: string;
|
|
30
|
-
model?: string;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
/**
|
|
34
|
-
* Resolve a provider choice (plus optional
|
|
35
|
-
* persist. "skip" yields `undefined`, which clears the section
|
|
36
|
-
* model
|
|
37
|
-
*
|
|
33
|
+
* Resolve a provider choice (plus an optional API key) to the LLM settings to
|
|
34
|
+
* persist. "skip" yields `undefined`, which clears the section — including any
|
|
35
|
+
* stale `model` key from a pre-oss#314 config, since setLlm replaces the
|
|
36
|
+
* section wholesale.
|
|
38
37
|
*/
|
|
39
38
|
export function buildLlmForChoice(choice: ProviderChoice, inputs: SelectionInputs = {}): LlmSettings | undefined {
|
|
40
39
|
if (choice === "skip") return undefined;
|
|
41
40
|
const settings: LlmSettings = { provider: choice };
|
|
42
|
-
if (inputs.model) settings.model = inputs.model;
|
|
43
41
|
if (inputs.apiKey) settings.api_key = inputs.apiKey;
|
|
44
42
|
return settings;
|
|
45
43
|
}
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
1
|
+
// A diagnostic snapshot of the settings `up` launched with, persisted as
|
|
2
|
+
// `startup-config.json` in the data dir and removed by `down`. Nothing in the
|
|
3
|
+
// CLI or daemon reads it back — it exists for humans and support tooling
|
|
4
|
+
// inspecting a running stack, so keep it truthful: a field that no component
|
|
5
|
+
// consumes does not belong here (oss#314 removed the write-only llm_* trio).
|
|
6
|
+
// JSON keys stay snake_case for continuity with previously-written files.
|
|
4
7
|
|
|
5
8
|
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
9
|
import { join } from "node:path";
|
|
7
10
|
import { STARTUP_CONFIG_FILE } from "../constants.js";
|
|
8
11
|
|
|
9
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The persisted launch snapshot. `loadStartupConfig` parses leniently (a cast,
|
|
14
|
+
* not a validator), so files written by older CLIs with extra fields — e.g.
|
|
15
|
+
* the removed llm_* trio — load without migration.
|
|
16
|
+
*/
|
|
10
17
|
export interface StartupConfig {
|
|
11
18
|
data_dir: string;
|
|
12
19
|
log_dir: string;
|
|
13
20
|
temporal_addr: string;
|
|
14
|
-
llm_provider: string;
|
|
15
|
-
llm_model: string;
|
|
16
|
-
llm_base_url: string;
|
|
17
21
|
execution_mode: string;
|
|
18
22
|
sandbox_image: string;
|
|
19
23
|
sandbox_auto_pull: boolean;
|
|
@@ -82,9 +82,6 @@ describe("startup-config", () => {
|
|
|
82
82
|
data_dir: dir,
|
|
83
83
|
log_dir: join(dir, "logs"),
|
|
84
84
|
temporal_addr: "127.0.0.1:7233",
|
|
85
|
-
llm_provider: "anthropic",
|
|
86
|
-
llm_model: "claude",
|
|
87
|
-
llm_base_url: "",
|
|
88
85
|
execution_mode: "local",
|
|
89
86
|
sandbox_image: "",
|
|
90
87
|
sandbox_auto_pull: false,
|
|
@@ -98,6 +95,29 @@ describe("startup-config", () => {
|
|
|
98
95
|
removeStartupConfig(dir);
|
|
99
96
|
expect(loadStartupConfig(dir)).toBeNull();
|
|
100
97
|
});
|
|
98
|
+
|
|
99
|
+
it("loads files written by older CLIs carrying since-removed fields (e.g. the llm_* trio)", () => {
|
|
100
|
+
// The lenient-load contract oss#314 leans on: no migration for on-disk
|
|
101
|
+
// files — extra keys simply come along and nothing consumes them.
|
|
102
|
+
const dir = tempDir("stigmer-startup-");
|
|
103
|
+
const legacy = {
|
|
104
|
+
data_dir: dir,
|
|
105
|
+
log_dir: join(dir, "logs"),
|
|
106
|
+
temporal_addr: "127.0.0.1:7233",
|
|
107
|
+
llm_provider: "anthropic",
|
|
108
|
+
llm_model: "claude",
|
|
109
|
+
llm_base_url: "",
|
|
110
|
+
execution_mode: "local",
|
|
111
|
+
sandbox_image: "",
|
|
112
|
+
sandbox_auto_pull: false,
|
|
113
|
+
sandbox_cleanup: true,
|
|
114
|
+
sandbox_ttl: 0,
|
|
115
|
+
stigmer_server_pid: 999,
|
|
116
|
+
server_only: false,
|
|
117
|
+
};
|
|
118
|
+
writeFileSync(join(dir, "startup-config.json"), JSON.stringify(legacy));
|
|
119
|
+
expect(loadStartupConfig(dir)).toMatchObject({ temporal_addr: "127.0.0.1:7233", stigmer_server_pid: 999 });
|
|
120
|
+
});
|
|
101
121
|
});
|
|
102
122
|
|
|
103
123
|
describe("log-rotation", () => {
|
package/src/local/status.test.ts
CHANGED
|
@@ -62,12 +62,12 @@ describe("buildStatusResult", () => {
|
|
|
62
62
|
expect(field(result, "Web UI", "Temporal")).toBe("http://localhost:8233");
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
it("
|
|
65
|
+
it("ignores a stale model key from a pre-oss#314 config — the pin never reached execution, so status must not display it", async () => {
|
|
66
66
|
writeHealth({ daemon_pid: process.pid, started_at: new Date().toISOString(), components: {} });
|
|
67
67
|
writeFileSync(configPath(home), "backend:\n type: local\n local:\n llm:\n provider: anthropic\n model: claude-x\n api_key: sk-x\n");
|
|
68
68
|
|
|
69
69
|
const result = await buildStatusResult(home, open);
|
|
70
|
-
expect(field(result, "LLM Configuration", "Model")).toBe("
|
|
70
|
+
expect(field(result, "LLM Configuration", "Model")).toBe("Auto (platform default)");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
it("reports a non-anthropic provider as unknown — local execution is Anthropic-only", async () => {
|
package/src/local/status.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { load as loadConfig } from "../config/config.js";
|
|
|
13
13
|
import { configPath } from "../config/paths.js";
|
|
14
14
|
import { CommandResult } from "../output/command-result.js";
|
|
15
15
|
import { SERVER_PORT, TEMPORAL_UI_PORT, WEB_CONSOLE_PORT } from "./constants.js";
|
|
16
|
-
import { resolveApiKey,
|
|
16
|
+
import { resolveApiKey, resolveProvider } from "./llm-config.js";
|
|
17
17
|
import { tcpConnects } from "./net/tcp.js";
|
|
18
18
|
import { dataDir } from "./paths.js";
|
|
19
19
|
import { type ComponentState, type HealthState, loadHealthState } from "./state/health-state.js";
|
|
@@ -130,12 +130,11 @@ function addLlmSection(result: CommandResult, home: string): void {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
//
|
|
134
|
-
// execution default
|
|
135
|
-
//
|
|
136
|
-
const model = resolveModel(config);
|
|
133
|
+
// The model line is a constant: the platform model registry picks the
|
|
134
|
+
// execution default and the CLI has no model concept to report (oss#314
|
|
135
|
+
// removed the dead config pin). Per-run overrides ride `stigmer run --model`.
|
|
137
136
|
section.field("Provider", "Anthropic (Cloud)");
|
|
138
|
-
section.field("Model",
|
|
137
|
+
section.field("Model", "Auto (platform default)");
|
|
139
138
|
section.field("API Key", resolveApiKey(config) !== "" ? "Configured ✓" : "Not configured ✗");
|
|
140
139
|
}
|
|
141
140
|
|
|
@@ -1,13 +1,16 @@
|
|
|
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
|
+
import { create } from "@bufbuild/protobuf";
|
|
8
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
9
|
+
import { PushSkillRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
7
10
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
8
11
|
import type { Stigmer } from "@stigmer/sdk";
|
|
9
|
-
import { unzipSync } from "fflate";
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { unzipSync, zipSync } from "fflate";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
14
|
import { classify, ExitCode } from "../errors/index.js";
|
|
12
15
|
import {
|
|
13
16
|
analyzeDryRun,
|
|
@@ -16,7 +19,10 @@ import {
|
|
|
16
19
|
hasSkillFile,
|
|
17
20
|
parseSkillMetadata,
|
|
18
21
|
parseVisibility,
|
|
22
|
+
pushRouted,
|
|
19
23
|
pushSkill,
|
|
24
|
+
pushSkillFromArchive,
|
|
25
|
+
readSkillArchive,
|
|
20
26
|
shortHash,
|
|
21
27
|
} from "./skill.js";
|
|
22
28
|
|
|
@@ -113,12 +119,17 @@ describe("parseVisibility", () => {
|
|
|
113
119
|
|
|
114
120
|
// A minimal fake of the SDK surface pushSkill touches: skill.push + skill.updateVisibility.
|
|
115
121
|
function fakeClient(pushMeta: { id?: string; visibility?: ApiResourceVisibility }) {
|
|
116
|
-
const calls = {
|
|
122
|
+
const calls = {
|
|
123
|
+
push: 0,
|
|
124
|
+
pushedArtifacts: [] as Uint8Array[],
|
|
125
|
+
updateVisibility: [] as Array<{ resourceId: string; visibility: ApiResourceVisibility }>,
|
|
126
|
+
};
|
|
117
127
|
const skillMessage = { metadata: { ...pushMeta } };
|
|
118
128
|
const client = {
|
|
119
129
|
skill: {
|
|
120
|
-
async push() {
|
|
130
|
+
async push(request?: { artifact?: Uint8Array }) {
|
|
121
131
|
calls.push++;
|
|
132
|
+
if (request?.artifact !== undefined) calls.pushedArtifacts.push(request.artifact);
|
|
122
133
|
return skillMessage;
|
|
123
134
|
},
|
|
124
135
|
async updateVisibility(input: { resourceId: string; visibility: ApiResourceVisibility }) {
|
|
@@ -199,6 +210,113 @@ describe("createSkillZip", () => {
|
|
|
199
210
|
});
|
|
200
211
|
});
|
|
201
212
|
|
|
213
|
+
describe("createSkillZip determinism", () => {
|
|
214
|
+
// Version identity is the server-side SHA-256 of the zip bytes, so identical
|
|
215
|
+
// content must produce identical bytes no matter when or where it was
|
|
216
|
+
// checked out (stigmer/stigmer#671). Distinct source mtimes model the fresh
|
|
217
|
+
// CI checkout; distinct wall-clock runs are inherent to running twice.
|
|
218
|
+
it("produces byte-identical zips for identical content across mtimes and wall-clock time", () => {
|
|
219
|
+
const otherDir = mkdtempSync(join(tmpdir(), "skill-test-b-"));
|
|
220
|
+
vi.useFakeTimers();
|
|
221
|
+
try {
|
|
222
|
+
for (const d of [dir, otherDir]) {
|
|
223
|
+
writeFileSync(join(d, "SKILL.md"), SKILL_MD);
|
|
224
|
+
mkdirSync(join(d, "references"));
|
|
225
|
+
writeFileSync(join(d, "references", "guide.md"), "# Guide\n");
|
|
226
|
+
}
|
|
227
|
+
// Backdate one copy: same content, different filesystem timestamps
|
|
228
|
+
// (models a fresh CI checkout).
|
|
229
|
+
const past = new Date("2020-06-15T12:00:00Z");
|
|
230
|
+
utimesSync(join(otherDir, "SKILL.md"), past, past);
|
|
231
|
+
utimesSync(join(otherDir, "references", "guide.md"), past, past);
|
|
232
|
+
|
|
233
|
+
// Advance the clock between runs: without a pinned mtime, fflate stamps
|
|
234
|
+
// zip-creation time into every entry (DOS 2-second granularity), so two
|
|
235
|
+
// pushes minutes apart would differ even from the same directory.
|
|
236
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
237
|
+
const a = createSkillZip(dir, NO_IGNORE).bytes;
|
|
238
|
+
vi.setSystemTime(new Date("2026-01-01T00:05:00Z"));
|
|
239
|
+
const b = createSkillZip(otherDir, NO_IGNORE).bytes;
|
|
240
|
+
expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true);
|
|
241
|
+
} finally {
|
|
242
|
+
vi.useRealTimers();
|
|
243
|
+
rmSync(otherDir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("changes bytes when content changes", () => {
|
|
248
|
+
writeFileSync(join(dir, "SKILL.md"), SKILL_MD);
|
|
249
|
+
const before = createSkillZip(dir, NO_IGNORE).bytes;
|
|
250
|
+
writeFileSync(join(dir, "extra.md"), "new content\n");
|
|
251
|
+
const after = createSkillZip(dir, NO_IGNORE).bytes;
|
|
252
|
+
expect(Buffer.from(before).equals(Buffer.from(after))).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("readSkillArchive", () => {
|
|
257
|
+
function writeArchive(files: Record<string, string>): string {
|
|
258
|
+
const zipped = zipSync(
|
|
259
|
+
Object.fromEntries(Object.entries(files).map(([p, c]) => [p, new TextEncoder().encode(c)])),
|
|
260
|
+
);
|
|
261
|
+
const archivePath = join(dir, "skill.zip");
|
|
262
|
+
writeFileSync(archivePath, zipped);
|
|
263
|
+
return archivePath;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
it("accepts an archive with a root SKILL.md and reports entry stats", () => {
|
|
267
|
+
const archivePath = writeArchive({
|
|
268
|
+
"SKILL.md": SKILL_MD,
|
|
269
|
+
"references/guide.md": "# Guide\n",
|
|
270
|
+
});
|
|
271
|
+
const archive = readSkillArchive(archivePath);
|
|
272
|
+
expect(archive.meta.name).toBe("my-skill");
|
|
273
|
+
expect(archive.fileCount).toBe(2);
|
|
274
|
+
expect(archive.totalSize).toBeGreaterThan(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("rejects an archive whose SKILL.md is only nested (root-only contract, DD-018)", () => {
|
|
278
|
+
const archivePath = writeArchive({ "my-skill/SKILL.md": SKILL_MD });
|
|
279
|
+
expect(() => readSkillArchive(archivePath)).toThrow(/root of/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("rejects a file that is not a ZIP archive", () => {
|
|
283
|
+
const archivePath = join(dir, "not-a-zip.zip");
|
|
284
|
+
writeFileSync(archivePath, "plain text");
|
|
285
|
+
expect(() => readSkillArchive(archivePath)).toThrow(/not a valid ZIP/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("rejects a missing file with a usage error", () => {
|
|
289
|
+
const err = (() => {
|
|
290
|
+
try {
|
|
291
|
+
readSkillArchive(join(dir, "absent.zip"));
|
|
292
|
+
} catch (e) {
|
|
293
|
+
return e;
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("pushSkillFromArchive", () => {
|
|
301
|
+
it("uploads the archive bytes untouched (checksum parity) and applies declared visibility", async () => {
|
|
302
|
+
const zipped = zipSync({
|
|
303
|
+
"SKILL.md": new TextEncoder().encode("---\nname: my-skill\nvisibility: public\n---\n# S\n"),
|
|
304
|
+
});
|
|
305
|
+
const archivePath = join(dir, "skill.zip");
|
|
306
|
+
writeFileSync(archivePath, zipped);
|
|
307
|
+
const { client, calls } = fakeClient({ id: "skill-123", visibility: ApiResourceVisibility.visibility_private });
|
|
308
|
+
|
|
309
|
+
const result = await pushSkillFromArchive(client, archivePath, "stigmer", "", "release v1.2.3");
|
|
310
|
+
|
|
311
|
+
expect(calls.push).toBe(1);
|
|
312
|
+
expect(Buffer.from(calls.pushedArtifacts[0]).equals(Buffer.from(zipped))).toBe(true);
|
|
313
|
+
expect(calls.updateVisibility).toEqual([
|
|
314
|
+
{ resourceId: "skill-123", visibility: ApiResourceVisibility.visibility_public },
|
|
315
|
+
]);
|
|
316
|
+
expect(result.skillName).toBe("my-skill");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
202
320
|
describe("analyzeDryRun", () => {
|
|
203
321
|
it("reports counts and pattern sources without producing bytes", () => {
|
|
204
322
|
writeFileSync(join(dir, "SKILL.md"), SKILL_MD);
|
|
@@ -235,3 +353,77 @@ describe("formatBytes / shortHash", () => {
|
|
|
235
353
|
expect(shortHash("abc")).toBe("sha256:abc");
|
|
236
354
|
});
|
|
237
355
|
});
|
|
356
|
+
|
|
357
|
+
// ─── pushRouted — transfer-lane size routing (#675) ──────────────────────
|
|
358
|
+
|
|
359
|
+
describe("pushRouted size routing", () => {
|
|
360
|
+
const INLINE_MAX = 10 * 1024 * 1024 - 64 * 1024;
|
|
361
|
+
|
|
362
|
+
function routingFakeClient(overrides: Record<string, any> = {}) {
|
|
363
|
+
const calls = { push: [] as any[], mint: [] as any[] };
|
|
364
|
+
const client = {
|
|
365
|
+
skill: {
|
|
366
|
+
async push(req: any) {
|
|
367
|
+
calls.push.push(req);
|
|
368
|
+
return { metadata: { id: "skill_x" } };
|
|
369
|
+
},
|
|
370
|
+
async createArtifactUploadUrl(req: any) {
|
|
371
|
+
calls.mint.push(req);
|
|
372
|
+
return { url: "http://localhost:7234/v1/skill-artifacts/uploads/sau_t", artifactUploadRef: "sau_t", ttlSeconds: 900 };
|
|
373
|
+
},
|
|
374
|
+
...overrides,
|
|
375
|
+
},
|
|
376
|
+
} as any;
|
|
377
|
+
return { client, calls };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
vi.unstubAllGlobals();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("keeps small artifacts inline (no mint, bytes in the request)", async () => {
|
|
385
|
+
const { client, calls } = routingFakeClient();
|
|
386
|
+
const req = create(PushSkillRequestSchema, { org: "acme", artifact: new Uint8Array(1024) });
|
|
387
|
+
|
|
388
|
+
await pushRouted(client, req);
|
|
389
|
+
|
|
390
|
+
expect(calls.mint).toHaveLength(0);
|
|
391
|
+
expect(calls.push).toHaveLength(1);
|
|
392
|
+
expect(calls.push[0].artifact.length).toBe(1024);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("stages large artifacts over HTTP and pushes by reference", async () => {
|
|
396
|
+
const { client, calls } = routingFakeClient();
|
|
397
|
+
const artifact = new Uint8Array(INLINE_MAX + 1);
|
|
398
|
+
const req = create(PushSkillRequestSchema, { org: "acme", artifact, tag: "stable", message: "big" });
|
|
399
|
+
|
|
400
|
+
let putBytes = 0;
|
|
401
|
+
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: any) => {
|
|
402
|
+
putBytes = init.body.length;
|
|
403
|
+
return { ok: true } as any;
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
await pushRouted(client, req);
|
|
407
|
+
|
|
408
|
+
expect(calls.mint).toHaveLength(1);
|
|
409
|
+
expect(calls.mint[0].sizeBytes).toBe(BigInt(artifact.length));
|
|
410
|
+
expect(putBytes).toBe(artifact.length);
|
|
411
|
+
expect(calls.push).toHaveLength(1);
|
|
412
|
+
expect(calls.push[0].artifact.length).toBe(0);
|
|
413
|
+
expect(calls.push[0].artifactUploadRef).toBe("sau_t");
|
|
414
|
+
// The rewrite must not lose the rest of the request.
|
|
415
|
+
expect(calls.push[0].tag).toBe("stable");
|
|
416
|
+
expect(calls.push[0].message).toBe("big");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("fails loud against servers that predate the transfer lane", async () => {
|
|
420
|
+
const { client } = routingFakeClient({
|
|
421
|
+
async createArtifactUploadUrl() {
|
|
422
|
+
throw new ConnectError("unknown method", Code.Unimplemented);
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
const req = create(PushSkillRequestSchema, { org: "acme", artifact: new Uint8Array(INLINE_MAX + 1) });
|
|
426
|
+
|
|
427
|
+
await expect(pushRouted(client, req)).rejects.toThrow(/Upgrade stigmer-server/);
|
|
428
|
+
});
|
|
429
|
+
});
|
package/src/resources/skill.ts
CHANGED
|
@@ -8,19 +8,32 @@
|
|
|
8
8
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
9
|
import { join, relative, resolve } from "node:path";
|
|
10
10
|
import { create } from "@bufbuild/protobuf";
|
|
11
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
11
12
|
import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
12
|
-
import {
|
|
13
|
+
import type { PushSkillRequest } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
14
|
+
import { CreateSkillArtifactUploadUrlRequestSchema, PushSkillRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
13
15
|
import { GitProvenanceSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/status_pb";
|
|
14
16
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
15
17
|
import { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
16
18
|
import type { Stigmer } from "@stigmer/sdk";
|
|
17
|
-
import { zipSync } from "fflate";
|
|
19
|
+
import { strFromU8, unzipSync, zipSync } from "fflate";
|
|
18
20
|
import { parse as parseYaml } from "yaml";
|
|
19
21
|
import { UsageError } from "../errors/index.js";
|
|
20
22
|
import { getGitBranchName, getGitCommit, getGitRemoteUrl, getGitRepoRoot } from "./git.js";
|
|
21
23
|
import { createMatcher, REASON_TEXT, type Reason } from "./ignore/index.js";
|
|
22
24
|
|
|
23
25
|
export const SKILL_FILE = "SKILL.md";
|
|
26
|
+
|
|
27
|
+
// A skill version's identity is the server-side SHA-256 of the uploaded zip
|
|
28
|
+
// bytes, so packaging must be a pure function of content — otherwise
|
|
29
|
+
// re-pushing unchanged content registers a new version and the server's
|
|
30
|
+
// unchanged-content no-op never fires (stigmer/stigmer#671). fflate stamps
|
|
31
|
+
// zip-creation time into every entry when no mtime is given; pinning the DOS
|
|
32
|
+
// epoch (the earliest representable zip timestamp) removes the only
|
|
33
|
+
// byte-level variance. Local-field Date construction is deliberate: DOS
|
|
34
|
+
// timestamps store wall-clock fields, so this encodes identically in every
|
|
35
|
+
// timezone.
|
|
36
|
+
const DETERMINISTIC_ZIP_MTIME = new Date(1980, 0, 1);
|
|
24
37
|
// Kebab-case, optionally scoped with dot-separated namespaces (e.g.
|
|
25
38
|
// "platform.planton-architecture"). Every segment must be alphanumeric, so no
|
|
26
39
|
// leading/trailing/consecutive separators. The derived slug renders dots as hyphens.
|
|
@@ -102,7 +115,16 @@ export function parseSkillMetadata(dir: string): SkillMetadata {
|
|
|
102
115
|
} catch (err) {
|
|
103
116
|
throw new Error(`failed to read ${SKILL_FILE}: ${(err as Error).message}`);
|
|
104
117
|
}
|
|
118
|
+
return parseSkillMetadataContent(content);
|
|
119
|
+
}
|
|
105
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Parse SKILL.md content directly — the shared core behind the directory
|
|
123
|
+
* path (`parseSkillMetadata`) and the pre-packaged archive path
|
|
124
|
+
* (`pushSkillFromArchive`), where the content comes out of a zip entry
|
|
125
|
+
* rather than the filesystem.
|
|
126
|
+
*/
|
|
127
|
+
export function parseSkillMetadataContent(content: string): SkillMetadata {
|
|
106
128
|
const frontmatter = extractFrontmatter(content);
|
|
107
129
|
const parsed = (parseYaml(frontmatter) ?? {}) as Record<string, unknown>;
|
|
108
130
|
const name = typeof parsed.name === "string" ? parsed.name : "";
|
|
@@ -233,7 +255,7 @@ export function createSkillZip(
|
|
|
233
255
|
};
|
|
234
256
|
walk(dir, "");
|
|
235
257
|
|
|
236
|
-
const bytes = zipSync(files, { level: 6 });
|
|
258
|
+
const bytes = zipSync(files, { level: 6, mtime: DETERMINISTIC_ZIP_MTIME });
|
|
237
259
|
return { bytes, stats };
|
|
238
260
|
}
|
|
239
261
|
|
|
@@ -300,6 +322,68 @@ export function analyzeDryRun(dir: string, options: IgnoreOptions): DryRunAnalys
|
|
|
300
322
|
return { stats, patternSources, sampleIgnored, sampleIncluded };
|
|
301
323
|
}
|
|
302
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Largest artifact pushed inline in the gRPC request (#675). The server's
|
|
327
|
+
* transport cap is 10MB for the WHOLE message, so the artifact leaves 64KB
|
|
328
|
+
* of headroom for the request envelope (org, tag, provenance, framing).
|
|
329
|
+
* Mirrors the Go SDK's maxInlineArtifactBytes.
|
|
330
|
+
*/
|
|
331
|
+
const MAX_INLINE_ARTIFACT_BYTES = 10 * 1024 * 1024 - 64 * 1024;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Push, routing the artifact by size (#675): small artifacts travel inline
|
|
335
|
+
* in the request (one round trip, unchanged behavior); larger ones are
|
|
336
|
+
* staged over HTTP via createArtifactUploadUrl — a capability URL, so no
|
|
337
|
+
* auth header — and pushed by reference. The 100MB skill limit therefore
|
|
338
|
+
* no longer collides with the 10MB gRPC message cap.
|
|
339
|
+
*/
|
|
340
|
+
export async function pushRouted(client: Stigmer, request: PushSkillRequest): Promise<Skill> {
|
|
341
|
+
if (request.artifact.length <= MAX_INLINE_ARTIFACT_BYTES) {
|
|
342
|
+
return client.skill.push(request);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let minted;
|
|
346
|
+
try {
|
|
347
|
+
minted = await client.skill.createArtifactUploadUrl(
|
|
348
|
+
create(CreateSkillArtifactUploadUrlRequestSchema, {
|
|
349
|
+
org: request.org,
|
|
350
|
+
sizeBytes: BigInt(request.artifact.length),
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (err instanceof ConnectError && err.code === Code.Unimplemented) {
|
|
355
|
+
// Pre-transfer-lane server: an artifact this size physically cannot
|
|
356
|
+
// travel inline. Say so instead of surfacing the raw transport error.
|
|
357
|
+
throw new UsageError(
|
|
358
|
+
`skill artifact is ${formatBytes(request.artifact.length)}, above the ~10MB gRPC message cap, ` +
|
|
359
|
+
"and this server does not support the HTTP artifact transfer lane.\n\n" +
|
|
360
|
+
"Upgrade stigmer-server to push skills of this size.",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const resp = await fetch(minted.url, {
|
|
367
|
+
method: "PUT",
|
|
368
|
+
// Buffer keeps undici's BodyInit typing happy where a bare Uint8Array
|
|
369
|
+
// view does not (Node-only code path, so Buffer is always available).
|
|
370
|
+
body: Buffer.from(request.artifact),
|
|
371
|
+
headers: { "content-type": "application/zip" },
|
|
372
|
+
});
|
|
373
|
+
if (!resp.ok) {
|
|
374
|
+
const detail = (await resp.text().catch(() => "")).slice(0, 512).trim();
|
|
375
|
+
throw new Error(`skill artifact upload rejected with HTTP ${resp.status}${detail === "" ? "" : `: ${detail}`}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Same request, artifact traveling by reference instead of by value.
|
|
379
|
+
const byRef = create(PushSkillRequestSchema, {
|
|
380
|
+
...request,
|
|
381
|
+
artifact: new Uint8Array(0),
|
|
382
|
+
artifactUploadRef: minted.artifactUploadRef,
|
|
383
|
+
});
|
|
384
|
+
return client.skill.push(byRef);
|
|
385
|
+
}
|
|
386
|
+
|
|
303
387
|
/** Push a skill from a local directory. Git provenance is auto-detected. */
|
|
304
388
|
export async function pushSkill(
|
|
305
389
|
client: Stigmer,
|
|
@@ -326,7 +410,7 @@ export async function pushSkill(
|
|
|
326
410
|
message,
|
|
327
411
|
gitProvenance: provenance,
|
|
328
412
|
});
|
|
329
|
-
const response = await client
|
|
413
|
+
const response = await pushRouted(client, request);
|
|
330
414
|
const applied = await applyDeclaredVisibility(client, response, visibility);
|
|
331
415
|
return toResult(applied, name, message, stats.totalSize, visibility);
|
|
332
416
|
}
|
|
@@ -368,11 +452,99 @@ export async function pushSkillFromClone(
|
|
|
368
452
|
message: params.message,
|
|
369
453
|
gitProvenance: provenance,
|
|
370
454
|
});
|
|
371
|
-
const response = await client
|
|
455
|
+
const response = await pushRouted(client, request);
|
|
372
456
|
const applied = await applyDeclaredVisibility(client, response, visibility);
|
|
373
457
|
return toResult(applied, name, params.message, stats.totalSize, visibility);
|
|
374
458
|
}
|
|
375
459
|
|
|
460
|
+
/** A validated pre-packaged skill archive (`--archive`), ready to upload. */
|
|
461
|
+
export interface SkillArchive {
|
|
462
|
+
/** The archive file's exact bytes — uploaded untouched. */
|
|
463
|
+
readonly bytes: Uint8Array;
|
|
464
|
+
/** Metadata parsed from the archive's root SKILL.md. */
|
|
465
|
+
readonly meta: SkillMetadata;
|
|
466
|
+
/** Number of file entries (directory markers excluded). */
|
|
467
|
+
readonly fileCount: number;
|
|
468
|
+
/** Total uncompressed size of all file entries, in bytes. */
|
|
469
|
+
readonly totalSize: number;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Read and validate a pre-packaged skill archive.
|
|
474
|
+
*
|
|
475
|
+
* Client-side validation is deliberately minimal — root SKILL.md present and
|
|
476
|
+
* frontmatter parses (the same contract the console's upload preview checks);
|
|
477
|
+
* the server remains the authoritative validator. The unzip filter inflates
|
|
478
|
+
* ONLY SKILL.md: entry metadata is enough for the count/size summary, and
|
|
479
|
+
* validation must not pay for decompressing a large artifact.
|
|
480
|
+
*/
|
|
481
|
+
export function readSkillArchive(archivePath: string): SkillArchive {
|
|
482
|
+
let raw: Buffer;
|
|
483
|
+
try {
|
|
484
|
+
raw = readFileSync(archivePath);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
throw new UsageError(`failed to read archive ${archivePath}: ${(err as Error).message}`);
|
|
487
|
+
}
|
|
488
|
+
const bytes = new Uint8Array(raw);
|
|
489
|
+
|
|
490
|
+
let fileCount = 0;
|
|
491
|
+
let totalSize = 0;
|
|
492
|
+
let unzipped: Record<string, Uint8Array>;
|
|
493
|
+
try {
|
|
494
|
+
unzipped = unzipSync(bytes, {
|
|
495
|
+
filter: (info) => {
|
|
496
|
+
if (!info.name.endsWith("/")) {
|
|
497
|
+
fileCount++;
|
|
498
|
+
totalSize += info.originalSize;
|
|
499
|
+
}
|
|
500
|
+
return info.name === SKILL_FILE;
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
} catch (err) {
|
|
504
|
+
throw new UsageError(`${archivePath} is not a valid ZIP archive: ${(err as Error).message}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const skillMd = unzipped[SKILL_FILE];
|
|
508
|
+
if (skillMd === undefined) {
|
|
509
|
+
throw new UsageError(
|
|
510
|
+
`${SKILL_FILE} not found at the root of ${archivePath}\n\n` +
|
|
511
|
+
`A skill archive must contain ${SKILL_FILE} at its root (not inside a directory) defining the skill interface`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { bytes, meta: parseSkillMetadataContent(strFromU8(skillMd)), fileCount, totalSize };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Push a pre-packaged skill archive as-is (`--archive`).
|
|
520
|
+
*
|
|
521
|
+
* The bytes are uploaded untouched, so the engine's version hash is the
|
|
522
|
+
* SHA-256 of the file on disk — release pipelines that publish checksums get
|
|
523
|
+
* engine version identities that match them (stigmer/stigmer#671). Git
|
|
524
|
+
* provenance is deliberately omitted: the archive was built elsewhere, so the
|
|
525
|
+
* local checkout says nothing about the artifact's origin.
|
|
526
|
+
*/
|
|
527
|
+
export async function pushSkillFromArchive(
|
|
528
|
+
client: Stigmer,
|
|
529
|
+
archivePath: string,
|
|
530
|
+
org: string,
|
|
531
|
+
tag: string,
|
|
532
|
+
message: string,
|
|
533
|
+
): Promise<PushResult> {
|
|
534
|
+
const archive = readSkillArchive(archivePath);
|
|
535
|
+
const request = create(PushSkillRequestSchema, {
|
|
536
|
+
org,
|
|
537
|
+
artifact: archive.bytes,
|
|
538
|
+
tag: tag === "" ? "latest" : tag,
|
|
539
|
+
message,
|
|
540
|
+
});
|
|
541
|
+
// Size-routed like every other push path (#675): pre-packaged archives are
|
|
542
|
+
// the release-pipeline lane, exactly where content-heavy skills come from.
|
|
543
|
+
const response = await pushRouted(client, request);
|
|
544
|
+
const applied = await applyDeclaredVisibility(client, response, archive.meta.visibility);
|
|
545
|
+
return toResult(applied, archive.meta.name, message, archive.totalSize, archive.meta.visibility);
|
|
546
|
+
}
|
|
547
|
+
|
|
376
548
|
/**
|
|
377
549
|
* Apply the SKILL.md-declared visibility after a push. The push RPC carries
|
|
378
550
|
* artifact + provenance but not access level (visibility is metadata, not part
|