@hachej/boring-ui-plugin-cli 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runBoringUiPluginCli
4
- } from "./chunk-HY4ZELTX.js";
5
- import "./chunk-6FD653KO.js";
4
+ } from "./chunk-LWVRDO4C.js";
5
+ import "./chunk-3WN35EV3.js";
6
6
 
7
7
  // src/bin.ts
8
8
  runBoringUiPluginCli().catch((error) => {
@@ -119,6 +119,28 @@ function validatePiPackages(issues, value) {
119
119
  }
120
120
  });
121
121
  }
122
+ function validatePiSlashCommands(issues, value) {
123
+ if (value === void 0) return;
124
+ if (!Array.isArray(value)) {
125
+ issues.push(issue("INVALID_FIELD", "pi.slashCommands", "pi.slashCommands must be an array when provided"));
126
+ return;
127
+ }
128
+ value.forEach((entry, index) => {
129
+ const field = `pi.slashCommands[${index}]`;
130
+ if (!isRecord(entry)) {
131
+ issues.push(issue("INVALID_FIELD", field, `${field} must be an object with name and description`));
132
+ return;
133
+ }
134
+ if (typeof entry.name !== "string" || entry.name.length === 0) {
135
+ issues.push(issue("INVALID_FIELD", `${field}.name`, `${field}.name must be a non-empty string (no leading slash)`));
136
+ } else if (entry.name.startsWith("/")) {
137
+ issues.push(issue("INVALID_FIELD", `${field}.name`, `${field}.name must not start with "/" \u2014 omit the slash`));
138
+ }
139
+ if (typeof entry.description !== "string" || entry.description.length === 0) {
140
+ issues.push(issue("INVALID_FIELD", `${field}.description`, `${field}.description must be a non-empty string`));
141
+ }
142
+ });
143
+ }
122
144
  function validatePiField(issues, pi) {
123
145
  if (pi === void 0) return void 0;
124
146
  if (!isRecord(pi)) {
@@ -131,6 +153,7 @@ function validatePiField(issues, pi) {
131
153
  if (pi.systemPrompt !== void 0 && typeof pi.systemPrompt !== "string") {
132
154
  issues.push(issue("INVALID_FIELD", "pi.systemPrompt", "pi.systemPrompt must be a string when provided"));
133
155
  }
156
+ validatePiSlashCommands(issues, pi.slashCommands);
134
157
  return pi;
135
158
  }
136
159
  function validateBoringPluginManifest(raw) {
@@ -5,7 +5,7 @@ import {
5
5
  listPluginSources,
6
6
  removePluginSource,
7
7
  validateBoringPluginManifest
8
- } from "./chunk-6FD653KO.js";
8
+ } from "./chunk-3WN35EV3.js";
9
9
 
10
10
  // src/server/index.ts
11
11
  import { join as join4, resolve as resolve4 } from "path";
@@ -161,6 +161,7 @@ function scaffoldPlugin(opts) {
161
161
  }
162
162
  const tplFront = join2(templatesDir, "front-canonical.tsx");
163
163
  const tplPackage = join2(templatesDir, "package-canonical.json");
164
+ const tplAgent = join2(templatesDir, "agent-canonical.ts");
164
165
  for (const tpl of [tplFront, tplPackage]) {
165
166
  if (!existsSync2(tpl)) {
166
167
  throw new Error(`canonical template missing: ${tpl}`);
@@ -181,6 +182,10 @@ function scaffoldPlugin(opts) {
181
182
  `);
182
183
  const frontSource = substitute(readFileSync2(tplFront, "utf8"), opts.name, label);
183
184
  write("front/index.tsx", frontSource);
185
+ if (existsSync2(tplAgent)) {
186
+ const agentSource = substitute(readFileSync2(tplAgent, "utf8"), opts.name, label);
187
+ write("agent/index.ts", agentSource);
188
+ }
184
189
  write(".gitignore", "# Machine-managed sidecars written by the boring-ui plugin runtime.\n.boring-signature.json\n");
185
190
  return { pluginDir, filesCreated };
186
191
  }
@@ -606,6 +611,7 @@ function parseCreateArgs(positionals) {
606
611
  // src/server/testPlugin.ts
607
612
  var DEFAULT_TIMEOUT_MS = 1e4;
608
613
  var POLL_MS = 500;
614
+ var NO_BROWSER_GRACE_MS = 1500;
609
615
  function inferSelfTestUrl(explicitUrl, env = process.env) {
610
616
  const envUrl = env.BORING_UI_SELF_TEST_URL ?? env.BORING_UI_URL ?? env.BORING_WORKSPACE_URL;
611
617
  const portUrl = env.PORT ? `http://127.0.0.1:${env.PORT}` : void 0;
@@ -779,22 +785,25 @@ async function runPluginSelfTest(options) {
779
785
  let lastState = "missing";
780
786
  let lastStatus;
781
787
  let openErrors = [];
788
+ let openedAtLeastOnce = false;
782
789
  while (Date.now() - start <= timeoutMs) {
790
+ const now = Date.now();
791
+ if (!openedAtLeastOnce || now - lastOpenAt >= POLL_MS) {
792
+ openErrors = await openPanel({ baseUrl, headers, pluginId, panelId, panelInstanceId });
793
+ lastOpenAt = Date.now();
794
+ openedAtLeastOnce = openErrors.length === 0 || openedAtLeastOnce;
795
+ }
783
796
  const status = await pollPaneStatus({ baseUrl, headers, workspaceId, pluginId, panelId, panelInstanceId, minReportedAtMs: start });
784
797
  lastState = status.state;
785
798
  lastStatus = status.status;
786
- if (status.state === "no-browser-connected") {
787
- return buildResult({ pluginId, workspaceId, revision, reloadErrors, panelId, panelInstanceId, state: "no-browser-connected", status: lastStatus });
788
- }
789
799
  if (status.state === "ready" || status.state === "error") break;
790
- if (Date.now() - lastOpenAt >= POLL_MS) {
791
- openErrors = await openPanel({ baseUrl, headers, pluginId, panelId, panelInstanceId });
792
- lastOpenAt = Date.now();
800
+ if (status.state === "no-browser-connected" && openedAtLeastOnce && Date.now() - lastOpenAt >= NO_BROWSER_GRACE_MS) {
801
+ return buildResult({ pluginId, workspaceId, revision, reloadErrors, panelId, panelInstanceId, state: "no-browser-connected", status: lastStatus });
793
802
  }
794
803
  await new Promise((resolve5) => setTimeout(resolve5, POLL_MS));
795
804
  }
796
805
  if (openErrors.length > 0) reloadErrors.push(...openErrors);
797
- const finalState = lastState === "ready" || lastState === "error" ? lastState : "timeout";
806
+ const finalState = lastState === "ready" || lastState === "error" ? lastState : lastState === "no-browser-connected" && openedAtLeastOnce ? "no-browser-connected" : "timeout";
798
807
  return buildResult({ pluginId, workspaceId, revision, reloadErrors, panelId, panelInstanceId, state: finalState, status: lastStatus });
799
808
  }
800
809
  function buildResult(args) {
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  runPluginSelfTest,
9
9
  scaffoldPlugin,
10
10
  verifyPlugin
11
- } from "./chunk-HY4ZELTX.js";
11
+ } from "./chunk-LWVRDO4C.js";
12
12
  import {
13
13
  formatPluginSourceList,
14
14
  installPluginSource,
@@ -17,7 +17,7 @@ import {
17
17
  readPluginSourceRecordsForRoots,
18
18
  removePluginSource,
19
19
  resolvePluginSourceScopePaths
20
- } from "./chunk-6FD653KO.js";
20
+ } from "./chunk-3WN35EV3.js";
21
21
  export {
22
22
  createPlugin,
23
23
  findHintForError,
@@ -6,7 +6,7 @@ import {
6
6
  readPluginSourceRecordsForRoots,
7
7
  removePluginSource,
8
8
  resolvePluginSourceScopePaths
9
- } from "./chunk-6FD653KO.js";
9
+ } from "./chunk-3WN35EV3.js";
10
10
  export {
11
11
  formatPluginSourceList,
12
12
  installPluginSource,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-plugin-cli",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Slim boring-ui plugin authoring CLI for workspace runtimes.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "@types/node": "^22.15.3",
27
27
  "tsup": "^8.4.0",
28
28
  "typescript": "^5.8.3",
29
- "vitest": "^3.1.3"
29
+ "vitest": "^3.2.6"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
@@ -0,0 +1,45 @@
1
+ // CANONICAL agent/index.ts for a boring-ui runtime plugin.
2
+ // Registers a hot-reloadable Pi slash command for deterministic plugin activation.
3
+ //
4
+ // The command opens its panel through the in-process workspace UI bridge via
5
+ // `openPanel` from "@hachej/boring-workspace/plugin" — the SAME path the agent's
6
+ // own `exec_ui` tool uses. No BORING_UI_URL, no env vars, no HTTP self-call:
7
+ // the bridge is already connected to the browser.
8
+
9
+ import { NoWorkspaceUiBridgeError, notify, openPanel } from "@hachej/boring-workspace/plugin"
10
+
11
+ const PLUGIN_ID = "<kebab-name>"
12
+ const PANEL_ID = "<kebab-name>.panel"
13
+ const PANEL_TITLE = "<Label>"
14
+ const OPEN_COMMAND = "open-<kebab-name>"
15
+
16
+ export default function (pi: any) {
17
+ // User-facing deterministic slash command. Add pi.registerTool(...) below
18
+ // when this plugin also needs an LLM-callable tool.
19
+ pi.registerCommand(OPEN_COMMAND, {
20
+ description: `Open the ${PANEL_TITLE} panel`,
21
+ handler: async () => {
22
+ try {
23
+ await openPanel({
24
+ id: `${PLUGIN_ID}.slash-open`,
25
+ component: PANEL_ID,
26
+ params: { source: `/${OPEN_COMMAND}` },
27
+ })
28
+ // Surfaces as a workspace toast (unlike Pi's ctx.ui.notify, which is a
29
+ // terminal notification that is swallowed in server/headless mode).
30
+ await notify(`Opened ${PANEL_TITLE}.`, "info")
31
+ } catch (error) {
32
+ if (error instanceof NoWorkspaceUiBridgeError) {
33
+ // Running outside a workspace agent (e.g. a bare Pi CLI). Nothing to
34
+ // open; rethrow so the caller logs a clear reason.
35
+ throw error
36
+ }
37
+ await notify(
38
+ `Could not open ${PANEL_TITLE}: ${error instanceof Error ? error.message : String(error)}`,
39
+ "error",
40
+ ).catch(() => {})
41
+ throw error
42
+ }
43
+ },
44
+ })
45
+ }
@@ -0,0 +1,113 @@
1
+ // CANONICAL file visualizer front/index.tsx for a boring-ui runtime plugin.
2
+ // Recipe contract: the imports, surfaceResolvers shape, file fetch route, and
3
+ // workspace-id header below are supported public API. Do not grep workspace
4
+ // internals for these names unless `boring-ui-plugin test` fails. Change
5
+ // FILE_EXT and parser/rendering for your file type.
6
+
7
+ import React, { useEffect, useMemo, useState } from "react"
8
+ import { definePlugin, WORKSPACE_OPEN_PATH_SURFACE_KIND, type PaneProps } from "@hachej/boring-workspace/plugin"
9
+ import { useApiBaseUrl, useWorkspaceRequestId } from "@hachej/boring-workspace"
10
+
11
+ const MAIN_PANEL_ID = "<kebab-name>.panel"
12
+ const FILE_EXT = ".csv"
13
+
14
+ interface FilePaneParams {
15
+ path?: string
16
+ }
17
+
18
+ function parseCsv(text: string): string[][] {
19
+ return text
20
+ .trim()
21
+ .split(/\r?\n/)
22
+ .filter(Boolean)
23
+ .map((row) => row.split(",").map((cell) => cell.trim()))
24
+ }
25
+
26
+ function FilePane({ params }: PaneProps<FilePaneParams>) {
27
+ const path = params?.path ?? ""
28
+ const apiBaseUrl = useApiBaseUrl()
29
+ const workspaceId = useWorkspaceRequestId()
30
+ const [text, setText] = useState("")
31
+ const [error, setError] = useState<string | null>(null)
32
+
33
+ useEffect(() => {
34
+ if (!path) return
35
+ let cancelled = false
36
+ const url = `${apiBaseUrl}/api/v1/files/raw?path=${encodeURIComponent(path)}`
37
+ fetch(url, {
38
+ credentials: "include",
39
+ headers: workspaceId ? { "x-boring-workspace-id": workspaceId } : {},
40
+ })
41
+ .then((response) => {
42
+ if (!response.ok) throw new Error(`Failed to load ${path}: ${response.status}`)
43
+ return response.text()
44
+ })
45
+ .then((body) => { if (!cancelled) setText(body) })
46
+ .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) })
47
+ return () => { cancelled = true }
48
+ }, [apiBaseUrl, path, workspaceId])
49
+
50
+ const rows = useMemo(() => parseCsv(text), [text])
51
+ const headers = rows[0] ?? []
52
+ const dataRows = rows.slice(1)
53
+ const numericValues = dataRows
54
+ .map((row) => Number(row[1] ?? row[0]))
55
+ .filter((value) => Number.isFinite(value))
56
+ const max = Math.max(1, ...numericValues)
57
+
58
+ return (
59
+ <div className="flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground">
60
+ <div className="border-b border-border px-4 py-3">
61
+ <div className="text-sm font-semibold"><Label></div>
62
+ <div className="text-xs text-muted-foreground">{path || `Open a ${FILE_EXT} file`}</div>
63
+ </div>
64
+ <div className="min-h-0 flex-1 overflow-auto p-4">
65
+ {error ? <div className="text-sm text-destructive">{error}</div> : null}
66
+ <table className="w-full border-collapse text-sm">
67
+ <thead>
68
+ <tr>{headers.map((header, index) => <th key={index} className="border border-border px-2 py-1 text-left">{header}</th>)}</tr>
69
+ </thead>
70
+ <tbody>
71
+ {dataRows.map((row, rowIndex) => (
72
+ <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex} className="border border-border px-2 py-1">{cell}</td>)}</tr>
73
+ ))}
74
+ </tbody>
75
+ </table>
76
+ <svg className="mt-4 h-32 w-full overflow-visible" viewBox="0 0 320 120" role="img" aria-label="CSV value chart">
77
+ {numericValues.map((value, index) => {
78
+ const width = 280 / Math.max(1, numericValues.length)
79
+ const height = (value / max) * 100
80
+ return <rect key={index} x={10 + index * width} y={110 - height} width={Math.max(2, width - 2)} height={height} fill="currentColor" opacity="0.7" />
81
+ })}
82
+ </svg>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ export default definePlugin({
89
+ id: "<kebab-name>",
90
+ label: "<Label>",
91
+ panels: [
92
+ { id: MAIN_PANEL_ID, label: "<Label>", component: FilePane },
93
+ ],
94
+ commands: [
95
+ { id: "<kebab-name>.open", title: "Open <Label>", panelId: MAIN_PANEL_ID },
96
+ ],
97
+ surfaceResolvers: [
98
+ {
99
+ id: "<kebab-name>.open-file",
100
+ kind: WORKSPACE_OPEN_PATH_SURFACE_KIND,
101
+ resolve: (request) => {
102
+ if (request.kind !== WORKSPACE_OPEN_PATH_SURFACE_KIND) return null
103
+ if (!request.target.endsWith(FILE_EXT)) return null
104
+ return {
105
+ id: `<kebab-name>:${request.target}`,
106
+ component: MAIN_PANEL_ID,
107
+ title: request.target.split("/").pop() ?? "<Label>",
108
+ params: { path: request.target },
109
+ }
110
+ },
111
+ },
112
+ ],
113
+ })
@@ -14,6 +14,10 @@
14
14
  "front": "front/index.tsx"
15
15
  },
16
16
  "pi": {
17
- "systemPrompt": "<Label> plugin — describe what it does so the agent knows when to use it."
17
+ "systemPrompt": "<Label> plugin — describe what it does so the agent knows when to use it.",
18
+ "extensions": ["agent/index.ts"],
19
+ "slashCommands": [
20
+ { "name": "open-<kebab-name>", "description": "Open the <Label> panel" }
21
+ ]
18
22
  }
19
23
  }