@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.
- package/README.md +337 -0
- package/package.json +32 -0
- package/src/audit/logger.js +30 -0
- package/src/cli/commands/cloud.js +89 -0
- package/src/cli/commands/doctor.js +18 -0
- package/src/cli/commands/login.js +59 -0
- package/src/cli/commands/logout.js +20 -0
- package/src/cli/commands/remote.js +64 -0
- package/src/cli/commands/status.js +32 -0
- package/src/cli/commands/update.js +31 -0
- package/src/cli/help.js +52 -0
- package/src/cli/main.js +55 -0
- package/src/cli/options.js +34 -0
- package/src/cli.js +20 -0
- package/src/core/dispatcher.js +49 -0
- package/src/core/errors.js +37 -0
- package/src/core/permissions.js +53 -0
- package/src/core/runtime.js +45 -0
- package/src/events/bus.js +39 -0
- package/src/permissions/approvals.js +47 -0
- package/src/permissions/controller.js +92 -0
- package/src/permissions/evaluator.js +93 -0
- package/src/permissions/manager.js +57 -0
- package/src/permissions/policy.js +28 -0
- package/src/permissions/store.js +27 -0
- package/src/security/path.js +61 -0
- package/src/security/scope.js +45 -0
- package/src/server.js +1 -0
- package/src/services/commands.js +107 -0
- package/src/services/files.js +104 -0
- package/src/services/index.js +11 -0
- package/src/services/system.js +17 -0
- package/src/transport/cloud.js +206 -0
- package/src/transport/mcp/manifest-store.js +32 -0
- package/src/transport/mcp/response.js +25 -0
- package/src/transport/mcp/server.js +114 -0
- package/src/transport/mcp/tools/commands.js +33 -0
- package/src/transport/mcp/tools/files.js +55 -0
- package/src/transport/mcp/tools/index.js +1 -0
- package/src/transport/mcp/tools/registry.js +92 -0
- package/src/transport/mcp/tools/system.js +16 -0
- package/src/tui/app.js +392 -0
- package/src/tui/commands.js +71 -0
- package/src/tui/index.js +37 -0
- package/src/tui/layout.js +48 -0
- package/src/tui/model.js +62 -0
- package/src/tui/profiles.js +32 -0
- package/src/utils/credentials.js +35 -0
- package/src/utils/text.js +180 -0
- 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
|
+
}
|
package/src/tui/index.js
ADDED
|
@@ -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
|
+
}
|
package/src/tui/model.js
ADDED
|
@@ -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
|
+
}
|