portawhip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. package/surface-matrix.yaml +93 -0
@@ -0,0 +1,782 @@
1
+ #!/usr/bin/env node
2
+
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { Box, Text, render, useApp, useInput, useStdin, useStdout } from "ink";
5
+ import ansiEscapes from "ansi-escapes";
6
+ import { CONFIG_SYNC_BACKENDS } from "../core/surface/config-sync-backends.mjs";
7
+ import { DEFAULT_CACHE_PATH, runEnrichment } from "../core/registry/enrich.mjs";
8
+ import { collectSurfaceInventory } from "../core/surface/surface-inventory.mjs";
9
+ import { collectSyncConfig } from "./sync/sync-config.mjs";
10
+ import { LINK_SCOPES, linkActionNeedsConfirmation, linkCommandForInput, runLinkAction } from "./tui-actions.mjs";
11
+
12
+ const TABS = ["overview", "sync", "connectors", "hooks", "enrich", "capabilities"];
13
+ const TAB_COPY = {
14
+ overview: {
15
+ title: "Start here",
16
+ description: "See what portawhip found and choose the next check.",
17
+ action: "Press 2 for sync, 3 for connectors, 4 for hooks, 5 for enrich, 6 for capabilities.",
18
+ },
19
+ sync: {
20
+ title: "Config sync",
21
+ description: "Preview or apply config sync across agent hosts.",
22
+ action: "Use f for safe profiles first, p to preview, then a twice to apply.",
23
+ },
24
+ connectors: {
25
+ title: "MCP + instruction links",
26
+ description: "Check whether each host has the harness-router MCP server and instructions linked.",
27
+ action: "Choose scope with g, press l twice to repair links, or x twice to remove them.",
28
+ },
29
+ hooks: {
30
+ title: "Native hooks",
31
+ description: "Check prompt/tool hooks that let the router suggest capabilities at the right moment.",
32
+ action: "Choose scope with g, press l twice to repair hooks, or x twice to remove them.",
33
+ },
34
+ enrich: {
35
+ title: "Tool descriptions",
36
+ description: "Fill cached descriptions for discovered tools so routing works from natural language.",
37
+ action: "Press e to refresh the enrichment cache.",
38
+ },
39
+ capabilities: {
40
+ title: "Routable capabilities",
41
+ description: "Browse the skills, MCP servers, and CLI tools the router can suggest.",
42
+ action: "Use up/down to inspect rows; details appear below the list.",
43
+ },
44
+ };
45
+ const MIN_LIST_HEIGHT = 3;
46
+ const MAX_DETAIL_HEIGHT = 9;
47
+ const SYNC_SCOPES = ["all", "project", "global"];
48
+ const SYNC_DIRECTIONS = [
49
+ { label: "auto", from: null, to: null },
50
+ { label: "claude->codex", from: "claude", to: "codex" },
51
+ { label: "codex->claude", from: "codex", to: "claude" },
52
+ ];
53
+ const SYNC_INCLUDE_PRESETS = [
54
+ { label: "none", include: null },
55
+ { label: "instructions", include: "instructions" },
56
+ { label: "mcp", include: "mcp" },
57
+ { label: "hooks", include: "hooks" },
58
+ { label: "permissions", include: "permissions" },
59
+ ];
60
+ const SYNC_PROFILES = [
61
+ { id: "manual", label: "manual", backends: null, scope: null, include: null },
62
+ { id: "ai-project-instructions", label: "ai project instructions", backends: ["ai-config-sync"], scope: "project", include: "instructions" },
63
+ { id: "ai-global-instructions", label: "ai global instructions", backends: ["ai-config-sync"], scope: "global", include: "instructions" },
64
+ { id: "ai-project-mcp", label: "ai project mcp", backends: ["ai-config-sync"], scope: "project", include: "mcp" },
65
+ { id: "asm-status", label: "asm status", backends: ["agent-skill-manager"], scope: "all", include: null },
66
+ { id: "agents-check", label: "agents check", backends: ["agents-dotdir"], scope: "all", include: null },
67
+ ];
68
+ const STATUS_COLORS = {
69
+ linked: "green",
70
+ available: "green",
71
+ success: "green",
72
+ ok: "green",
73
+ changed: "green",
74
+ "no-op": "green",
75
+ ready: "cyan",
76
+ warning: "yellow",
77
+ error: "red",
78
+ missing: "yellow",
79
+ unsupported: "gray",
80
+ "mcp-only": "cyan",
81
+ enriched: "green",
82
+ "bare-name": "yellow",
83
+ };
84
+
85
+ function shortPath(path) {
86
+ if (!path) return "";
87
+ const parts = path.split(/[\\/]/);
88
+ return parts.length > 4 ? `.../${parts.slice(-3).join("/")}` : path;
89
+ }
90
+
91
+ function truncate(value, width) {
92
+ const text = String(value ?? "");
93
+ if (width <= 0) return "";
94
+ if (text.length <= width) return text;
95
+ if (width <= 3) return ".".repeat(width);
96
+ return `${text.slice(0, width - 3)}...`;
97
+ }
98
+
99
+ function statusColor(status) {
100
+ return STATUS_COLORS[status] ?? "white";
101
+ }
102
+
103
+ function layoutForRows(terminalRows, hasDetail, hasHelp = false) {
104
+ const headerRows = 4;
105
+ const helpRows = hasHelp ? 6 : 0;
106
+ const footerRows = 1;
107
+ const margins = hasDetail ? 2 : 1;
108
+ const available = Math.max(MIN_LIST_HEIGHT, terminalRows - headerRows - helpRows - footerRows - margins);
109
+ const detailHeight = hasDetail && available >= MIN_LIST_HEIGHT + 4 ? Math.min(MAX_DETAIL_HEIGHT, Math.max(4, Math.floor(available * 0.35))) : 0;
110
+ const listHeight = Math.max(MIN_LIST_HEIGHT, available - detailHeight);
111
+ return { detailHeight, listHeight };
112
+ }
113
+
114
+ function messageColor(message) {
115
+ if (!message) return "gray";
116
+ const lower = message.toLowerCase();
117
+ return lower.includes("error") || lower.includes("needs") || lower.includes("failed") ? "red" : "green";
118
+ }
119
+
120
+ function printHelp() {
121
+ console.log(`portawhip TUI
122
+
123
+ Usage:
124
+ node scripts/tui.mjs
125
+ node scripts/tui.mjs --summary
126
+ node scripts/tui.mjs --help
127
+
128
+ Keys:
129
+ 1-6 jump tabs, tab/right next tab, left previous tab
130
+ up/down select rows, h or ? help, r refresh, q quit
131
+ sync tab: f profile, b backend, g scope, d direction, i include
132
+ sync actions: s status, p preview, a apply (press twice to confirm)
133
+ connectors/hooks tabs: g scope, s status, l install or repair (press twice), x remove (press twice)
134
+
135
+ Safety:
136
+ sync apply requires one backend plus an include selector or safe profile.
137
+ connector and hook changes require a second key press to confirm.
138
+ sync-config does not run unpinned npx backends unless --allow-npx is used on the CLI.`);
139
+ }
140
+
141
+ function enterFullscreen() {
142
+ process.stdout.write(ansiEscapes.enterAlternativeScreen + ansiEscapes.cursorHide + ansiEscapes.clearViewport);
143
+ }
144
+
145
+ function exitFullscreen() {
146
+ process.stdout.write(ansiEscapes.cursorShow + ansiEscapes.exitAlternativeScreen);
147
+ }
148
+
149
+ function useInventory() {
150
+ const [state, setState] = useState({ loading: true, error: null, inventory: null });
151
+ const refresh = async () => {
152
+ setState((current) => ({ ...current, loading: true, error: null }));
153
+ try {
154
+ setState({ loading: false, error: null, inventory: await collectSurfaceInventory() });
155
+ } catch (error) {
156
+ setState({ loading: false, error, inventory: null });
157
+ }
158
+ };
159
+
160
+ useEffect(() => {
161
+ refresh();
162
+ }, []);
163
+
164
+ return { ...state, refresh };
165
+ }
166
+
167
+ function useTerminalSize() {
168
+ const { stdout } = useStdout();
169
+ const readSize = () => ({
170
+ columns: stdout.columns ?? process.stdout.columns ?? 100,
171
+ rows: stdout.rows ?? process.stdout.rows ?? 30,
172
+ });
173
+ const [size, setSize] = useState(readSize);
174
+
175
+ useEffect(() => {
176
+ const onResize = () => setSize(readSize());
177
+ stdout.on("resize", onResize);
178
+ return () => stdout.off("resize", onResize);
179
+ }, [stdout]);
180
+
181
+ return size;
182
+ }
183
+
184
+ function Header({ tab, loading, enriching, syncing, linking }) {
185
+ const copy = TAB_COPY[tab];
186
+ return React.createElement(
187
+ Box,
188
+ { flexDirection: "column", marginBottom: 1 },
189
+ React.createElement(
190
+ Box,
191
+ null,
192
+ React.createElement(Text, { color: "cyan", bold: true }, "portawhip"),
193
+ React.createElement(Text, null, " surface"),
194
+ loading ? React.createElement(Text, { color: "yellow" }, " refresh") : null,
195
+ enriching ? React.createElement(Text, { color: "yellow" }, " enrich") : null,
196
+ syncing ? React.createElement(Text, { color: "yellow" }, " sync") : null,
197
+ linking ? React.createElement(Text, { color: "yellow" }, " link") : null,
198
+ ),
199
+ React.createElement(
200
+ Box,
201
+ null,
202
+ TABS.map((item, index) =>
203
+ React.createElement(
204
+ Text,
205
+ { key: item, inverse: item === tab, color: item === tab ? "black" : "white" },
206
+ ` ${index + 1}:${item} `,
207
+ ),
208
+ ),
209
+ ),
210
+ React.createElement(
211
+ Text,
212
+ { color: "gray", wrap: "truncate-end" },
213
+ `${copy.title}: ${copy.description}`,
214
+ ),
215
+ );
216
+ }
217
+
218
+ function Overview({ inventory }) {
219
+ const rows = [
220
+ ["sync backends", Object.keys(CONFIG_SYNC_BACKENDS).length, Object.fromEntries(Object.entries(CONFIG_SYNC_BACKENDS).map(([id, backend]) => [id, Object.keys(backend.supports).filter((action) => backend.supports[action]).join("/")]))],
221
+ ["connectors", inventory.connectors.length, inventory.summary.connectors],
222
+ ["hooks", inventory.hooks.length, inventory.summary.hooks],
223
+ ["enrich", inventory.enrichments.length, inventory.summary.enrichments],
224
+ ["capabilities", inventory.capabilities.length, inventory.summary.capabilities],
225
+ ];
226
+
227
+ return React.createElement(
228
+ Box,
229
+ { flexDirection: "column" },
230
+ React.createElement(Text, { color: "gray" }, `generated ${inventory.generatedAt}`),
231
+ React.createElement(Text, { color: "gray" }, inventory.cwd),
232
+ React.createElement(
233
+ Box,
234
+ { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", marginTop: 1, marginBottom: 1 },
235
+ React.createElement(
236
+ Text,
237
+ null,
238
+ React.createElement(Text, { color: "cyan", bold: true }, "Workspace pulse"),
239
+ React.createElement(Text, { color: "gray" }, " live inventory"),
240
+ ),
241
+ rows.map(([label, count, summary]) =>
242
+ React.createElement(
243
+ Box,
244
+ { key: label },
245
+ React.createElement(Text, { color: count > 0 ? "green" : "gray" }, count > 0 ? "● " : "○ "),
246
+ React.createElement(Text, { bold: true }, `${label.padEnd(14)} `),
247
+ React.createElement(Text, { color: "cyan", bold: true }, `${String(count).padStart(4)} `),
248
+ React.createElement(
249
+ Text,
250
+ { color: "gray" },
251
+ Object.entries(summary)
252
+ .map(([key, value]) => `${key}:${value}`)
253
+ .join(" "),
254
+ ),
255
+ ),
256
+ ),
257
+ ),
258
+ React.createElement(Text, { color: "cyan", bold: true }, "Start here"),
259
+ React.createElement(Text, null, "1 Press 2, then f to choose a safe sync profile and p to preview."),
260
+ React.createElement(Text, null, "2 Press 3 or 4, choose a scope with g, then l twice to repair links."),
261
+ React.createElement(Text, null, "3 Press 5, then e when tool descriptions need enrichment."),
262
+ React.createElement(Text, { color: "gray" }, "Press h or ? for the key map. Destructive actions always ask twice."),
263
+ );
264
+ }
265
+
266
+ function syncSelection({ backendIndex, scopeIndex, directionIndex, includeIndex, profileIndex }) {
267
+ const backendIds = Object.keys(CONFIG_SYNC_BACKENDS);
268
+ const backendChoice = backendIndex === 0 ? "all" : backendIds[backendIndex - 1];
269
+ const profile = SYNC_PROFILES[profileIndex];
270
+ const direction = SYNC_DIRECTIONS[directionIndex];
271
+ const includePreset = SYNC_INCLUDE_PRESETS[includeIndex];
272
+ const backends = profile.backends ?? (backendChoice === "all" ? backendIds : [backendChoice]);
273
+ return {
274
+ backendChoice,
275
+ profile,
276
+ backends,
277
+ options: {
278
+ scope: profile.scope ?? SYNC_SCOPES[scopeIndex],
279
+ include: profile.include ?? includePreset.include,
280
+ from: direction.from,
281
+ to: direction.to,
282
+ cwd: process.cwd(),
283
+ },
284
+ labels: {
285
+ backend: profile.backends ? profile.backends.join(",") : backendChoice,
286
+ profile: profile.label,
287
+ scope: profile.scope ?? SYNC_SCOPES[scopeIndex],
288
+ include: profile.include ?? includePreset.label,
289
+ direction: direction.label,
290
+ },
291
+ };
292
+ }
293
+
294
+ function syncRowsFromState(syncResult, selection, action) {
295
+ if (syncResult?.rows?.length) return syncResult.rows;
296
+ return selection.backends.map((backendId) => {
297
+ const backend = CONFIG_SYNC_BACKENDS[backendId];
298
+ return {
299
+ backend: backend.id,
300
+ label: backend.label,
301
+ action,
302
+ status: backend.supports[action] ? "ready" : "unsupported",
303
+ ok: backend.supports[action],
304
+ summary: backend.supports[action] ? backend.description : `${backend.label} does not support ${action}`,
305
+ command: [],
306
+ output: "",
307
+ next_actions: backend.supports[action] ? [`Press ${action === "status" ? "s" : action === "preview" ? "p" : "a"} to run ${action}.`] : ["Choose a supported action."],
308
+ };
309
+ });
310
+ }
311
+
312
+ function scrollOffset(selected, rowCount, height) {
313
+ if (rowCount <= height) return 0;
314
+ const half = Math.floor(height / 2);
315
+ const maxOffset = Math.max(0, rowCount - height);
316
+ return Math.min(Math.max(0, selected - half), maxOffset);
317
+ }
318
+
319
+ function Rows({ rows, selected, height, renderRow }) {
320
+ const listHeight = Math.max(MIN_LIST_HEIGHT, height);
321
+ const offset = scrollOffset(selected, rows.length, listHeight);
322
+ const needsRange = rows.length > listHeight;
323
+ const rowBudget = needsRange ? Math.max(1, listHeight - 1) : listHeight;
324
+ const visibleRows = rows.slice(offset, offset + rowBudget);
325
+ const rangeLabel = needsRange ? ` ${offset + 1}-${offset + visibleRows.length}/${rows.length}` : "";
326
+
327
+ return React.createElement(
328
+ Box,
329
+ { flexDirection: "column" },
330
+ rangeLabel ? React.createElement(Text, { color: "gray" }, rangeLabel) : null,
331
+ visibleRows.map((row, index) => renderRow(row, offset + index === selected)),
332
+ );
333
+ }
334
+
335
+ function ConnectorRows({ rows, selected, height, width }) {
336
+ return React.createElement(Rows, {
337
+ rows,
338
+ selected,
339
+ height,
340
+ renderRow: (row, active) =>
341
+ React.createElement(
342
+ Box,
343
+ { key: `${row.hostId}-${row.scope}-${row.path ?? "mcp"}` },
344
+ React.createElement(Text, { inverse: active }, active ? ">" : " "),
345
+ React.createElement(Text, { bold: true }, ` ${row.hostId.padEnd(20)}`),
346
+ React.createElement(Text, { color: "gray" }, `${row.scope.padEnd(8)}`),
347
+ React.createElement(Text, { color: statusColor(row.mcpStatus) }, `mcp:${row.mcpStatus.padEnd(8)} `),
348
+ React.createElement(
349
+ Text,
350
+ { color: statusColor(row.instructionStatus) },
351
+ `instruction:${row.instructionStatus.padEnd(10)} `,
352
+ ),
353
+ React.createElement(Text, { color: "gray", wrap: "truncate-end" }, truncate(shortPath(row.path), Math.max(10, width - 70))),
354
+ ),
355
+ });
356
+ }
357
+
358
+ function HookRows({ rows, selected, height, width }) {
359
+ return React.createElement(Rows, {
360
+ rows,
361
+ selected,
362
+ height,
363
+ renderRow: (row, active) =>
364
+ React.createElement(
365
+ Box,
366
+ { key: `${row.hostId}-${row.scope}` },
367
+ React.createElement(Text, { inverse: active }, active ? ">" : " "),
368
+ React.createElement(Text, { bold: true }, ` ${row.hostId.padEnd(20)}`),
369
+ React.createElement(Text, { color: "gray" }, `${row.scope.padEnd(8)}`),
370
+ React.createElement(Text, { color: statusColor(row.status) }, `${row.status.padEnd(12)} `),
371
+ React.createElement(
372
+ Text,
373
+ { color: "gray", wrap: "truncate-end" },
374
+ truncate(row.details.join(", ") || shortPath(row.path), Math.max(10, width - 45)),
375
+ ),
376
+ ),
377
+ });
378
+ }
379
+
380
+ function CapabilityRows({ rows, selected, height, width }) {
381
+ return React.createElement(Rows, {
382
+ rows,
383
+ selected,
384
+ height,
385
+ renderRow: (row, active) =>
386
+ React.createElement(
387
+ Box,
388
+ { key: `${row.type}-${row.id}` },
389
+ React.createElement(Text, { inverse: active }, active ? ">" : " "),
390
+ React.createElement(Text, { bold: true }, ` ${row.id.slice(0, 28).padEnd(30)}`),
391
+ React.createElement(Text, { color: "cyan" }, `${row.type.padEnd(8)}`),
392
+ React.createElement(Text, { color: "gray" }, `${row.origin.padEnd(12)}`),
393
+ React.createElement(Text, { wrap: "truncate-end" }, truncate(row.description, Math.max(10, width - 55))),
394
+ ),
395
+ });
396
+ }
397
+
398
+ function EnrichRows({ rows, selected, height, width }) {
399
+ return React.createElement(Rows, {
400
+ rows,
401
+ selected,
402
+ height,
403
+ renderRow: (row, active) =>
404
+ React.createElement(
405
+ Box,
406
+ { key: `${row.type}-${row.id}` },
407
+ React.createElement(Text, { inverse: active }, active ? ">" : " "),
408
+ React.createElement(Text, { bold: true }, ` ${row.id.slice(0, 26).padEnd(28)}`),
409
+ React.createElement(Text, { color: "cyan" }, `${row.type.padEnd(6)}`),
410
+ React.createElement(Text, { color: statusColor(row.status) }, `${row.status.padEnd(10)} `),
411
+ React.createElement(Text, { color: "gray" }, `triggers:${String(row.triggerCount).padEnd(3)} `),
412
+ React.createElement(Text, { wrap: "truncate-end" }, truncate(row.description, Math.max(10, width - 62))),
413
+ ),
414
+ });
415
+ }
416
+
417
+ function SyncRows({ rows, selected, height, width }) {
418
+ return React.createElement(Rows, {
419
+ rows,
420
+ selected,
421
+ height,
422
+ renderRow: (row, active) =>
423
+ React.createElement(
424
+ Box,
425
+ { key: `${row.backend}-${row.action}` },
426
+ React.createElement(Text, { inverse: active }, active ? ">" : " "),
427
+ React.createElement(Text, { bold: true }, ` ${row.backend.slice(0, 22).padEnd(24)}`),
428
+ React.createElement(Text, { color: statusColor(row.status) }, `${row.status.padEnd(11)} `),
429
+ React.createElement(Text, { color: "cyan" }, `${row.action.padEnd(8)} `),
430
+ React.createElement(Text, { wrap: "truncate-end" }, truncate(row.summary, Math.max(10, width - 48))),
431
+ ),
432
+ });
433
+ }
434
+
435
+ function SyncControls({ action, selection, armedApply }) {
436
+ const applyHint = armedApply ? "confirm apply: press a again" : "a apply";
437
+ return React.createElement(
438
+ Box,
439
+ { flexDirection: "column", marginBottom: 1 },
440
+ React.createElement(
441
+ Text,
442
+ null,
443
+ React.createElement(Text, { color: "gray" }, "mode "),
444
+ React.createElement(Text, { color: "cyan", bold: true }, action),
445
+ React.createElement(Text, { color: "gray" }, " backend "),
446
+ React.createElement(Text, null, selection.labels.backend),
447
+ React.createElement(Text, { color: "gray" }, " profile "),
448
+ React.createElement(Text, null, selection.labels.profile),
449
+ ),
450
+ React.createElement(
451
+ Text,
452
+ null,
453
+ React.createElement(Text, { color: "gray" }, "scope "),
454
+ React.createElement(Text, null, selection.labels.scope),
455
+ React.createElement(Text, { color: "gray" }, " include "),
456
+ React.createElement(Text, null, selection.labels.include),
457
+ React.createElement(Text, { color: "gray" }, " direction "),
458
+ React.createElement(Text, null, selection.labels.direction),
459
+ React.createElement(Text, { color: "gray" }, ` s status p preview ${applyHint}`),
460
+ ),
461
+ );
462
+ }
463
+
464
+ function LinkControls({ tab, scope, action, armedAction }) {
465
+ const title = TAB_COPY[tab].title;
466
+ const confirmHint = armedAction ? `confirm ${action}: press ${action === "install" ? "l" : "x"} again` : "l repair x remove";
467
+ return React.createElement(
468
+ Box,
469
+ { flexDirection: "column", marginBottom: 1 },
470
+ React.createElement(
471
+ Text,
472
+ null,
473
+ React.createElement(Text, { color: "gray" }, "scope "),
474
+ React.createElement(Text, { color: "cyan", bold: true }, scope),
475
+ React.createElement(Text, { color: "gray" }, " action "),
476
+ React.createElement(Text, null, action),
477
+ ),
478
+ React.createElement(Text, { color: "gray" }, `g scope s status ${confirmHint} (${title})`),
479
+ );
480
+ }
481
+
482
+ function EmptyState({ tab }) {
483
+ const copy = TAB_COPY[tab];
484
+ return React.createElement(
485
+ Box,
486
+ { flexDirection: "column" },
487
+ React.createElement(Text, { color: "yellow" }, "No rows yet."),
488
+ React.createElement(Text, { color: "gray" }, copy.action),
489
+ );
490
+ }
491
+
492
+ function HelpPanel({ tab, width }) {
493
+ const copy = TAB_COPY[tab];
494
+ const rows = [
495
+ "1-6 jump tabs tab/right next tab left previous tab",
496
+ "up/down select rows r refresh h or ? toggle help q quit",
497
+ "sync tab: f profile b backend g scope d direction i include s status p preview a apply",
498
+ "connectors/hooks tabs: g scope s status l repair (press twice) x remove (press twice)",
499
+ "enrich tab: e refresh cached tool descriptions",
500
+ ];
501
+ return React.createElement(
502
+ Box,
503
+ { borderStyle: "single", borderColor: "cyan", paddingX: 1, flexDirection: "column", marginBottom: 1 },
504
+ React.createElement(Text, { bold: true }, `Help - ${copy.title}`),
505
+ React.createElement(Text, { color: "gray", wrap: "truncate-end" }, truncate(copy.action, Math.max(10, width - 4))),
506
+ rows.map((row) => React.createElement(Text, { key: row, color: "gray", wrap: "truncate-end" }, truncate(row, Math.max(10, width - 4)))),
507
+ );
508
+ }
509
+
510
+ function Detail({ row, tab, width, height }) {
511
+ if (height <= 0) return null;
512
+ if (!row) return React.createElement(Text, { color: "gray" }, "No row selected.");
513
+ const pairs = Object.entries(row).filter(([, value]) => value !== null && value !== undefined && value !== "");
514
+ return React.createElement(
515
+ Box,
516
+ { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", height, marginTop: 1 },
517
+ React.createElement(Text, { bold: true }, `${TAB_COPY[tab].title} detail`),
518
+ pairs.slice(0, Math.max(1, height - 3)).map(([key, value]) =>
519
+ React.createElement(
520
+ Text,
521
+ { key, wrap: "truncate-end" },
522
+ React.createElement(Text, { color: "gray", wrap: "truncate-end" }, `${key}: `),
523
+ truncate(Array.isArray(value) ? value.join(" ") : typeof value === "object" ? JSON.stringify(value) : String(value), Math.max(10, width - key.length - 5)),
524
+ ),
525
+ ),
526
+ );
527
+ }
528
+
529
+ function App() {
530
+ const { exit } = useApp();
531
+ const { isRawModeSupported } = useStdin();
532
+ const { columns, rows: terminalRows } = useTerminalSize();
533
+ const { loading, error, inventory, refresh } = useInventory();
534
+ const [enriching, setEnriching] = useState(false);
535
+ const [syncing, setSyncing] = useState(false);
536
+ const [linking, setLinking] = useState(false);
537
+ const [message, setMessage] = useState("");
538
+ const [tabIndex, setTabIndex] = useState(0);
539
+ const [selected, setSelected] = useState(0);
540
+ const [syncResult, setSyncResult] = useState(null);
541
+ const [syncAction, setSyncAction] = useState("status");
542
+ const [backendIndex, setBackendIndex] = useState(0);
543
+ const [scopeIndex, setScopeIndex] = useState(0);
544
+ const [directionIndex, setDirectionIndex] = useState(0);
545
+ const [includeIndex, setIncludeIndex] = useState(0);
546
+ const [profileIndex, setProfileIndex] = useState(0);
547
+ const [armedApply, setArmedApply] = useState(false);
548
+ const [linkScopeIndex, setLinkScopeIndex] = useState(0);
549
+ const [linkAction, setLinkAction] = useState("status");
550
+ const [linkResult, setLinkResult] = useState(null);
551
+ const [armedLinkAction, setArmedLinkAction] = useState(null);
552
+ const [showHelp, setShowHelp] = useState(false);
553
+ const tab = TABS[tabIndex];
554
+ const linkScope = LINK_SCOPES[linkScopeIndex];
555
+ const syncSelectionState = useMemo(
556
+ () => syncSelection({ backendIndex, scopeIndex, directionIndex, includeIndex, profileIndex }),
557
+ [backendIndex, scopeIndex, directionIndex, includeIndex, profileIndex],
558
+ );
559
+ const rows = useMemo(() => {
560
+ if (!inventory) return [];
561
+ if (tab === "sync") return syncRowsFromState(syncResult, syncSelectionState, syncAction);
562
+ if (tab === "connectors") return linkResult?.tab === "connectors" ? linkResult.rows : inventory.connectors;
563
+ if (tab === "hooks") return linkResult?.tab === "hooks" ? linkResult.rows : inventory.hooks;
564
+ if (tab === "enrich") return inventory.enrichments;
565
+ if (tab === "capabilities") return inventory.capabilities;
566
+ return [];
567
+ }, [inventory, tab, syncResult, syncSelectionState, syncAction, linkResult]);
568
+
569
+ useEffect(() => {
570
+ setSelected((index) => Math.min(index, Math.max(rows.length - 1, 0)));
571
+ }, [rows.length]);
572
+
573
+ const layout = layoutForRows(terminalRows, tab !== "overview", showHelp);
574
+
575
+ useInput(
576
+ (input, key) => {
577
+ if (input === "q" || key.escape) exit();
578
+ const numericTab = Number(input);
579
+ if (Number.isInteger(numericTab) && numericTab >= 1 && numericTab <= TABS.length) {
580
+ setTabIndex(numericTab - 1);
581
+ setSelected(0);
582
+ setArmedApply(false);
583
+ setArmedLinkAction(null);
584
+ return;
585
+ }
586
+ if (input === "h" || input === "?") {
587
+ setShowHelp((visible) => !visible);
588
+ return;
589
+ }
590
+ if (input === "r") {
591
+ setMessage("");
592
+ setLinkResult(null);
593
+ setArmedLinkAction(null);
594
+ refresh();
595
+ }
596
+ if (tab === "sync" && !syncing) {
597
+ const runSync = (action) => {
598
+ if (action === "apply") {
599
+ if (syncSelectionState.backends.length !== 1) {
600
+ setMessage("apply needs one selected backend or one-backend safe profile");
601
+ return;
602
+ }
603
+ if (!syncSelectionState.options.include) {
604
+ setMessage("apply needs an include selector or safe profile");
605
+ return;
606
+ }
607
+ if (!armedApply) {
608
+ setArmedApply(true);
609
+ setMessage("apply is armed; press a again to run");
610
+ return;
611
+ }
612
+ }
613
+ setSyncing(true);
614
+ setArmedApply(false);
615
+ setSyncAction(action);
616
+ setMessage("");
617
+ Promise.resolve()
618
+ .then(() =>
619
+ collectSyncConfig({
620
+ action,
621
+ backends: syncSelectionState.backends,
622
+ options: syncSelectionState.options,
623
+ }),
624
+ )
625
+ .then((result) => {
626
+ setSyncResult(result);
627
+ setMessage(result.summary);
628
+ })
629
+ .catch((err) => setMessage(err.message))
630
+ .finally(() => setSyncing(false));
631
+ };
632
+ if (input === "s") runSync("status");
633
+ if (input === "p") runSync("preview");
634
+ if (input === "a") runSync("apply");
635
+ if (input === "b") {
636
+ setBackendIndex((index) => (index + 1) % (Object.keys(CONFIG_SYNC_BACKENDS).length + 1));
637
+ setSyncResult(null);
638
+ setArmedApply(false);
639
+ }
640
+ if (input === "g") {
641
+ setScopeIndex((index) => (index + 1) % SYNC_SCOPES.length);
642
+ setSyncResult(null);
643
+ setArmedApply(false);
644
+ }
645
+ if (input === "d") {
646
+ setDirectionIndex((index) => (index + 1) % SYNC_DIRECTIONS.length);
647
+ setSyncResult(null);
648
+ setArmedApply(false);
649
+ }
650
+ if (input === "i") {
651
+ setIncludeIndex((index) => (index + 1) % SYNC_INCLUDE_PRESETS.length);
652
+ setSyncResult(null);
653
+ setArmedApply(false);
654
+ }
655
+ if (input === "f") {
656
+ setProfileIndex((index) => (index + 1) % SYNC_PROFILES.length);
657
+ setSyncResult(null);
658
+ setArmedApply(false);
659
+ }
660
+ }
661
+ if (input === "e" && tab === "enrich" && !enriching) {
662
+ setEnriching(true);
663
+ setMessage("");
664
+ runEnrichment({ cachePath: DEFAULT_CACHE_PATH })
665
+ .then((entries) => {
666
+ setMessage(`enriched ${Object.keys(entries).length} cached tools`);
667
+ return refresh();
668
+ })
669
+ .catch((err) => setMessage(err.message))
670
+ .finally(() => setEnriching(false));
671
+ }
672
+ if ((tab === "connectors" || tab === "hooks") && !linking) {
673
+ const requestedAction = linkCommandForInput(tab, input);
674
+ if (input === "g") {
675
+ setLinkScopeIndex((index) => (index + 1) % LINK_SCOPES.length);
676
+ setLinkResult(null);
677
+ setArmedLinkAction(null);
678
+ }
679
+ if (requestedAction) {
680
+ if (linkActionNeedsConfirmation(requestedAction) && armedLinkAction !== requestedAction) {
681
+ setLinkAction(requestedAction);
682
+ setArmedLinkAction(requestedAction);
683
+ setMessage(`${requestedAction} is armed; press ${input} again to run`);
684
+ return;
685
+ }
686
+ setLinking(true);
687
+ setArmedLinkAction(null);
688
+ setLinkAction(requestedAction);
689
+ setMessage("");
690
+ runLinkAction({ tab, command: requestedAction, scope: linkScope })
691
+ .then((result) => {
692
+ setLinkResult({ ...result, tab });
693
+ setMessage(result.summary);
694
+ return refresh();
695
+ })
696
+ .catch((err) => setMessage(err.message))
697
+ .finally(() => setLinking(false));
698
+ }
699
+ }
700
+ if (key.tab || key.rightArrow) {
701
+ setTabIndex((index) => (index + 1) % TABS.length);
702
+ setSelected(0);
703
+ setArmedApply(false);
704
+ setArmedLinkAction(null);
705
+ }
706
+ if (key.leftArrow) {
707
+ setTabIndex((index) => (index - 1 + TABS.length) % TABS.length);
708
+ setSelected(0);
709
+ setArmedApply(false);
710
+ setArmedLinkAction(null);
711
+ }
712
+ if (key.upArrow) setSelected((index) => Math.max(0, index - 1));
713
+ if (key.downArrow) setSelected((index) => Math.min(Math.max(rows.length - 1, 0), index + 1));
714
+ },
715
+ { isActive: isRawModeSupported },
716
+ );
717
+
718
+ if (error) return React.createElement(Text, { color: "red" }, error.message);
719
+ if (!inventory) return React.createElement(Text, { color: "yellow" }, "Loading portawhip surface...");
720
+
721
+ return React.createElement(
722
+ Box,
723
+ { flexDirection: "column", paddingX: 1, width: columns, height: terminalRows },
724
+ React.createElement(Header, { tab, loading, enriching, syncing, linking }),
725
+ showHelp ? React.createElement(HelpPanel, { tab, width: columns }) : null,
726
+ tab === "overview" ? React.createElement(Overview, { inventory }) : null,
727
+ tab === "sync" ? React.createElement(SyncControls, { action: syncAction, selection: syncSelectionState, armedApply }) : null,
728
+ tab === "connectors" || tab === "hooks"
729
+ ? React.createElement(LinkControls, { tab, scope: linkScope, action: linkAction, armedAction: armedLinkAction })
730
+ : null,
731
+ tab !== "overview" && rows.length === 0 ? React.createElement(EmptyState, { tab }) : null,
732
+ tab === "sync" && rows.length > 0
733
+ ? React.createElement(SyncRows, { rows, selected, height: Math.max(MIN_LIST_HEIGHT, layout.listHeight - 2), width: columns })
734
+ : null,
735
+ tab === "connectors" && rows.length > 0
736
+ ? React.createElement(ConnectorRows, { rows, selected, height: layout.listHeight, width: columns })
737
+ : null,
738
+ tab === "hooks" && rows.length > 0 ? React.createElement(HookRows, { rows, selected, height: layout.listHeight, width: columns }) : null,
739
+ tab === "enrich" && rows.length > 0 ? React.createElement(EnrichRows, { rows, selected, height: layout.listHeight, width: columns }) : null,
740
+ tab === "capabilities" && rows.length > 0
741
+ ? React.createElement(CapabilityRows, { rows, selected, height: layout.listHeight, width: columns })
742
+ : null,
743
+ tab === "overview" || rows.length === 0 ? null : React.createElement(Detail, { row: rows[selected], tab, width: columns, height: layout.detailHeight }),
744
+ message ? React.createElement(Text, { color: messageColor(message) }, message) : null,
745
+ React.createElement(
746
+ Box,
747
+ { marginTop: 1 },
748
+ React.createElement(Text, { color: "gray", wrap: "truncate-end" }, `${TAB_COPY[tab].action} h help r refresh q quit`),
749
+ ),
750
+ );
751
+ }
752
+
753
+ const cliArgs = new Set(process.argv.slice(2));
754
+
755
+ if (cliArgs.has("--help") || cliArgs.has("-h")) {
756
+ printHelp();
757
+ } else if (!process.stdin.isTTY || !process.stdout.isTTY || cliArgs.has("--summary")) {
758
+ const inventory = await collectSurfaceInventory();
759
+ const attention = [
760
+ ...(inventory.summary.connectors.missing ? [`connectors missing=${inventory.summary.connectors.missing}`] : []),
761
+ ...(inventory.summary.connectors["mcp-only"] ? [`connectors mcp-only=${inventory.summary.connectors["mcp-only"]}`] : []),
762
+ ...(inventory.summary.hooks.missing ? [`hooks missing=${inventory.summary.hooks.missing}`] : []),
763
+ ...(inventory.summary.enrichments["bare-name"] ? [`enrich bare-name=${inventory.summary.enrichments["bare-name"]}`] : []),
764
+ ...(inventory.summary.surfaceAttention?.length ? [`surface gaps=${inventory.summary.surfaceAttention.join(",")}`] : []),
765
+ ];
766
+ console.log(
767
+ [
768
+ `portawhip surface ${inventory.generatedAt}`,
769
+ `sync backends ${Object.keys(CONFIG_SYNC_BACKENDS).length}: ${Object.keys(CONFIG_SYNC_BACKENDS).join(",")}`,
770
+ `connectors ${inventory.connectors.length}: ${JSON.stringify(inventory.summary.connectors)}`,
771
+ `hooks ${inventory.hooks.length}: ${JSON.stringify(inventory.summary.hooks)}`,
772
+ `enrich ${inventory.enrichments.length}: ${JSON.stringify(inventory.summary.enrichments)}`,
773
+ `capabilities ${inventory.capabilities.length}: ${JSON.stringify(inventory.summary.capabilities)}`,
774
+ `attention ${attention.length ? attention.join("; ") : "none"}`,
775
+ "run with --help for keys and safety notes",
776
+ ].join("\n"),
777
+ );
778
+ } else {
779
+ enterFullscreen();
780
+ const instance = render(React.createElement(App));
781
+ instance.waitUntilExit().finally(exitFullscreen);
782
+ }