@stigmer/cli 3.15.3-dev.20260914121148 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -4
  2. package/commands/apply.d.ts.map +1 -1
  3. package/commands/apply.js +56 -4
  4. package/commands/apply.js.map +1 -1
  5. package/commands/push.d.ts.map +1 -1
  6. package/commands/push.js +122 -16
  7. package/commands/push.js.map +1 -1
  8. package/commands/validate.d.ts.map +1 -1
  9. package/commands/validate.js +39 -7
  10. package/commands/validate.js.map +1 -1
  11. package/output/command-result.d.ts +8 -0
  12. package/output/command-result.d.ts.map +1 -1
  13. package/output/command-result.js +13 -0
  14. package/output/command-result.js.map +1 -1
  15. package/package.json +6 -5
  16. package/registry/aliases.d.ts.map +1 -1
  17. package/registry/aliases.js.map +1 -1
  18. package/registry/index.d.ts +3 -3
  19. package/registry/index.d.ts.map +1 -1
  20. package/registry/index.js +3 -3
  21. package/registry/index.js.map +1 -1
  22. package/registry/metadata.d.ts.map +1 -1
  23. package/registry/metadata.js +85 -18
  24. package/registry/metadata.js.map +1 -1
  25. package/registry/registry.d.ts.map +1 -1
  26. package/registry/registry.js.map +1 -1
  27. package/registry/verb-support.d.ts.map +1 -1
  28. package/registry/verb-support.js +81 -13
  29. package/registry/verb-support.js.map +1 -1
  30. package/resources/delete.d.ts.map +1 -1
  31. package/resources/delete.js +35 -9
  32. package/resources/delete.js.map +1 -1
  33. package/resources/get-bindings.d.ts.map +1 -1
  34. package/resources/get-bindings.js +56 -13
  35. package/resources/get-bindings.js.map +1 -1
  36. package/resources/list.d.ts.map +1 -1
  37. package/resources/list.js +62 -14
  38. package/resources/list.js.map +1 -1
  39. package/resources/plugin.d.ts +80 -0
  40. package/resources/plugin.d.ts.map +1 -0
  41. package/resources/plugin.js +305 -0
  42. package/resources/plugin.js.map +1 -0
  43. package/resources/skill.d.ts +1 -0
  44. package/resources/skill.d.ts.map +1 -1
  45. package/resources/skill.js +1 -1
  46. package/resources/skill.js.map +1 -1
  47. package/src/commands/apply.ts +133 -21
  48. package/src/commands/push.ts +270 -41
  49. package/src/commands/validate.test.ts +111 -22
  50. package/src/commands/validate.ts +68 -12
  51. package/src/output/command-result.test.ts +9 -0
  52. package/src/output/command-result.ts +14 -0
  53. package/src/registry/aliases.test.ts +24 -5
  54. package/src/registry/aliases.ts +6 -1
  55. package/src/registry/index.ts +19 -3
  56. package/src/registry/metadata.ts +85 -18
  57. package/src/registry/registry.test.ts +86 -37
  58. package/src/registry/registry.ts +8 -2
  59. package/src/registry/verb-support.ts +85 -14
  60. package/src/resources/delete.ts +106 -26
  61. package/src/resources/get-bindings.ts +95 -17
  62. package/src/resources/list.ts +102 -28
  63. package/src/resources/plugin.test.ts +233 -0
  64. package/src/resources/plugin.ts +432 -0
  65. package/src/resources/skill.ts +1 -1
@@ -10,6 +10,7 @@ import { AgentInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentins
10
10
  import { ChannelAppSchema } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/api_pb";
11
11
  import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
12
12
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
13
+ import { PluginSchema } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/api_pb";
13
14
  import { ScheduleSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
14
15
  import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
15
16
  import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
@@ -26,24 +27,87 @@ export interface ResourceResult {
26
27
  readonly message: Message;
27
28
  }
28
29
 
29
- type Getter = (client: Stigmer, ref: ParsedReference) => Promise<ResourceResult>;
30
+ type Getter = (
31
+ client: Stigmer,
32
+ ref: ParsedReference,
33
+ ) => Promise<ResourceResult>;
30
34
 
31
35
  // Every kind declaring Get in the verb matrix must have an entry here (or a
32
36
  // documented special case, e.g. organization) — the conformance test in
33
37
  // registry/registry.test.ts enforces it, so the two cannot drift.
34
38
  export const GET_BINDINGS: ReadonlyMap<ApiResourceKind, Getter> = new Map([
35
- [ApiResourceKind.agent, refGetter(ApiResourceKind.agent, AgentSchema, (c) => c.agent)],
36
- [ApiResourceKind.agent_instance, refGetter(ApiResourceKind.agent_instance, AgentInstanceSchema, (c) => c.agentInstance)],
37
- [ApiResourceKind.workflow, refGetter(ApiResourceKind.workflow, WorkflowSchema, (c) => c.workflow)],
38
- [ApiResourceKind.workflow_instance, refGetter(ApiResourceKind.workflow_instance, WorkflowInstanceSchema, (c) => c.workflowInstance)],
39
- [ApiResourceKind.mcp_server, refGetter(ApiResourceKind.mcp_server, McpServerSchema, (c) => c.mcpServer)],
40
- [ApiResourceKind.project, refGetter(ApiResourceKind.project, ProjectSchema, (c) => c.project)],
41
- [ApiResourceKind.environment, refGetter(ApiResourceKind.environment, EnvironmentSchema, (c) => c.environment)],
42
- [ApiResourceKind.agent_channel, refGetter(ApiResourceKind.agent_channel, AgentChannelSchema, (c) => c.agentChannel)],
43
- [ApiResourceKind.channel_app, refGetter(ApiResourceKind.channel_app, ChannelAppSchema, (c) => c.channelapp)],
44
- [ApiResourceKind.schedule, refGetter(ApiResourceKind.schedule, ScheduleSchema, (c) => c.schedule)],
45
- [ApiResourceKind.skill, refGetter(ApiResourceKind.skill, SkillSchema, (c) => c.skill)],
46
- [ApiResourceKind.api_key, idOnlyGetter(ApiKeySchema, (c) => c.apiKey, "API keys")],
39
+ [
40
+ ApiResourceKind.agent,
41
+ refGetter(ApiResourceKind.agent, AgentSchema, (c) => c.agent),
42
+ ],
43
+ [
44
+ ApiResourceKind.agent_instance,
45
+ refGetter(
46
+ ApiResourceKind.agent_instance,
47
+ AgentInstanceSchema,
48
+ (c) => c.agentInstance,
49
+ ),
50
+ ],
51
+ [
52
+ ApiResourceKind.workflow,
53
+ refGetter(ApiResourceKind.workflow, WorkflowSchema, (c) => c.workflow),
54
+ ],
55
+ [
56
+ ApiResourceKind.workflow_instance,
57
+ refGetter(
58
+ ApiResourceKind.workflow_instance,
59
+ WorkflowInstanceSchema,
60
+ (c) => c.workflowInstance,
61
+ ),
62
+ ],
63
+ [
64
+ ApiResourceKind.mcp_server,
65
+ refGetter(ApiResourceKind.mcp_server, McpServerSchema, (c) => c.mcpServer),
66
+ ],
67
+ [
68
+ ApiResourceKind.project,
69
+ refGetter(ApiResourceKind.project, ProjectSchema, (c) => c.project),
70
+ ],
71
+ [
72
+ ApiResourceKind.environment,
73
+ refGetter(
74
+ ApiResourceKind.environment,
75
+ EnvironmentSchema,
76
+ (c) => c.environment,
77
+ ),
78
+ ],
79
+ [
80
+ ApiResourceKind.agent_channel,
81
+ refGetter(
82
+ ApiResourceKind.agent_channel,
83
+ AgentChannelSchema,
84
+ (c) => c.agentChannel,
85
+ ),
86
+ ],
87
+ [
88
+ ApiResourceKind.channel_app,
89
+ refGetter(
90
+ ApiResourceKind.channel_app,
91
+ ChannelAppSchema,
92
+ (c) => c.channelapp,
93
+ ),
94
+ ],
95
+ [
96
+ ApiResourceKind.schedule,
97
+ refGetter(ApiResourceKind.schedule, ScheduleSchema, (c) => c.schedule),
98
+ ],
99
+ [
100
+ ApiResourceKind.skill,
101
+ refGetter(ApiResourceKind.skill, SkillSchema, (c) => c.skill),
102
+ ],
103
+ [
104
+ ApiResourceKind.plugin,
105
+ refGetter(ApiResourceKind.plugin, PluginSchema, (c) => c.plugin),
106
+ ],
107
+ [
108
+ ApiResourceKind.api_key,
109
+ idOnlyGetter(ApiKeySchema, (c) => c.apiKey, "API keys"),
110
+ ],
47
111
  ]);
48
112
 
49
113
  export function getterFor(kind: ApiResourceKind): Getter | undefined {
@@ -53,20 +117,34 @@ export function getterFor(kind: ApiResourceKind): Getter | undefined {
53
117
  // A resource fetchable by ID or by org/slug reference.
54
118
  interface RefClient {
55
119
  get(id: string): Promise<Message>;
56
- getByReference(ref: { org: string; slug: string; kind: number }): Promise<Message>;
120
+ getByReference(ref: {
121
+ org: string;
122
+ slug: string;
123
+ kind: number;
124
+ }): Promise<Message>;
57
125
  }
58
126
 
59
- function refGetter(kind: ApiResourceKind, schema: DescMessage, pick: (c: Stigmer) => RefClient): Getter {
127
+ function refGetter(
128
+ kind: ApiResourceKind,
129
+ schema: DescMessage,
130
+ pick: (c: Stigmer) => RefClient,
131
+ ): Getter {
60
132
  return async (client, ref) => {
61
133
  const sub = pick(client);
62
134
  const message =
63
- ref.kind === "id" ? await sub.get(ref.id) : await sub.getByReference({ org: ref.org, slug: ref.slug, kind });
135
+ ref.kind === "id"
136
+ ? await sub.get(ref.id)
137
+ : await sub.getByReference({ org: ref.org, slug: ref.slug, kind });
64
138
  return { schema, message };
65
139
  };
66
140
  }
67
141
 
68
142
  // A resource fetchable only by ID (e.g. API keys are not slug-addressable).
69
- function idOnlyGetter(schema: DescMessage, pick: (c: Stigmer) => { get(id: string): Promise<Message> }, label: string): Getter {
143
+ function idOnlyGetter(
144
+ schema: DescMessage,
145
+ pick: (c: Stigmer) => { get(id: string): Promise<Message> },
146
+ label: string,
147
+ ): Getter {
70
148
  return async (client, ref) => {
71
149
  if (ref.kind !== "id") {
72
150
  throw new UsageError(`${label} can only be fetched by ID`);
@@ -35,7 +35,14 @@ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organizat
35
35
  import type { Stigmer } from "@stigmer/sdk";
36
36
  import { UsageError } from "../errors/index.js";
37
37
  import type { OutputFormat } from "../output/index.js";
38
- import { bool, type JsonObject, obj, renderCollection, str, type TableShape } from "./render.js";
38
+ import {
39
+ bool,
40
+ type JsonObject,
41
+ obj,
42
+ renderCollection,
43
+ str,
44
+ type TableShape,
45
+ } from "./render.js";
39
46
 
40
47
  // Kinds that list through the SearchService (list mode: empty query, org
41
48
  // scope). Must stay in step with the server's SearchableKinds allowlist
@@ -43,14 +50,16 @@ import { bool, type JsonObject, obj, renderCollection, str, type TableShape } fr
43
50
  // search_criteria.go): a kind present here but absent there silently lists
44
51
  // as empty. The LIST_HANDLERS kinds are deliberately NOT here — see the
45
52
  // header: not search-indexed, or better served by their dedicated RPC.
46
- export const SEARCH_KINDS: ReadonlySet<ApiResourceKind> = new Set<ApiResourceKind>([
47
- ApiResourceKind.agent,
48
- ApiResourceKind.workflow,
49
- ApiResourceKind.mcp_server,
50
- ApiResourceKind.project,
51
- ApiResourceKind.skill,
52
- ApiResourceKind.environment,
53
- ]);
53
+ export const SEARCH_KINDS: ReadonlySet<ApiResourceKind> =
54
+ new Set<ApiResourceKind>([
55
+ ApiResourceKind.agent,
56
+ ApiResourceKind.workflow,
57
+ ApiResourceKind.mcp_server,
58
+ ApiResourceKind.project,
59
+ ApiResourceKind.skill,
60
+ ApiResourceKind.plugin,
61
+ ApiResourceKind.environment,
62
+ ]);
54
63
 
55
64
  // One fetched page of a listing: what to render (entries + schema) and how
56
65
  // (table). Rendering and --limit truncation happen centrally in
@@ -64,12 +73,19 @@ interface ListPage {
64
73
  // Where the RPC paginates, the handler still forwards `limit` as the page
65
74
  // size so the server does the bounding; the dispatcher's slice is then a
66
75
  // no-op. Where it doesn't, the slice IS the bound.
67
- type ListFn = (client: Stigmer, org: string, limit: number) => Promise<ListPage>;
76
+ type ListFn = (
77
+ client: Stigmer,
78
+ org: string,
79
+ limit: number,
80
+ ) => Promise<ListPage>;
68
81
 
69
82
  // Dedicated-RPC list handlers for the non-search-indexed kinds. Every kind
70
83
  // declaring List in the verb matrix must appear here or in SEARCH_KINDS —
71
84
  // the conformance test enforces it, so the two cannot drift.
72
- export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<ApiResourceKind, ListFn>([
85
+ export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<
86
+ ApiResourceKind,
87
+ ListFn
88
+ >([
73
89
  [
74
90
  ApiResourceKind.organization,
75
91
  async (client) => {
@@ -77,7 +93,11 @@ export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<ApiRe
77
93
  // a caller's org set is small by nature). The paginated `find` RPC is
78
94
  // platform-admin-only — the wrong surface for `list organization`.
79
95
  const result = await client.organization.findMyOrganizations();
80
- return { schema: OrganizationSchema, entries: result.entries, table: ORG_TABLE };
96
+ return {
97
+ schema: OrganizationSchema,
98
+ entries: result.entries,
99
+ table: ORG_TABLE,
100
+ };
81
101
  },
82
102
  ],
83
103
  [
@@ -85,25 +105,43 @@ export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<ApiRe
85
105
  async (client) => {
86
106
  // findAll is caller-scoped and unpaginated (Empty request).
87
107
  const result = await client.apiKey.findAll();
88
- return { schema: ApiKeySchema, entries: result.entries, table: APIKEY_TABLE };
108
+ return {
109
+ schema: ApiKeySchema,
110
+ entries: result.entries,
111
+ table: APIKEY_TABLE,
112
+ };
89
113
  },
90
114
  ],
91
115
  [
92
116
  ApiResourceKind.agent_instance,
93
117
  async (client, org, limit) => {
94
118
  const result = await client.agentInstance.list(
95
- create(ListAgentInstancesRequestSchema, { org, pageInfo: create(PageInfoSchema, { num: 1, size: limit }) }),
119
+ create(ListAgentInstancesRequestSchema, {
120
+ org,
121
+ pageInfo: create(PageInfoSchema, { num: 1, size: limit }),
122
+ }),
96
123
  );
97
- return { schema: AgentInstanceSchema, entries: result.items, table: INSTANCE_TABLE };
124
+ return {
125
+ schema: AgentInstanceSchema,
126
+ entries: result.items,
127
+ table: INSTANCE_TABLE,
128
+ };
98
129
  },
99
130
  ],
100
131
  [
101
132
  ApiResourceKind.agent_channel,
102
133
  async (client, org, limit) => {
103
134
  const result = await client.agentChannel.list(
104
- create(ListAgentChannelsRequestSchema, { org, pageInfo: create(PageInfoSchema, { num: 1, size: limit }) }),
135
+ create(ListAgentChannelsRequestSchema, {
136
+ org,
137
+ pageInfo: create(PageInfoSchema, { num: 1, size: limit }),
138
+ }),
105
139
  );
106
- return { schema: AgentChannelSchema, entries: result.items, table: AGENT_CHANNEL_TABLE };
140
+ return {
141
+ schema: AgentChannelSchema,
142
+ entries: result.items,
143
+ table: AGENT_CHANNEL_TABLE,
144
+ };
107
145
  },
108
146
  ],
109
147
  [
@@ -112,8 +150,14 @@ export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<ApiRe
112
150
  // listByOrg has no pagination on its contract (the per-org set is small
113
151
  // by design); the dispatcher's slice bounds the output.
114
152
  // `channelapp` (not `channelApp`) is a recorded SDK codegen naming quirk.
115
- const result = await client.channelapp.listByOrg(create(ListChannelAppsByOrgInputSchema, { org }));
116
- return { schema: ChannelAppSchema, entries: result.entries, table: CHANNEL_APP_TABLE };
153
+ const result = await client.channelapp.listByOrg(
154
+ create(ListChannelAppsByOrgInputSchema, { org }),
155
+ );
156
+ return {
157
+ schema: ChannelAppSchema,
158
+ entries: result.entries,
159
+ table: CHANNEL_APP_TABLE,
160
+ };
117
161
  },
118
162
  ],
119
163
  [
@@ -128,9 +172,16 @@ export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<ApiRe
128
172
  );
129
173
  }
130
174
  const result = await client.schedule.list(
131
- create(ListSchedulesRequestSchema, { org, pageInfo: create(PageInfoSchema, { num: 1, size: limit }) }),
175
+ create(ListSchedulesRequestSchema, {
176
+ org,
177
+ pageInfo: create(PageInfoSchema, { num: 1, size: limit }),
178
+ }),
132
179
  );
133
- return { schema: ScheduleSchema, entries: result.items, table: SCHEDULE_TABLE };
180
+ return {
181
+ schema: ScheduleSchema,
182
+ entries: result.items,
183
+ table: SCHEDULE_TABLE,
184
+ };
134
185
  },
135
186
  ],
136
187
  [
@@ -142,8 +193,14 @@ export const LIST_HANDLERS: ReadonlyMap<ApiResourceKind, ListFn> = new Map<ApiRe
142
193
  // api_key above — the agent_instance precedent, not an oversight.
143
194
  // Promoted from a bespoke pre-gate route in commands/list.ts by
144
195
  // stigmer/stigmer#469 (it had shipped working-but-unadvertised).
145
- const result = await client.session.list(create(ListSessionsRequestSchema, { pageSize: limit }));
146
- return { schema: SessionSchema, entries: result.entries, table: SESSION_TABLE };
196
+ const result = await client.session.list(
197
+ create(ListSessionsRequestSchema, { pageSize: limit }),
198
+ );
199
+ return {
200
+ schema: SessionSchema,
201
+ entries: result.entries,
202
+ table: SESSION_TABLE,
203
+ };
147
204
  },
148
205
  ],
149
206
  ]);
@@ -162,7 +219,12 @@ export async function listResources(
162
219
  // rather than per-handler is what keeps the flag honest for every kind —
163
220
  // organization and api_key shipped ignoring it when handlers owned this
164
221
  // (stigmer/stigmer#312).
165
- return renderCollection(page.schema, page.entries.slice(0, limit), format, page.table);
222
+ return renderCollection(
223
+ page.schema,
224
+ page.entries.slice(0, limit),
225
+ format,
226
+ page.table,
227
+ );
166
228
  }
167
229
 
168
230
  async function fetchListPage(
@@ -178,8 +240,16 @@ async function fetchListPage(
178
240
  if (!SEARCH_KINDS.has(kind)) {
179
241
  throw new UsageError("list is not implemented for this resource type");
180
242
  }
181
- const result = await client.search.query({ kinds: [kind], org, page: { num: 1, size: limit } });
182
- return { schema: SearchResultSchema, entries: result.entries, table: SEARCH_TABLE };
243
+ const result = await client.search.query({
244
+ kinds: [kind],
245
+ org,
246
+ page: { num: 1, size: limit },
247
+ });
248
+ return {
249
+ schema: SearchResultSchema,
250
+ entries: result.entries,
251
+ table: SEARCH_TABLE,
252
+ };
183
253
  }
184
254
 
185
255
  // Shared with `search` — both render SearchService results identically.
@@ -258,7 +328,9 @@ const CHANNEL_APP_TABLE: TableShape = {
258
328
  str(metadata, "id"),
259
329
  str(metadata, "slug"),
260
330
  providerOf(obj(json, "spec")),
261
- date(str(obj(obj(obj(json, "status"), "audit"), "spec_audit"), "created_at")),
331
+ date(
332
+ str(obj(obj(obj(json, "status"), "audit"), "spec_audit"), "created_at"),
333
+ ),
262
334
  ];
263
335
  },
264
336
  };
@@ -332,7 +404,9 @@ function apiKeyExpiry(spec: JsonObject): string {
332
404
 
333
405
  function truncate(text: string, max: number): string {
334
406
  const normalized = text.replace(/\s+/g, " ").trim();
335
- return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
407
+ return normalized.length > max
408
+ ? `${normalized.slice(0, max - 1)}…`
409
+ : normalized;
336
410
  }
337
411
 
338
412
  function date(timestamp: string): string {
@@ -0,0 +1,233 @@
1
+ // Pins the plugin directory walker: detection by any of the four manifests,
2
+ // sorted sized entries, the ignore matcher applied (defaults, .gitignore,
3
+ // .stigmerignore), symlinks never followed, and the JSON projection that
4
+ // reduces overlay documents to their paths.
5
+
6
+ import {
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
16
+ import { readPluginPackage } from "@stigmer/plugin-package";
17
+ import {
18
+ describePlugin,
19
+ isPluginDirectory,
20
+ readPluginDirectory,
21
+ } from "./plugin.js";
22
+
23
+ let root: string;
24
+
25
+ function write(rel: string, content: string): void {
26
+ const full = join(root, rel);
27
+ mkdirSync(join(full, ".."), { recursive: true });
28
+ writeFileSync(full, content);
29
+ }
30
+
31
+ beforeAll(() => {
32
+ root = mkdtempSync(join(tmpdir(), "stigmer-plugin-walk-"));
33
+ write(
34
+ ".cursor-plugin/plugin.json",
35
+ JSON.stringify({
36
+ name: "walker",
37
+ skills: "./skills/",
38
+ mcpServers: "./mcp.json",
39
+ variables: {
40
+ type: "object",
41
+ properties: { TOKEN: { type: "string" } },
42
+ required: ["TOKEN"],
43
+ },
44
+ }),
45
+ );
46
+ write(
47
+ "mcp.json",
48
+ JSON.stringify({
49
+ mcpServers: {
50
+ api: {
51
+ type: "http",
52
+ url: "https://api.example.com/mcp",
53
+ headers: { Authorization: "Bearer ${TOKEN}" },
54
+ },
55
+ },
56
+ }),
57
+ );
58
+ write(
59
+ "skills/greet/SKILL.md",
60
+ "---\nname: greet\ndescription: Says hello.\n---\nSay hello.\n",
61
+ );
62
+ write("skills/greet/scripts/hello.sh", "echo hello");
63
+ write("skills/greet/secrets.yaml", "never: included");
64
+ write("skills/greet/.env", "SECRET=1");
65
+ write("node_modules/dep/index.js", "module.exports = 1");
66
+ write("ai.stigmer/agent.yaml", "kind: Agent\n");
67
+ write(".gitignore", "*.log\n");
68
+ write("debug.log", "ignored by .gitignore");
69
+ write(".stigmerignore", "drafts/\n");
70
+ write("drafts/idea.md", "ignored by .stigmerignore");
71
+ symlinkSync(join(root, "mcp.json"), join(root, "mcp-link.json"));
72
+ });
73
+
74
+ afterAll(() => {
75
+ rmSync(root, { recursive: true, force: true });
76
+ });
77
+
78
+ describe("isPluginDirectory", () => {
79
+ it("is true for a directory holding any of the four manifests and false otherwise", () => {
80
+ expect(isPluginDirectory(root)).toBe(true);
81
+ expect(isPluginDirectory(join(root, "skills"))).toBe(false);
82
+ expect(isPluginDirectory(join(root, "mcp.json"))).toBe(false);
83
+ expect(isPluginDirectory(join(root, "does-not-exist"))).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("readPluginDirectory", () => {
88
+ it("lists sorted, sized, contained entries and applies every ignore source", () => {
89
+ const { files, stats } = readPluginDirectory(root);
90
+ expect(files.entries.map((e) => e.path)).toEqual([
91
+ ".cursor-plugin/plugin.json",
92
+ ".gitignore",
93
+ ".stigmerignore",
94
+ "ai.stigmer/agent.yaml",
95
+ "mcp.json",
96
+ "skills/greet/SKILL.md",
97
+ "skills/greet/scripts/hello.sh",
98
+ ]);
99
+ expect(
100
+ files.entries.find((e) => e.path === "skills/greet/scripts/hello.sh")
101
+ ?.size,
102
+ ).toBe("echo hello".length);
103
+ // .env and secrets.yaml (security defaults), debug.log (.gitignore); the
104
+ // symlink is neither a file nor a directory and is skipped uncounted.
105
+ expect(stats).toMatchObject({
106
+ filesIncluded: 7,
107
+ filesIgnored: 3,
108
+ dirsSkipped: 2,
109
+ });
110
+ });
111
+
112
+ it("reads exactly the listed bytes", () => {
113
+ const { files } = readPluginDirectory(root);
114
+ expect(new TextDecoder().decode(files.read("ai.stigmer/agent.yaml"))).toBe(
115
+ "kind: Agent\n",
116
+ );
117
+ });
118
+
119
+ it("feeds the reader a plugin it accepts", () => {
120
+ const { files, stats } = readPluginDirectory(root);
121
+ const outcome = readPluginPackage(files);
122
+ expect(outcome.ok).toBe(true);
123
+ if (!outcome.ok) return;
124
+ const description = describePlugin(outcome.plugin, outcome.warnings, stats);
125
+ expect(description.plugin.overlay).toEqual({
126
+ agent: "ai.stigmer/agent.yaml",
127
+ workflows: [],
128
+ mcpServers: [],
129
+ });
130
+ expect(description.excludedFiles).toBe(3);
131
+ expect(JSON.stringify(description)).not.toContain("bytes");
132
+ });
133
+ });
134
+
135
+ describe("zipPluginFiles", () => {
136
+ it("writes the walked list deterministically and round-trips through the library's own reader", async () => {
137
+ const { zipPluginFiles } = await import("./plugin.js");
138
+ const { unzipSync } = await import("fflate");
139
+ const directory = readPluginDirectory(root);
140
+ const first = zipPluginFiles(directory.files);
141
+ const second = zipPluginFiles(directory.files);
142
+ expect(Buffer.from(first).equals(Buffer.from(second))).toBe(true);
143
+ const entries = Object.keys(unzipSync(first)).sort();
144
+ expect(entries).toEqual(directory.files.entries.map((e) => e.path));
145
+ expect(entries).not.toContain("debug.log");
146
+ expect(entries).not.toContain("mcp-link.json");
147
+ });
148
+ });
149
+
150
+ describe("renderPushOutcome", () => {
151
+ it("summarises the install by kind, lists members and carries warnings", async () => {
152
+ const { renderPushOutcome } = await import("./plugin.js");
153
+ const { create } = await import("@bufbuild/protobuf");
154
+ const { PluginSchema } =
155
+ await import("@stigmer/protos/ai/stigmer/agentic/plugin/v1/api_pb");
156
+ const { PluginMemberSchema } =
157
+ await import("@stigmer/protos/ai/stigmer/agentic/plugin/v1/io_pb");
158
+ const { PluginDialect } =
159
+ await import("@stigmer/protos/ai/stigmer/agentic/plugin/v1/spec_pb");
160
+ const { PluginState } =
161
+ await import("@stigmer/protos/ai/stigmer/agentic/plugin/v1/status_pb");
162
+ const { ApiResourceKind } =
163
+ await import("@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb");
164
+ const { renderResult } = await import("../output/command-result.js");
165
+
166
+ const plugin = create(PluginSchema, {
167
+ metadata: { id: "plg_1", slug: "thermos" },
168
+ spec: {
169
+ name: "thermos",
170
+ version: "1.2.0",
171
+ dialect: PluginDialect.CURSOR,
172
+ },
173
+ status: {
174
+ digest: "a".repeat(64),
175
+ state: PluginState.READY,
176
+ materialized: { skills: 2, mcpServers: 1, agents: 1, workflows: 0 },
177
+ warnings: [
178
+ {
179
+ kind: "model-hint-unresolved",
180
+ message:
181
+ "sub-agent 'reviewer' names model 'sonnet'; it runs on the session's model",
182
+ },
183
+ ],
184
+ },
185
+ });
186
+ const members = [
187
+ create(PluginMemberSchema, {
188
+ kind: ApiResourceKind.skill,
189
+ id: "skl_1",
190
+ slug: "review",
191
+ name: "review",
192
+ }),
193
+ create(PluginMemberSchema, {
194
+ kind: ApiResourceKind.skill,
195
+ id: "skl_2",
196
+ slug: "triage",
197
+ name: "triage",
198
+ }),
199
+ create(PluginMemberSchema, {
200
+ kind: ApiResourceKind.mcp_server,
201
+ id: "mcp_1",
202
+ slug: "github",
203
+ name: "github",
204
+ }),
205
+ create(PluginMemberSchema, {
206
+ kind: ApiResourceKind.agent,
207
+ id: "agt_1",
208
+ slug: "thermos",
209
+ name: "thermos",
210
+ }),
211
+ ];
212
+ const result = renderPushOutcome({ plugin, members, archiveBytes: 2048 });
213
+ const lines: string[] = [];
214
+ const original = process.stderr.write.bind(process.stderr);
215
+ process.stderr.write = ((chunk: string | Uint8Array) => {
216
+ lines.push(String(chunk));
217
+ return true;
218
+ }) as typeof process.stderr.write;
219
+ try {
220
+ renderResult(result, "human");
221
+ } finally {
222
+ process.stderr.write = original;
223
+ }
224
+ const text = lines.join("");
225
+ expect(text).toContain(
226
+ "Installed plugin 'thermos' (2 skills, 1 MCP server, 1 agent) with 1 warning",
227
+ );
228
+ expect(text).toContain("Skills");
229
+ expect(text).toContain("review, triage");
230
+ expect(text).toContain("Cursor plugin");
231
+ expect(text).toContain("runs on the session's model");
232
+ });
233
+ });