windows-development-sandbox 1.1.0 → 1.4.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 +3 -2
- package/config.example.json +38 -5
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.js +3 -1
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/agent-commands.js +1 -1
- package/dist/cli/commands/context.js +2 -1
- package/dist/cli/commands/data/clean.js +6 -1
- package/dist/cli/commands/data/collect.js +3 -3
- package/dist/cli/commands/index.d.ts +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/launch.js +36 -3
- package/dist/cli/commands/maintenance.d.ts +2 -0
- package/dist/cli/commands/maintenance.js +35 -2
- package/dist/cli/commands/sessions.js +3 -3
- package/dist/cli/commands/user-config.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/manifest.js +5 -0
- package/dist/cli/menu/apps.d.ts +21 -0
- package/dist/cli/menu/apps.js +122 -0
- package/dist/cli/menu/command-form.d.ts +28 -0
- package/dist/cli/menu/command-form.js +219 -0
- package/dist/cli/menu/index.d.ts +26 -0
- package/dist/cli/menu/index.js +180 -0
- package/dist/cli/menu/settings/categories.d.ts +9 -0
- package/dist/cli/menu/settings/categories.js +28 -0
- package/dist/cli/menu/settings/config-file.d.ts +40 -0
- package/dist/cli/menu/settings/config-file.js +71 -0
- package/dist/cli/menu/settings/editors.d.ts +30 -0
- package/dist/cli/menu/settings/editors.js +305 -0
- package/dist/cli/menu/settings/index.d.ts +32 -0
- package/dist/cli/menu/settings/index.js +136 -0
- package/dist/cli/menu/settings/schema-node.d.ts +62 -0
- package/dist/cli/menu/settings/schema-node.js +104 -0
- package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
- package/dist/cli/menu/several.js +102 -0
- package/dist/cli/tui/app.d.ts +39 -0
- package/dist/cli/tui/app.js +52 -0
- package/dist/cli/tui/dialogs.d.ts +57 -0
- package/dist/cli/tui/dialogs.js +162 -0
- package/dist/cli/tui/frame.d.ts +18 -0
- package/dist/cli/tui/frame.js +27 -0
- package/dist/cli/tui/index.d.ts +7 -0
- package/dist/cli/tui/index.js +8 -0
- package/dist/cli/tui/list-screen.d.ts +101 -0
- package/dist/cli/tui/list-screen.js +299 -0
- package/dist/cli/tui/terminal.d.ts +37 -0
- package/dist/cli/tui/terminal.js +89 -0
- package/dist/cli/tui/text.d.ts +20 -0
- package/dist/cli/tui/text.js +105 -0
- package/dist/cli/tui/theme.d.ts +45 -0
- package/dist/cli/tui/theme.js +30 -0
- package/dist/core/config.js +9 -6
- package/dist/core/local-config.d.ts +15 -0
- package/dist/core/local-config.js +63 -4
- package/dist/core/schema/apps.d.ts +40 -0
- package/dist/core/schema/apps.js +27 -0
- package/dist/core/schema/config.d.ts +66 -1
- package/dist/core/schema/config.js +18 -6
- package/dist/core/schema/profile.d.ts +5 -0
- package/dist/core/schema/profile.js +4 -2
- package/dist/core/schema/sandbox.d.ts +28 -0
- package/dist/core/schema/sandbox.js +28 -0
- package/dist/core/schema/toolchains.d.ts +3 -0
- package/dist/core/schema/toolchains.js +6 -1
- package/dist/core/types.d.ts +6 -0
- package/dist/core/util.d.ts +2 -0
- package/dist/core/util.js +2 -0
- package/dist/core/workspace.js +22 -5
- package/dist/guest/apps/index.d.ts +10 -0
- package/dist/guest/apps/index.js +206 -0
- package/dist/guest/apps/resolvers.d.ts +8 -0
- package/dist/guest/apps/resolvers.js +90 -0
- package/dist/guest/audit-parse.d.ts +7 -0
- package/dist/guest/audit-parse.js +24 -0
- package/dist/guest/audit.d.ts +4 -0
- package/dist/guest/audit.js +47 -0
- package/dist/guest/daemon/relays.d.ts +1 -0
- package/dist/guest/daemon/relays.js +18 -0
- package/dist/guest/daemon.js +4 -0
- package/dist/guest/env.d.ts +1 -0
- package/dist/guest/env.js +3 -1
- package/dist/guest/helpers.d.ts +1 -1
- package/dist/guest/main.js +15 -2
- package/dist/guest/netproxy.d.ts +1 -0
- package/dist/guest/netproxy.js +100 -0
- package/dist/guest/ptyhost.js +14 -4
- package/dist/guest/setup/claude.d.ts +0 -4
- package/dist/guest/setup/claude.js +48 -2
- package/dist/guest/setup/network.d.ts +9 -0
- package/dist/guest/setup/network.js +97 -4
- package/dist/guest/setup/user.js +4 -0
- package/dist/guest/toolchains/native/mesa.js +13 -3
- package/dist/guest/toolchains/plan.d.ts +3 -1
- package/dist/guest/toolchains/plan.js +53 -9
- package/dist/guest/toolchains/python.js +26 -2
- package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
- package/dist/guest/toolchains/vscode-profile.js +69 -0
- package/dist/guest/toolchains/vscode.d.ts +17 -0
- package/dist/guest/toolchains/vscode.js +87 -1
- package/dist/host/attach.js +2 -1
- package/dist/host/audit/collect.d.ts +34 -0
- package/dist/host/audit/collect.js +126 -0
- package/dist/host/audit/log.d.ts +46 -0
- package/dist/host/audit/log.js +143 -0
- package/dist/host/generate.d.ts +6 -0
- package/dist/host/generate.js +10 -0
- package/dist/host/launch/mappings.js +5 -1
- package/dist/host/launch/stage.js +1 -1
- package/dist/host/motw.d.ts +17 -0
- package/dist/host/motw.js +74 -0
- package/dist/host/platform.js +3 -0
- package/dist/host/port-forward.d.ts +8 -0
- package/dist/host/port-forward.js +37 -0
- package/dist/host/repo-guard.d.ts +10 -3
- package/dist/host/repo-guard.js +142 -14
- package/dist/host/stops.d.ts +67 -0
- package/dist/host/stops.js +126 -0
- package/dist/host/terminal.d.ts +8 -0
- package/dist/host/terminal.js +45 -1
- package/dist/host/workspace-view/index.d.ts +20 -0
- package/dist/host/workspace-view/index.js +53 -0
- package/dist/host/workspace-view/rules.d.ts +26 -0
- package/dist/host/workspace-view/rules.js +93 -0
- package/dist/host/workspace-view/view.d.ts +50 -0
- package/dist/host/workspace-view/view.js +281 -0
- package/dist/runtime/daemon.js +1319 -552
- package/dist/runtime/db.js +168 -13
- package/dist/runtime/main.js +2898 -608
- package/dist/runtime/netproxy.js +25936 -0
- package/dist/runtime/view.js +272 -23
- package/dist/selftest/audit.test.d.ts +2 -0
- package/dist/selftest/audit.test.js +57 -0
- package/dist/selftest/channel.test.js +7 -0
- package/dist/selftest/cli.test.js +47 -17
- package/dist/selftest/fixtures.d.ts +45 -0
- package/dist/selftest/fixtures.js +3 -1
- package/dist/selftest/generate.test.js +7 -2
- package/dist/selftest/index.js +5 -2
- package/dist/selftest/live-network.test.d.ts +2 -0
- package/dist/selftest/live-network.test.js +63 -0
- package/dist/selftest/local-config.test.js +16 -3
- package/dist/selftest/network.test.js +12 -0
- package/dist/selftest/projects.test.js +1 -1
- package/dist/selftest/repo-guard.test.js +59 -1
- package/dist/selftest/sync.test.js +50 -10
- package/dist/selftest/toolchains.test.js +30 -0
- package/dist/selftest/workspace-view.test.d.ts +2 -0
- package/dist/selftest/workspace-view.test.js +89 -0
- package/dist/shared/apps.d.ts +49 -0
- package/dist/shared/apps.js +114 -0
- package/dist/shared/netpolicy.d.ts +27 -0
- package/dist/shared/netpolicy.js +51 -0
- package/dist/shared/pty-client.d.ts +9 -4
- package/dist/shared/pty-client.js +139 -17
- package/dist/shared/rundir.d.ts +2 -0
- package/dist/shared/rundir.js +2 -0
- package/dist/shared/schema.d.ts +10 -0
- package/dist/shared/schema.js +2 -0
- package/dist/shared/system.d.ts +1 -0
- package/dist/shared/system.js +1 -0
- package/dist/sync/claude-mem.js +3 -2
- package/dist/sync/history.d.ts +6 -0
- package/dist/sync/history.js +26 -11
- package/dist/sync/watcher.js +78 -6
- package/package.json +2 -1
- package/sandbox/cargoshim.cs +56 -0
- package/sandbox/cpucap.cs +33 -0
- package/sandbox/cpucap.ps1 +4 -35
- package/sandbox/logon.cmd +6 -0
- package/sandbox/memlog.ps1 +34 -0
- package/sandbox/pagefile.cs +31 -0
- package/sandbox/pagefile.ps1 +21 -0
- package/sandbox/ptyhost.cs +105 -7
- package/schemas/config.schema.json +303 -2
- package/schemas/sandbox-config.schema.json +126 -12
- package/dist/cli/menu-multi.js +0 -82
- package/dist/cli/menu.d.ts +0 -14
- package/dist/cli/menu.js +0 -290
- package/dist/cli/settings/edit.d.ts +0 -13
- package/dist/cli/settings/edit.js +0 -19
- package/dist/cli/settings/index.d.ts +0 -8
- package/dist/cli/settings/index.js +0 -131
- package/dist/cli/settings/prompts.d.ts +0 -7
- package/dist/cli/settings/prompts.js +0 -48
- package/dist/cli/settings/schema.d.ts +0 -33
- package/dist/cli/settings/schema.js +0 -29
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The apps catalogue in the menu: every app by category, searchable, ticked = installed in every sandbox. VS Code and
|
|
2
|
+
// Windows Terminal are listed too (they have settings of their own, which ticking changes). Your own programs
|
|
3
|
+
// (apps.custom) are edited as a list of small forms. Saved to your config file (or, for catalogue apps only, the
|
|
4
|
+
// project's .sandbox-config.json).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import _ from 'lodash';
|
|
8
|
+
import { APP_CATEGORIES, APPS } from '../../shared/apps.js';
|
|
9
|
+
import { Text, Theme } from '../tui/index.js';
|
|
10
|
+
import { ValueEditor } from './settings/editors.js';
|
|
11
|
+
const CUSTOM = '@custom';
|
|
12
|
+
export class AppsCatalogue {
|
|
13
|
+
app;
|
|
14
|
+
file;
|
|
15
|
+
dataPath;
|
|
16
|
+
constructor(app, file, dataPath) {
|
|
17
|
+
this.app = app;
|
|
18
|
+
this.file = file;
|
|
19
|
+
this.dataPath = dataPath;
|
|
20
|
+
}
|
|
21
|
+
/** Downloaded already (into this data folder), so the next sandbox starts it without a download. */
|
|
22
|
+
installed(entry) {
|
|
23
|
+
if (entry.id === 'vscode')
|
|
24
|
+
return fs.existsSync(path.join(this.dataPath, 'tools', 'vscode', 'Code.exe'));
|
|
25
|
+
if (entry.id === 'windows-terminal')
|
|
26
|
+
return fs.existsSync(path.join(this.dataPath, 'tools', 'terminal', 'WindowsTerminal.exe'));
|
|
27
|
+
return fs.existsSync(path.join(this.dataPath, 'tools', 'apps', entry.id, '.agent-sandbox-app'));
|
|
28
|
+
}
|
|
29
|
+
/** Is this entry on now (its setting, or listed in apps.install)? */
|
|
30
|
+
chosen(entry, install) {
|
|
31
|
+
return entry.setting ? _.get(this.file.userConfig(), entry.setting) === true : install.includes(entry.id);
|
|
32
|
+
}
|
|
33
|
+
item(entry, install) {
|
|
34
|
+
const notes = [
|
|
35
|
+
Theme.muted('Download ') + '~' + entry.size + ' once, kept in C:\\AgentData\\tools\\' + (entry.setting ? '' : 'apps\\') + entry.id,
|
|
36
|
+
Theme.muted('Starts ') + (entry.cli ? 'from a terminal (on PATH)' : 'from the desktop or Start > Sandbox apps'),
|
|
37
|
+
...(entry.needs?.includes('mesa') ? [Theme.muted('Graphics ') + 'OpenGL on the CPU (Mesa) is added for it'] : []),
|
|
38
|
+
...(entry.fullGitOnly ? [Theme.warn('Needs provisioning.git.access "full" (it pushes with its own git).')] : []),
|
|
39
|
+
...(entry.setting ? [Theme.muted('Setting ') + entry.setting] : []),
|
|
40
|
+
];
|
|
41
|
+
return {
|
|
42
|
+
value: entry.id, label: entry.name, category: entry.category, description: entry.about, details: notes, keywords: entry.id,
|
|
43
|
+
hint: (this.installed(entry) ? Theme.ok('installed ') : '') + Theme.muted(entry.size), checked: this.chosen(entry, install),
|
|
44
|
+
...(this.file.isProject && entry.setting ? { disabled: 'A project cannot change this; it is in your own settings.' } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Shows the catalogue until you save (Enter) or leave it (Esc); returns the notice to show afterwards. */
|
|
48
|
+
async run() {
|
|
49
|
+
for (;;) {
|
|
50
|
+
const install = (this.file.isProject ? this.file.valueInFile(['apps', 'install']) : this.file.userConfig().apps.install) ?? [];
|
|
51
|
+
const custom = this.file.isProject ? {} : this.file.userConfig().apps.custom;
|
|
52
|
+
const entries = APPS.filter((a) => !(this.file.isProject && a.setting));
|
|
53
|
+
const picked = await this.app.list({
|
|
54
|
+
path: [this.file.isProject ? 'Project settings' : 'Apps'],
|
|
55
|
+
intro: 'Tick the apps every sandbox should have (space), then Enter to save. Each is downloaded once and kept; c = your own programs.',
|
|
56
|
+
multi: true,
|
|
57
|
+
categories: [...APP_CATEGORIES, ...(this.file.isProject ? [] : [{ id: 'custom', label: 'Your own programs', description: 'Programs you added yourself (c to add or change them).' }])],
|
|
58
|
+
items: [
|
|
59
|
+
...entries.map((a) => this.item(a, install)),
|
|
60
|
+
...Object.entries(custom).map(([id, a]) => ({ value: CUSTOM + id, label: a.name, category: 'custom', description: a.github ? 'From the latest ' + a.github + ' release.' : 'From ' + a.url, hint: Theme.muted(a.cli ? 'command line' : 'desktop'), checked: true, disabled: 'Your own programs are always installed; c to change or remove them.' })),
|
|
61
|
+
],
|
|
62
|
+
actions: this.file.isProject ? [] : [{ key: 'c', label: 'your own programs' }],
|
|
63
|
+
});
|
|
64
|
+
if (picked.kind === 'back')
|
|
65
|
+
return undefined;
|
|
66
|
+
if (picked.kind === 'action') {
|
|
67
|
+
const notice = await this.editCustom();
|
|
68
|
+
if (notice)
|
|
69
|
+
return notice;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (picked.kind !== 'multi')
|
|
73
|
+
continue;
|
|
74
|
+
return this.save(picked.values.filter((v) => !v.startsWith(CUSTOM)));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
save(ids) {
|
|
78
|
+
const problems = [];
|
|
79
|
+
let changed = 0;
|
|
80
|
+
for (const entry of APPS.filter((a) => a.setting && !this.file.isProject)) {
|
|
81
|
+
const on = ids.includes(entry.id);
|
|
82
|
+
if (on === this.chosen(entry, []))
|
|
83
|
+
continue;
|
|
84
|
+
const result = this.file.set(entry.setting.split('.'), on);
|
|
85
|
+
if (typeof result === 'object')
|
|
86
|
+
problems.push(...result.problems);
|
|
87
|
+
else if (result === 'saved')
|
|
88
|
+
changed++;
|
|
89
|
+
}
|
|
90
|
+
const install = APPS.filter((a) => !a.setting && ids.includes(a.id)).map((a) => a.id);
|
|
91
|
+
const result = this.file.set(['apps', 'install'], install.length || this.file.isProject ? install : undefined);
|
|
92
|
+
if (typeof result === 'object')
|
|
93
|
+
problems.push(...result.problems);
|
|
94
|
+
else if (result === 'saved')
|
|
95
|
+
changed++;
|
|
96
|
+
if (problems.length)
|
|
97
|
+
return Theme.error(Theme.glyph.cross + ' Not saved: ' + problems.join('; '));
|
|
98
|
+
if (!changed)
|
|
99
|
+
return Theme.muted('Apps unchanged.');
|
|
100
|
+
return Theme.ok(Theme.glyph.ok + ' Saved: ' + (install.length ? install.length + ' app' + (install.length === 1 ? '' : 's') : 'no apps') + '.') + Theme.muted(this.file.isProject ? ' The next launch asks you to approve the project file again.' : ' The next sandbox installs what it does not have yet.');
|
|
101
|
+
}
|
|
102
|
+
/** Your own programs: the apps.custom map, each entry a form (GitHub release or link, program, command line or not). */
|
|
103
|
+
async editCustom() {
|
|
104
|
+
const node = this.file.schema.child('apps')?.child('custom');
|
|
105
|
+
if (!node)
|
|
106
|
+
return undefined;
|
|
107
|
+
const edited = await new ValueEditor(this.app).edit(['Apps', 'Your own programs'], ['apps', 'custom'], node, this.file.userConfig().apps.custom);
|
|
108
|
+
if (!edited)
|
|
109
|
+
return undefined;
|
|
110
|
+
const result = this.file.set(['apps', 'custom'], edited.value);
|
|
111
|
+
if (typeof result === 'object')
|
|
112
|
+
return Theme.error(Theme.glyph.cross + ' Not saved: ' + result.problems.join('; '));
|
|
113
|
+
return result === 'saved' ? Theme.ok(Theme.glyph.ok + ' Saved your programs (' + Object.keys(edited.value).length + ').') : undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** For the home screen: "12 apps chosen". */
|
|
117
|
+
export const appsSummary = (file) => {
|
|
118
|
+
const config = file.userConfig();
|
|
119
|
+
const n = config.apps.install.length + Object.keys(config.apps.custom).length;
|
|
120
|
+
return n ? n + ' chosen' : 'none chosen';
|
|
121
|
+
};
|
|
122
|
+
export const appLabel = (id) => APPS.find((a) => a.id === id)?.name ?? Text.humanize(id);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Context } from '../commands/context.js';
|
|
2
|
+
import { type CommandSpecT, type OptionSpecT } from '../manifest.js';
|
|
3
|
+
import { App } from '../tui/index.js';
|
|
4
|
+
/** What an option accepts, checked while typing (undefined = fine). */
|
|
5
|
+
export declare const optionProblem: (o: OptionSpecT, v: string) => string | undefined;
|
|
6
|
+
export declare class CommandForm {
|
|
7
|
+
private readonly app;
|
|
8
|
+
private readonly ctx;
|
|
9
|
+
private readonly spec;
|
|
10
|
+
/** Answers by field id (arg:<name>, opt:<flag>, json, extra). */
|
|
11
|
+
private readonly values;
|
|
12
|
+
constructor(app: App, ctx: Context, spec: CommandSpecT, preset?: string[]);
|
|
13
|
+
/** Values a choices list names, filled in from config and the running sandbox. */
|
|
14
|
+
private choices;
|
|
15
|
+
private first;
|
|
16
|
+
private fields;
|
|
17
|
+
private id;
|
|
18
|
+
/** The command line the form stands for now. */
|
|
19
|
+
argv(): string[];
|
|
20
|
+
/** The name of a required argument that has no value yet. */
|
|
21
|
+
private missing;
|
|
22
|
+
private item;
|
|
23
|
+
/** Shows the form until you run the command (its argv) or go back (null). */
|
|
24
|
+
run(): Promise<string[] | null>;
|
|
25
|
+
private edit;
|
|
26
|
+
/** `data clean` targets, ticked from a list with their sizes (never "all": that is typed on purpose). */
|
|
27
|
+
private dataTargets;
|
|
28
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// One command as a form: its arguments and options listed with their current values, each changed in place, and the
|
|
2
|
+
// exact command line it will run shown live (the same line you could type yourself). Built from manifest.ts, so a new
|
|
3
|
+
// command or option appears here on its own.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { humanBytes } from '../../core/disk.js';
|
|
7
|
+
import { fail, plainText } from '../../core/util.js';
|
|
8
|
+
import { listAccounts } from '../../host/accounts.js';
|
|
9
|
+
import { runningSessions } from '../../host/channel.js';
|
|
10
|
+
import { isSandboxRunning } from '../../host/platform.js';
|
|
11
|
+
import { splitArgs } from '../args.js';
|
|
12
|
+
import { cleanTargetOf, collectDataEntries } from '../commands/data/collect.js';
|
|
13
|
+
import { commandLine } from '../history.js';
|
|
14
|
+
import { askedWhen } from '../manifest.js';
|
|
15
|
+
import { Text, Theme } from '../tui/index.js';
|
|
16
|
+
import { validNodeSpec, validPythonSpec } from '../validate.js';
|
|
17
|
+
/** What an option accepts, checked while typing (undefined = fine). */
|
|
18
|
+
export const optionProblem = (o, v) => {
|
|
19
|
+
if (v === '')
|
|
20
|
+
return undefined;
|
|
21
|
+
if (o.type === 'number' && !(Number(v) > 0))
|
|
22
|
+
return 'Enter a number greater than 0.';
|
|
23
|
+
if (o.flag === '--node' && !validNodeSpec(v))
|
|
24
|
+
return 'Use a number like 22 or 22.11.0, or lts, latest.';
|
|
25
|
+
if (o.flag === '--python' && !validPythonSpec(v))
|
|
26
|
+
return 'Use 3, 3.12 or 3.12.4.';
|
|
27
|
+
if (o.value === 'dir' && !fs.existsSync(path.resolve(v)))
|
|
28
|
+
return 'That folder does not exist.';
|
|
29
|
+
if (o.value === 'file') {
|
|
30
|
+
const missing = splitArgs(v).find((f) => !fs.existsSync(path.resolve(f)));
|
|
31
|
+
if (missing)
|
|
32
|
+
return missing + ' does not exist.';
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
const RUN = '@run';
|
|
37
|
+
export class CommandForm {
|
|
38
|
+
app;
|
|
39
|
+
ctx;
|
|
40
|
+
spec;
|
|
41
|
+
/** Answers by field id (arg:<name>, opt:<flag>, json, extra). */
|
|
42
|
+
values = new Map();
|
|
43
|
+
constructor(app, ctx, spec, preset = []) {
|
|
44
|
+
this.app = app;
|
|
45
|
+
this.ctx = ctx;
|
|
46
|
+
this.spec = spec;
|
|
47
|
+
for (let i = 0; i < preset.length; i++) {
|
|
48
|
+
const option = spec.options.find((o) => o.flag === preset[i]);
|
|
49
|
+
if (option?.type === 'boolean')
|
|
50
|
+
this.values.set('opt:' + option.flag, true);
|
|
51
|
+
else if (option)
|
|
52
|
+
this.values.set('opt:' + option.flag, [...(this.values.get('opt:' + option.flag) ?? []), preset[++i]]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Values a choices list names, filled in from config and the running sandbox. */
|
|
56
|
+
choices(choices) {
|
|
57
|
+
if (Array.isArray(choices))
|
|
58
|
+
return choices;
|
|
59
|
+
if (choices === 'harnesses')
|
|
60
|
+
return Object.keys(this.ctx.loaded.config.harnesses);
|
|
61
|
+
if (choices === 'accounts')
|
|
62
|
+
return listAccounts(this.ctx.storageRoot, this.ctx.dataPath).map((a) => a.name);
|
|
63
|
+
if (choices === 'sessions')
|
|
64
|
+
return isSandboxRunning() ? runningSessions(this.ctx.dataPath).map((s) => plainText(s.id)) : [];
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
first() { return this.values.get('arg:' + this.spec.args[0]?.name)?.[0] ?? ''; }
|
|
68
|
+
fields() {
|
|
69
|
+
const first = this.first();
|
|
70
|
+
return [
|
|
71
|
+
...this.spec.args.map((spec, index) => ({ kind: 'arg', spec, index })).filter((f) => f.kind === 'arg' && (f.index === 0 || askedWhen(f.spec.when, first))),
|
|
72
|
+
...this.spec.options.filter((o) => !o.noMenu && askedWhen(o.when, first) && !(typeof o.choices === 'string' && !this.choices(o.choices).length)).map((spec) => ({ kind: 'option', spec })),
|
|
73
|
+
...(this.spec.json && !(this.spec.name === 'data' && first === 'clean') ? [{ kind: 'json' }] : []),
|
|
74
|
+
...(this.spec.passthrough ? [{ kind: 'extra' }] : []),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
id(f) { return f.kind === 'arg' ? 'arg:' + f.spec.name : f.kind === 'option' ? 'opt:' + f.spec.flag : f.kind; }
|
|
78
|
+
/** The command line the form stands for now. */
|
|
79
|
+
argv() {
|
|
80
|
+
const fields = this.fields();
|
|
81
|
+
const list = (f) => this.values.get(this.id(f)) ?? [];
|
|
82
|
+
const argv = [this.spec.name, ...fields.filter((f) => f.kind === 'arg').flatMap(list)];
|
|
83
|
+
for (const f of fields) {
|
|
84
|
+
if (f.kind !== 'option')
|
|
85
|
+
continue;
|
|
86
|
+
const v = this.values.get(this.id(f));
|
|
87
|
+
if (v === true)
|
|
88
|
+
argv.push(f.spec.flag);
|
|
89
|
+
else if (Array.isArray(v))
|
|
90
|
+
for (const x of v)
|
|
91
|
+
argv.push(f.spec.flag, x);
|
|
92
|
+
}
|
|
93
|
+
if (this.values.get('json') === true)
|
|
94
|
+
argv.push('--json');
|
|
95
|
+
const extra = list({ kind: 'extra' });
|
|
96
|
+
if (extra.length)
|
|
97
|
+
argv.push(...(this.spec.passthrough === 'agent' ? ['--', ...extra] : extra));
|
|
98
|
+
return argv;
|
|
99
|
+
}
|
|
100
|
+
/** The name of a required argument that has no value yet. */
|
|
101
|
+
missing() {
|
|
102
|
+
const field = this.fields().find((f) => f.kind === 'arg' && f.spec.required && !(this.values.get(this.id(f))?.length));
|
|
103
|
+
return field?.kind === 'arg' ? Text.humanize(field.spec.name) : undefined;
|
|
104
|
+
}
|
|
105
|
+
item(f) {
|
|
106
|
+
const v = this.values.get(this.id(f));
|
|
107
|
+
const shown = v === true ? Theme.ok('On') : v === false || v === undefined ? Theme.muted(f.kind === 'option' && f.spec.type === 'boolean' || f.kind === 'json' ? 'Off' : f.kind === 'option' && f.spec.default !== undefined ? 'default ' + f.spec.default : f.kind === 'arg' && f.spec.required ? 'required' : 'not set') : Array.isArray(v) && v.length ? v.join(' ') : Theme.muted('not set');
|
|
108
|
+
if (f.kind === 'arg')
|
|
109
|
+
return { value: this.id(f), label: Text.humanize(f.spec.name), hint: shown, description: f.spec.description, section: 'What', keywords: f.spec.name };
|
|
110
|
+
if (f.kind === 'json')
|
|
111
|
+
return { value: 'json', label: 'Machine-readable output', hint: shown, description: 'Print JSON instead of a table (--json).', section: 'Output' };
|
|
112
|
+
if (f.kind === 'extra')
|
|
113
|
+
return { value: 'extra', label: this.spec.passthrough === 'agent' ? 'Extra arguments for the agent' : 'Arguments for the command', hint: shown, description: this.spec.passthrough === 'agent' ? 'Passed on to the agent, e.g. --continue.' : 'The rest of the command line.', section: 'Output' };
|
|
114
|
+
return { value: this.id(f), label: f.spec.flag.replace(/^--/, '').replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()), hint: shown, description: f.spec.description, section: f.spec.advanced ? 'Advanced' : 'Options', keywords: f.spec.flag };
|
|
115
|
+
}
|
|
116
|
+
/** Shows the form until you run the command (its argv) or go back (null). */
|
|
117
|
+
async run() {
|
|
118
|
+
let focus;
|
|
119
|
+
let notice;
|
|
120
|
+
const path = [Text.humanize(this.spec.name)];
|
|
121
|
+
for (;;) {
|
|
122
|
+
const fields = this.fields();
|
|
123
|
+
const line = commandLine(this.argv());
|
|
124
|
+
const picked = await this.app.list({
|
|
125
|
+
path,
|
|
126
|
+
intro: this.spec.summary.replace(/^./, (c) => c.toUpperCase()) + '.',
|
|
127
|
+
status: Theme.muted('$ ') + Theme.accent(line),
|
|
128
|
+
items: [
|
|
129
|
+
{ value: RUN, label: Theme.ok('▶ Run'), hint: Theme.muted(Text.truncate(line, 60)), description: 'Runs: ' + line, section: 'Run' },
|
|
130
|
+
...fields.map((f) => this.item(f)),
|
|
131
|
+
],
|
|
132
|
+
startAt: focus,
|
|
133
|
+
actions: [{ key: 'r', label: 'clear' }],
|
|
134
|
+
notice,
|
|
135
|
+
});
|
|
136
|
+
notice = undefined;
|
|
137
|
+
if (picked.kind === 'back')
|
|
138
|
+
return null;
|
|
139
|
+
if (picked.kind === 'pick' && picked.value === RUN) {
|
|
140
|
+
const missing = this.missing();
|
|
141
|
+
if (missing) {
|
|
142
|
+
notice = Theme.warn(missing + ' is required.');
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
return this.argv();
|
|
146
|
+
}
|
|
147
|
+
if (picked.kind !== 'pick' && picked.kind !== 'action')
|
|
148
|
+
continue;
|
|
149
|
+
const field = fields.find((f) => this.id(f) === picked.value);
|
|
150
|
+
if (!field)
|
|
151
|
+
continue;
|
|
152
|
+
focus = picked.value;
|
|
153
|
+
if (picked.kind === 'action') {
|
|
154
|
+
this.values.delete(this.id(field));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
await this.edit(field, path);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async edit(f, path) {
|
|
161
|
+
const id = this.id(f);
|
|
162
|
+
const current = this.values.get(id);
|
|
163
|
+
if (f.kind === 'json' || (f.kind === 'option' && f.spec.type === 'boolean')) {
|
|
164
|
+
this.values.set(id, current !== true);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const label = f.kind === 'extra' ? 'Arguments' : f.kind === 'arg' ? Text.humanize(f.spec.name) : f.spec.flag;
|
|
168
|
+
const about = f.kind === 'extra' ? [] : [f.spec.description];
|
|
169
|
+
if (f.kind === 'arg' && this.spec.name === 'data' && f.spec.name === 'targets') {
|
|
170
|
+
const picked = await this.dataTargets(path);
|
|
171
|
+
if (picked)
|
|
172
|
+
this.values.set(id, picked);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const list = f.kind === 'extra' ? [] : this.choices(f.spec.choices);
|
|
176
|
+
const many = (f.kind === 'option' && f.spec.type === 'list') || (f.kind === 'arg' && f.spec.variadic);
|
|
177
|
+
if (list.length && many) {
|
|
178
|
+
const picked = await this.app.list({ path: [...path, label], intro: about, multi: true, items: list.map((v) => ({ value: v, label: v, checked: Array.isArray(current) && current.includes(v) })) });
|
|
179
|
+
if (picked.kind === 'multi')
|
|
180
|
+
this.values.set(id, picked.values);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (list.length) {
|
|
184
|
+
const optional = !(f.kind === 'arg' && f.spec.required);
|
|
185
|
+
const picked = await this.app.choose([...path, label], about.join(' '), [...list.map((v) => ({ value: v, label: v })), ...(optional ? [{ value: '', label: Theme.muted('(not set)') }] : [])], Array.isArray(current) ? current[0] : undefined);
|
|
186
|
+
if (picked !== undefined) {
|
|
187
|
+
if (picked === '')
|
|
188
|
+
this.values.delete(id);
|
|
189
|
+
else
|
|
190
|
+
this.values.set(id, [picked]);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (f.kind === 'arg' && typeof f.spec.choices === 'string')
|
|
195
|
+
fail('There is nothing to choose from: no ' + f.spec.choices + ' yet.', f.spec.choices === 'accounts' ? 'Sign in inside the sandbox, then run: sandbox account save <name>' : 'Start one first (sandbox claude).');
|
|
196
|
+
const typed = await this.app.text({
|
|
197
|
+
path: [...path, label], label, about, allowEmpty: true,
|
|
198
|
+
initial: Array.isArray(current) ? (many || f.kind === 'extra' ? current.map((x) => (/\s/.test(x) ? '"' + x + '"' : x)).join(' ') : current[0]) : '',
|
|
199
|
+
validate: (v) => (f.kind === 'option' ? optionProblem(f.spec, v) ?? true : true),
|
|
200
|
+
});
|
|
201
|
+
if (typed === undefined)
|
|
202
|
+
return;
|
|
203
|
+
const value = many || f.kind === 'extra' ? splitArgs(typed) : typed.trim() ? [typed.trim()] : [];
|
|
204
|
+
if (value.length)
|
|
205
|
+
this.values.set(id, value);
|
|
206
|
+
else
|
|
207
|
+
this.values.delete(id);
|
|
208
|
+
}
|
|
209
|
+
/** `data clean` targets, ticked from a list with their sizes (never "all": that is typed on purpose). */
|
|
210
|
+
async dataTargets(path) {
|
|
211
|
+
const rows = collectDataEntries(this.ctx.dataPath, this.ctx.storageRoot).flatMap((e) => { const target = cleanTargetOf(e); return target ? [{ target, bytes: e.bytes }] : []; });
|
|
212
|
+
if (!rows.length) {
|
|
213
|
+
await this.app.message({ path, title: 'Nothing is saved yet', lines: ['There is nothing to delete.'] });
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const picked = await this.app.list({ path: [...path, 'Delete what'], intro: 'Tick what to delete (space), then Enter. You confirm once more before anything is deleted.', multi: true, items: rows.map((r) => ({ value: r.target, label: r.target, hint: humanBytes(r.bytes) })) });
|
|
217
|
+
return picked.kind === 'multi' && picked.values.length ? picked.values : undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context } from '../commands/context.js';
|
|
2
|
+
export { optionProblem } from './command-form.js';
|
|
3
|
+
export { candidateFolders, severalAgentsArgv } from './several.js';
|
|
4
|
+
export declare class Menu {
|
|
5
|
+
private readonly ctx;
|
|
6
|
+
private readonly runArgv;
|
|
7
|
+
private readonly app;
|
|
8
|
+
private last;
|
|
9
|
+
/** The result of the last action (saved apps ...), shown on the home screen once. */
|
|
10
|
+
private notice;
|
|
11
|
+
constructor(ctx: Context, runArgv: (argv: string[]) => Promise<number>);
|
|
12
|
+
private status;
|
|
13
|
+
/** Is this agent already in the sandbox's persistent storage (so starting it needs no install)? */
|
|
14
|
+
private installed;
|
|
15
|
+
private homeItems;
|
|
16
|
+
/** Runs the menu until you leave it, or until a command that takes over the terminal (launch, attach) has run. */
|
|
17
|
+
start(): Promise<number>;
|
|
18
|
+
private fileFor;
|
|
19
|
+
/** Does what a home entry says. A number ends the menu with that exit code. */
|
|
20
|
+
private perform;
|
|
21
|
+
/** Runs an argv like typing it, on the normal screen; the menu comes back afterwards unless the command took over. */
|
|
22
|
+
private execute;
|
|
23
|
+
/** The settings screen may have switched agentCommands (Beta): apply it now, from the saved file. */
|
|
24
|
+
private applyAgentCommands;
|
|
25
|
+
}
|
|
26
|
+
export declare const menu: (ctx: Context, run: (argv: string[]) => Promise<number>) => Promise<number>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The interactive menu (`sandbox` with no arguments): a full-screen app. The home screen groups everything into
|
|
2
|
+
// categories (start, sessions, sandbox, apps, settings ...); commands open as a form that shows the exact command line,
|
|
3
|
+
// which then runs like typing it. Settings and the project's .sandbox-config.json are edited in place.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { errMsg, plainText } from '../../core/util.js';
|
|
7
|
+
import { loadConfig } from '../../core/config.js';
|
|
8
|
+
import { runningSessions } from '../../host/channel.js';
|
|
9
|
+
import { isSandboxRunning } from '../../host/platform.js';
|
|
10
|
+
import { syncAgentCommands } from '../commands/agent-commands.js';
|
|
11
|
+
import { commandLine, readHistory } from '../history.js';
|
|
12
|
+
import { COMMANDS, commandSpec } from '../manifest.js';
|
|
13
|
+
import { App, ExitRequested, Frame, Text, Theme } from '../tui/index.js';
|
|
14
|
+
import { AppsCatalogue, appsSummary } from './apps.js';
|
|
15
|
+
import { CommandForm } from './command-form.js';
|
|
16
|
+
import { ConfigFile, SettingsEditor } from './settings/index.js';
|
|
17
|
+
import { SeveralAgents } from './several.js';
|
|
18
|
+
export { optionProblem } from './command-form.js';
|
|
19
|
+
export { candidateFolders, severalAgentsArgv } from './several.js';
|
|
20
|
+
const CATEGORIES = [
|
|
21
|
+
{ id: 'start', label: 'Start', description: 'Start an agent (or several) in a sandbox, or a sandbox with only your tools.' },
|
|
22
|
+
{ id: 'sessions', label: 'Sessions', description: 'Agent sessions in the running sandbox: show one in this terminal, list them, restart them.' },
|
|
23
|
+
{ id: 'sandbox', label: 'Sandbox', description: 'The running sandbox: its status, window, databases, projects, and stopping it.' },
|
|
24
|
+
{ id: 'apps', label: 'Apps & tools', description: 'Desktop apps and command-line tools for every sandbox: editors, database clients, API clients, game engines, cloud CLIs ... and programs of your own.' },
|
|
25
|
+
{ id: 'settings', label: 'Settings', description: 'Every setting, for all projects or just this one, edited in place.' },
|
|
26
|
+
{ id: 'accounts', label: 'Claude accounts', description: 'Saved Claude logins: save one, switch, set the default.' },
|
|
27
|
+
{ id: 'data', label: 'Data & cleanup', description: 'What is kept between sandboxes (tools, databases, logins, logs), and deleting it.' },
|
|
28
|
+
{ id: 'health', label: 'Health & repair', description: 'Check this PC, find and fix problems, read the logs.' },
|
|
29
|
+
];
|
|
30
|
+
/** Readable names for commands on the home screen (the manifest's summaries are the descriptions). */
|
|
31
|
+
const TITLES = {
|
|
32
|
+
start: 'Start a sandbox without an agent', 'host-claude': 'Claude on this PC (NOT sandboxed)', attach: 'Show a session in this terminal', sessions: 'List sessions',
|
|
33
|
+
restart: 'Restart this project\'s agents', status: 'Sandbox status', db: 'Databases', exec: 'Run a command inside', stop: 'Stop the sandbox', projects: 'Projects',
|
|
34
|
+
show: 'Show the sandbox window', hide: 'Hide the sandbox window', account: 'Claude accounts', data: 'Saved data', clean: 'Clean up old files', sync: 'Sync history now',
|
|
35
|
+
logs: 'Logs', setup: 'Set up this PC', doctor: 'Doctor (check this PC)', repair: 'Repair', 'self-test': 'Self-test', generate: 'Preview a start (no sandbox)',
|
|
36
|
+
config: 'Config file', audit: 'Audit log', 'agent-commands': 'Agent commands (Beta)', launch: 'Start an agent',
|
|
37
|
+
};
|
|
38
|
+
const CATEGORY_OF_GROUP = { Agents: 'start', Sandbox: 'sandbox', Sessions: 'sessions', Accounts: 'accounts', Data: 'data', Health: 'health', Config: 'settings' };
|
|
39
|
+
export class Menu {
|
|
40
|
+
ctx;
|
|
41
|
+
runArgv;
|
|
42
|
+
app = new App();
|
|
43
|
+
last;
|
|
44
|
+
/** The result of the last action (saved apps ...), shown on the home screen once. */
|
|
45
|
+
notice;
|
|
46
|
+
constructor(ctx, runArgv) {
|
|
47
|
+
this.ctx = ctx;
|
|
48
|
+
this.runArgv = runArgv;
|
|
49
|
+
this.app.status = () => this.status();
|
|
50
|
+
try {
|
|
51
|
+
Frame.version = JSON.parse(fs.readFileSync(path.join(ctx.packageRoot, 'package.json'), 'utf8')).version ?? '';
|
|
52
|
+
}
|
|
53
|
+
catch { /* no version shown */ }
|
|
54
|
+
}
|
|
55
|
+
status() {
|
|
56
|
+
const folder = Theme.muted(Text.middle(process.cwd(), 70));
|
|
57
|
+
if (!isSandboxRunning())
|
|
58
|
+
return Theme.muted(Theme.glyph.off + ' No sandbox running') + ' ' + folder;
|
|
59
|
+
const n = runningSessions(this.ctx.dataPath).length;
|
|
60
|
+
return Theme.ok(Theme.glyph.on + ' Sandbox running') + Theme.muted(' · ' + n + ' session' + (n === 1 ? '' : 's')) + ' ' + folder;
|
|
61
|
+
}
|
|
62
|
+
/** Is this agent already in the sandbox's persistent storage (so starting it needs no install)? */
|
|
63
|
+
installed(name) {
|
|
64
|
+
const h = this.ctx.loaded.config.harnesses[name];
|
|
65
|
+
const dir = path.join(this.ctx.dataPath, 'tools', 'agents', name);
|
|
66
|
+
return Boolean(h) && [h.hostExecutable, h.npmBin && h.npmBin + '.cmd', h.uvBin && h.uvBin + '.exe'].some((f) => f && fs.existsSync(path.join(dir, f)));
|
|
67
|
+
}
|
|
68
|
+
homeItems() {
|
|
69
|
+
const config = this.ctx.loaded.config;
|
|
70
|
+
const def = config.defaultHarness;
|
|
71
|
+
const last = readHistory()[0];
|
|
72
|
+
const items = [];
|
|
73
|
+
const add = (category, section, label, action, description, hint) => { items.push({ value: JSON.stringify(action), label, category, section, description, hint }); };
|
|
74
|
+
if (last)
|
|
75
|
+
add('start', 'Quick start', 'Repeat last', { kind: 'run', argv: last.argv }, 'Runs the last command again.', Theme.muted(Text.truncate(commandLine(last.argv), 50)));
|
|
76
|
+
add('start', 'Quick start', 'Start ' + def + ' in this folder', { kind: 'run', argv: ['launch', '--harness', def] }, 'Starts ' + def + ' in a sandbox for ' + process.cwd() + ' (or another session in the running sandbox).', this.installed(def) ? Theme.ok('ready') : Theme.muted('installs on first start'));
|
|
77
|
+
add('start', 'Quick start', 'Start ' + def + ' with options…', { kind: 'form', command: 'launch', preset: ['--harness', def] }, 'Choose options first: attach this terminal, Node or Python version, databases, scripts ...');
|
|
78
|
+
add('start', 'Quick start', 'Start several agents…', { kind: 'several' }, 'One or more projects, one or more agents each, all in one sandbox; attach to any of them.');
|
|
79
|
+
for (const name of Object.keys(config.harnesses).filter((n) => n !== def))
|
|
80
|
+
add('start', 'Other agents', 'Start ' + name, { kind: 'form', command: 'launch', preset: ['--harness', name] }, 'Opens the start form for ' + name + '.', this.installed(name) ? Theme.ok('ready') : Theme.muted('installs on first start'));
|
|
81
|
+
if (isSandboxRunning()) {
|
|
82
|
+
for (const s of runningSessions(this.ctx.dataPath).slice(-12)) {
|
|
83
|
+
add('sessions', 'Running now', plainText(s.harness) + ' · ' + plainText(path.win32.basename(s.cwd)), { kind: 'run', argv: ['attach', plainText(s.id)] }, 'Shows this session in this terminal (Ctrl+] detaches; the session keeps running).', Theme.muted(plainText(s.id)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const userFile = ConfigFile.user(this.ctx.loaded.configPath);
|
|
87
|
+
add('apps', 'Apps', 'Browse the apps catalogue…', { kind: 'apps', target: 'user' }, 'Tick the apps every sandbox should have: by category, searchable, with sizes. Each downloads once and is kept.', Theme.muted(appsSummary(userFile)));
|
|
88
|
+
add('apps', 'Apps', 'Apps for this project only…', { kind: 'apps', target: 'project' }, 'Catalogue apps this project needs (saved in its .sandbox-config.json, added to yours).');
|
|
89
|
+
add('settings', 'Settings', 'Settings for all projects', { kind: 'settings', target: 'user' }, 'Your own settings, used by every project: languages, databases, the sandbox machine, agents, security ... (Tab switches to this project\'s.)', Theme.scopeGlobal(' YOURS '));
|
|
90
|
+
add('settings', 'Settings', 'Settings for this project', { kind: 'settings', target: 'project' }, 'This folder\'s .sandbox-config.json: only what a project may set, on top of yours. A changed file needs your approval at the next launch. (Tab switches to all projects.)', Theme.scopeProject(' PROJECT '));
|
|
91
|
+
for (const spec of COMMANDS.filter((s) => !s.hidden && s.name !== 'launch')) {
|
|
92
|
+
const category = CATEGORY_OF_GROUP[spec.group];
|
|
93
|
+
const section = category === 'start' ? 'More' : category === 'settings' ? 'Tools' : 'Commands';
|
|
94
|
+
add(category, section, TITLES[spec.name] ?? Text.humanize(spec.name), { kind: 'form', command: spec.name }, spec.summary.replace(/^./, (c) => c.toUpperCase()) + '.', Theme.muted('sandbox ' + spec.name));
|
|
95
|
+
}
|
|
96
|
+
return items;
|
|
97
|
+
}
|
|
98
|
+
/** Runs the menu until you leave it, or until a command that takes over the terminal (launch, attach) has run. */
|
|
99
|
+
async start() {
|
|
100
|
+
if (!process.stdin.isTTY) {
|
|
101
|
+
console.log('The menu needs an interactive terminal. Run sandbox --help for the commands.');
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const picked = await this.app.list({ path: [], items: this.homeItems(), categories: CATEGORIES, startAt: this.last, escapeLabel: 'quit', notice: this.notice });
|
|
107
|
+
this.notice = undefined;
|
|
108
|
+
if (picked.kind === 'back')
|
|
109
|
+
return 0;
|
|
110
|
+
if (picked.kind !== 'pick')
|
|
111
|
+
continue;
|
|
112
|
+
this.last = picked.value;
|
|
113
|
+
const code = await this.perform(JSON.parse(picked.value));
|
|
114
|
+
if (code !== undefined)
|
|
115
|
+
return code;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// Ctrl+C anywhere: leave quietly.
|
|
120
|
+
if (e instanceof ExitRequested || (e instanceof Error && e.name === 'ExitPromptError'))
|
|
121
|
+
return 130;
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
this.app.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
fileFor(target) { return target === 'user' ? ConfigFile.user(this.ctx.loaded.configPath) : ConfigFile.project(process.cwd()); }
|
|
129
|
+
/** Does what a home entry says. A number ends the menu with that exit code. */
|
|
130
|
+
async perform(action) {
|
|
131
|
+
if (action.kind === 'settings') {
|
|
132
|
+
await new SettingsEditor(this.app, { user: this.fileFor('user'), project: this.fileFor('project') }, action.target, this.ctx.dataPath).run();
|
|
133
|
+
this.applyAgentCommands();
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (action.kind === 'apps') {
|
|
137
|
+
this.notice = await new AppsCatalogue(this.app, this.fileFor(action.target), this.ctx.dataPath).run();
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const argv = action.kind === 'run' ? action.argv : action.kind === 'several' ? await new SeveralAgents(this.app, this.ctx).run() : await new CommandForm(this.app, this.ctx, commandSpec(action.command), action.preset).run();
|
|
141
|
+
return argv ? this.execute(argv) : undefined;
|
|
142
|
+
}
|
|
143
|
+
/** Runs an argv like typing it, on the normal screen; the menu comes back afterwards unless the command took over. */
|
|
144
|
+
async execute(argv) {
|
|
145
|
+
const code = await this.app.suspend(async () => {
|
|
146
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H' + Theme.muted('$ ' + commandLine(argv)) + '\n\n');
|
|
147
|
+
return this.runArgv(argv);
|
|
148
|
+
});
|
|
149
|
+
if (commandSpec(argv[0])?.interactive)
|
|
150
|
+
return code;
|
|
151
|
+
await pressAnyKey((code === 0 ? Theme.ok('Done.') : Theme.warn('Exit code ' + code + '.')) + Theme.muted(' Press any key for the menu…'));
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/** The settings screen may have switched agentCommands (Beta): apply it now, from the saved file. */
|
|
155
|
+
applyAgentCommands() {
|
|
156
|
+
try {
|
|
157
|
+
syncAgentCommands(this.ctx, loadConfig(this.ctx.loaded.configPath ?? undefined).config);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
console.warn('Agent commands (Beta): ' + errMsg(e));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Waits for one key on the normal screen, so a command's output stays until you have read it. Ctrl+C leaves. */
|
|
165
|
+
const pressAnyKey = (message) => new Promise((resolve, reject) => {
|
|
166
|
+
process.stdout.write('\n' + message);
|
|
167
|
+
const { stdin } = process;
|
|
168
|
+
stdin.setRawMode(true);
|
|
169
|
+
stdin.resume();
|
|
170
|
+
stdin.once('data', (key) => {
|
|
171
|
+
stdin.setRawMode(false);
|
|
172
|
+
stdin.pause();
|
|
173
|
+
process.stdout.write('\n');
|
|
174
|
+
if (key[0] === 3)
|
|
175
|
+
reject(new ExitRequested());
|
|
176
|
+
else
|
|
177
|
+
resolve();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
export const menu = (ctx, run) => new Menu(ctx, run).start();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SettingsCategory {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description: string;
|
|
5
|
+
keys: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare const CATEGORIES: SettingsCategory[];
|
|
8
|
+
export declare const nameOf: (key: string) => string;
|
|
9
|
+
export declare const categoryOf: (topKey: string) => SettingsCategory | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// How the settings are grouped on screen: categories in the sidebar, readable names for sections and toolchains. Every
|
|
2
|
+
// top-level setting of both files must belong to a category (a self-test checks it), so nothing is ever hidden.
|
|
3
|
+
import { Text } from '../../tui/index.js';
|
|
4
|
+
export const CATEGORIES = [
|
|
5
|
+
{ id: 'general', label: 'General', description: 'Which agent starts, where the project appears in the sandbox, and extra folders from this PC.', keys: ['defaultHarness', 'harness', 'guestWorkspace', 'readOnly', 'mappings', 'scripts'] },
|
|
6
|
+
{ id: 'machine', label: 'Sandbox machine', description: 'Memory, CPU, window, GPU, clipboard, audio and network of the sandbox itself.', keys: ['sandbox'] },
|
|
7
|
+
{ id: 'languages', label: 'Languages & tools', description: 'Toolchains installed in the sandbox. "auto" follows your project\'s files and what this PC has.', keys: ['toolchains'] },
|
|
8
|
+
{ id: 'apps', label: 'Apps', description: 'Desktop apps installed in the sandbox: editors, database clients, API clients, game engines ... (also: Apps catalogue on the home screen).', keys: ['apps'] },
|
|
9
|
+
{ id: 'databases', label: 'Databases', description: 'Databases that run inside the sandbox. Their data persists between sandboxes.', keys: ['databases'] },
|
|
10
|
+
{ id: 'agents', label: 'Agents & attaching', description: 'The agents you can start, attaching your terminal to them, and the (Beta) agent commands.', keys: ['harnesses', 'attach', 'agentCommands'] },
|
|
11
|
+
{ id: 'claude', label: 'Claude profile', description: 'Which of your Claude settings, history and memory are copied in and synced back.', keys: ['claudeProfile'] },
|
|
12
|
+
{ id: 'network', label: 'Network', description: 'What the sandbox can reach: your LAN, only some hosts, blocked hosts, DNS, a proxy, and ports forwarded from this PC to servers inside. (No network at all: Sandbox machine > Networking.)', keys: ['network'] },
|
|
13
|
+
{ id: 'security', label: 'Security & privacy', description: 'Git-ignored files kept out, secrets kept out of the sandbox, download checks and what is kept on this PC.', keys: ['workspaceFilter', 'audit', 'secrets', 'downloads', 'storage'] },
|
|
14
|
+
{ id: 'setup', label: 'Setup & hooks', description: 'How tools get into the sandbox (copied from this PC or downloaded), Git, the terminal, and hook scripts.', keys: ['provisioning', 'hooks'] },
|
|
15
|
+
{ id: 'projects', label: 'Projects', description: 'Several projects in one sandbox: project roots mapped at every start, adding folders while it runs.', keys: ['projects'] },
|
|
16
|
+
{ id: 'desktop', label: 'Desktop look', description: 'Copy your theme, accent colour, taskbar and wallpaper into the sandbox.', keys: ['personalization'] },
|
|
17
|
+
];
|
|
18
|
+
/** Readable names for sections and toolchains (anything missing gets a name made from its key). */
|
|
19
|
+
const NAMES = {
|
|
20
|
+
sandbox: 'Sandbox machine', attach: 'Attaching your terminal', agentCommands: 'Agent commands (Beta)', provisioning: 'Provisioning', downloads: 'Downloads',
|
|
21
|
+
secrets: 'Secrets', claudeProfile: 'Claude profile', storage: 'Storage on this PC', network: 'Network', hooks: 'Hooks', personalization: 'Desktop look',
|
|
22
|
+
projects: 'Projects', harnesses: 'Agents', databases: 'Databases', toolchains: 'Toolchains', git: 'Git', terminal: 'Windows Terminal', conpty: 'Pseudo console',
|
|
23
|
+
node: 'Node.js', python: 'Python', java: 'Java (JDK)', dotnet: '.NET', go: 'Go', rust: 'Rust', cpp: 'C / C++ (MinGW)', msvc: 'Visual C++ (MSVC)', ruby: 'Ruby',
|
|
24
|
+
php: 'PHP', deno: 'Deno', bun: 'Bun', pwsh: 'PowerShell 7', vscode: 'VS Code', mesa: 'OpenGL (Mesa)', uv: 'uv', pnpm: 'pnpm', yarn: 'Yarn', cli: 'Command-line tools',
|
|
25
|
+
postgres: 'PostgreSQL', mysql: 'MySQL', mongodb: 'MongoDB', redis: 'Redis', dynamodb: 'DynamoDB Local', environment: 'Environment variables', workspaceFilter: 'Git-ignored files', audit: 'Audit log',
|
|
26
|
+
};
|
|
27
|
+
export const nameOf = (key) => NAMES[key] ?? Text.humanize(key);
|
|
28
|
+
export const categoryOf = (topKey) => CATEGORIES.find((c) => c.keys.includes(topKey));
|