@hachej/boring-ui-cli 0.1.17 → 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 +189 -8
  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-B6a7H7rM.js → CodeEditor-DQqOn4xz-BpvvkFT7.js} +1 -1
  7. package/public/assets/{FileTree-Dvaud3jU-DNCQ4FDl.js → FileTree-DHVB9rpk-FMcsf2w-.js} +10 -10
  8. package/public/assets/{MarkdownEditor-sLkqTXDj-lFnLm1Im.js → MarkdownEditor-L1KDH0bM-D-LbfGkW.js} +1 -1
  9. package/public/assets/{_baseUniq-DdPJOgPg.js → _baseUniq-CMA9Ia5A.js} +1 -1
  10. package/public/assets/{arc-LgyX0a8W.js → arc-tnXyejqK.js} +1 -1
  11. package/public/assets/{architectureDiagram-Q4EWVU46-GZeUAJwF.js → architectureDiagram-Q4EWVU46-BSOUkwG2.js} +1 -1
  12. package/public/assets/{blockDiagram-DXYQGD6D-OSYK-_zS.js → blockDiagram-DXYQGD6D-beerpyJi.js} +1 -1
  13. package/public/assets/{c4Diagram-AHTNJAMY-BCBP7uHE.js → c4Diagram-AHTNJAMY-uB419B41.js} +1 -1
  14. package/public/assets/channel-C2_9gQqR.js +1 -0
  15. package/public/assets/{chunk-4BX2VUAB-CRy_H01e.js → chunk-4BX2VUAB-CpStyzoR.js} +1 -1
  16. package/public/assets/{chunk-4TB4RGXK-Dpk7ncuw.js → chunk-4TB4RGXK-BL531Lcw.js} +1 -1
  17. package/public/assets/{chunk-55IACEB6-CZYv6OBA.js → chunk-55IACEB6-CRDe66a9.js} +1 -1
  18. package/public/assets/{chunk-EDXVE4YY-NqKsVs05.js → chunk-EDXVE4YY-NaBO_txy.js} +1 -1
  19. package/public/assets/{chunk-FMBD7UC4-BgIQ6c_c.js → chunk-FMBD7UC4-Dn2Fmb5W.js} +1 -1
  20. package/public/assets/{chunk-OYMX7WX6-SyRoLN54.js → chunk-OYMX7WX6-iffOaivX.js} +1 -1
  21. package/public/assets/{chunk-QZHKN3VN-DzGRRm9f.js → chunk-QZHKN3VN-BKRi-Sxw.js} +1 -1
  22. package/public/assets/{chunk-YZCP3GAM-CfIqY3Yu.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-DTcdWPD5.js → cose-bilkent-S5V4N54A-BFfdAG94.js} +1 -1
  27. package/public/assets/{dagre-KV5264BT-k2lWP3qf.js → dagre-KV5264BT-BvfHllE1.js} +1 -1
  28. package/public/assets/{diagram-5BDNPKRD-DTUoxviQ.js → diagram-5BDNPKRD-DML8a0lh.js} +1 -1
  29. package/public/assets/{diagram-G4DWMVQ6-CLo507Hm.js → diagram-G4DWMVQ6-DL30StUK.js} +1 -1
  30. package/public/assets/{diagram-MMDJMWI5-Ben6XGgC.js → diagram-MMDJMWI5-D3-Kryyd.js} +1 -1
  31. package/public/assets/{diagram-TYMM5635-BsJA5XC_.js → diagram-TYMM5635-HDTViX5R.js} +1 -1
  32. package/public/assets/{erDiagram-SMLLAGMA-BLbGPA6K.js → erDiagram-SMLLAGMA-DV46TbuX.js} +1 -1
  33. package/public/assets/{flowDiagram-DWJPFMVM-D4Sv719o.js → flowDiagram-DWJPFMVM-BXqQu2_i.js} +1 -1
  34. package/public/assets/{ganttDiagram-T4ZO3ILL-D6kGugJe.js → ganttDiagram-T4ZO3ILL-CnPz0E5a.js} +1 -1
  35. package/public/assets/{gitGraphDiagram-UUTBAWPF-CVyzhaSE.js → gitGraphDiagram-UUTBAWPF-C25lUVfz.js} +1 -1
  36. package/public/assets/{graph-kiXKPMn9.js → graph-CSby6HJp.js} +1 -1
  37. package/public/assets/{highlighted-body-OFNGDK62-DcMPgvKJ.js → highlighted-body-OFNGDK62-D5Bucj_m.js} +1 -1
  38. package/public/assets/{index-BHlQtZ6_.js → index-CrQ5eYxl.js} +301 -295
  39. package/public/assets/{index-BTJucb1M.js → index-DLyovEn7.js} +1 -1
  40. package/public/assets/index-RUcotPWN.css +1 -0
  41. package/public/assets/{infoDiagram-42DDH7IO-BH27HU_c.js → infoDiagram-42DDH7IO-D1tkIwny.js} +1 -1
  42. package/public/assets/{ishikawaDiagram-UXIWVN3A-CdGOupZp.js → ishikawaDiagram-UXIWVN3A-D6zbyxWx.js} +1 -1
  43. package/public/assets/{journeyDiagram-VCZTEJTY-iJT59osB.js → journeyDiagram-VCZTEJTY-ExQUgQxi.js} +1 -1
  44. package/public/assets/{kanban-definition-6JOO6SKY-CZ5wWUdF.js → kanban-definition-6JOO6SKY-E3gNRZLm.js} +1 -1
  45. package/public/assets/{layout-BTIHgp6K.js → layout-BF96CGik.js} +1 -1
  46. package/public/assets/{linear-CCrC51HM.js → linear-6x4QueCv.js} +1 -1
  47. package/public/assets/{min-CrbJ9M-f.js → min-Cyn_UaFw.js} +1 -1
  48. package/public/assets/{mindmap-definition-QFDTVHPH-diXcpv4F.js → mindmap-definition-QFDTVHPH-CcpKJ1lB.js} +1 -1
  49. package/public/assets/{pieDiagram-DEJITSTG-CPIefI_d.js → pieDiagram-DEJITSTG-PVIVMgVu.js} +1 -1
  50. package/public/assets/{quadrantDiagram-34T5L4WZ-oif1uGqW.js → quadrantDiagram-34T5L4WZ-BTyRfzd6.js} +1 -1
  51. package/public/assets/{requirementDiagram-MS252O5E-7TD_ygCy.js → requirementDiagram-MS252O5E-CU9X0jFW.js} +1 -1
  52. package/public/assets/{sankeyDiagram-XADWPNL6-CMu1tHyh.js → sankeyDiagram-XADWPNL6-DyZdSLSv.js} +1 -1
  53. package/public/assets/{sequenceDiagram-FGHM5R23-B9mC8imJ.js → sequenceDiagram-FGHM5R23-CuJRqb6t.js} +1 -1
  54. package/public/assets/{stateDiagram-FHFEXIEX-D6PL1POV.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-DuQkd9Ua.js → timeline-definition-GMOUNBTQ-DCSOAQyL.js} +1 -1
  57. package/public/assets/{vennDiagram-DHZGUBPP-dF0MNyOc.js → vennDiagram-DHZGUBPP-BXxgH4Ub.js} +1 -1
  58. package/public/assets/{wardley-RL74JXVD-BS64Vt29.js → wardley-RL74JXVD-BWSOnFou.js} +1 -1
  59. package/public/assets/{wardleyDiagram-NUSXRM2D-BRkEc3lh.js → wardleyDiagram-NUSXRM2D-BrsRzGry.js} +1 -1
  60. package/public/assets/{xychartDiagram-5P7HB3ND-CQm5c61-.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-BJh30Beu.js +0 -1
  80. package/public/assets/classDiagram-6PBFFD2Q-P4A3FztV.js +0 -1
  81. package/public/assets/classDiagram-v2-HSJHXN6E-P4A3FztV.js +0 -1
  82. package/public/assets/clone-DWkLJZp8.js +0 -1
  83. package/public/assets/index-CyEGNm7Z.css +0 -1
  84. package/public/assets/stateDiagram-v2-QKLJ7IA2-DyoW21-W.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,11 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync
12
+ } from "node:fs";
4
13
  import { createRequire } from "node:module";
5
- import { basename, isAbsolute, join, resolve } from "node:path";
14
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
6
16
  import { parseArgs } from "node:util";
7
- import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
8
17
  import { createLocalWorkspaceRegistry } from "./localWorkspaces.js";
18
+ import { scaffoldPlugin } from "./scaffoldPlugin.js";
19
+ import { findHintForError, formatVerifyResult, verifyPlugin } from "./verifyPlugin.js";
9
20
  const MODE_MAP = {
10
21
  "local": "direct",
11
22
  // no sandbox, full network access
@@ -97,7 +108,8 @@ const AUTH_GUIDE = [
97
108
  " Credentials are saved at ~/.pi/agent/auth.json. Then refresh the browser.",
98
109
  ""
99
110
  ].join("\n");
100
- function checkAuth() {
111
+ async function checkAuth() {
112
+ const { AuthStorage, ModelRegistry } = await import("@mariozechner/pi-coding-agent");
101
113
  const authStorage = AuthStorage.create();
102
114
  const registry = ModelRegistry.create(authStorage);
103
115
  return registry.getAvailable().length;
@@ -105,7 +117,7 @@ function checkAuth() {
105
117
  async function startFolderMode(opts) {
106
118
  const workspaceRoot = process.env.BORING_AGENT_WORKSPACE_ROOT ?? resolve(opts.folderArg ?? process.cwd());
107
119
  const projectName = basename(resolve(workspaceRoot)) || "workspace";
108
- const modelCount = checkAuth();
120
+ const modelCount = await checkAuth();
109
121
  console.log(`
110
122
  ${projectName}`);
111
123
  console.log(` workspace ${workspaceRoot}`);
@@ -197,7 +209,7 @@ async function startWorkspacesMode(opts) {
197
209
  getWorkspaceId: async (request) => (await workspaceFromRequest(request)).id,
198
210
  getWorkspaceRoot: async (workspaceId) => (await requireWorkspace(workspaceId)).path,
199
211
  getSessionNamespace: async ({ workspaceId }) => `local-workspace-${workspaceId}`,
200
- getResourceLoaderOptions: async ({ workspaceRoot }) => ({
212
+ getPi: async ({ workspaceRoot }) => ({
201
213
  additionalSkillPaths: [join(workspaceRoot, ".agents", "skills")]
202
214
  }),
203
215
  getExtraTools: async ({ workspaceId, workspaceRoot, workspaceFsCapability }) => [
@@ -227,9 +239,112 @@ Boring UI`);
227
239
  console.log(`
228
240
  ${initialUrl}
229
241
  `);
230
- if (checkAuth() === 0) console.log(AUTH_GUIDE);
242
+ if (await checkAuth() === 0) console.log(AUTH_GUIDE);
231
243
  openBrowser(initialUrl);
232
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
+ }
233
348
  async function handleWorkspacesCommand(opts) {
234
349
  const registry = createLocalWorkspaceRegistry();
235
350
  const subcommand = opts.positionals[1];
@@ -278,7 +393,8 @@ async function runCli(options) {
278
393
  port: { type: "string", short: "p" },
279
394
  host: { type: "string" },
280
395
  mode: { type: "string", short: "m" },
281
- name: { type: "string", short: "n" }
396
+ name: { type: "string", short: "n" },
397
+ path: { type: "string" }
282
398
  },
283
399
  allowPositionals: true,
284
400
  strict: false
@@ -298,6 +414,13 @@ async function runCli(options) {
298
414
  cliMode,
299
415
  mode
300
416
  };
417
+ if (positionals[0] === "plugin") {
418
+ await handlePluginCommand({
419
+ positionals,
420
+ args: { path: args.path }
421
+ });
422
+ return;
423
+ }
301
424
  if (positionals[0] === "workspaces") {
302
425
  await handleWorkspacesCommand({
303
426
  ...base,
@@ -306,11 +429,69 @@ async function runCli(options) {
306
429
  });
307
430
  return;
308
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
+ }
309
440
  await startFolderMode({
310
441
  ...base,
311
442
  folderArg: positionals[0]
312
443
  });
313
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
+ }
314
495
  export {
315
496
  registerStatic,
316
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.17",
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.17",
19
- "@hachej/boring-workspace": "0.1.17",
20
- "@hachej/boring-ui-kit": "0.1.17"
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",