@stigmer/cli 3.15.3-dev.20260914121148 → 3.15.3-dev.20260916211208

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
@@ -0,0 +1,432 @@
1
+ // A plugin directory as the reader's input, and the reader's outcome as
2
+ // something the CLI can print.
3
+ //
4
+ // `@stigmer/plugin-package` is pure over a `PluginFiles` list; this module is
5
+ // the CLI's edge for it. The walk is the skill packager's discipline
6
+ // (`createSkillZip`): sorted entries, symlinks never followed, the same
7
+ // gitignore-compatible matcher deciding inclusion, so what `validate` reads
8
+ // is exactly what a push of the same directory would package. Sizes come
9
+ // from `stat` so the library can refuse an over-cap document before reading
10
+ // it; the library re-checks the bytes it gets back.
11
+ //
12
+ // `describePlugin` is the JSON projection for `--json`: the normalised
13
+ // package with overlay documents reduced to their paths (the bytes are the
14
+ // user's own files, and a byte array serialises badly).
15
+ //
16
+ // The push half: the same walked file list, zipped the way the skill
17
+ // packager zips (deterministic bytes, so the server's digest is a function
18
+ // of content), pushed through the SDK's routed plugin client, and the
19
+ // members read back for the install summary. `validate -f`, `push plugin
20
+ // --dry-run` and `push plugin` share one description renderer so the
21
+ // author reads one vocabulary at every step.
22
+
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { create, toJson } from "@bufbuild/protobuf";
26
+ import { zipSync } from "fflate";
27
+ import {
28
+ MANIFEST_LOCATIONS,
29
+ readPluginPackage,
30
+ type PluginDialect,
31
+ type PluginFileEntry,
32
+ type PluginFiles,
33
+ type PluginFinding,
34
+ type PluginPackage,
35
+ } from "@stigmer/plugin-package";
36
+ import {
37
+ PluginSchema,
38
+ type Plugin,
39
+ } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/api_pb";
40
+ import {
41
+ PluginMemberSchema,
42
+ PushPluginRequestSchema,
43
+ type PluginMember,
44
+ } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/io_pb";
45
+ import { PluginDialect as PluginDialectProto } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/spec_pb";
46
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
47
+ import type { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
48
+ import type { Stigmer } from "@stigmer/sdk";
49
+ import { UsageError } from "../errors/index.js";
50
+ import { CommandResult } from "../output/index.js";
51
+ import { createMatcher } from "./ignore/index.js";
52
+ import {
53
+ DETERMINISTIC_ZIP_MTIME,
54
+ formatBytes,
55
+ shortHash,
56
+ type IgnoreOptions,
57
+ type ZipStats,
58
+ } from "./skill.js";
59
+
60
+ /** The four manifest locations; a directory holding any of them is a plugin. */
61
+ export const PLUGIN_MANIFEST_PATHS: readonly string[] =
62
+ Object.values(MANIFEST_LOCATIONS);
63
+
64
+ /** True when `path` is a directory holding one of the four plugin manifests. */
65
+ export function isPluginDirectory(path: string): boolean {
66
+ try {
67
+ if (!statSync(path).isDirectory()) return false;
68
+ } catch {
69
+ return false;
70
+ }
71
+ return PLUGIN_MANIFEST_PATHS.some((manifest) => {
72
+ try {
73
+ return statSync(join(path, ...manifest.split("/"))).isFile();
74
+ } catch {
75
+ return false;
76
+ }
77
+ });
78
+ }
79
+
80
+ export interface PluginDirectory {
81
+ readonly files: PluginFiles;
82
+ /** What the walk included and left out, for the summary line. */
83
+ readonly stats: ZipStats;
84
+ }
85
+
86
+ /** Validate's ignore posture: the defaults and the directory's own ignore files, no flags. */
87
+ export const DEFAULT_IGNORE_OPTIONS: IgnoreOptions = {
88
+ respectGitignore: true,
89
+ extraIgnore: [],
90
+ extraInclude: [],
91
+ };
92
+
93
+ /** Walk `dir` through the ignore matcher into a `PluginFiles`. */
94
+ export function readPluginDirectory(
95
+ dir: string,
96
+ options: IgnoreOptions = DEFAULT_IGNORE_OPTIONS,
97
+ ): PluginDirectory {
98
+ const matcher = createMatcher({
99
+ rootDir: dir,
100
+ respectGitignore: options.respectGitignore,
101
+ includeDefaults: true,
102
+ extraIgnore: options.extraIgnore,
103
+ extraInclude: options.extraInclude,
104
+ });
105
+ const stats: ZipStats = {
106
+ filesIncluded: 0,
107
+ filesIgnored: 0,
108
+ dirsSkipped: 0,
109
+ totalSize: 0,
110
+ };
111
+ const entries: PluginFileEntry[] = [];
112
+
113
+ const walk = (currentDir: string, prefix: string): void => {
114
+ const dirents = readdirSync(currentDir, { withFileTypes: true }).sort(
115
+ (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
116
+ );
117
+ for (const dirent of dirents) {
118
+ const relPath = prefix === "" ? dirent.name : `${prefix}/${dirent.name}`;
119
+ const full = join(currentDir, dirent.name);
120
+ if (dirent.isDirectory()) {
121
+ if (matcher.matchWithReason(relPath, true).ignored) {
122
+ stats.dirsSkipped++;
123
+ continue;
124
+ }
125
+ walk(full, relPath);
126
+ continue;
127
+ }
128
+ // Symlinks are neither files nor directories here and are never followed.
129
+ if (!dirent.isFile()) continue;
130
+ if (matcher.matchWithReason(relPath, false).ignored) {
131
+ stats.filesIgnored++;
132
+ continue;
133
+ }
134
+ const size = statSync(full).size;
135
+ entries.push({ path: relPath, size });
136
+ stats.filesIncluded++;
137
+ stats.totalSize += size;
138
+ }
139
+ };
140
+ walk(dir, "");
141
+
142
+ return {
143
+ files: {
144
+ entries,
145
+ read: (path) =>
146
+ new Uint8Array(readFileSync(join(dir, ...path.split("/")))),
147
+ },
148
+ stats,
149
+ };
150
+ }
151
+
152
+ /** The `--json` payload: the package with overlay documents as paths, plus the findings. */
153
+ export interface PluginDescription {
154
+ readonly plugin: Omit<PluginPackage, "overlay"> & {
155
+ readonly overlay: {
156
+ readonly agent?: string;
157
+ readonly workflows: readonly {
158
+ readonly name: string;
159
+ readonly path: string;
160
+ }[];
161
+ readonly mcpServers: readonly {
162
+ readonly server: string;
163
+ readonly path: string;
164
+ }[];
165
+ };
166
+ };
167
+ readonly warnings: readonly PluginFinding[];
168
+ readonly excludedFiles: number;
169
+ }
170
+
171
+ export function describePlugin(
172
+ plugin: PluginPackage,
173
+ warnings: readonly PluginFinding[],
174
+ stats: ZipStats,
175
+ ): PluginDescription {
176
+ const { overlay, ...rest } = plugin;
177
+ return {
178
+ plugin: {
179
+ ...rest,
180
+ overlay: {
181
+ ...(overlay.agent !== undefined && { agent: overlay.agent.path }),
182
+ workflows: overlay.workflows.map((w) => ({
183
+ name: w.name,
184
+ path: w.path,
185
+ })),
186
+ mcpServers: overlay.mcpServers.map((s) => ({
187
+ server: s.server,
188
+ path: s.path,
189
+ })),
190
+ },
191
+ },
192
+ warnings,
193
+ excludedFiles: stats.filesIgnored,
194
+ };
195
+ }
196
+
197
+ // ─── Rendering, shared by `validate -f <dir>` and `push plugin --dry-run` ────
198
+
199
+ /** Human labels for the four dialects, in the vocabulary the docs use. */
200
+ export const DIALECT_LABELS: Readonly<Record<PluginDialect, string>> = {
201
+ "agent-plugins": "Agent Plugins 1.0",
202
+ claude: "Claude Code plugin",
203
+ cursor: "Cursor plugin",
204
+ codex: "Codex plugin",
205
+ };
206
+
207
+ /** The library's refusal as the CLI's one UsageError: every problem, then every warning. */
208
+ export function pluginRefusal(
209
+ dir: string,
210
+ errors: readonly PluginFinding[],
211
+ warnings: readonly PluginFinding[],
212
+ ): UsageError {
213
+ const lines = [
214
+ `${dir}: plugin cannot be installed, ${count(errors.length, "problem")} found:`,
215
+ ...errors.map((finding) => ` - ${finding.message}`),
216
+ ];
217
+ if (warnings.length > 0) {
218
+ lines.push(
219
+ `and ${count(warnings.length, "warning")}:`,
220
+ ...warnings.map((finding) => ` - ${finding.message}`),
221
+ );
222
+ }
223
+ return new UsageError(lines.join("\n"));
224
+ }
225
+
226
+ /**
227
+ * What the package would install, as sections on a result — the one
228
+ * description `validate` prints and `push --dry-run` prints under its own
229
+ * headline, so an author reads the same words offline and before a push.
230
+ */
231
+ export function describePackageOn(
232
+ result: CommandResult,
233
+ plugin: PluginPackage,
234
+ warnings: readonly PluginFinding[],
235
+ stats: ZipStats,
236
+ ): CommandResult {
237
+ const about = result.addSection("Plugin");
238
+ about.field("Name", plugin.name);
239
+ if (plugin.version !== undefined) about.field("Version", plugin.version);
240
+ about.field("Format", DIALECT_LABELS[plugin.dialect]);
241
+ about.field("Manifests", plugin.manifestsFound.join(", "));
242
+ about.field(
243
+ "Files",
244
+ `${stats.filesIncluded} read, ${stats.filesIgnored} excluded by ignore rules`,
245
+ );
246
+
247
+ const contents = result.addSection("Installs");
248
+ contents.field("Skills", named(plugin.skills.map((s) => s.name)));
249
+ contents.field(
250
+ "MCP servers",
251
+ named(plugin.mcpServers.map((s) => `${s.name} (${s.transport})`)),
252
+ );
253
+ contents.field("Sub-agents", named(plugin.subAgents.map((a) => a.name)));
254
+ contents.field(
255
+ "Variables",
256
+ named(
257
+ plugin.variables.map(
258
+ (v) =>
259
+ `${v.name}${v.optional ? " (optional)" : ""}${v.declaredBy === "inferred" ? " (inferred)" : ""}`,
260
+ ),
261
+ ),
262
+ );
263
+ const overlay = [
264
+ ...(plugin.overlay.agent !== undefined ? ["agent"] : []),
265
+ ...plugin.overlay.workflows.map((w) => `workflow ${w.name}`),
266
+ ...plugin.overlay.mcpServers.map((s) => `server overlay ${s.server}`),
267
+ ];
268
+ if (overlay.length > 0) contents.field("Stigmer overlay", overlay.join(", "));
269
+
270
+ if (warnings.length > 0) {
271
+ const section = result.addSection("Warnings");
272
+ for (const warning of warnings) section.item(warning.message);
273
+ }
274
+ if (plugin.ignored.length > 0) {
275
+ const section = result.addSection("Not installed");
276
+ for (const component of plugin.ignored)
277
+ section.item(`${component.kind} (${component.path})`);
278
+ }
279
+ return result.withData(describePlugin(plugin, warnings, stats));
280
+ }
281
+
282
+ export function count(n: number, noun: string): string {
283
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
284
+ }
285
+
286
+ function named(items: readonly string[]): string {
287
+ return items.length === 0 ? "none" : `${items.length}: ${items.join(", ")}`;
288
+ }
289
+
290
+ // ─── Push ────────────────────────────────────────────────────────────────
291
+
292
+ /**
293
+ * The archive a push sends: the SAME file list `validate` read, zipped
294
+ * deterministically (sorted entries, one DOS-epoch mtime, the skill
295
+ * packager's level), so the digest the server records is a pure function
296
+ * of the plugin's content and a second push of an unchanged directory is
297
+ * the server's no-op.
298
+ */
299
+ export function zipPluginFiles(files: PluginFiles): Uint8Array {
300
+ const tree: Record<string, Uint8Array> = {};
301
+ for (const entry of files.entries) {
302
+ tree[entry.path] = files.read(entry.path);
303
+ }
304
+ return zipSync(tree, { level: 6, mtime: DETERMINISTIC_ZIP_MTIME });
305
+ }
306
+
307
+ export interface PushPluginOptions {
308
+ readonly org: string;
309
+ readonly visibility: ApiResourceVisibility | undefined;
310
+ readonly message: string;
311
+ readonly ignoreOptions: IgnoreOptions;
312
+ }
313
+
314
+ export interface PushPluginOutcome {
315
+ readonly plugin: Plugin;
316
+ readonly members: readonly PluginMember[];
317
+ readonly archiveBytes: number;
318
+ }
319
+
320
+ /**
321
+ * Read, validate offline (the same refusal `validate` prints, before a byte
322
+ * moves), zip, push, then read the members back. The server re-validates
323
+ * with the same library; the offline pass exists so an author never waits
324
+ * on the network to hear a sentence the CLI could say.
325
+ */
326
+ export async function pushPlugin(
327
+ client: Stigmer,
328
+ dir: string,
329
+ options: PushPluginOptions,
330
+ ): Promise<PushPluginOutcome> {
331
+ const directory = readPluginDirectory(dir, options.ignoreOptions);
332
+ const outcome = readPluginPackage(directory.files);
333
+ if (!outcome.ok) {
334
+ throw pluginRefusal(dir, outcome.errors, outcome.warnings);
335
+ }
336
+ const archive = zipPluginFiles(directory.files);
337
+ const plugin = await client.plugin.push(
338
+ create(PushPluginRequestSchema, {
339
+ org: options.org,
340
+ artifact: archive,
341
+ message: options.message,
342
+ ...(options.visibility !== undefined && {
343
+ visibility: options.visibility,
344
+ }),
345
+ }),
346
+ );
347
+ const members = (await client.plugin.listMembers(plugin.metadata?.id ?? ""))
348
+ .members;
349
+ return { plugin, members, archiveBytes: archive.length };
350
+ }
351
+
352
+ /** The install as the user reads it: what landed, what was skipped, what to know. */
353
+ export function renderPushOutcome(outcome: PushPluginOutcome): CommandResult {
354
+ const { plugin, members } = outcome;
355
+ const warnings = plugin.status?.warnings ?? [];
356
+ const counts = plugin.status?.materialized;
357
+ const summary = [
358
+ count(counts?.skills ?? 0, "skill"),
359
+ count(counts?.mcpServers ?? 0, "MCP server"),
360
+ count(counts?.agents ?? 0, "agent"),
361
+ ...(counts !== undefined && counts.workflows > 0
362
+ ? [count(counts.workflows, "workflow")]
363
+ : []),
364
+ ].join(", ");
365
+ const headline = `Installed plugin '${plugin.metadata?.slug ?? plugin.spec?.name ?? ""}' (${summary})`;
366
+ const result =
367
+ warnings.length === 0
368
+ ? CommandResult.success(headline)
369
+ : CommandResult.warning(
370
+ `${headline} with ${count(warnings.length, "warning")}`,
371
+ );
372
+
373
+ const about = result.addSection("Plugin");
374
+ about.field("ID", plugin.metadata?.id ?? "");
375
+ about.field("Slug", plugin.metadata?.slug ?? "");
376
+ if (plugin.spec?.version) about.field("Version", plugin.spec.version);
377
+ about.field("Digest", shortHash(plugin.status?.digest ?? ""));
378
+ about.field(
379
+ "Format",
380
+ plugin.spec === undefined ? "" : dialectLabel(plugin.spec.dialect),
381
+ );
382
+ about.field("Size", formatBytes(outcome.archiveBytes));
383
+
384
+ const installed = result.addSection("Installed");
385
+ for (const kind of [
386
+ ApiResourceKind.skill,
387
+ ApiResourceKind.mcp_server,
388
+ ApiResourceKind.agent,
389
+ ApiResourceKind.workflow,
390
+ ]) {
391
+ const slugs = members.filter((m) => m.kind === kind).map((m) => m.slug);
392
+ if (slugs.length > 0)
393
+ installed.field(
394
+ MEMBER_KIND_LABELS[kind] ?? ApiResourceKind[kind],
395
+ slugs.join(", "),
396
+ );
397
+ }
398
+ if (warnings.length > 0) {
399
+ const section = result.addSection("Warnings");
400
+ for (const warning of warnings) section.item(warning.message);
401
+ }
402
+ return result.withData({
403
+ plugin: toJson(PluginSchema, plugin),
404
+ members: members.map((m) => toJson(PluginMemberSchema, m)),
405
+ });
406
+ }
407
+
408
+ const MEMBER_KIND_LABELS: Partial<Record<ApiResourceKind, string>> = {
409
+ [ApiResourceKind.skill]: "Skills",
410
+ [ApiResourceKind.mcp_server]: "MCP servers",
411
+ [ApiResourceKind.agent]: "Agents",
412
+ [ApiResourceKind.workflow]: "Workflows",
413
+ };
414
+
415
+ function dialectLabel(dialect: PluginDialectProto): string {
416
+ switch (dialect) {
417
+ case PluginDialectProto.AGENT_PLUGINS:
418
+ return DIALECT_LABELS["agent-plugins"];
419
+ case PluginDialectProto.CLAUDE:
420
+ return DIALECT_LABELS.claude;
421
+ case PluginDialectProto.CURSOR:
422
+ return DIALECT_LABELS.cursor;
423
+ case PluginDialectProto.CODEX:
424
+ return DIALECT_LABELS.codex;
425
+ case PluginDialectProto.UNSPECIFIED:
426
+ return "";
427
+ default: {
428
+ const exhaustive: never = dialect;
429
+ return String(exhaustive);
430
+ }
431
+ }
432
+ }
@@ -31,7 +31,7 @@ export const SKILL_FILE = "SKILL.md";
31
31
  // byte-level variance. Local-field Date construction is deliberate: DOS
32
32
  // timestamps store wall-clock fields, so this encodes identically in every
33
33
  // timezone.
34
- const DETERMINISTIC_ZIP_MTIME = new Date(1980, 0, 1);
34
+ export const DETERMINISTIC_ZIP_MTIME = new Date(1980, 0, 1);
35
35
  // Kebab-case, optionally scoped with dot-separated namespaces (e.g.
36
36
  // "platform.planton-architecture"). Every segment must be alphanumeric, so no
37
37
  // leading/trailing/consecutive separators. The derived slug renders dots as hyphens.