@zenera/cli 1.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/LICENSE +21 -0
- package/README.md +239 -0
- package/dist/args.d.ts +40 -0
- package/dist/args.js +99 -0
- package/dist/audit.d.ts +53 -0
- package/dist/audit.js +144 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +103 -0
- package/dist/command.d.ts +14 -0
- package/dist/command.js +12 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +287 -0
- package/dist/commands/index.d.ts +22 -0
- package/dist/commands/index.js +56 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/inspect.d.ts +3 -0
- package/dist/commands/inspect.js +158 -0
- package/dist/commands/key.d.ts +3 -0
- package/dist/commands/key.js +335 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +101 -0
- package/dist/commands/models.d.ts +9 -0
- package/dist/commands/models.js +120 -0
- package/dist/commands/open.d.ts +9 -0
- package/dist/commands/open.js +270 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +167 -0
- package/dist/commands/sandbox.d.ts +3 -0
- package/dist/commands/sandbox.js +112 -0
- package/dist/commands/version.d.ts +6 -0
- package/dist/commands/version.js +39 -0
- package/dist/engine.d.ts +49 -0
- package/dist/engine.js +208 -0
- package/dist/external.d.ts +10 -0
- package/dist/external.js +56 -0
- package/dist/home.d.ts +31 -0
- package/dist/home.js +108 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.js +44 -0
- package/dist/keys.d.ts +124 -0
- package/dist/keys.js +309 -0
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +31 -0
- package/dist/liveness.d.ts +23 -0
- package/dist/liveness.js +221 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +155 -0
- package/dist/narrate.d.ts +19 -0
- package/dist/narrate.js +124 -0
- package/dist/podman.d.ts +46 -0
- package/dist/podman.js +254 -0
- package/dist/projects.d.ts +70 -0
- package/dist/projects.js +232 -0
- package/dist/resolve.d.ts +27 -0
- package/dist/resolve.js +138 -0
- package/dist/sandbox.d.ts +36 -0
- package/dist/sandbox.js +104 -0
- package/dist/scaffold.d.ts +29 -0
- package/dist/scaffold.js +220 -0
- package/dist/session.d.ts +77 -0
- package/dist/session.js +156 -0
- package/dist/term.d.ts +69 -0
- package/dist/term.js +242 -0
- package/dist/tui/app.d.ts +8 -0
- package/dist/tui/app.js +257 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +134 -0
- package/dist/tui/wrap.d.ts +12 -0
- package/dist/tui/wrap.js +62 -0
- package/dist/validate.d.ts +145 -0
- package/dist/validate.js +959 -0
- package/package.json +76 -0
- package/templates/.github/copilot-instructions.md +1579 -0
- package/templates/.github/prompts/new-agent.prompt.md +38 -0
- package/templates/.github/prompts/new-skill.prompt.md +37 -0
- package/templates/.github/prompts/review-project.prompt.md +31 -0
- package/templates/.github/skills/zen-cli/SKILL.md +110 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { loadProject, readProjectConfig } from '@zenera/neo';
|
|
2
|
+
import { parse } from "../args.js";
|
|
3
|
+
import { KeyStore, PROVIDERS, SHAPES } from "../keys.js";
|
|
4
|
+
import { project as resolveProject } from "../resolve.js";
|
|
5
|
+
import { bold, credentialError, cyan, dim, green, invalidError, json, note, red, table, writeAll, yellow, } from "../term.js";
|
|
6
|
+
import { availableTools } from "../validate.js";
|
|
7
|
+
const USAGE = 'zen models [--project <name|dir>]';
|
|
8
|
+
/**
|
|
9
|
+
* Everything a run would resolve, resolved — and nothing called. Loading a
|
|
10
|
+
* project constructs the model clients, so a config that names an impossible
|
|
11
|
+
* provider or an agent that hands off to nobody fails here, in a command that
|
|
12
|
+
* costs nothing, instead of three seconds into a run that costs money.
|
|
13
|
+
*/
|
|
14
|
+
export const models = {
|
|
15
|
+
summary: 'Resolve providers and models and validate the config, calling nothing.',
|
|
16
|
+
usage: USAGE,
|
|
17
|
+
run: async (ctx) => {
|
|
18
|
+
const { values } = parse(ctx.args, { project: { type: 'string' } }, USAGE);
|
|
19
|
+
const found = await resolveProject({ cwd: ctx.cwd, project: values.project });
|
|
20
|
+
const dir = found.dir;
|
|
21
|
+
const store = await KeyStore.open();
|
|
22
|
+
// Asked before materialising, because materialising is exactly what
|
|
23
|
+
// erases the difference between "the environment had it" and "the
|
|
24
|
+
// keyring supplied it".
|
|
25
|
+
const fromEnv = new Set(PROVIDERS.filter((p) => process.env[SHAPES[p].env]));
|
|
26
|
+
store.materialize();
|
|
27
|
+
let project;
|
|
28
|
+
try {
|
|
29
|
+
// The same tools a run would register. Without them every
|
|
30
|
+
// `workspace:*` or `sandbox:*` selector in the config resolves
|
|
31
|
+
// against an empty list and the project fails to load with
|
|
32
|
+
// "no tools in group" — a report about this command, not about
|
|
33
|
+
// the project.
|
|
34
|
+
const { config } = readProjectConfig(dir);
|
|
35
|
+
project = await loadProject(dir, { tools: availableTools(dir, config) });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
// Constructing a client needs a credential, so a keyless machine
|
|
40
|
+
// cannot get far enough to validate anything. Saying "invalid
|
|
41
|
+
// project" there would be a lie about whose fault it is.
|
|
42
|
+
if (!fromEnv.size && !PROVIDERS.some((p) => store.active(p))) {
|
|
43
|
+
throw credentialError(message, 'add a key: zen key add openai');
|
|
44
|
+
}
|
|
45
|
+
throw invalidError(message, dir);
|
|
46
|
+
}
|
|
47
|
+
const agents = project.agents.map((a) => ({
|
|
48
|
+
name: a.name,
|
|
49
|
+
entry: a.name === project.entry,
|
|
50
|
+
model: a.model?.id ?? project.config.model ?? null,
|
|
51
|
+
tools: a.tools.map((t) => t.name),
|
|
52
|
+
handoffs: a.handoffs,
|
|
53
|
+
}));
|
|
54
|
+
// Resolved through the registry, which proves the ref parses and the
|
|
55
|
+
// provider exists without any call being made.
|
|
56
|
+
const embeddings = Object.keys(project.config.embeddings ?? {})
|
|
57
|
+
.concat(project.config.embedding && !project.config.embeddings?.[project.config.embedding]
|
|
58
|
+
? [project.config.embedding]
|
|
59
|
+
: [])
|
|
60
|
+
.map((name) => ({
|
|
61
|
+
name,
|
|
62
|
+
model: project.embedder(name)?.id ?? null,
|
|
63
|
+
default: name === project.config.embedding,
|
|
64
|
+
}));
|
|
65
|
+
const credentials = PROVIDERS.map((p) => ({
|
|
66
|
+
provider: p,
|
|
67
|
+
env: SHAPES[p].env,
|
|
68
|
+
source: fromEnv.has(p)
|
|
69
|
+
? 'environment'
|
|
70
|
+
: store.active(p)
|
|
71
|
+
? 'keyring'
|
|
72
|
+
: 'missing',
|
|
73
|
+
}));
|
|
74
|
+
if (ctx.json) {
|
|
75
|
+
json({
|
|
76
|
+
project: found.name,
|
|
77
|
+
source: project.source,
|
|
78
|
+
providers: project.models.names(),
|
|
79
|
+
agents,
|
|
80
|
+
embeddings,
|
|
81
|
+
credentials,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
note(`${bold(found.name)} ${dim(project.source)}`);
|
|
86
|
+
note('');
|
|
87
|
+
note(bold('Agents'));
|
|
88
|
+
writeAll(table(agents.map((a) => [
|
|
89
|
+
` ${a.entry ? green('→') : ' '} ${a.name}`,
|
|
90
|
+
cyan(a.model ?? dim('inherited')),
|
|
91
|
+
dim(a.tools.length ? a.tools.join(' ') : 'no tools'),
|
|
92
|
+
dim(a.handoffs.length ? `→ ${a.handoffs.join(', ')}` : ''),
|
|
93
|
+
])));
|
|
94
|
+
if (embeddings.length) {
|
|
95
|
+
note('');
|
|
96
|
+
note(bold('Embeddings'));
|
|
97
|
+
writeAll(table(embeddings.map((e) => [
|
|
98
|
+
` ${e.default ? green('→') : ' '} ${e.name}`,
|
|
99
|
+
cyan(e.model ?? dim('unresolved')),
|
|
100
|
+
])));
|
|
101
|
+
}
|
|
102
|
+
note('');
|
|
103
|
+
note(bold('Credentials'));
|
|
104
|
+
writeAll(table(credentials.map((c) => [
|
|
105
|
+
` ${c.provider}`,
|
|
106
|
+
dim(c.env),
|
|
107
|
+
c.source === 'missing' ? red('missing') : green(c.source),
|
|
108
|
+
])));
|
|
109
|
+
const declared = project.models.names();
|
|
110
|
+
if (declared.length) {
|
|
111
|
+
note('');
|
|
112
|
+
note(`${bold('Providers')} ${dim(declared.join(', '))}`);
|
|
113
|
+
}
|
|
114
|
+
if (credentials.every((c) => c.source === 'missing')) {
|
|
115
|
+
note('');
|
|
116
|
+
note(yellow('nothing can be reached — add a key: zen key add openai'));
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
//# sourceMappingURL=models.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from '../command.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A project is resolved by name or from the cwd, and the path is handed to an
|
|
4
|
+
* editor. It exists because opening the project is the thing everybody does
|
|
5
|
+
* second, and because getting the editor right — which one, with which
|
|
6
|
+
* arguments, attached or detached — is fiddly enough to be worth settling once.
|
|
7
|
+
*/
|
|
8
|
+
export declare const open: Command;
|
|
9
|
+
//# sourceMappingURL=open.d.ts.map
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { delimiter, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { one, parse } from "../args.js";
|
|
6
|
+
import { project as resolveProject } from "../resolve.js";
|
|
7
|
+
import { copilotInstructions, editorSettings } from "../scaffold.js";
|
|
8
|
+
import { CliError, cyan, dim, EXIT, json, note, usageError } from "../term.js";
|
|
9
|
+
const USAGE = 'zen open [project] [--editor <cmd>] [--wait]';
|
|
10
|
+
/**
|
|
11
|
+
* A project is resolved by name or from the cwd, and the path is handed to an
|
|
12
|
+
* editor. It exists because opening the project is the thing everybody does
|
|
13
|
+
* second, and because getting the editor right — which one, with which
|
|
14
|
+
* arguments, attached or detached — is fiddly enough to be worth settling once.
|
|
15
|
+
*/
|
|
16
|
+
export const open = {
|
|
17
|
+
summary: 'Open a project in your editor.',
|
|
18
|
+
usage: USAGE,
|
|
19
|
+
details: [
|
|
20
|
+
'Editor: --editor, then $ZENERA_EDITOR, then the editor this terminal',
|
|
21
|
+
'belongs to, then $VISUAL or $EDITOR, then a known editor on PATH or',
|
|
22
|
+
'installed, then the platform opener.',
|
|
23
|
+
'The editor files `zen init` writes (.vscode/settings.json and the',
|
|
24
|
+
'.github/ tree) are refreshed in the directory being opened; edits to',
|
|
25
|
+
'them do not survive.',
|
|
26
|
+
'VS Code and its forks are launched with --disable-workspace-trust so',
|
|
27
|
+
'the settings written there apply to the window straight away.',
|
|
28
|
+
],
|
|
29
|
+
run: async (ctx) => {
|
|
30
|
+
const { values, positionals } = parse(ctx.args, {
|
|
31
|
+
editor: { type: 'string' },
|
|
32
|
+
wait: { type: 'boolean' },
|
|
33
|
+
}, USAGE);
|
|
34
|
+
const found = await resolveProject({
|
|
35
|
+
cwd: ctx.cwd,
|
|
36
|
+
project: one(positionals, 'project', USAGE),
|
|
37
|
+
});
|
|
38
|
+
const dir = found.dir;
|
|
39
|
+
const editor = choose(values.editor, values.wait === true);
|
|
40
|
+
verify(editor);
|
|
41
|
+
// The editor reads the settings and instructions of the folder it was
|
|
42
|
+
// opened on, and a project may predate either of them — or the version
|
|
43
|
+
// this `zen` writes. They are ours, so they are written fresh before
|
|
44
|
+
// the window is there to read them.
|
|
45
|
+
const written = [editorSettings(dir), ...copilotInstructions(dir)];
|
|
46
|
+
if (ctx.json) {
|
|
47
|
+
json({
|
|
48
|
+
name: found.name,
|
|
49
|
+
path: dir,
|
|
50
|
+
editor: [editor.command, ...editor.args].join(' '),
|
|
51
|
+
from: editor.from,
|
|
52
|
+
files: written,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
note(`opening ${cyan(dir)} with ${editor.label} ${dim(`(${editor.from})`)}`);
|
|
57
|
+
for (const file of written) {
|
|
58
|
+
note(` ${dim(`wrote ${file}`)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await launch(editor, dir);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Choosing one
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* The window would otherwise open untrusted, and the settings `zen init`
|
|
69
|
+
* writes into the project are restricted ones: they are ignored until someone
|
|
70
|
+
* clicks through the trust dialog. This is a directory the user just asked to
|
|
71
|
+
* open by name, so answering that question for them is the point.
|
|
72
|
+
*/
|
|
73
|
+
const TRUST = '--disable-workspace-trust';
|
|
74
|
+
/** Guessed only when nothing was asked for, in the order they are tried. */
|
|
75
|
+
const KNOWN = [
|
|
76
|
+
{ command: 'code', label: 'VS Code', app: 'Visual Studio Code', trusts: true },
|
|
77
|
+
{ command: 'cursor', label: 'Cursor', app: 'Cursor', trusts: true },
|
|
78
|
+
{
|
|
79
|
+
command: 'code-insiders',
|
|
80
|
+
label: 'VS Code Insiders',
|
|
81
|
+
app: 'Visual Studio Code - Insiders',
|
|
82
|
+
trusts: true,
|
|
83
|
+
},
|
|
84
|
+
{ command: 'windsurf', label: 'Windsurf', app: 'Windsurf', trusts: true },
|
|
85
|
+
{ command: 'zed', label: 'Zed', app: 'Zed', waits: '-w' },
|
|
86
|
+
{ command: 'subl', label: 'Sublime Text', app: 'Sublime Text', waits: '-w' },
|
|
87
|
+
{ command: 'idea', label: 'IntelliJ IDEA', app: 'IntelliJ IDEA' },
|
|
88
|
+
];
|
|
89
|
+
function choose(asked, wait) {
|
|
90
|
+
const explicit = asked ?? process.env.ZENERA_EDITOR;
|
|
91
|
+
if (explicit !== undefined && explicit.trim() !== '') {
|
|
92
|
+
return {
|
|
93
|
+
...split(explicit),
|
|
94
|
+
from: asked !== undefined ? '--editor' : '$ZENERA_EDITOR',
|
|
95
|
+
attached: wait,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Before $EDITOR, deliberately. $EDITOR conventionally names something to
|
|
99
|
+
// edit *a file* with — it is very often `vim`, set once years ago — and
|
|
100
|
+
// this command opens a directory as a project. The editor you are already
|
|
101
|
+
// sitting in is the better answer to that, and $ZENERA_EDITOR above is the
|
|
102
|
+
// way to say otherwise.
|
|
103
|
+
const host = hosting(wait);
|
|
104
|
+
if (host !== undefined)
|
|
105
|
+
return host;
|
|
106
|
+
// $VISUAL and $EDITOR are, by convention, editors that run *in* the
|
|
107
|
+
// terminal, so they are attached and waited for unless proven otherwise.
|
|
108
|
+
for (const name of ['VISUAL', 'EDITOR']) {
|
|
109
|
+
const value = process.env[name];
|
|
110
|
+
if (value !== undefined && value.trim() !== '') {
|
|
111
|
+
return { ...split(value), from: `$${name}`, attached: true };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const known of KNOWN) {
|
|
115
|
+
if (lookup(known.command) !== undefined) {
|
|
116
|
+
return {
|
|
117
|
+
command: known.command,
|
|
118
|
+
args: [
|
|
119
|
+
...(known.trusts === true ? [TRUST] : []),
|
|
120
|
+
...(wait ? [known.waits ?? '--wait'] : []),
|
|
121
|
+
],
|
|
122
|
+
label: known.label,
|
|
123
|
+
from: 'PATH',
|
|
124
|
+
attached: wait,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Installed but never asked to add itself to PATH — which is the default
|
|
129
|
+
// for VS Code on macOS, and so the case this whole branch exists for.
|
|
130
|
+
// LaunchServices can open it without any of that.
|
|
131
|
+
for (const known of KNOWN) {
|
|
132
|
+
const bundle = known.app === undefined ? undefined : installed(known.app);
|
|
133
|
+
if (bundle !== undefined) {
|
|
134
|
+
return {
|
|
135
|
+
command: 'open',
|
|
136
|
+
args: wait ? ['-W', '-a', bundle] : ['-a', bundle],
|
|
137
|
+
label: known.label,
|
|
138
|
+
from: 'Applications',
|
|
139
|
+
attached: wait,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Nothing named an editor, so hand the directory to the desktop and let it
|
|
144
|
+
// decide. On macOS that is Finder unless a folder handler is registered.
|
|
145
|
+
const command = process.platform === 'darwin'
|
|
146
|
+
? 'open'
|
|
147
|
+
: process.platform === 'win32'
|
|
148
|
+
? 'explorer'
|
|
149
|
+
: 'xdg-open';
|
|
150
|
+
return { command, args: [], label: command, from: 'system', attached: false };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The editor whose integrated terminal this is. VS Code and every fork of it
|
|
154
|
+
* export `VSCODE_GIT_ASKPASS_MAIN`, which points inside the running
|
|
155
|
+
* installation, so the app root is four directories up from it and
|
|
156
|
+
* `product.json` there names both the CLI and the product. That is exact where
|
|
157
|
+
* guessing is not: it picks Cursor when you are in Cursor, and it works when
|
|
158
|
+
* the shell command was never installed, which on macOS is the default.
|
|
159
|
+
*/
|
|
160
|
+
function hosting(wait) {
|
|
161
|
+
const askpass = process.env.VSCODE_GIT_ASKPASS_MAIN;
|
|
162
|
+
if (process.env.TERM_PROGRAM !== 'vscode' || askpass === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
// <appRoot>/extensions/git/dist/askpass-main.js
|
|
165
|
+
const appRoot = resolve(askpass, '..', '..', '..', '..');
|
|
166
|
+
let product;
|
|
167
|
+
try {
|
|
168
|
+
product = JSON.parse(readFileSync(join(appRoot, 'product.json'), 'utf8'));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined; // not a layout we recognise; fall through to the guesses
|
|
172
|
+
}
|
|
173
|
+
if (product.applicationName === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
const cli = join(appRoot, 'bin', product.applicationName);
|
|
176
|
+
for (const candidate of [cli, `${cli}.cmd`]) {
|
|
177
|
+
if (!existsSync(candidate))
|
|
178
|
+
continue;
|
|
179
|
+
return {
|
|
180
|
+
command: candidate,
|
|
181
|
+
args: wait ? [TRUST, '--wait'] : [TRUST],
|
|
182
|
+
label: product.nameLong ?? product.applicationName,
|
|
183
|
+
from: 'this terminal',
|
|
184
|
+
attached: wait,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
/** The macOS bundle for an application name, in either place they live. */
|
|
190
|
+
function installed(app) {
|
|
191
|
+
if (process.platform !== 'darwin')
|
|
192
|
+
return undefined;
|
|
193
|
+
for (const dir of ['/Applications', join(homedir(), 'Applications')]) {
|
|
194
|
+
const bundle = join(dir, `${app}.app`);
|
|
195
|
+
if (existsSync(bundle))
|
|
196
|
+
return bundle;
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* `$EDITOR` may carry arguments — `code -n`, `emacsclient -c`. Splitting on
|
|
202
|
+
* whitespace rather than handing the string to a shell: the path is always an
|
|
203
|
+
* argument, so nothing in it is ever interpreted.
|
|
204
|
+
*/
|
|
205
|
+
function split(value) {
|
|
206
|
+
const parts = value.trim().split(/\s+/);
|
|
207
|
+
return { command: parts[0], args: parts.slice(1), label: parts[0] };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* PATH lookup without shelling out to `which`, so the resolution failure can
|
|
211
|
+
* be reported before anything is spawned rather than as an ENOENT nobody sees
|
|
212
|
+
* on a detached child.
|
|
213
|
+
*/
|
|
214
|
+
function lookup(command) {
|
|
215
|
+
if (command.includes(sep) || command.includes('/')) {
|
|
216
|
+
return existsSync(command) ? command : undefined;
|
|
217
|
+
}
|
|
218
|
+
const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
219
|
+
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
|
220
|
+
if (dir === '')
|
|
221
|
+
continue;
|
|
222
|
+
for (const extension of extensions) {
|
|
223
|
+
const candidate = join(dir, command + extension);
|
|
224
|
+
try {
|
|
225
|
+
accessSync(candidate, constants.X_OK);
|
|
226
|
+
return candidate;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// not here, keep walking
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Running it
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
/**
|
|
239
|
+
* Both ways this can be wrong, settled before anything is spawned and before
|
|
240
|
+
* anything is said: an ENOENT on a detached child is a failure nobody would
|
|
241
|
+
* ever see, and a terminal editor with no terminal to take over would hang.
|
|
242
|
+
*/
|
|
243
|
+
function verify(editor) {
|
|
244
|
+
if (editor.from !== 'system' && lookup(editor.command) === undefined) {
|
|
245
|
+
throw usageError(`no such editor: ${editor.command}`, editor.from === 'PATH'
|
|
246
|
+
? 'name one with --editor, or set $ZENERA_EDITOR'
|
|
247
|
+
: `${editor.from} names a command that is not on PATH`);
|
|
248
|
+
}
|
|
249
|
+
if ((editor.from === '$VISUAL' || editor.from === '$EDITOR') && !process.stdin.isTTY) {
|
|
250
|
+
throw usageError(`${editor.from} names a terminal editor and there is no terminal here`, 'name a windowed one with --editor, or set $ZENERA_EDITOR');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function launch(editor, dir) {
|
|
254
|
+
const args = [...editor.args, dir];
|
|
255
|
+
if (!editor.attached) {
|
|
256
|
+
// Detached and unreferenced: the window outlives this process, which is
|
|
257
|
+
// the whole point of a GUI editor.
|
|
258
|
+
spawn(editor.command, args, { stdio: 'ignore', detached: true }).unref();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const code = await new Promise((settle, reject) => {
|
|
262
|
+
const child = spawn(editor.command, args, { stdio: 'inherit' });
|
|
263
|
+
child.once('error', reject);
|
|
264
|
+
child.once('close', (status) => settle(status ?? 0));
|
|
265
|
+
});
|
|
266
|
+
if (code !== 0) {
|
|
267
|
+
throw new CliError(`${editor.label} exited with ${code}`, EXIT.failed);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
//# sourceMappingURL=open.js.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from "../args.js";
|
|
3
|
+
import * as Engine from "../engine.js";
|
|
4
|
+
import { duration, Narrator, stopMark, summary } from "../narrate.js";
|
|
5
|
+
import * as Projects from "../projects.js";
|
|
6
|
+
import { target } from "../resolve.js";
|
|
7
|
+
import { display } from "../session.js";
|
|
8
|
+
import { bold, cyan, dim, json, note, readStdin, usageError, write } from "../term.js";
|
|
9
|
+
import { parseChoice } from "../tui/theme.js";
|
|
10
|
+
const USAGE = 'zen run [project] [prompt] [options]';
|
|
11
|
+
export const run = {
|
|
12
|
+
summary: 'Run the project — the TUI on a terminal, one shot otherwise.',
|
|
13
|
+
usage: USAGE,
|
|
14
|
+
details: [
|
|
15
|
+
' --project <name|dir> Which project. Inferred from the directory.',
|
|
16
|
+
' --session <id> Continue a particular session.',
|
|
17
|
+
' --new Start a fresh one.',
|
|
18
|
+
' --workspace <dir> What the agent can read and write.',
|
|
19
|
+
' --model <ref> Override the default model.',
|
|
20
|
+
' --image <ref> Override the sandbox image commands run in.',
|
|
21
|
+
' --read-only Give the agent no way to write.',
|
|
22
|
+
' --quiet Answer only; no narration.',
|
|
23
|
+
' --plain One shot, even on a terminal.',
|
|
24
|
+
' --theme <dark|light> Force the palette. Detected otherwise; $ZENERA_THEME.',
|
|
25
|
+
' --out <file> Write the answer here as well as to stdout.',
|
|
26
|
+
' --yes Accept the questions this would otherwise ask.',
|
|
27
|
+
'',
|
|
28
|
+
'The first word is the project when it names one, as in `zen run acme`,',
|
|
29
|
+
'and the first word of the prompt when it does not. --project settles it.',
|
|
30
|
+
'',
|
|
31
|
+
'The prompt comes from the argument, or stdin, or the TUI. There is no',
|
|
32
|
+
'`resume`: a session continues itself, because its state is what it is.',
|
|
33
|
+
'',
|
|
34
|
+
'With a prompt on the command line nothing is asked: a fresh session, the',
|
|
35
|
+
'directory you are in as the workspace, writable. --session, --workspace',
|
|
36
|
+
'and --read-only override that; the TUI still asks.',
|
|
37
|
+
],
|
|
38
|
+
run: async (ctx) => {
|
|
39
|
+
const { values, positionals } = parse(ctx.args, {
|
|
40
|
+
project: { type: 'string' },
|
|
41
|
+
session: { type: 'string' },
|
|
42
|
+
new: { type: 'boolean' },
|
|
43
|
+
workspace: { type: 'string' },
|
|
44
|
+
model: { type: 'string' },
|
|
45
|
+
image: { type: 'string' },
|
|
46
|
+
'read-only': { type: 'boolean' },
|
|
47
|
+
yes: { type: 'boolean' },
|
|
48
|
+
quiet: { type: 'boolean' },
|
|
49
|
+
plain: { type: 'boolean' },
|
|
50
|
+
theme: { type: 'string' },
|
|
51
|
+
out: { type: 'string' },
|
|
52
|
+
}, USAGE);
|
|
53
|
+
const piped = await readStdin();
|
|
54
|
+
// `zen run acme` is what everyone types before finding --project, and a
|
|
55
|
+
// project name is a bare word where a prompt is a sentence. So the
|
|
56
|
+
// first positional is read as a project when it names one and as the
|
|
57
|
+
// first word of the prompt when it does not — which also makes
|
|
58
|
+
// `zen run acme "what changed?"` mean what it looks like. --project is
|
|
59
|
+
// there for the day a project is called "why".
|
|
60
|
+
const [head, ...rest] = positionals;
|
|
61
|
+
const named = !values.project && head ? await Projects.find(head) : undefined;
|
|
62
|
+
const prompt = (named ? rest : positionals).join(' ').trim() || piped;
|
|
63
|
+
if (values.theme !== undefined && !parseChoice(values.theme)) {
|
|
64
|
+
throw usageError(`unknown theme: ${values.theme}`, 'dark, light or auto');
|
|
65
|
+
}
|
|
66
|
+
// A prompt on the command line is a request for an answer, not a
|
|
67
|
+
// conversation to pick up. So it answers the three questions itself:
|
|
68
|
+
// a fresh session, the directory it was typed in as the workspace,
|
|
69
|
+
// and no confirmation for it — `zen run acme "what changed?"` should
|
|
70
|
+
// read the code that is right there. Every flag still wins, and the
|
|
71
|
+
// TUI, where there is someone to ask, still asks.
|
|
72
|
+
const shot = Boolean(prompt);
|
|
73
|
+
const where = await target({
|
|
74
|
+
cwd: ctx.cwd,
|
|
75
|
+
project: values.project ?? (named ? head : undefined),
|
|
76
|
+
session: values.session,
|
|
77
|
+
fresh: values.new || (shot && !values.session),
|
|
78
|
+
workspace: values.workspace ?? (shot ? ctx.cwd : undefined),
|
|
79
|
+
yes: values.yes || shot,
|
|
80
|
+
});
|
|
81
|
+
const engine = await Engine.open({
|
|
82
|
+
project: where.project,
|
|
83
|
+
session: where.session,
|
|
84
|
+
readOnly: values['read-only'],
|
|
85
|
+
model: values.model,
|
|
86
|
+
image: values.image,
|
|
87
|
+
yes: values.yes || ctx.json,
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
// The TUI is for the case it is actually good at: a person at a
|
|
91
|
+
// terminal, with nothing to say yet. A prompt on the command line
|
|
92
|
+
// is a request for an answer, and drawing a full-screen interface
|
|
93
|
+
// over it would be worse than not drawing one.
|
|
94
|
+
const drawing = !prompt &&
|
|
95
|
+
!values.plain &&
|
|
96
|
+
!values.quiet &&
|
|
97
|
+
!ctx.json &&
|
|
98
|
+
Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
99
|
+
if (drawing) {
|
|
100
|
+
const { start } = await import("../tui/app.js");
|
|
101
|
+
await start(engine, {
|
|
102
|
+
readOnly: Boolean(values['read-only']),
|
|
103
|
+
theme: values.theme,
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!prompt) {
|
|
108
|
+
throw usageError('nothing to ask', 'give a prompt, or pipe one in');
|
|
109
|
+
}
|
|
110
|
+
await once(engine, prompt, values, ctx.json, ctx.cwd);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await engine.close();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// One shot
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
async function once(engine, prompt, values, asJson, cwd) {
|
|
121
|
+
const narrator = new Narrator({
|
|
122
|
+
quiet: Boolean(values.quiet) || asJson,
|
|
123
|
+
live: Boolean(process.stderr.isTTY),
|
|
124
|
+
});
|
|
125
|
+
// Ctrl-C asks the run to stop rather than killing the process, so the turn
|
|
126
|
+
// still lands on disk and the session stays resumable.
|
|
127
|
+
const stopping = new AbortController();
|
|
128
|
+
const onInterrupt = () => stopping.abort();
|
|
129
|
+
process.once('SIGINT', onInterrupt);
|
|
130
|
+
if (!values.quiet && !asJson) {
|
|
131
|
+
note(`${bold(engine.name)} ${dim(engine.session.id)} ` + dim(display(engine.workspace, cwd)));
|
|
132
|
+
}
|
|
133
|
+
let outcome;
|
|
134
|
+
try {
|
|
135
|
+
outcome = await Engine.run(engine, prompt, narrator.handle, stopping.signal);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
narrator.done();
|
|
139
|
+
process.off('SIGINT', onInterrupt);
|
|
140
|
+
}
|
|
141
|
+
if (values.out) {
|
|
142
|
+
await writeFile(values.out, `${outcome.text}\n`, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
if (asJson) {
|
|
145
|
+
json({
|
|
146
|
+
session: engine.session.id,
|
|
147
|
+
run: outcome.run.id,
|
|
148
|
+
agent: outcome.result.agent,
|
|
149
|
+
stopReason: outcome.result.stopReason,
|
|
150
|
+
durationMs: outcome.durationMs,
|
|
151
|
+
usage: outcome.result.usage,
|
|
152
|
+
output: outcome.text,
|
|
153
|
+
report: outcome.run.report,
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
write(outcome.text);
|
|
158
|
+
if (!values.quiet) {
|
|
159
|
+
note('');
|
|
160
|
+
note(`${stopMark(outcome.result.stopReason)} ${dim(duration(outcome.durationMs))} ` +
|
|
161
|
+
dim(summary(outcome.result.usage)));
|
|
162
|
+
if (outcome.report) {
|
|
163
|
+
note(dim(`report: ${cyan(display(outcome.report, cwd))}`));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=run.js.map
|