@buildifyx/desktop-agent 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 (50) hide show
  1. package/README.md +337 -0
  2. package/package.json +32 -0
  3. package/src/audit/logger.js +30 -0
  4. package/src/cli/commands/cloud.js +89 -0
  5. package/src/cli/commands/doctor.js +18 -0
  6. package/src/cli/commands/login.js +59 -0
  7. package/src/cli/commands/logout.js +20 -0
  8. package/src/cli/commands/remote.js +64 -0
  9. package/src/cli/commands/status.js +32 -0
  10. package/src/cli/commands/update.js +31 -0
  11. package/src/cli/help.js +52 -0
  12. package/src/cli/main.js +55 -0
  13. package/src/cli/options.js +34 -0
  14. package/src/cli.js +20 -0
  15. package/src/core/dispatcher.js +49 -0
  16. package/src/core/errors.js +37 -0
  17. package/src/core/permissions.js +53 -0
  18. package/src/core/runtime.js +45 -0
  19. package/src/events/bus.js +39 -0
  20. package/src/permissions/approvals.js +47 -0
  21. package/src/permissions/controller.js +92 -0
  22. package/src/permissions/evaluator.js +93 -0
  23. package/src/permissions/manager.js +57 -0
  24. package/src/permissions/policy.js +28 -0
  25. package/src/permissions/store.js +27 -0
  26. package/src/security/path.js +61 -0
  27. package/src/security/scope.js +45 -0
  28. package/src/server.js +1 -0
  29. package/src/services/commands.js +107 -0
  30. package/src/services/files.js +104 -0
  31. package/src/services/index.js +11 -0
  32. package/src/services/system.js +17 -0
  33. package/src/transport/cloud.js +206 -0
  34. package/src/transport/mcp/manifest-store.js +32 -0
  35. package/src/transport/mcp/response.js +25 -0
  36. package/src/transport/mcp/server.js +114 -0
  37. package/src/transport/mcp/tools/commands.js +33 -0
  38. package/src/transport/mcp/tools/files.js +55 -0
  39. package/src/transport/mcp/tools/index.js +1 -0
  40. package/src/transport/mcp/tools/registry.js +92 -0
  41. package/src/transport/mcp/tools/system.js +16 -0
  42. package/src/tui/app.js +392 -0
  43. package/src/tui/commands.js +71 -0
  44. package/src/tui/index.js +37 -0
  45. package/src/tui/layout.js +48 -0
  46. package/src/tui/model.js +62 -0
  47. package/src/tui/profiles.js +32 -0
  48. package/src/utils/credentials.js +35 -0
  49. package/src/utils/text.js +180 -0
  50. package/src/version.js +111 -0
package/src/tui/app.js ADDED
@@ -0,0 +1,392 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Box, Text, useApp, useInput } from 'ink';
3
+ import { detectProfile, PROFILES } from './profiles.js';
4
+ import { groupActivities, shortTime, statusColor, statusGlyph } from './model.js';
5
+ import { useTuiLayout } from './layout.js';
6
+
7
+ const h = React.createElement;
8
+ const ACTIONS = ['allow', 'ask', 'deny'];
9
+ const CATEGORIES = ['read', 'write', 'command', 'dangerous', 'outsideRoot'];
10
+
11
+ function cycleAction(current, direction) {
12
+ const index = ACTIONS.indexOf(current);
13
+ return ACTIONS[(index + direction + ACTIONS.length) % ACTIONS.length];
14
+ }
15
+
16
+ function title(text) {
17
+ return h(Text, { bold: true }, text);
18
+ }
19
+
20
+ function Header({ version, root, mode, profile, pendingCount, layout, toolManifest, toolsChanged }) {
21
+ const toolLabel = toolManifest ? `Tools ${toolManifest.count} · ${toolManifest.shortHash}` : 'Tools ?';
22
+ const toolState = toolsChanged ? ' · TOOLS CHANGED' : '';
23
+
24
+ if (layout.narrow) {
25
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
26
+ h(Box, { justifyContent: 'space-between' },
27
+ h(Text, { bold: true }, `BuildifyX Desktop Agent v${version}`),
28
+ h(Text, { bold: true }, pendingCount ? `● WAITING ${pendingCount}` : '● ONLINE')
29
+ ),
30
+ h(Text, { wrap: 'truncate-end' }, `Root ${root}`),
31
+ h(Text, null, `${mode} · ${PROFILES[profile].label} · ${toolLabel}${toolState}`)
32
+ );
33
+ }
34
+
35
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
36
+ h(Box, { justifyContent: 'space-between' },
37
+ h(Text, { bold: true }, `BuildifyX Desktop Agent v${version}`),
38
+ h(Text, { bold: true }, pendingCount ? `MCP ● WAITING (${pendingCount})` : 'MCP ● ONLINE')
39
+ ),
40
+ h(Box, { justifyContent: 'space-between' },
41
+ h(Text, { wrap: 'truncate-end' }, `Root ${root}`),
42
+ h(Text, null, `Mode ${mode} Permissions ${PROFILES[profile].label}`)
43
+ ),
44
+ h(Text, { color: toolsChanged ? 'yellow' : undefined }, `${toolLabel}${toolState}`)
45
+ );
46
+ }
47
+
48
+ function ActivityPanel({ activities, selected, layout }) {
49
+ const windowSize = layout.activityWindow;
50
+ const half = Math.floor(windowSize / 2);
51
+ const start = Math.max(0, Math.min(Math.max(0, activities.length - windowSize), selected - half));
52
+ const visible = activities.slice(start, start + windowSize);
53
+
54
+ return h(Box, { width: layout.activityWidth, borderStyle: 'classic', paddingX: 1, flexDirection: 'column', height: layout.panelHeight },
55
+ title(`ACTIVITY ${activities.length}`),
56
+ visible.length === 0
57
+ ? h(Text, { dimColor: true }, 'Waiting for MCP tool calls...')
58
+ : visible.map((item, localIndex) => {
59
+ const actualIndex = start + localIndex;
60
+ const active = actualIndex === selected;
61
+ return h(Box, { key: item.requestId, flexDirection: 'column', marginBottom: layout.compact ? 0 : 1 },
62
+ h(Box, null,
63
+ h(Text, { bold: active }, active ? '› ' : ' '),
64
+ h(Text, { dimColor: !active }, `${shortTime(item.started.timestamp)} `),
65
+ h(Text, { color: statusColor(item.status), bold: active }, `${statusGlyph(item.status)} `),
66
+ h(Text, { bold: active }, item.started.tool)
67
+ ),
68
+ item.summary ? h(Text, { dimColor: !active, wrap: 'truncate-end' }, ` ${item.summary}`) : null
69
+ );
70
+ })
71
+ );
72
+ }
73
+
74
+ function RequestDetails({ item, compact }) {
75
+ if (!item) return h(Text, { dimColor: true }, 'No request selected.');
76
+ const resourceLimit = compact ? 3 : 7;
77
+ const resources = item.resources.slice(-resourceLimit);
78
+
79
+ return h(Box, { flexDirection: 'column' },
80
+ h(Text, null, `Tool: ${item.started.tool}`),
81
+ item.summary ? h(Text, { wrap: 'truncate-end' }, `Request: ${item.summary}`) : null,
82
+ h(Text, null, `Status: ${item.status.toUpperCase()}`),
83
+ item.permission ? h(Text, null, `Permission: ${item.permission.category} → ${item.permission.decision}`) : null,
84
+ item.process ? h(Text, { wrap: 'truncate-end' }, `Command: ${item.process.command} ${(item.process.args ?? []).join(' ')}`) : null,
85
+ item.process ? h(Text, { wrap: 'truncate-end' }, `Cwd: ${item.process.cwd}`) : null,
86
+ ...resources.map((resource) => h(Text, { key: resource.id, wrap: 'truncate-end' }, `${String(resource.operation).toUpperCase().padEnd(11)} ${resource.path}`)),
87
+ item.resources.length > resources.length ? h(Text, { dimColor: true }, `… ${item.resources.length - resources.length} more resources`) : null,
88
+ item.completed ? h(Text, null, `Duration: ${item.completed.durationMs} ms`) : null,
89
+ item.failed ? h(Text, null, `Error: ${item.failed.error?.code ?? 'ERROR'}`) : null,
90
+ item.failed && !compact ? h(Text, { wrap: 'wrap' }, ` ${item.failed.error?.message ?? 'Request failed'}`) : null
91
+ );
92
+ }
93
+
94
+ function ApprovalPanel({ request, selected }) {
95
+ const choices = ['Allow once', 'Always allow this request', 'Deny'];
96
+ return h(Box, { flexDirection: 'column' },
97
+ h(Text, { bold: true }, 'APPROVAL REQUIRED'),
98
+ h(Text, null, ''),
99
+ h(Text, { wrap: 'wrap' }, request.description),
100
+ request.pathInfo ? h(Text, { wrap: 'truncate-end' }, `Path: ${request.pathInfo.resolved}`) : null,
101
+ h(Text, { dimColor: true, wrap: 'wrap' }, request.evaluation?.reason ?? request.category),
102
+ h(Text, null, ''),
103
+ ...choices.map((choice, index) => h(Text, { key: choice, bold: selected === index }, `${selected === index ? '›' : ' '} ${choice}`)),
104
+ h(Text, null, ''),
105
+ h(Text, { dimColor: true }, '↑↓ Select Enter Confirm Q Back/Deny')
106
+ );
107
+ }
108
+
109
+ function StatusPanel({ selectedItem, request, approvalSelected, layout }) {
110
+ return h(Box, { width: layout.statusWidth, borderStyle: 'classic', paddingX: 1, flexDirection: 'column', height: layout.panelHeight },
111
+ request
112
+ ? h(ApprovalPanel, { request, selected: approvalSelected })
113
+ : h(React.Fragment, null, title('STATUS'), h(RequestDetails, { item: selectedItem, compact: layout.compact }))
114
+ );
115
+ }
116
+
117
+ function PermissionsSummary({ policy, profile, compact, toolManifest, toolsChanged }) {
118
+ const category = policy.categories;
119
+ const toolText = toolManifest ? `Tools ${toolManifest.count} · ${toolManifest.shortHash}${toolsChanged ? ' · CHANGED' : ''}` : 'Tools unavailable';
120
+ const lines = compact
121
+ ? [
122
+ `Profile ${PROFILES[profile].label} · Read ${String(category.read).toUpperCase()} · Write ${String(category.write).toUpperCase()} · Outside ${String(category.outsideRoot).toUpperCase()}`,
123
+ `Command ${String(category.command).toUpperCase()} · Dangerous ${String(category.dangerous).toUpperCase()} · ${toolText}`
124
+ ]
125
+ : [
126
+ `Profile ${PROFILES[profile].label}`,
127
+ `Files Read ${String(category.read).toUpperCase()} Write ${String(category.write).toUpperCase()} Outside ${String(category.outsideRoot).toUpperCase()}`,
128
+ `Commands Normal ${String(category.command).toUpperCase()} Dangerous ${String(category.dangerous).toUpperCase()} Custom ${policy.commandRules.length}`,
129
+ `Roots primary + ${policy.additionalRoots.length} additional ${toolText}`
130
+ ];
131
+
132
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column' },
133
+ title('PERMISSIONS / MCP'),
134
+ ...lines.map((line) => h(Text, { key: line, wrap: 'truncate-end', color: toolsChanged && line.includes('Tools') ? 'yellow' : undefined }, line))
135
+ );
136
+ }
137
+
138
+ function Controls({ screen, inputMode, compact }) {
139
+ let lines;
140
+ if (inputMode) lines = ['Enter: Save Q on empty input: Back'];
141
+ else if (screen === 'permissions') lines = ['↑↓ Select ←→ Change A Auto O Read-only F Full Q Back'];
142
+ else if (screen === 'roots') lines = ['↑↓ Select A Add root D Delete root Q Back'];
143
+ else if (screen === 'commands') lines = ['↑↓ Select ←→ Change A Add rule D Delete rule Q Back'];
144
+ else if (screen === 'tools') lines = ['Q: Back Ctrl+C: Quit'];
145
+ else if (screen === 'help') lines = ['Q: Back Ctrl+C: Quit'];
146
+ else lines = compact
147
+ ? ['↑↓ Activity P Permissions R Roots C Commands T Tools ?: Help Ctrl+C Quit']
148
+ : ['↑↓ Activity P: Permissions R: Roots C: Commands T: MCP Tools', 'L: Audit info ?: Help Q: Back Ctrl+C: Quit'];
149
+
150
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column' },
151
+ title('CONTROLS'),
152
+ ...lines.map((line) => h(Text, { key: line, wrap: 'truncate-end' }, line))
153
+ );
154
+ }
155
+
156
+ function PermissionsScreen({ policy, selected }) {
157
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column', flexGrow: 1 },
158
+ title('PERMISSIONS'),
159
+ h(Text, { dimColor: true }, 'Change how ChatGPT can operate this machine.'),
160
+ h(Text, null, ''),
161
+ ...CATEGORIES.map((category, index) => h(Box, { key: category },
162
+ h(Text, { bold: index === selected }, `${index === selected ? '›' : ' '} ${category.padEnd(16)}`),
163
+ h(Text, { bold: index === selected }, `[ ${String(policy.categories[category]).toUpperCase().padEnd(5)} ]`)
164
+ )),
165
+ h(Text, null, ''),
166
+ h(Text, null, `Additional roots: ${policy.additionalRoots.length}`),
167
+ h(Text, null, `Custom command rules: ${policy.commandRules.length}`)
168
+ );
169
+ }
170
+
171
+ function RootsScreen({ root, roots, selected, inputMode, buffer }) {
172
+ const all = [root, ...roots];
173
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column', flexGrow: 1 },
174
+ title('ALLOWED ROOTS'),
175
+ h(Text, { dimColor: true }, 'ChatGPT file tools may access these locations.'),
176
+ h(Text, null, ''),
177
+ ...all.map((value, index) => h(Text, { key: `${value}-${index}`, bold: index === selected, wrap: 'truncate-end' }, `${index === selected ? '›' : ' '} ${index === 0 ? '[PRIMARY] ' : ''}${value}`)),
178
+ inputMode === 'root' ? h(Text, null, `\nNew root: ${buffer}█`) : null
179
+ );
180
+ }
181
+
182
+ function CommandsScreen({ rules, selected, inputMode, buffer }) {
183
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column', flexGrow: 1 },
184
+ title('CUSTOM COMMAND RULES'),
185
+ h(Text, { dimColor: true }, 'Rules match executable + argument prefix. No shell strings.'),
186
+ h(Text, null, ''),
187
+ rules.length === 0 ? h(Text, { dimColor: true }, 'No custom command rules.') : null,
188
+ ...rules.map((rule, index) => h(Text, { key: `${rule.executable}-${index}`, bold: index === selected, wrap: 'truncate-end' },
189
+ `${index === selected ? '›' : ' '} ${(rule.executable + ' ' + (rule.argsPrefix ?? []).join(' ')).trim().padEnd(28)} [ ${String(rule.action).toUpperCase()} ]`
190
+ )),
191
+ inputMode === 'command' ? h(Text, null, `\nNew rule: ${buffer}█`) : null
192
+ );
193
+ }
194
+
195
+ function ToolsScreen({ manifest, manifestState, toolsUrl }) {
196
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column', flexGrow: 1 },
197
+ title('MCP TOOLS'),
198
+ manifestState?.changed
199
+ ? h(Text, { color: 'yellow', bold: true }, 'Tool definitions changed since the previous bdxa run. ChatGPT may need to refresh tool discovery.')
200
+ : h(Text, { color: 'green' }, 'Tool definitions match the previous bdxa run.'),
201
+ h(Text, null, ''),
202
+ h(Text, null, `Registered: ${manifest?.count ?? 0}`),
203
+ h(Text, null, `Schema hash: ${manifest?.hash ?? 'unknown'}`),
204
+ manifestState?.previousHash ? h(Text, { dimColor: true }, `Previous: ${manifestState.previousHash}`) : null,
205
+ h(Text, { wrap: 'truncate-end' }, `Manifest URL: ${toolsUrl ?? 'unavailable'}`),
206
+ h(Text, null, ''),
207
+ ...(manifest?.tools ?? []).map((tool) => h(Box, { key: tool.name, flexDirection: 'column', marginBottom: 1 },
208
+ h(Text, { bold: true }, `✓ ${tool.name}`),
209
+ h(Text, { dimColor: true }, ` ${tool.permission ?? 'unknown'} · ${tool.title}`)
210
+ )),
211
+ h(Text, { dimColor: true }, 'This shows what bdxa currently serves. It cannot directly inspect ChatGPT\'s cached tool snapshot.')
212
+ );
213
+ }
214
+
215
+ function HelpScreen() {
216
+ return h(Box, { borderStyle: 'classic', paddingX: 1, flexDirection: 'column', flexGrow: 1 },
217
+ title('HELP'),
218
+ h(Text, null, ' ↑↓ Select MCP activity'),
219
+ h(Text, null, ' P Edit permissions'),
220
+ h(Text, null, ' R Manage allowed roots'),
221
+ h(Text, null, ' C Manage custom command rules'),
222
+ h(Text, null, ' T Inspect MCP tools and schema hash'),
223
+ h(Text, null, ' L Show audit log path'),
224
+ h(Text, null, ' Q Back'),
225
+ h(Text, null, ' Ctrl+C Quit'),
226
+ h(Text, null, ''),
227
+ h(Text, null, 'APPROVALS'),
228
+ h(Text, null, ' ↑↓ Select decision'),
229
+ h(Text, null, ' Enter Confirm'),
230
+ h(Text, null, ' Q Back / deny request')
231
+ );
232
+ }
233
+
234
+ async function applyProfile(policyManager, profileKey) {
235
+ const profile = PROFILES[profileKey];
236
+ if (!profile?.categories) return;
237
+ for (const [category, action] of Object.entries(profile.categories)) await policyManager.setCategory(category, action);
238
+ }
239
+
240
+ export function App({ eventBus, approvalQueue, policyManager, version, root, mode, auditPath, toolManifest, toolManifestState, toolsUrl, onQuit }) {
241
+ const { exit } = useApp();
242
+ const layout = useTuiLayout();
243
+ const [events, setEvents] = useState(eventBus.getHistory());
244
+ const [pending, setPending] = useState(approvalQueue.getPending());
245
+ const [policy, setPolicy] = useState(policyManager.get());
246
+ const [screen, setScreen] = useState('dashboard');
247
+ const [activitySelected, setActivitySelected] = useState(0);
248
+ const [permissionSelected, setPermissionSelected] = useState(0);
249
+ const [rootSelected, setRootSelected] = useState(0);
250
+ const [commandSelected, setCommandSelected] = useState(0);
251
+ const [approvalSelected, setApprovalSelected] = useState(0);
252
+ const [inputMode, setInputMode] = useState(null);
253
+ const [buffer, setBuffer] = useState('');
254
+ const initialMessage = toolManifestState?.changed
255
+ ? `TOOLS CHANGED: ${toolManifest?.count ?? '?'} registered (${toolManifest?.shortHash ?? 'unknown'}). Press T to inspect; ChatGPT may need refresh.`
256
+ : 'Waiting for MCP requests...';
257
+ const [message, setMessage] = useState(initialMessage);
258
+
259
+ useEffect(() => eventBus.subscribe(() => setEvents(eventBus.getHistory())), [eventBus]);
260
+ useEffect(() => approvalQueue.subscribe((next) => { setPending(next); if (next.length) setApprovalSelected(0); }), [approvalQueue]);
261
+ useEffect(() => policyManager.subscribe((next) => setPolicy({ ...next, categories: { ...next.categories } })), [policyManager]);
262
+
263
+ const activities = useMemo(() => groupActivities(events), [events]);
264
+ const profile = detectProfile(policy);
265
+ const request = pending[0] ?? null;
266
+ const selectedIndex = activities.length ? Math.min(activitySelected, activities.length - 1) : -1;
267
+ const selectedItem = selectedIndex >= 0 ? activities[selectedIndex] : null;
268
+
269
+ useEffect(() => {
270
+ if (activities.length && activitySelected === 0) setActivitySelected(activities.length - 1);
271
+ }, [activities.length]);
272
+
273
+ async function quit() {
274
+ await onQuit();
275
+ exit();
276
+ }
277
+
278
+ useInput(async (input, key) => {
279
+ if (key.ctrl && input.toLowerCase() === 'c') { await quit(); return; }
280
+
281
+ if (request) {
282
+ if (input === 'q' || input === 'Q') {
283
+ approvalQueue.resolve(request.id, { action: 'deny', remember: null });
284
+ setMessage('Permission request denied.');
285
+ } else if (key.upArrow) setApprovalSelected((value) => Math.max(0, value - 1));
286
+ else if (key.downArrow) setApprovalSelected((value) => Math.min(2, value + 1));
287
+ else if (key.return) {
288
+ if (approvalSelected === 0) approvalQueue.resolve(request.id, { action: 'allow', remember: null });
289
+ else if (approvalSelected === 1) approvalQueue.resolve(request.id, { action: 'allow', remember: request.toolName === 'run_command' ? 'command' : request.pathInfo?.scope === 'outside' ? 'root' : null });
290
+ else approvalQueue.resolve(request.id, { action: 'deny', remember: null });
291
+ }
292
+ return;
293
+ }
294
+
295
+ if (inputMode) {
296
+ if ((input === 'q' || input === 'Q') && buffer.length === 0) { setInputMode(null); setBuffer(''); return; }
297
+ if (key.return) {
298
+ const value = buffer.trim();
299
+ if (value && inputMode === 'root') { await policyManager.addRoot(value); setMessage(`Added root: ${value}`); }
300
+ if (value && inputMode === 'command') {
301
+ const [executable, ...argsPrefix] = value.split(/\s+/);
302
+ await policyManager.addCommandRule({ executable, argsPrefix, action: 'ask' });
303
+ setMessage(`Added command rule: ${value} → ASK`);
304
+ }
305
+ setInputMode(null); setBuffer(''); return;
306
+ }
307
+ if (key.backspace || key.delete) { setBuffer((value) => value.slice(0, -1)); return; }
308
+ if (input && !key.ctrl && !key.meta) setBuffer((value) => value + input);
309
+ return;
310
+ }
311
+
312
+ if (input === 'q' || input === 'Q') {
313
+ if (screen !== 'dashboard') { setScreen('dashboard'); setMessage('Back to dashboard.'); }
314
+ else setMessage('Ctrl+C quits the agent.');
315
+ return;
316
+ }
317
+
318
+ if (screen === 'dashboard') {
319
+ if (key.upArrow) setActivitySelected((value) => Math.max(0, value - 1));
320
+ else if (key.downArrow) setActivitySelected((value) => Math.min(Math.max(activities.length - 1, 0), value + 1));
321
+ else if (input === 'p' || input === 'P') setScreen('permissions');
322
+ else if (input === 'r' || input === 'R') setScreen('roots');
323
+ else if (input === 'c' || input === 'C') setScreen('commands');
324
+ else if (input === 't' || input === 'T') setScreen('tools');
325
+ else if (input === '?' || input === 'h' || input === 'H') setScreen('help');
326
+ else if (input === 'l' || input === 'L') setMessage(`Audit log: ${auditPath ?? '~/.buildifyx/audit.log'}`);
327
+ return;
328
+ }
329
+
330
+ if (screen === 'permissions') {
331
+ if (key.upArrow) setPermissionSelected((value) => Math.max(0, value - 1));
332
+ else if (key.downArrow) setPermissionSelected((value) => Math.min(CATEGORIES.length - 1, value + 1));
333
+ else if (key.leftArrow) {
334
+ const category = CATEGORIES[permissionSelected];
335
+ await policyManager.setCategory(category, cycleAction(policy.categories[category], -1));
336
+ } else if (key.rightArrow || input === ' ') {
337
+ const category = CATEGORIES[permissionSelected];
338
+ await policyManager.setCategory(category, cycleAction(policy.categories[category], 1));
339
+ } else if (input === 'a' || input === 'A') await applyProfile(policyManager, 'auto');
340
+ else if (input === 'o' || input === 'O') await applyProfile(policyManager, 'readOnly');
341
+ else if (input === 'f' || input === 'F') await applyProfile(policyManager, 'fullAccess');
342
+ return;
343
+ }
344
+
345
+ if (screen === 'roots') {
346
+ const total = policy.additionalRoots.length + 1;
347
+ if (key.upArrow) setRootSelected((value) => Math.max(0, value - 1));
348
+ else if (key.downArrow) setRootSelected((value) => Math.min(total - 1, value + 1));
349
+ else if (input === 'a' || input === 'A') { setInputMode('root'); setBuffer(''); }
350
+ else if ((input === 'd' || input === 'D') && rootSelected > 0) {
351
+ await policyManager.removeRoot(rootSelected - 1);
352
+ setRootSelected((value) => Math.max(0, value - 1));
353
+ }
354
+ return;
355
+ }
356
+
357
+ if (screen === 'commands') {
358
+ const total = policy.commandRules.length;
359
+ if (key.upArrow) setCommandSelected((value) => Math.max(0, value - 1));
360
+ else if (key.downArrow) setCommandSelected((value) => Math.min(Math.max(total - 1, 0), value + 1));
361
+ else if ((key.leftArrow || key.rightArrow || input === ' ') && total) {
362
+ const rule = policy.commandRules[commandSelected];
363
+ await policyManager.addCommandRule({ ...rule, action: cycleAction(rule.action, key.leftArrow ? -1 : 1) });
364
+ } else if (input === 'a' || input === 'A') { setInputMode('command'); setBuffer(''); }
365
+ else if ((input === 'd' || input === 'D') && total) {
366
+ await policyManager.removeCommandRule(commandSelected);
367
+ setCommandSelected((value) => Math.max(0, value - 1));
368
+ }
369
+ }
370
+ });
371
+
372
+ let main;
373
+ if (screen === 'permissions') main = h(PermissionsScreen, { policy, selected: permissionSelected });
374
+ else if (screen === 'roots') main = h(RootsScreen, { root, roots: policy.additionalRoots, selected: rootSelected, inputMode, buffer });
375
+ else if (screen === 'commands') main = h(CommandsScreen, { rules: policy.commandRules, selected: commandSelected, inputMode, buffer });
376
+ else if (screen === 'tools') main = h(ToolsScreen, { manifest: toolManifest, manifestState: toolManifestState, toolsUrl });
377
+ else if (screen === 'help') main = h(HelpScreen);
378
+ else main = h(Box, { flexDirection: layout.narrow ? 'column' : 'row' },
379
+ h(ActivityPanel, { activities, selected: selectedIndex, layout }),
380
+ h(StatusPanel, { selectedItem, request, approvalSelected, layout })
381
+ );
382
+
383
+ return h(Box, { flexDirection: 'column', width: '100%', height: Math.max(16, layout.rows - 1) },
384
+ h(Header, { version, root, mode, profile, pendingCount: pending.length, layout, toolManifest, toolsChanged: toolManifestState?.changed }),
385
+ main,
386
+ screen === 'dashboard' && layout.showPermissionsSummary
387
+ ? h(PermissionsSummary, { policy, profile, compact: layout.compact, toolManifest, toolsChanged: toolManifestState?.changed })
388
+ : null,
389
+ h(Controls, { screen, inputMode, compact: layout.compact }),
390
+ h(Text, { color: toolManifestState?.changed ? 'yellow' : 'cyan', wrap: 'truncate-end' }, message)
391
+ );
392
+ }
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+ import { PROFILES } from './profiles.js';
3
+
4
+ function splitWords(value) {
5
+ return value.trim().split(/\s+/).filter(Boolean);
6
+ }
7
+
8
+ export async function executeSlashCommand(commandLine, { policyManager, setOverlay, setMessage, onQuit }) {
9
+ const [command, ...args] = splitWords(commandLine);
10
+ const name = command?.replace(/^\//, '').toLowerCase();
11
+
12
+ if (!name) return;
13
+
14
+ if (name === 'help') {
15
+ setOverlay({ type: 'help' });
16
+ return;
17
+ }
18
+
19
+ if (name === 'permissions' || name === 'permission') {
20
+ setOverlay({ type: 'permissions', selected: 0 });
21
+ return;
22
+ }
23
+
24
+ if (name === 'status') {
25
+ setOverlay({ type: 'status' });
26
+ return;
27
+ }
28
+
29
+ if (name === 'allow' || name === 'ask' || name === 'deny') {
30
+ if (!args.length) {
31
+ setMessage(`Usage: /${name} <command> [args prefix]`);
32
+ return;
33
+ }
34
+ const [executable, ...argsPrefix] = args;
35
+ await policyManager.addCommandRule({ executable, argsPrefix, action: name });
36
+ setMessage(`Saved rule: ${executable} ${argsPrefix.join(' ')} → ${name}`.trim());
37
+ return;
38
+ }
39
+
40
+ if (name === 'root') {
41
+ if (!args.length) {
42
+ setOverlay({ type: 'roots' });
43
+ return;
44
+ }
45
+ const value = path.resolve(args.join(' '));
46
+ await policyManager.addRoot(value);
47
+ setMessage(`Added allowed root: ${value}`);
48
+ return;
49
+ }
50
+
51
+ if (name === 'auto' || name === 'readonly' || name === 'fullaccess') {
52
+ const profileName = name === 'auto' ? 'auto' : name === 'readonly' ? 'readOnly' : 'fullAccess';
53
+ for (const [category, action] of Object.entries(PROFILES[profileName].categories)) {
54
+ await policyManager.setCategory(category, action);
55
+ }
56
+ setMessage(`Permissions: ${PROFILES[profileName].label}`);
57
+ return;
58
+ }
59
+
60
+ if (name === 'clear') {
61
+ setMessage('__CLEAR__');
62
+ return;
63
+ }
64
+
65
+ if (name === 'quit' || name === 'exit') {
66
+ await onQuit();
67
+ return;
68
+ }
69
+
70
+ setMessage(`Unknown command: /${name}. Type /help.`);
71
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { App } from './app.js';
4
+
5
+ const ENTER_ALT_SCREEN = '\u001B[?1049h\u001B[H';
6
+ const LEAVE_ALT_SCREEN = '\u001B[?1049l';
7
+
8
+ export function startTui(options) {
9
+ process.stdout.write(ENTER_ALT_SCREEN);
10
+
11
+ const instance = render(React.createElement(App, options), {
12
+ exitOnCtrlC: false,
13
+ patchConsole: false
14
+ });
15
+
16
+ let restored = false;
17
+ function restore() {
18
+ if (restored) return;
19
+ restored = true;
20
+ process.stdout.write(LEAVE_ALT_SCREEN);
21
+ }
22
+
23
+ return {
24
+ ...instance,
25
+ async waitUntilExit() {
26
+ try {
27
+ await instance.waitUntilExit();
28
+ } finally {
29
+ restore();
30
+ }
31
+ },
32
+ unmount() {
33
+ instance.unmount();
34
+ restore();
35
+ }
36
+ };
37
+ }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ function terminalSize() {
4
+ return {
5
+ columns: Math.max(40, process.stdout.columns ?? 100),
6
+ rows: Math.max(16, process.stdout.rows ?? 32)
7
+ };
8
+ }
9
+
10
+ export function useTerminalSize() {
11
+ const [size, setSize] = useState(terminalSize);
12
+
13
+ useEffect(() => {
14
+ const handleResize = () => setSize(terminalSize());
15
+ process.stdout.on('resize', handleResize);
16
+ return () => process.stdout.off('resize', handleResize);
17
+ }, []);
18
+
19
+ return size;
20
+ }
21
+
22
+ export function useTuiLayout() {
23
+ const { columns, rows } = useTerminalSize();
24
+
25
+ return useMemo(() => {
26
+ const narrow = columns < 84;
27
+ const compact = columns < 110 || rows < 30;
28
+ const showPermissionsSummary = rows >= 22;
29
+ const reservedRows = showPermissionsSummary ? 13 : 8;
30
+ const availableRows = Math.max(8, rows - reservedRows);
31
+ const panelHeight = narrow
32
+ ? Math.max(7, Math.floor(availableRows / 2))
33
+ : Math.max(10, availableRows);
34
+ const activityWindow = Math.max(2, Math.floor((panelHeight - 2) / (compact ? 2 : 3)));
35
+
36
+ return {
37
+ columns,
38
+ rows,
39
+ narrow,
40
+ compact,
41
+ showPermissionsSummary,
42
+ panelHeight,
43
+ activityWindow,
44
+ activityWidth: narrow ? '100%' : compact ? '55%' : '60%',
45
+ statusWidth: narrow ? '100%' : compact ? '45%' : '40%'
46
+ };
47
+ }, [columns, rows]);
48
+ }
@@ -0,0 +1,62 @@
1
+ export function shortTime(timestamp) {
2
+ return new Date(timestamp).toLocaleTimeString([], {
3
+ hour: '2-digit',
4
+ minute: '2-digit',
5
+ second: '2-digit',
6
+ hour12: false
7
+ });
8
+ }
9
+
10
+ export function summarizeInput(input = {}) {
11
+ if (input.command) return `${input.command} ${(input.args ?? []).join(' ')}`.trim();
12
+ if (input.path) return input.path;
13
+ return '';
14
+ }
15
+
16
+ export function groupActivities(events) {
17
+ const groups = new Map();
18
+
19
+ for (const event of events) {
20
+ if (!event.requestId || event.type.startsWith('permission.')) continue;
21
+ if (!groups.has(event.requestId)) groups.set(event.requestId, []);
22
+ groups.get(event.requestId).push(event);
23
+ }
24
+
25
+ return [...groups.entries()]
26
+ .map(([requestId, items]) => {
27
+ const started = items.find((event) => event.type === 'tool.started');
28
+ if (!started) return null;
29
+ const completed = items.find((event) => event.type === 'tool.completed');
30
+ const failed = items.find((event) => event.type === 'tool.failed');
31
+ const permission = items.find((event) => event.type === 'permission.evaluated');
32
+ const process = items.find((event) => event.type === 'process.started');
33
+ const resources = items.filter((event) => event.type === 'resource.accessed');
34
+ const last = items.at(-1) ?? started;
35
+ return {
36
+ requestId,
37
+ started,
38
+ completed,
39
+ failed,
40
+ permission,
41
+ process,
42
+ resources,
43
+ last,
44
+ summary: summarizeInput(started.input),
45
+ status: failed ? 'failed' : completed ? 'done' : 'running'
46
+ };
47
+ })
48
+ .filter(Boolean)
49
+ .sort((left, right) => new Date(left.started.timestamp) - new Date(right.started.timestamp));
50
+ }
51
+
52
+ export function statusGlyph(status) {
53
+ if (status === 'failed') return '✗';
54
+ if (status === 'done') return '✓';
55
+ return '●';
56
+ }
57
+
58
+ export function statusColor(status) {
59
+ if (status === 'failed') return 'red';
60
+ if (status === 'done') return 'green';
61
+ return 'yellow';
62
+ }
@@ -0,0 +1,32 @@
1
+ export const PROFILE_ORDER = ['auto', 'readOnly', 'fullAccess', 'custom'];
2
+
3
+ export const PROFILES = Object.freeze({
4
+ auto: {
5
+ label: 'Auto',
6
+ description: 'Read, edit and normal commands automatically; ask for dangerous or outside-root access.',
7
+ categories: { read: 'allow', write: 'allow', command: 'allow', dangerous: 'ask', outsideRoot: 'ask' }
8
+ },
9
+ readOnly: {
10
+ label: 'Read only',
11
+ description: 'Read automatically; ask before writes or commands.',
12
+ categories: { read: 'allow', write: 'ask', command: 'ask', dangerous: 'ask', outsideRoot: 'ask' }
13
+ },
14
+ fullAccess: {
15
+ label: 'Full access',
16
+ description: 'Allow all configured tools, dangerous commands and outside-root access without asking.',
17
+ categories: { read: 'allow', write: 'allow', command: 'allow', dangerous: 'allow', outsideRoot: 'allow' }
18
+ },
19
+ custom: {
20
+ label: 'Custom',
21
+ description: 'Tune each permission category individually.',
22
+ categories: null
23
+ }
24
+ });
25
+
26
+ export function detectProfile(policy) {
27
+ for (const key of ['auto', 'readOnly', 'fullAccess']) {
28
+ const expected = PROFILES[key].categories;
29
+ if (Object.entries(expected).every(([name, value]) => policy.categories[name] === value)) return key;
30
+ }
31
+ return 'custom';
32
+ }