@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.
- 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/apply/apply.d.ts.map +1 -1
- package/resources/apply/apply.js +49 -2
- package/resources/apply/apply.js.map +1 -1
- package/resources/apply/declarative.d.ts +3 -3
- package/resources/apply/declarative.d.ts.map +1 -1
- package/resources/apply/declarative.js +28 -9
- package/resources/apply/declarative.js.map +1 -1
- package/resources/apply/handlers.d.ts +9 -0
- package/resources/apply/handlers.d.ts.map +1 -1
- package/resources/apply/handlers.js +1 -0
- package/resources/apply/handlers.js.map +1 -1
- 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/apply/apply.test.ts +134 -0
- package/src/resources/apply/apply.ts +63 -2
- package/src/resources/apply/declarative.integration.test.ts +79 -1
- package/src/resources/apply/declarative.ts +27 -10
- package/src/resources/apply/handlers.ts +10 -0
- package/src/resources/skill.test.ts +119 -5
- 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
|
|
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, {
|
|
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
|
|
280
|
-
* to the member-eligible ones,
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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
|
-
|
|
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 = {
|
|
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
|