@hachej/boring-ui-cli 0.1.16 → 0.1.18

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 (84) hide show
  1. package/README.md +8 -0
  2. package/dist/server/cli.js +203 -10
  3. package/dist/server/scaffoldPlugin.js +72 -0
  4. package/dist/server/verifyPlugin.js +235 -0
  5. package/package.json +6 -5
  6. package/public/assets/{CodeEditor-DQqOn4xz-KzhiCz--.js → CodeEditor-DQqOn4xz-BpvvkFT7.js} +1 -1
  7. package/public/assets/{FileTree-Dl-qUAB0-B2la_qys.js → FileTree-DHVB9rpk-FMcsf2w-.js} +10 -10
  8. package/public/assets/{MarkdownEditor-yc6mFsnI-But_i4l5.js → MarkdownEditor-L1KDH0bM-D-LbfGkW.js} +39 -39
  9. package/public/assets/{_baseUniq-DdgAi7yq.js → _baseUniq-CMA9Ia5A.js} +1 -1
  10. package/public/assets/{arc-CCqUtds5.js → arc-tnXyejqK.js} +1 -1
  11. package/public/assets/{architectureDiagram-Q4EWVU46-B_KUn6cs.js → architectureDiagram-Q4EWVU46-BSOUkwG2.js} +1 -1
  12. package/public/assets/{blockDiagram-DXYQGD6D-DZgTILG7.js → blockDiagram-DXYQGD6D-beerpyJi.js} +1 -1
  13. package/public/assets/{c4Diagram-AHTNJAMY-CJflQswY.js → c4Diagram-AHTNJAMY-uB419B41.js} +1 -1
  14. package/public/assets/channel-C2_9gQqR.js +1 -0
  15. package/public/assets/{chunk-4BX2VUAB-iDI_gnLq.js → chunk-4BX2VUAB-CpStyzoR.js} +1 -1
  16. package/public/assets/{chunk-4TB4RGXK-DKWIFYxg.js → chunk-4TB4RGXK-BL531Lcw.js} +1 -1
  17. package/public/assets/{chunk-55IACEB6-D__oMXZt.js → chunk-55IACEB6-CRDe66a9.js} +1 -1
  18. package/public/assets/{chunk-EDXVE4YY-Do1Ka_m9.js → chunk-EDXVE4YY-NaBO_txy.js} +1 -1
  19. package/public/assets/{chunk-FMBD7UC4-lANxhjIk.js → chunk-FMBD7UC4-Dn2Fmb5W.js} +1 -1
  20. package/public/assets/{chunk-OYMX7WX6-D5V4Ykah.js → chunk-OYMX7WX6-iffOaivX.js} +1 -1
  21. package/public/assets/{chunk-QZHKN3VN-Qd0rJnNE.js → chunk-QZHKN3VN-BKRi-Sxw.js} +1 -1
  22. package/public/assets/{chunk-YZCP3GAM-CFwEplp0.js → chunk-YZCP3GAM-CFQPe6Lu.js} +1 -1
  23. package/public/assets/classDiagram-6PBFFD2Q-BX66CTk2.js +1 -0
  24. package/public/assets/classDiagram-v2-HSJHXN6E-BX66CTk2.js +1 -0
  25. package/public/assets/clone-BUVN29h2.js +1 -0
  26. package/public/assets/{cose-bilkent-S5V4N54A-DfVWRV6i.js → cose-bilkent-S5V4N54A-BFfdAG94.js} +1 -1
  27. package/public/assets/{dagre-KV5264BT-DwkYud_A.js → dagre-KV5264BT-BvfHllE1.js} +1 -1
  28. package/public/assets/{diagram-5BDNPKRD-BVmqnZKF.js → diagram-5BDNPKRD-DML8a0lh.js} +1 -1
  29. package/public/assets/{diagram-G4DWMVQ6-Be8h-c7h.js → diagram-G4DWMVQ6-DL30StUK.js} +1 -1
  30. package/public/assets/{diagram-MMDJMWI5-CeQIY42x.js → diagram-MMDJMWI5-D3-Kryyd.js} +1 -1
  31. package/public/assets/{diagram-TYMM5635-2m1E87ZC.js → diagram-TYMM5635-HDTViX5R.js} +1 -1
  32. package/public/assets/{erDiagram-SMLLAGMA-CEAPDf64.js → erDiagram-SMLLAGMA-DV46TbuX.js} +1 -1
  33. package/public/assets/{flowDiagram-DWJPFMVM-70-QVqzZ.js → flowDiagram-DWJPFMVM-BXqQu2_i.js} +1 -1
  34. package/public/assets/{ganttDiagram-T4ZO3ILL-CQUeK6YZ.js → ganttDiagram-T4ZO3ILL-CnPz0E5a.js} +1 -1
  35. package/public/assets/{gitGraphDiagram-UUTBAWPF-BAxMyzkG.js → gitGraphDiagram-UUTBAWPF-C25lUVfz.js} +1 -1
  36. package/public/assets/{graph-DBYJAYcR.js → graph-CSby6HJp.js} +1 -1
  37. package/public/assets/{highlighted-body-OFNGDK62-D32MCa1A.js → highlighted-body-OFNGDK62-D5Bucj_m.js} +1 -1
  38. package/public/assets/{index-DfV9nLZO.js → index-CrQ5eYxl.js} +301 -295
  39. package/public/assets/{index-Dnr-P_qO.js → index-DLyovEn7.js} +1 -1
  40. package/public/assets/index-RUcotPWN.css +1 -0
  41. package/public/assets/{infoDiagram-42DDH7IO-D4XwdS2Z.js → infoDiagram-42DDH7IO-D1tkIwny.js} +1 -1
  42. package/public/assets/{ishikawaDiagram-UXIWVN3A-7F_PxA8e.js → ishikawaDiagram-UXIWVN3A-D6zbyxWx.js} +1 -1
  43. package/public/assets/{journeyDiagram-VCZTEJTY-CBJ4F9tD.js → journeyDiagram-VCZTEJTY-ExQUgQxi.js} +1 -1
  44. package/public/assets/{kanban-definition-6JOO6SKY-DNxo2cA_.js → kanban-definition-6JOO6SKY-E3gNRZLm.js} +1 -1
  45. package/public/assets/{layout-xHv827iK.js → layout-BF96CGik.js} +1 -1
  46. package/public/assets/{linear-C4D-wE9b.js → linear-6x4QueCv.js} +1 -1
  47. package/public/assets/{min-DRKJR04f.js → min-Cyn_UaFw.js} +1 -1
  48. package/public/assets/{mindmap-definition-QFDTVHPH-BFEYUDaK.js → mindmap-definition-QFDTVHPH-CcpKJ1lB.js} +1 -1
  49. package/public/assets/{pieDiagram-DEJITSTG-OgFPC-fQ.js → pieDiagram-DEJITSTG-PVIVMgVu.js} +1 -1
  50. package/public/assets/{quadrantDiagram-34T5L4WZ-gcFm5ymy.js → quadrantDiagram-34T5L4WZ-BTyRfzd6.js} +1 -1
  51. package/public/assets/{requirementDiagram-MS252O5E-C-NXT7Xx.js → requirementDiagram-MS252O5E-CU9X0jFW.js} +1 -1
  52. package/public/assets/{sankeyDiagram-XADWPNL6-BVTpXbQ9.js → sankeyDiagram-XADWPNL6-DyZdSLSv.js} +1 -1
  53. package/public/assets/{sequenceDiagram-FGHM5R23-6nnB6xSY.js → sequenceDiagram-FGHM5R23-CuJRqb6t.js} +1 -1
  54. package/public/assets/{stateDiagram-FHFEXIEX-vvjjWHqR.js → stateDiagram-FHFEXIEX-5mIpjfjp.js} +1 -1
  55. package/public/assets/stateDiagram-v2-QKLJ7IA2-yZsODTx6.js +1 -0
  56. package/public/assets/{timeline-definition-GMOUNBTQ-DfgoENQV.js → timeline-definition-GMOUNBTQ-DCSOAQyL.js} +1 -1
  57. package/public/assets/{vennDiagram-DHZGUBPP-CwtXgny7.js → vennDiagram-DHZGUBPP-BXxgH4Ub.js} +1 -1
  58. package/public/assets/{wardley-RL74JXVD-YWQ--wCx.js → wardley-RL74JXVD-BWSOnFou.js} +1 -1
  59. package/public/assets/{wardleyDiagram-NUSXRM2D-0iSD2F3A.js → wardleyDiagram-NUSXRM2D-BrsRzGry.js} +1 -1
  60. package/public/assets/{xychartDiagram-5P7HB3ND-DpVtsfdW.js → xychartDiagram-5P7HB3ND-CLF12YPc.js} +1 -1
  61. package/public/index.html +2 -2
  62. package/templates/front-canonical.tsx +62 -0
  63. package/templates/package-canonical.json +19 -0
  64. package/templates/plugin/README.md +64 -0
  65. package/templates/plugin/package.json +67 -0
  66. package/templates/plugin/src/front/__tests__/samplePlugin.test.ts +53 -0
  67. package/templates/plugin/src/front/index.ts +45 -0
  68. package/templates/plugin/src/front/panels.tsx +8 -0
  69. package/templates/plugin/src/front/surfaceResolver.ts +19 -0
  70. package/templates/plugin/src/server/index.ts +29 -0
  71. package/templates/plugin/src/shared/constants.ts +2 -0
  72. package/templates/plugin/src/shared/index.ts +2 -0
  73. package/templates/plugin/src/shared/types.ts +3 -0
  74. package/templates/plugin/src/test-setup.ts +43 -0
  75. package/templates/plugin/tsconfig.json +23 -0
  76. package/templates/plugin/tsup.config.ts +21 -0
  77. package/templates/plugin/vitest.config.ts +20 -0
  78. package/templates/server-canonical.ts +42 -0
  79. package/public/assets/channel-DGvkobtu.js +0 -1
  80. package/public/assets/classDiagram-6PBFFD2Q-CbsNjbES.js +0 -1
  81. package/public/assets/classDiagram-v2-HSJHXN6E-CbsNjbES.js +0 -1
  82. package/public/assets/clone-CGN5Up_u.js +0 -1
  83. package/public/assets/index-CyEGNm7Z.css +0 -1
  84. package/public/assets/stateDiagram-v2-QKLJ7IA2-UKC2Fz8g.js +0 -1
package/README.md CHANGED
@@ -92,6 +92,14 @@ pnpm --filter @hachej/boring-ui-cli build
92
92
  npx ./packages/cli/dist/index.js
93
93
  ```
94
94
 
95
+ ### Scaffold a Plugin
96
+
97
+ ```bash
98
+ boring-ui plugin create my-plugin --path plugins
99
+ ```
100
+
101
+ This copies the bundled plugin template from `templates/plugin/`, renames the sample identifiers, and creates `plugins/my-plugin`.
102
+
95
103
  ---
96
104
 
97
105
  ## Authentication
@@ -1,16 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
4
- import { basename, isAbsolute, join, resolve } from "node:path";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync
12
+ } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
5
16
  import { parseArgs } from "node:util";
6
- import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
7
17
  import { createLocalWorkspaceRegistry } from "./localWorkspaces.js";
18
+ import { scaffoldPlugin } from "./scaffoldPlugin.js";
19
+ import { findHintForError, formatVerifyResult, verifyPlugin } from "./verifyPlugin.js";
8
20
  const MODE_MAP = {
9
21
  "local": "direct",
10
22
  // no sandbox, full network access
11
23
  "local-sandbox": "local"
12
24
  // bwrap isolated, no network (Linux only)
13
25
  };
26
+ const require2 = createRequire(import.meta.url);
27
+ const CLI_VERSION = (() => {
28
+ try {
29
+ const pkg = require2("../../package.json");
30
+ return pkg.version ?? "0.0.0";
31
+ } catch {
32
+ return "0.0.0";
33
+ }
34
+ })();
14
35
  function httpError(message, statusCode) {
15
36
  const error = new Error(message);
16
37
  error.statusCode = statusCode;
@@ -87,7 +108,8 @@ const AUTH_GUIDE = [
87
108
  " Credentials are saved at ~/.pi/agent/auth.json. Then refresh the browser.",
88
109
  ""
89
110
  ].join("\n");
90
- function checkAuth() {
111
+ async function checkAuth() {
112
+ const { AuthStorage, ModelRegistry } = await import("@mariozechner/pi-coding-agent");
91
113
  const authStorage = AuthStorage.create();
92
114
  const registry = ModelRegistry.create(authStorage);
93
115
  return registry.getAvailable().length;
@@ -95,7 +117,7 @@ function checkAuth() {
95
117
  async function startFolderMode(opts) {
96
118
  const workspaceRoot = process.env.BORING_AGENT_WORKSPACE_ROOT ?? resolve(opts.folderArg ?? process.cwd());
97
119
  const projectName = basename(resolve(workspaceRoot)) || "workspace";
98
- const modelCount = checkAuth();
120
+ const modelCount = await checkAuth();
99
121
  console.log(`
100
122
  ${projectName}`);
101
123
  console.log(` workspace ${workspaceRoot}`);
@@ -111,7 +133,8 @@ ${projectName}`);
111
133
  });
112
134
  app.get("/api/v1/workspace/meta", async () => ({
113
135
  workspaceRoot,
114
- projectName
136
+ projectName,
137
+ version: CLI_VERSION
115
138
  }));
116
139
  await registerStatic(app, opts.publicDir);
117
140
  await app.listen({ port: opts.port, host: opts.host });
@@ -186,7 +209,7 @@ async function startWorkspacesMode(opts) {
186
209
  getWorkspaceId: async (request) => (await workspaceFromRequest(request)).id,
187
210
  getWorkspaceRoot: async (workspaceId) => (await requireWorkspace(workspaceId)).path,
188
211
  getSessionNamespace: async ({ workspaceId }) => `local-workspace-${workspaceId}`,
189
- getResourceLoaderOptions: async ({ workspaceRoot }) => ({
212
+ getPi: async ({ workspaceRoot }) => ({
190
213
  additionalSkillPaths: [join(workspaceRoot, ".agents", "skills")]
191
214
  }),
192
215
  getExtraTools: async ({ workspaceId, workspaceRoot, workspaceFsCapability }) => [
@@ -200,7 +223,8 @@ async function startWorkspacesMode(opts) {
200
223
  });
201
224
  app.get("/api/v1/workspace/meta", async () => ({
202
225
  projectName: "Boring UI",
203
- workspacesMode: true
226
+ workspacesMode: true,
227
+ version: CLI_VERSION
204
228
  }));
205
229
  await registerStatic(app, opts.publicDir);
206
230
  await app.listen({ port: opts.port, host: opts.host });
@@ -215,9 +239,112 @@ Boring UI`);
215
239
  console.log(`
216
240
  ${initialUrl}
217
241
  `);
218
- if (checkAuth() === 0) console.log(AUTH_GUIDE);
242
+ if (await checkAuth() === 0) console.log(AUTH_GUIDE);
219
243
  openBrowser(initialUrl);
220
244
  }
245
+ function findRepoRoot(from) {
246
+ let current = from;
247
+ while (true) {
248
+ if (existsSync(join(current, "pnpm-workspace.yaml"))) return current;
249
+ const parent = dirname(current);
250
+ if (parent === current) return null;
251
+ current = parent;
252
+ }
253
+ }
254
+ function walkDir(dir, base, out = []) {
255
+ for (const entry of readdirSync(dir)) {
256
+ if (entry === "node_modules" || entry === ".git" || entry === "dist") continue;
257
+ const fullPath = join(dir, entry);
258
+ const stat = statSync(fullPath);
259
+ if (stat.isDirectory()) {
260
+ walkDir(fullPath, base, out);
261
+ continue;
262
+ }
263
+ out.push(relative(base, fullPath));
264
+ }
265
+ return out;
266
+ }
267
+ function replaceInFile(filePath, replacements) {
268
+ let content = readFileSync(filePath, "utf8");
269
+ for (const [from, to] of Object.entries(replacements)) {
270
+ content = content.replaceAll(from, to);
271
+ }
272
+ writeFileSync(filePath, content, "utf8");
273
+ }
274
+ async function handlePluginCommand(opts) {
275
+ const subcommand = opts.positionals[1];
276
+ if (subcommand !== "create") {
277
+ console.log("Usage: boring-ui plugin create <name> [--path <dir>]");
278
+ console.log("");
279
+ console.log("Scaffold a new plugin from the template.");
280
+ console.log("");
281
+ console.log("Arguments:");
282
+ console.log(" <name> Plugin name (e.g. my-plugin)");
283
+ console.log(" --path Parent directory for the new plugin (default: plugins/)");
284
+ return;
285
+ }
286
+ const name = opts.positionals[2];
287
+ if (!name) throw new Error("usage: boring-ui plugin create <name>");
288
+ const __dirname = dirname(fileURLToPath(import.meta.url));
289
+ const packageRoot = resolve(__dirname, "..", "..");
290
+ const templateDir = join(packageRoot, "templates", "plugin");
291
+ if (!existsSync(templateDir)) {
292
+ throw new Error(
293
+ `Plugin template not found at ${templateDir}.
294
+ This build may not include the plugin template.`
295
+ );
296
+ }
297
+ const repoRoot = findRepoRoot(process.cwd());
298
+ const customPath = opts.args.path;
299
+ const targetParent = customPath ? resolve(customPath) : join(repoRoot ?? process.cwd(), "plugins");
300
+ const targetDir = join(targetParent, name);
301
+ if (existsSync(targetDir)) {
302
+ throw new Error(`Directory already exists: ${targetDir}`);
303
+ }
304
+ const id = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "");
305
+ if (!id) throw new Error(`invalid plugin name: ${name}`);
306
+ const symbolBase = id.replace(/-plugin$/, "") || id;
307
+ const pascalBase = symbolBase.split(/[-_]+/).map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1)).join("");
308
+ const camelBase = pascalBase.charAt(0).toLowerCase() + pascalBase.slice(1);
309
+ const upperBase = symbolBase.replace(/-/g, "_").toUpperCase();
310
+ console.log(`Scaffolding plugin "${id}" at ${targetDir}`);
311
+ mkdirSync(targetParent, { recursive: true });
312
+ cpSync(templateDir, targetDir, { recursive: true });
313
+ const files = walkDir(targetDir, targetDir);
314
+ const pkgName = `@hachej/boring-${id}`;
315
+ for (const file of files) {
316
+ const fullPath = join(targetDir, file);
317
+ replaceInFile(fullPath, {
318
+ "@hachej/boring-plugin-template": pkgName,
319
+ "sample-plugin": id,
320
+ "sample-panel": `${id}-panel`,
321
+ "sample.open": `${id}.open`,
322
+ "sample:": `${id}:`,
323
+ '"sample"': `"${id}"`,
324
+ "SAMPLE": upperBase,
325
+ "Sample": pascalBase,
326
+ "sampleSurfaceResolver": `${camelBase}SurfaceResolver`,
327
+ "samplePanel": `${camelBase}Panel`
328
+ });
329
+ if (file.includes("samplePlugin")) {
330
+ const newFile = file.replace(/samplePlugin/g, `${camelBase}Plugin`);
331
+ const oldPath = join(targetDir, file);
332
+ const newPath = join(targetDir, newFile);
333
+ if (oldPath !== newPath) {
334
+ mkdirSync(dirname(newPath), { recursive: true });
335
+ renameSync(oldPath, newPath);
336
+ }
337
+ }
338
+ }
339
+ console.log("");
340
+ console.log(`\u2713 Created plugin \`${id}\` at ${relative(process.cwd(), targetDir)}`);
341
+ console.log("");
342
+ console.log("Next steps:");
343
+ console.log(` cd ${relative(process.cwd(), targetDir)}`);
344
+ console.log(" pnpm install");
345
+ console.log(` pnpm --filter ${pkgName} typecheck`);
346
+ console.log(` pnpm --filter ${pkgName} test`);
347
+ }
221
348
  async function handleWorkspacesCommand(opts) {
222
349
  const registry = createLocalWorkspaceRegistry();
223
350
  const subcommand = opts.positionals[1];
@@ -266,7 +393,8 @@ async function runCli(options) {
266
393
  port: { type: "string", short: "p" },
267
394
  host: { type: "string" },
268
395
  mode: { type: "string", short: "m" },
269
- name: { type: "string", short: "n" }
396
+ name: { type: "string", short: "n" },
397
+ path: { type: "string" }
270
398
  },
271
399
  allowPositionals: true,
272
400
  strict: false
@@ -286,6 +414,13 @@ async function runCli(options) {
286
414
  cliMode,
287
415
  mode
288
416
  };
417
+ if (positionals[0] === "plugin") {
418
+ await handlePluginCommand({
419
+ positionals,
420
+ args: { path: args.path }
421
+ });
422
+ return;
423
+ }
289
424
  if (positionals[0] === "workspaces") {
290
425
  await handleWorkspacesCommand({
291
426
  ...base,
@@ -294,11 +429,69 @@ async function runCli(options) {
294
429
  });
295
430
  return;
296
431
  }
432
+ if (positionals[0] === "scaffold-plugin") {
433
+ handleScaffoldPluginCommand({ positionals });
434
+ return;
435
+ }
436
+ if (positionals[0] === "verify-plugin") {
437
+ handleVerifyPluginCommand({ positionals });
438
+ return;
439
+ }
297
440
  await startFolderMode({
298
441
  ...base,
299
442
  folderArg: positionals[0]
300
443
  });
301
444
  }
445
+ function defaultWorkspaceRoot() {
446
+ return process.env.BORING_AGENT_WORKSPACE_ROOT ?? process.cwd();
447
+ }
448
+ function handleVerifyPluginCommand(opts) {
449
+ const maybeName = opts.positionals[1];
450
+ const maybeWorkspace = opts.positionals[2];
451
+ const looksLikePath = maybeName && (maybeName.includes("/") || maybeName.startsWith("."));
452
+ const name = looksLikePath ? void 0 : maybeName;
453
+ const workspaceRoot = resolve(maybeWorkspace ?? (looksLikePath ? maybeName : defaultWorkspaceRoot()));
454
+ const result = verifyPlugin({ workspaceRoot, ...name ? { name } : {} });
455
+ console.log(formatVerifyResult(result));
456
+ if (!result.ok) {
457
+ const hints = [];
458
+ for (const outcome of result.outcomes) {
459
+ for (const err of outcome.errors) {
460
+ const hint = findHintForError(err);
461
+ if (hint) hints.push(` hint (${outcome.id}): ${hint}`);
462
+ }
463
+ }
464
+ if (hints.length > 0) {
465
+ console.log("");
466
+ console.log("Suggestions:");
467
+ for (const hint of hints) console.log(hint);
468
+ }
469
+ process.exit(1);
470
+ }
471
+ }
472
+ function handleScaffoldPluginCommand(opts) {
473
+ const name = opts.positionals[1];
474
+ if (!name) {
475
+ throw new Error("usage: boring-ui scaffold-plugin <name> [workspace]");
476
+ }
477
+ const workspaceRoot = resolve(opts.positionals[2] ?? defaultWorkspaceRoot());
478
+ const result = scaffoldPlugin({ name, workspaceRoot });
479
+ console.log(`scaffolded ${name}`);
480
+ console.log(` dir ${result.pluginDir}`);
481
+ for (const file of result.filesCreated) {
482
+ console.log(` + ${file}`);
483
+ }
484
+ console.log("");
485
+ console.log("Next steps:");
486
+ console.log(` 1. edit front/index.tsx for UI panels/commands/resolvers`);
487
+ console.log(` 2. add pi.extensions / skills for hot-reloadable agent behavior`);
488
+ console.log(` 3. bash \`boring-ui verify-plugin\` \u2014 confirms manifests + files are valid`);
489
+ console.log(` 4. ask the user: /reload`);
490
+ console.log("");
491
+ console.log("Advanced server integration:");
492
+ console.log(" boring.server is boot-time/static composition only. It is NOT hot-registered");
493
+ console.log(" by /reload for .pi/extensions user plugins; use Pi extensions for agent tools.");
494
+ }
302
495
  export {
303
496
  registerStatic,
304
497
  runCli
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
6
+ function scaffoldPlugin(opts) {
7
+ if (!KEBAB_RE.test(opts.name)) {
8
+ throw new Error(
9
+ `invalid plugin name "${opts.name}" \u2014 must be kebab-case (e.g. "my-plugin")`
10
+ );
11
+ }
12
+ const workspaceRoot = resolve(opts.workspaceRoot);
13
+ const pluginDir = join(workspaceRoot, ".pi", "extensions", opts.name);
14
+ mkdirSync(join(workspaceRoot, ".pi", "extensions"), { recursive: true });
15
+ try {
16
+ mkdirSync(pluginDir, { recursive: false });
17
+ } catch (error) {
18
+ const code = error.code;
19
+ if (code === "EEXIST") {
20
+ throw new Error(`plugin already exists at ${pluginDir}`);
21
+ }
22
+ throw error;
23
+ }
24
+ const templatesDir = opts.templatesDir ?? resolveBundledTemplatesDir();
25
+ if (!templatesDir) {
26
+ throw new Error(
27
+ "could not locate bundled CLI templates \u2014 pass `templatesDir` explicitly. (this usually indicates a broken @hachej/boring-ui-cli install)"
28
+ );
29
+ }
30
+ const tplFront = join(templatesDir, "front-canonical.tsx");
31
+ const tplPackage = join(templatesDir, "package-canonical.json");
32
+ for (const tpl of [tplFront, tplPackage]) {
33
+ if (!existsSync(tpl)) {
34
+ throw new Error(`canonical template missing: ${tpl}`);
35
+ }
36
+ }
37
+ const label = labelFromName(opts.name);
38
+ const filesCreated = [];
39
+ const write = (relPath, contents) => {
40
+ const target = join(pluginDir, relPath);
41
+ mkdirSync(join(target, ".."), { recursive: true });
42
+ writeFileSync(target, contents, "utf8");
43
+ filesCreated.push(target);
44
+ };
45
+ const pkgRaw = JSON.parse(readFileSync(tplPackage, "utf8"));
46
+ delete pkgRaw._doc_;
47
+ const pkgJson = substitute(JSON.stringify(pkgRaw, null, 2), opts.name, label);
48
+ write("package.json", `${pkgJson}
49
+ `);
50
+ const frontSource = substitute(readFileSync(tplFront, "utf8"), opts.name, label);
51
+ write("front/index.tsx", frontSource);
52
+ write(".gitignore", "# Machine-managed sidecars written by the boring-ui plugin runtime.\n.boring-signature.json\n");
53
+ return { pluginDir, filesCreated };
54
+ }
55
+ function labelFromName(name) {
56
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
57
+ }
58
+ function substitute(source, name, label) {
59
+ const paneName = `${pascalCase(name)}Pane`;
60
+ return source.replaceAll("<kebab-name>", name).replaceAll("&lt;kebab-name&gt;", name).replaceAll("<Label>", label).replaceAll(/\bMyPane\b/g, paneName);
61
+ }
62
+ function pascalCase(name) {
63
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
64
+ }
65
+ function resolveBundledTemplatesDir() {
66
+ const here = dirname(fileURLToPath(import.meta.url));
67
+ const candidate = resolve(here, "..", "..", "templates");
68
+ return existsSync(candidate) ? candidate : void 0;
69
+ }
70
+ export {
71
+ scaffoldPlugin
72
+ };
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import {
5
+ isSafePluginRelativePath,
6
+ validateBoringPluginManifest
7
+ } from "@hachej/boring-workspace/plugin";
8
+ function pluginFileSignature(path) {
9
+ if (!path || !existsSync(path)) return "missing";
10
+ const stat = statSync(path);
11
+ return `${stat.mtimeMs}:${stat.size}`;
12
+ }
13
+ function readPluginSignatureCache(pluginRootDir) {
14
+ const path = join(pluginRootDir, ".boring-signature.json");
15
+ if (!existsSync(path)) return null;
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(readFileSync(path, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ if (!parsed || typeof parsed !== "object") return null;
23
+ const obj = parsed;
24
+ if (obj.version !== 1) return null;
25
+ const sig = obj.serverSignature;
26
+ if (sig !== null && typeof sig !== "string") return null;
27
+ const loadedAt = typeof obj.loadedAt === "number" ? obj.loadedAt : 0;
28
+ return { version: 1, serverSignature: sig, loadedAt };
29
+ }
30
+ function verifyPlugin(opts) {
31
+ const workspaceRoot = resolve(opts.workspaceRoot);
32
+ const extensionsDir = join(workspaceRoot, ".pi", "extensions");
33
+ if (!existsSync(extensionsDir)) {
34
+ return {
35
+ outcomes: [],
36
+ ok: true,
37
+ extensionsDir,
38
+ extensionsDirMissing: true
39
+ };
40
+ }
41
+ const targets = [];
42
+ if (opts.name) {
43
+ const dir = join(extensionsDir, opts.name);
44
+ if (!existsSync(dir)) {
45
+ return {
46
+ outcomes: [
47
+ { id: opts.name, dir, ok: false, errors: [`plugin directory not found: ${dir}`], warnings: [] }
48
+ ],
49
+ ok: false,
50
+ extensionsDir,
51
+ extensionsDirMissing: false
52
+ };
53
+ }
54
+ targets.push(dir);
55
+ } else {
56
+ for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
57
+ if (!entry.isDirectory()) continue;
58
+ if (entry.name.startsWith(".") || entry.name.startsWith("preflight-")) continue;
59
+ targets.push(join(extensionsDir, entry.name));
60
+ }
61
+ }
62
+ const outcomes = targets.map((dir) => verifySinglePlugin(dir));
63
+ return {
64
+ outcomes,
65
+ ok: outcomes.every((o) => o.ok),
66
+ extensionsDir,
67
+ extensionsDirMissing: false
68
+ };
69
+ }
70
+ function isInsideRoot(rootReal, targetReal) {
71
+ const rel = relative(rootReal, targetReal);
72
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
73
+ }
74
+ function resolveExistingContainedPath(pluginDir, value, field) {
75
+ if (!isSafePluginRelativePath(value)) {
76
+ return { path: null, error: `${field} must be a safe relative path inside the plugin root: ${JSON.stringify(value)}` };
77
+ }
78
+ const abs = resolve(pluginDir, value);
79
+ if (!existsSync(abs)) return { path: null, error: `${field} points at "${value}" but that file does not exist (looked at ${abs})` };
80
+ const rootReal = realpathSync(pluginDir);
81
+ const targetReal = realpathSync(abs);
82
+ if (!isInsideRoot(rootReal, targetReal)) {
83
+ return { path: null, error: `${field} points at "${value}" but resolves outside the plugin root (looked at ${abs})` };
84
+ }
85
+ return { path: targetReal };
86
+ }
87
+ function verifySinglePlugin(pluginDir) {
88
+ const id = pluginDir.split(/[\\/]/).pop() ?? "<unknown>";
89
+ const errors = [];
90
+ const warnings = [];
91
+ const pkgPath = join(pluginDir, "package.json");
92
+ if (!existsSync(pkgPath)) {
93
+ return { id, dir: pluginDir, ok: false, errors: ["package.json missing"], warnings };
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ return { id, dir: pluginDir, ok: false, errors: [`package.json is not valid JSON: ${message}`], warnings };
101
+ }
102
+ const result = validateBoringPluginManifest(parsed);
103
+ if (!result.valid) {
104
+ for (const issue of result.issues) errors.push(formatIssue(issue));
105
+ return { id, dir: pluginDir, ok: false, errors, warnings };
106
+ }
107
+ const manifest = result.packageJson;
108
+ const boring = manifest.boring;
109
+ if (boring?.front) {
110
+ const resolved = resolveExistingContainedPath(pluginDir, boring.front, "boring.front");
111
+ if (resolved.error) errors.push(resolved.error);
112
+ }
113
+ let serverPathAbs = null;
114
+ if (typeof boring?.server === "string") {
115
+ const resolved = resolveExistingContainedPath(pluginDir, boring.server, "boring.server");
116
+ if (resolved.error) {
117
+ errors.push(resolved.error);
118
+ } else {
119
+ serverPathAbs = resolved.path;
120
+ warnings.push(
121
+ "boring.server file is valid, but workspace server entries are boot-time/static composition only. /reload does not import or register .pi/extensions server routes/agentTools; restart the workspace with this package passed via defaultPluginPackages or explicit plugins to activate server code."
122
+ );
123
+ }
124
+ }
125
+ const cache = readPluginSignatureCache(pluginDir);
126
+ if (cache) {
127
+ const currentServerSig = serverPathAbs ? pluginFileSignature(serverPathAbs) : null;
128
+ const cachedSig = cache.serverSignature;
129
+ if (cachedSig !== currentServerSig) {
130
+ warnings.push(
131
+ "server file changed since the workspace last loaded this plugin \u2014 /reload will hot-swap the front, but routes and agent tools stay on the previously loaded code. Stop and restart the workspace process (Ctrl-C, then re-run your dev command) to pick up the new code."
132
+ );
133
+ }
134
+ }
135
+ const piExt = manifest.pi?.extensions;
136
+ if (Array.isArray(piExt)) {
137
+ for (const ext of piExt) {
138
+ if (typeof ext !== "string") continue;
139
+ const resolved = resolveExistingContainedPath(pluginDir, ext, "pi.extensions entry");
140
+ if (resolved.error) errors.push(resolved.error);
141
+ }
142
+ }
143
+ const piSkills = manifest.pi?.skills;
144
+ if (Array.isArray(piSkills)) {
145
+ for (const skill of piSkills) {
146
+ if (typeof skill !== "string") continue;
147
+ const resolved = resolveExistingContainedPath(pluginDir, skill, "pi.skills entry");
148
+ if (resolved.error) errors.push(resolved.error);
149
+ }
150
+ }
151
+ return { id, dir: pluginDir, ok: errors.length === 0, errors, warnings };
152
+ }
153
+ function formatIssue(issue) {
154
+ return `${issue.code}: ${issue.field}: ${issue.message}`;
155
+ }
156
+ function formatVerifyResult(result) {
157
+ if (result.extensionsDirMissing) {
158
+ return [
159
+ `WARNING \u2014 no plugins to verify. Scanned: ${result.extensionsDir} (directory does not exist).`,
160
+ "",
161
+ "If you just wrote a plugin: check that you put it under THIS workspace's `.pi/extensions/` and not a different cwd. The scanned path above is `<cwd>/.pi/extensions/`."
162
+ ].join("\n");
163
+ }
164
+ if (result.outcomes.length === 0) {
165
+ return [
166
+ `WARNING \u2014 scanned ${result.extensionsDir} but found NO plugin directories.`,
167
+ "",
168
+ "If you just wrote a plugin: it is NOT in this dir. Either you wrote to a different `.pi/extensions/` (check your cwd), or the dir name starts with `.` / `preflight-` (skipped)."
169
+ ].join("\n");
170
+ }
171
+ const lines = [];
172
+ const failures = result.outcomes.filter((o) => !o.ok);
173
+ const withWarnings = result.outcomes.filter((o) => o.warnings.length > 0);
174
+ if (failures.length === 0) {
175
+ lines.push(`OK \u2014 ${result.outcomes.length} plugin(s) have valid manifests + present files. (scanned ${result.extensionsDir})`);
176
+ for (const outcome of result.outcomes) {
177
+ const tag = outcome.warnings.length > 0 ? "\u26A0" : "\u2713";
178
+ lines.push(` ${tag} ${outcome.id}`);
179
+ for (const w of outcome.warnings) {
180
+ lines.push(` WARN: ${w}`);
181
+ }
182
+ }
183
+ lines.push("");
184
+ if (withWarnings.length > 0) {
185
+ const n = withWarnings.length;
186
+ lines.push(`Manifests are valid, but ${n} plugin${n === 1 ? "" : "s"} need a workspace restart (NOT just /reload) to pick up server-side changes \u2014 see WARN lines above.`);
187
+ lines.push("");
188
+ }
189
+ lines.push("Note: this validator does NOT execute plugin code. Front/Pi syntax / type / runtime errors surface on /reload; boring.server files require static composition plus a process restart.");
190
+ } else {
191
+ lines.push(`FAILED \u2014 ${failures.length} of ${result.outcomes.length} plugin(s) have errors. (scanned ${result.extensionsDir})`);
192
+ lines.push("");
193
+ for (const outcome of result.outcomes) {
194
+ if (outcome.ok) {
195
+ const tag = outcome.warnings.length > 0 ? "\u26A0" : "\u2713";
196
+ lines.push(` ${tag} ${outcome.id}`);
197
+ for (const w of outcome.warnings) {
198
+ lines.push(` WARN: ${w}`);
199
+ }
200
+ } else {
201
+ lines.push(` \u2717 ${outcome.id} (${outcome.dir})`);
202
+ for (const err of outcome.errors) {
203
+ lines.push(` ${err}`);
204
+ }
205
+ for (const w of outcome.warnings) {
206
+ lines.push(` WARN: ${w}`);
207
+ }
208
+ }
209
+ }
210
+ lines.push("");
211
+ lines.push("Fix the errors above and run `boring-ui verify-plugin` again. Once it reports OK, ask the user to run /reload for front/Pi assets; boring.server changes require static composition plus restart.");
212
+ }
213
+ return lines.join("\n");
214
+ }
215
+ const COMMON_MISTAKE_HINTS = [
216
+ {
217
+ pattern: /boring\.server must be a safe relative path or false/i,
218
+ hint: 'Set `boring.server` to a string path like "server/index.ts", or to `false` (or omit the field). It is NOT a boolean true. For hot-reloadable agent behavior in .pi/extensions, prefer pi.extensions instead.'
219
+ },
220
+ {
221
+ pattern: /package\.json manifest must be an object/i,
222
+ hint: "package.json must parse as a JSON object \u2014 check for trailing commas or unquoted keys."
223
+ }
224
+ ];
225
+ function findHintForError(message) {
226
+ for (const m of COMMON_MISTAKE_HINTS) {
227
+ if (m.pattern.test(message)) return m.hint;
228
+ }
229
+ return void 0;
230
+ }
231
+ export {
232
+ findHintForError,
233
+ formatVerifyResult,
234
+ verifyPlugin
235
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Turn an agent into an app",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,15 +9,16 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/",
12
- "public/"
12
+ "public/",
13
+ "templates/"
13
14
  ],
14
15
  "dependencies": {
15
16
  "@fastify/static": "^8.0.0",
16
17
  "fastify": "^5.4.0",
17
18
  "lucide-react": "^1.8.0",
18
- "@hachej/boring-agent": "0.1.16",
19
- "@hachej/boring-workspace": "0.1.16",
20
- "@hachej/boring-ui-kit": "0.1.16"
19
+ "@hachej/boring-agent": "0.1.18",
20
+ "@hachej/boring-workspace": "0.1.18",
21
+ "@hachej/boring-ui-kit": "0.1.18"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@tailwindcss/vite": "^4.0.0",