@stigmer/cli 3.12.0 → 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.
Files changed (57) hide show
  1. package/commands/push.d.ts.map +1 -1
  2. package/commands/push.js +57 -0
  3. package/commands/push.js.map +1 -1
  4. package/commands/setup.d.ts.map +1 -1
  5. package/commands/setup.js +7 -3
  6. package/commands/setup.js.map +1 -1
  7. package/local/daemon/launch.js +2 -6
  8. package/local/daemon/launch.js.map +1 -1
  9. package/local/llm-config.d.ts +0 -6
  10. package/local/llm-config.d.ts.map +1 -1
  11. package/local/llm-config.js +8 -14
  12. package/local/llm-config.js.map +1 -1
  13. package/local/setup/wizard.d.ts +4 -5
  14. package/local/setup/wizard.d.ts.map +1 -1
  15. package/local/setup/wizard.js +7 -9
  16. package/local/setup/wizard.js.map +1 -1
  17. package/local/state/startup-config.d.ts +5 -4
  18. package/local/state/startup-config.d.ts.map +1 -1
  19. package/local/state/startup-config.js +6 -3
  20. package/local/state/startup-config.js.map +1 -1
  21. package/local/status.d.ts.map +1 -1
  22. package/local/status.js +5 -6
  23. package/local/status.js.map +1 -1
  24. package/package.json +5 -5
  25. package/resources/apply/apply.d.ts.map +1 -1
  26. package/resources/apply/apply.js +49 -2
  27. package/resources/apply/apply.js.map +1 -1
  28. package/resources/apply/declarative.d.ts +3 -3
  29. package/resources/apply/declarative.d.ts.map +1 -1
  30. package/resources/apply/declarative.js +28 -9
  31. package/resources/apply/declarative.js.map +1 -1
  32. package/resources/apply/handlers.d.ts +9 -0
  33. package/resources/apply/handlers.d.ts.map +1 -1
  34. package/resources/apply/handlers.js +1 -0
  35. package/resources/apply/handlers.js.map +1 -1
  36. package/resources/skill.d.ts +38 -0
  37. package/resources/skill.d.ts.map +1 -1
  38. package/resources/skill.js +84 -2
  39. package/resources/skill.js.map +1 -1
  40. package/src/commands/push.ts +74 -0
  41. package/src/commands/setup.ts +7 -4
  42. package/src/local/daemon/launch.ts +1 -6
  43. package/src/local/llm-config.test.ts +11 -14
  44. package/src/local/llm-config.ts +8 -15
  45. package/src/local/setup/wizard.test.ts +2 -3
  46. package/src/local/setup/wizard.ts +7 -9
  47. package/src/local/state/startup-config.ts +11 -7
  48. package/src/local/state/state.test.ts +23 -3
  49. package/src/local/status.test.ts +2 -2
  50. package/src/local/status.ts +5 -6
  51. package/src/resources/apply/apply.test.ts +134 -0
  52. package/src/resources/apply/apply.ts +63 -2
  53. package/src/resources/apply/declarative.integration.test.ts +79 -1
  54. package/src/resources/apply/declarative.ts +27 -10
  55. package/src/resources/apply/handlers.ts +10 -0
  56. package/src/resources/skill.test.ts +119 -5
  57. package/src/resources/skill.ts +108 -2
@@ -9,6 +9,8 @@
9
9
  import { create, fromJson, type JsonValue, type Message } from "@bufbuild/protobuf";
10
10
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
11
11
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
12
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
13
+ import { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
12
14
  import {
13
15
  type ApiResourceMetadata,
14
16
  ApiResourceMetadataSchema,
@@ -131,14 +133,16 @@ export async function applyMessage(
131
133
  org: string,
132
134
  dryRun: boolean,
133
135
  ): Promise<ApplyOutcome> {
134
- const warning = injectOrg(message, org);
136
+ const orgWarning = injectOrg(message, org);
135
137
  const created = (metaOf(message)?.id ?? "") === "";
136
138
 
137
139
  if (dryRun) {
138
- return { result: buildDryRunResult(handler, message), warning };
140
+ return { result: buildDryRunResult(handler, message), warning: orgWarning };
139
141
  }
140
142
 
141
143
  const applied = await handler.apply(controller, message);
144
+ const visibilityWarning = await applyDeclaredVisibility(controller, handler, message, applied);
145
+ const warning = combineWarnings(orgWarning, visibilityWarning);
142
146
  const result = buildApplyResult(handler, applied, created);
143
147
  if (handler.kind === ApiResourceKind.mcp_server) {
144
148
  return { result, appliedMcpServer: applied as McpServer, warning, applied };
@@ -146,6 +150,63 @@ export async function applyMessage(
146
150
  return { result, warning, applied };
147
151
  }
148
152
 
153
+ /**
154
+ * Land a manifest-declared visibility through the guarded door. Plain
155
+ * updates preserve the stored visibility on both editions (oss#573) — the
156
+ * `updateVisibility` RPC is the only mutation path, so when the applied
157
+ * resource comes back with a different level than the manifest declared,
158
+ * follow up with one RPC (the skill-push precedent: no-ops are skipped, an
159
+ * unchanged manifest costs nothing extra). Server-side guard rejections
160
+ * (unsupported level, default-instance) propagate as command failures —
161
+ * the spec update has landed at that point, and the error says so.
162
+ *
163
+ * Returns a warning (instead of following up) for kinds without the RPC:
164
+ * their manifests can only carry a level the create path already rejected,
165
+ * so a diff here means a pre-existing resource and an unsupported ask.
166
+ */
167
+ async function applyDeclaredVisibility(
168
+ controller: ControllerFn,
169
+ handler: ApplyHandler,
170
+ message: Message,
171
+ applied: Message,
172
+ ): Promise<string | undefined> {
173
+ const declared = metaOf(message)?.visibility ?? ApiResourceVisibility.api_resource_visibility_unspecified;
174
+ if (declared === ApiResourceVisibility.api_resource_visibility_unspecified) return undefined;
175
+
176
+ const appliedMeta = metaOf(applied);
177
+ const resourceId = appliedMeta?.id ?? "";
178
+ if (resourceId === "" || appliedMeta?.visibility === declared) return undefined;
179
+
180
+ if (handler.updateVisibility === undefined) {
181
+ return (
182
+ `${handler.displayName} visibility cannot be changed declaratively — ` +
183
+ "the manifest's metadata.visibility was ignored (the stored value is kept)"
184
+ );
185
+ }
186
+
187
+ try {
188
+ const updated = await handler.updateVisibility(
189
+ controller,
190
+ create(UpdateVisibilityInputSchema, { resourceId, visibility: declared }),
191
+ );
192
+ // Reflect the landed level on the outcome the caller already holds.
193
+ const updatedMeta = metaOf(updated);
194
+ if (appliedMeta !== undefined && updatedMeta !== undefined) {
195
+ appliedMeta.visibility = updatedMeta.visibility;
196
+ }
197
+ return undefined;
198
+ } catch (err) {
199
+ throw new UsageError(
200
+ `${handler.displayName} spec applied, but the manifest's visibility change was rejected: ${(err as Error).message}`,
201
+ );
202
+ }
203
+ }
204
+
205
+ function combineWarnings(...warnings: (string | undefined)[]): string | undefined {
206
+ const present = warnings.filter((w): w is string => w !== undefined);
207
+ return present.length > 0 ? present.join("; ") : undefined;
208
+ }
209
+
149
210
  /** Read a resource message's metadata (id/name/slug/org), if present. */
150
211
  export function resourceMetadata(message: Message): ApiResourceMetadata | undefined {
151
212
  return metaOf(message);
@@ -16,6 +16,7 @@ import { create } from "@bufbuild/protobuf";
16
16
  import { type ConnectRouter, createClient } from "@connectrpc/connect";
17
17
  import { connectNodeAdapter } from "@connectrpc/connect-node";
18
18
  import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
19
+ import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
19
20
  import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
20
21
  import { SkillCommandController } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/command_pb";
21
22
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
@@ -34,8 +35,12 @@ const openSessions = new Set<ServerHttp2Session>();
34
35
 
35
36
  let appliedProject: Project | undefined;
36
37
 
38
+ // Ordered log of backend calls, for the discovery-before-dependents contract.
39
+ let events: string[] = [];
40
+
37
41
  beforeEach(() => {
38
42
  appliedProject = undefined;
43
+ events = [];
39
44
  });
40
45
 
41
46
  function makeProject(dir: string): void {
@@ -53,7 +58,22 @@ function makeProject(dir: string): void {
53
58
 
54
59
  beforeAll(async () => {
55
60
  const routes = (router: ConnectRouter) => {
56
- router.service(AgentCommandController, { apply: (req) => req });
61
+ router.service(AgentCommandController, {
62
+ apply: (req) => {
63
+ events.push("agent.apply");
64
+ return req;
65
+ },
66
+ });
67
+ router.service(McpServerCommandController, {
68
+ apply: (req) => {
69
+ events.push("mcpserver.apply");
70
+ return req;
71
+ },
72
+ connect: () => {
73
+ events.push("mcpserver.connect");
74
+ return {};
75
+ },
76
+ });
57
77
  router.service(SkillCommandController, {
58
78
  push: (req) =>
59
79
  create(SkillSchema, {
@@ -123,4 +143,62 @@ describe("applyDeclarative", () => {
123
143
  rmSync(dir, { recursive: true, force: true });
124
144
  }
125
145
  });
146
+
147
+ it("discovers MCP capabilities before applying dependent resources", async () => {
148
+ // Pins the ordering contract behind apply-time enabled_tools validation
149
+ // (stigmer/stigmer#402): the server rejects agent enabled_tools against
150
+ // STORED capabilities, so discovery must refresh them between the MCP
151
+ // server applies and the applies that depend on them. Regressing to
152
+ // discovery-at-the-end resurrects the stale-capabilities race (a seedpack
153
+ // upgrade adding a tool + enabling it in an agent would fail bootstrap).
154
+ const dir = mkdtempSync(join(tmpdir(), "decl-it-order-"));
155
+ try {
156
+ writeFileSync(
157
+ join(dir, "stigmer.yaml"),
158
+ ["kind: Project", "metadata:", " name: Ordered", " slug: ordered", "spec:", " description: d", ""].join("\n"),
159
+ );
160
+ // Named so a naive filename sort would apply the agent first — the
161
+ // reconciler must order by dependency, not by scan order.
162
+ writeFileSync(
163
+ join(dir, "a-agent.yaml"),
164
+ [
165
+ "kind: Agent",
166
+ "metadata:",
167
+ " name: Consumer",
168
+ " slug: consumer",
169
+ "spec:",
170
+ " description: c",
171
+ "",
172
+ ].join("\n"),
173
+ );
174
+ writeFileSync(
175
+ join(dir, "z-server.yaml"),
176
+ [
177
+ "kind: McpServer",
178
+ "metadata:",
179
+ " name: Tools",
180
+ " slug: tools",
181
+ "spec:",
182
+ " description: t",
183
+ " stdio:",
184
+ " command: npx",
185
+ "",
186
+ ].join("\n"),
187
+ );
188
+
189
+ const detect = detectTrack(dir);
190
+ const result = await applyDeclarative(detect, {
191
+ controller: controllerFn,
192
+ stigmer,
193
+ org: "acme",
194
+ info: () => {},
195
+ warn: () => {},
196
+ });
197
+
198
+ expect(result.status).toBe("success");
199
+ expect(events).toEqual(["mcpserver.apply", "mcpserver.connect", "agent.apply"]);
200
+ } finally {
201
+ rmSync(dir, { recursive: true, force: true });
202
+ }
203
+ });
126
204
  });
@@ -276,9 +276,9 @@ const DEFAULT_IGNORE_OPTIONS = { respectGitignore: true, extraIgnore: [] as stri
276
276
 
277
277
  /**
278
278
  * Reconcile a project's membership: push skills first (agents may reference
279
- * them), apply each resource through the shared apply core, collect references
280
- * to the member-eligible ones, run post-apply MCP discovery, then apply the
281
- * project with that exact member set so the server can reconcile by
279
+ * them), apply MCP servers and run capability discovery on them, apply the
280
+ * remaining resources, collect references to the member-eligible ones, then
281
+ * apply the project with that exact member set so the server can reconcile by
282
282
  * set-difference (orphan pruning is server-side; see S1 in commands/apply.ts).
283
283
  */
284
284
  export async function reconcileProjectMembers(
@@ -295,13 +295,14 @@ export async function reconcileProjectMembers(
295
295
  members.push(reference(deps.org, ApiResourceKind.skill, pushed.slug));
296
296
  }
297
297
 
298
- // Then each resource, collecting member references from the APPLIED result so
299
- // membership carries the server's authoritative slug. (This is the unified
300
- // policy: the declarative track used to read the pre-apply YAML slug, the Go
301
- // synthesis track read the applied result — converging on the applied result
302
- // is correct for both and matches what the backend actually stored.)
298
+ // Each resource is applied through the shared apply core, collecting member
299
+ // references from the APPLIED result so membership carries the server's
300
+ // authoritative slug. (This is the unified policy: the declarative track
301
+ // used to read the pre-apply YAML slug, the Go synthesis track read the
302
+ // applied result — converging on the applied result is correct for both and
303
+ // matches what the backend actually stored.)
303
304
  const appliedMcpServers: McpServer[] = [];
304
- for (const res of resources) {
305
+ const applyOne = async (res: ReconcileResource): Promise<void> => {
305
306
  const outcome = await applyMessage(deps.controller, res.handler, res.message, deps.org, false);
306
307
  if (outcome.warning !== undefined) deps.warn(outcome.warning);
307
308
  if (outcome.appliedMcpServer !== undefined) appliedMcpServers.push(outcome.appliedMcpServer);
@@ -309,9 +310,25 @@ export async function reconcileProjectMembers(
309
310
  const slug = memberSlugOf(outcome.applied);
310
311
  if (slug) members.push(reference(deps.org, res.handler.kind, slug));
311
312
  }
312
- }
313
+ };
313
314
 
315
+ // MCP servers first, then capability discovery, then everything else — the
316
+ // dependency principle applied to capabilities, not just existence. The
317
+ // server validates agent enabled_tools against discovered capabilities at
318
+ // apply time (stigmer/stigmer#402), and the backend's own post-apply
319
+ // connect is asynchronous; discovering here means agents in this apply
320
+ // validate against the toolset this apply just shipped, not the previous
321
+ // generation's (which would falsely reject e.g. a seedpack upgrade that
322
+ // adds a tool and enables it in the same pass). Discovery stays
323
+ // best-effort: a failed connect warns, capabilities stay stale, and the
324
+ // dependent apply surfaces an actionable error listing last-known tools.
325
+ for (const res of resources) {
326
+ if (res.handler.kind === ApiResourceKind.mcp_server) await applyOne(res);
327
+ }
314
328
  await discoverAppliedMcpServers(deps.stigmer, appliedMcpServers, deps.org, deps.info);
329
+ for (const res of resources) {
330
+ if (res.handler.kind !== ApiResourceKind.mcp_server) await applyOne(res);
331
+ }
315
332
 
316
333
  injectOrg(project, deps.org);
317
334
  if (project.spec === undefined) project.spec = create(ProjectSpecSchema, {});
@@ -24,6 +24,7 @@
24
24
 
25
25
  import type { DescMessage, DescService, Message } from "@bufbuild/protobuf";
26
26
  import type { Client } from "@connectrpc/connect";
27
+ import type { UpdateVisibilityInput } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
27
28
  import { type Session, SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
28
29
  import { SessionCommandController } from "@stigmer/protos/ai/stigmer/agentic/session/v1/command_pb";
29
30
  import {
@@ -54,6 +55,14 @@ export interface ApplyHandler {
54
55
  readonly applyOrder: number;
55
56
  /** Drive the controller's `apply` RPC with the full resource message. */
56
57
  apply(controller: ControllerFn, message: Message): Promise<Message>;
58
+ /**
59
+ * Drive the controller's `updateVisibility` RPC — the only door for
60
+ * visibility changes (plain updates preserve stored visibility, oss#573).
61
+ * Present exactly when the kind has the RPC; the apply core follows up
62
+ * through it when a manifest-declared level differs from the stored one.
63
+ * Manifest kinds inherit the binding from the SDK registry.
64
+ */
65
+ updateVisibility?(controller: ControllerFn, input: UpdateVisibilityInput): Promise<Message>;
57
66
  }
58
67
 
59
68
  // The SDK registry's applyOrder values end at schedule = 12; the extras slot
@@ -72,6 +81,7 @@ const CLI_EXTRA_HANDLERS: readonly ApplyHandler[] = [
72
81
  schema: WorkflowInstanceSchema,
73
82
  applyOrder: 13,
74
83
  apply: (c, m) => c(WorkflowInstanceCommandController).apply(m as WorkflowInstance),
84
+ updateVisibility: (c, i) => c(WorkflowInstanceCommandController).updateVisibility(i),
75
85
  },
76
86
  {
77
87
  kind: ApiResourceKind.session,
@@ -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 = { push: 0, updateVisibility: [] as Array<{ resourceId: string; visibility: ApiResourceVisibility }> };
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);
@@ -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