@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
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { stamp } from "./ids.js";
|
|
4
|
+
import * as Projects from "./projects.js";
|
|
5
|
+
import { createSession, listSessions, requireSession, sessionPaths, } from "./session.js";
|
|
6
|
+
import { ago, choose, confirm, dim, isInteractive, usageError, warn, yellow } from "./term.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Project
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export async function project(want) {
|
|
11
|
+
if (want.project) {
|
|
12
|
+
return Projects.open(want.project);
|
|
13
|
+
}
|
|
14
|
+
const here = await Projects.current(want.cwd);
|
|
15
|
+
if (here) {
|
|
16
|
+
return here;
|
|
17
|
+
}
|
|
18
|
+
const registry = await Projects.Registry.open();
|
|
19
|
+
const known = registry.entries.filter((e) => existsSync(e.path));
|
|
20
|
+
if (known.length === 0) {
|
|
21
|
+
throw usageError('not inside a project, and none are registered', 'create one: zen init');
|
|
22
|
+
}
|
|
23
|
+
if (!isInteractive()) {
|
|
24
|
+
throw usageError('not inside a project', 'name one with --project');
|
|
25
|
+
}
|
|
26
|
+
const chosen = await choose('Which project?', known.map((e) => ({ label: e.name, detail: e.path, value: e })));
|
|
27
|
+
return Projects.openDir(chosen.path);
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Session
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
export async function target(want) {
|
|
33
|
+
const found = await project(want);
|
|
34
|
+
const dir = found.dir;
|
|
35
|
+
if (want.session && want.fresh) {
|
|
36
|
+
throw usageError('--session and --new contradict each other');
|
|
37
|
+
}
|
|
38
|
+
if (want.session) {
|
|
39
|
+
return {
|
|
40
|
+
project: found,
|
|
41
|
+
session: requireSession(dir, want.session),
|
|
42
|
+
created: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!want.fresh) {
|
|
46
|
+
const existing = await pickExisting(dir);
|
|
47
|
+
if (existing) {
|
|
48
|
+
return { project: found, session: existing, created: false };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { project: found, session: await create(dir, want), created: true };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resuming is the default: the common case is continuing what you were doing.
|
|
55
|
+
* With one session it is taken without asking; with several, the others are
|
|
56
|
+
* offered, because "the most recent" is only usually what you meant.
|
|
57
|
+
*
|
|
58
|
+
* A session that has never run has nothing to continue — it is indistinguishable
|
|
59
|
+
* from a fresh one, so it is left out rather than offered as a choice.
|
|
60
|
+
*/
|
|
61
|
+
async function pickExisting(projectDir) {
|
|
62
|
+
const sessions = (await listSessions(projectDir)).filter((s) => s.runs > 0 || s.busy);
|
|
63
|
+
if (sessions.length === 0) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (!isInteractive()) {
|
|
67
|
+
return sessionPaths(projectDir, sessions[0].id);
|
|
68
|
+
}
|
|
69
|
+
const choice = await choose('Session', [
|
|
70
|
+
...sessions.map((s) => ({
|
|
71
|
+
label: s.title ?? s.id,
|
|
72
|
+
detail: `${s.id} ${ago(s.lastRunAt ?? s.createdAt)} ` +
|
|
73
|
+
`${s.runs} run${s.runs === 1 ? '' : 's'}${s.busy ? yellow(' running') : ''}`,
|
|
74
|
+
value: s.id,
|
|
75
|
+
})),
|
|
76
|
+
{ label: dim('New session…'), value: undefined },
|
|
77
|
+
]);
|
|
78
|
+
return choice ? sessionPaths(projectDir, choice) : undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The id is minted before anything is written, so the workspace question can be
|
|
82
|
+
* asked against the real paths — and so a cancelled answer leaves no directory
|
|
83
|
+
* behind.
|
|
84
|
+
*/
|
|
85
|
+
async function create(projectDir, want) {
|
|
86
|
+
const id = stamp();
|
|
87
|
+
const planned = sessionPaths(projectDir, id);
|
|
88
|
+
const workspace = await chooseWorkspace(planned, want);
|
|
89
|
+
return createSession(projectDir, id, workspace);
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Workspace
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/**
|
|
95
|
+
* The workspace is what the agent can read and write, so pointing it outside
|
|
96
|
+
* the session is the useful case and the dangerous one. It is confirmed once,
|
|
97
|
+
* explicitly, naming the path — and a script has to say `--yes` to skip that.
|
|
98
|
+
* An agent with file tools rooted at `$HOME` should take more than one
|
|
99
|
+
* keystroke to arrange.
|
|
100
|
+
*/
|
|
101
|
+
export async function chooseWorkspace(session, want) {
|
|
102
|
+
const own = resolve(session.workspace);
|
|
103
|
+
if (want.workspace) {
|
|
104
|
+
const at = resolve(want.cwd, want.workspace);
|
|
105
|
+
await approve(at, session, want);
|
|
106
|
+
return at;
|
|
107
|
+
}
|
|
108
|
+
if (!isInteractive()) {
|
|
109
|
+
return own;
|
|
110
|
+
}
|
|
111
|
+
const chosen = await choose('Workspace — what the agent can read and write', [
|
|
112
|
+
{ label: 'A fresh, empty directory', detail: own, value: own },
|
|
113
|
+
{ label: 'The directory you started in', detail: want.cwd, value: want.cwd },
|
|
114
|
+
]);
|
|
115
|
+
await approve(chosen, session, want);
|
|
116
|
+
return chosen;
|
|
117
|
+
}
|
|
118
|
+
async function approve(at, session, want) {
|
|
119
|
+
if (contains(session.dir, at)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
warn(`the agent will be able to read and write ${at}`);
|
|
123
|
+
if (want.yes) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!isInteractive()) {
|
|
127
|
+
throw usageError('refusing a workspace outside the session without confirmation', 'pass --yes if that is what you meant');
|
|
128
|
+
}
|
|
129
|
+
if (!(await confirm('Continue?'))) {
|
|
130
|
+
throw usageError('cancelled');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function contains(parent, child) {
|
|
134
|
+
const from = resolve(parent);
|
|
135
|
+
const to = resolve(child);
|
|
136
|
+
return from === to || !relative(from, to).startsWith(`..${sep}`);
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxSpec } from '@zenera/neo';
|
|
2
|
+
import type { SessionPaths } from './session.ts';
|
|
3
|
+
export interface SandboxSetup {
|
|
4
|
+
pool: SandboxPool;
|
|
5
|
+
/** the resolved base spec, for `zn sandbox status` and the image pre-warm */
|
|
6
|
+
spec: SandboxSpec;
|
|
7
|
+
image: string;
|
|
8
|
+
/** host side of the container's `$HOME`, created only if it is ever needed */
|
|
9
|
+
home: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SandboxInputs {
|
|
12
|
+
config: ProjectConfig;
|
|
13
|
+
session: SessionPaths;
|
|
14
|
+
workspace: string;
|
|
15
|
+
readOnly?: boolean;
|
|
16
|
+
/** `--image` */
|
|
17
|
+
image?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
|
|
20
|
+
/**
|
|
21
|
+
* Whether anything in this project can reach a shell.
|
|
22
|
+
*
|
|
23
|
+
* Read off the *resolved* tool lists rather than off the config's selectors,
|
|
24
|
+
* because `sandbox:*`, `*` and a bare name all mean the same thing by the time
|
|
25
|
+
* the loader is done, and only one of those three is greppable.
|
|
26
|
+
*/
|
|
27
|
+
export declare function usesSandbox(project: AgentProject): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Asked before the first turn rather than at the first tool call, so a missing
|
|
30
|
+
* container engine costs nothing instead of costing a round trip and half a
|
|
31
|
+
* plan. A project that never shells out never gets here at all.
|
|
32
|
+
*/
|
|
33
|
+
export declare function preflight(setup: SandboxSetup, yes?: boolean): Promise<void>;
|
|
34
|
+
/** Best-effort teardown: losing a container must never lose a run. */
|
|
35
|
+
export declare function teardown(pool: SandboxPool): Promise<void>;
|
|
36
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
|
|
4
|
+
import { ensurePodmanReady } from "./podman.js";
|
|
5
|
+
import { warn } from "./term.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The sandbox, as this CLI wires it
|
|
8
|
+
//
|
|
9
|
+
// The library takes resolved values; the config states names and numbers. This
|
|
10
|
+
// is the layer between the two, and it owns the two decisions the library has
|
|
11
|
+
// no business making: which host variables are worth forwarding, and where the
|
|
12
|
+
// container's home directory lives.
|
|
13
|
+
//
|
|
14
|
+
// It lives in the session directory, which is the whole point. A session is
|
|
15
|
+
// already a self-contained thing — copy the directory and its conversation,
|
|
16
|
+
// memory and blobs travel with it — and a `pip install --user` that vanished
|
|
17
|
+
// on close would be the only part of a session that did not. So `$HOME` is a
|
|
18
|
+
// bind mount into `.data/sandbox/home`, and what the agent installs for itself
|
|
19
|
+
// is still there when the session is opened again. Everything *outside* the
|
|
20
|
+
// mounts is throwaway, which is what keeps a stale container from becoming a
|
|
21
|
+
// second, invisible configuration.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Where the persistent home lives, inside the container. */
|
|
24
|
+
const HOME = '/home/agent';
|
|
25
|
+
export function buildSandbox(opts) {
|
|
26
|
+
const base = { ...(opts.config.sandbox ?? {}), ...(opts.image ? { image: opts.image } : {}) };
|
|
27
|
+
const home = join(opts.session.data, 'sandbox', 'home');
|
|
28
|
+
const mounts = [{ host: home, at: HOME }];
|
|
29
|
+
const spec = toSpec(base, { HOME });
|
|
30
|
+
const agents = {};
|
|
31
|
+
for (const agent of opts.config.agents) {
|
|
32
|
+
if (agent.sandbox) {
|
|
33
|
+
agents[agent.name] = toSpec({ ...base, ...agent.sandbox }, { HOME });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const pool = new SandboxPool({
|
|
37
|
+
...spec,
|
|
38
|
+
agents,
|
|
39
|
+
root: opts.workspace,
|
|
40
|
+
key: opts.session.id,
|
|
41
|
+
readOnly: opts.readOnly,
|
|
42
|
+
mounts,
|
|
43
|
+
});
|
|
44
|
+
return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, home };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Config names a variable; this reads it. A name that is not set on this host
|
|
48
|
+
* is simply not forwarded — an empty string in the container is a different
|
|
49
|
+
* thing from an absent one, and tools test for absence.
|
|
50
|
+
*/
|
|
51
|
+
function toSpec(config, extra) {
|
|
52
|
+
const env = { ...extra };
|
|
53
|
+
for (const name of config.env ?? []) {
|
|
54
|
+
const value = process.env[name];
|
|
55
|
+
if (value !== undefined && value !== '') {
|
|
56
|
+
env[name] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
image: config.image,
|
|
61
|
+
cpus: config.cpus,
|
|
62
|
+
memory: config.memory,
|
|
63
|
+
network: config.network,
|
|
64
|
+
workdir: config.workdir,
|
|
65
|
+
timeout: config.timeout,
|
|
66
|
+
user: config.user,
|
|
67
|
+
persist: config.persist,
|
|
68
|
+
env,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether anything in this project can reach a shell.
|
|
73
|
+
*
|
|
74
|
+
* Read off the *resolved* tool lists rather than off the config's selectors,
|
|
75
|
+
* because `sandbox:*`, `*` and a bare name all mean the same thing by the time
|
|
76
|
+
* the loader is done, and only one of those three is greppable.
|
|
77
|
+
*/
|
|
78
|
+
export function usesSandbox(project) {
|
|
79
|
+
return project.agents.some((a) => a.tools.some((t) => t.group === SANDBOX_GROUP));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Asked before the first turn rather than at the first tool call, so a missing
|
|
83
|
+
* container engine costs nothing instead of costing a round trip and half a
|
|
84
|
+
* plan. A project that never shells out never gets here at all.
|
|
85
|
+
*/
|
|
86
|
+
export async function preflight(setup, yes) {
|
|
87
|
+
mkdirSync(setup.home, { recursive: true });
|
|
88
|
+
await ensurePodmanReady({
|
|
89
|
+
image: setup.image,
|
|
90
|
+
cpus: setup.spec.cpus,
|
|
91
|
+
memory: setup.spec.memory,
|
|
92
|
+
yes,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** Best-effort teardown: losing a container must never lose a run. */
|
|
96
|
+
export async function teardown(pool) {
|
|
97
|
+
try {
|
|
98
|
+
await pool.dispose();
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
warn(`could not clean up the sandbox: ${err instanceof Error ? err.message : String(err)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=sandbox.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `.vscode/settings.json` under `dir`, replacing what is there. The file
|
|
3
|
+
* is ours: it says how the editor is to treat a directory the agents write
|
|
4
|
+
* into, and a stale copy of that answer is worse than none. Returns the
|
|
5
|
+
* relative path.
|
|
6
|
+
*/
|
|
7
|
+
export declare function editorSettings(dir: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Writes the `.github/` tree under `dir`, replacing what is there — it
|
|
10
|
+
* describes the file formats of the version of `zen` in hand, so the current
|
|
11
|
+
* one is the only one worth having. Returns the relative paths written.
|
|
12
|
+
*/
|
|
13
|
+
export declare function copilotInstructions(dir: string): string[];
|
|
14
|
+
export interface ScaffoldOptions {
|
|
15
|
+
/** the project directory */
|
|
16
|
+
dir: string;
|
|
17
|
+
model: string;
|
|
18
|
+
/** extra lines for the model's configuration; their presence picks the object form */
|
|
19
|
+
modelOptions?: string;
|
|
20
|
+
/** give the default agent `exa:*` — set when a key for it is on hand */
|
|
21
|
+
web?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Writes a project. Never overwrites the project's own files: the caller
|
|
25
|
+
* decides whether it may. The editor files are the exception — they are ours,
|
|
26
|
+
* and are replaced.
|
|
27
|
+
*/
|
|
28
|
+
export declare function scaffold(opts: ScaffoldOptions): string[];
|
|
29
|
+
//# sourceMappingURL=scaffold.d.ts.map
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Scaffolding
|
|
6
|
+
//
|
|
7
|
+
// What `zen init` writes. Deliberately close to empty: a template full of
|
|
8
|
+
// commented-out options is a template nobody reads and everybody deletes. The
|
|
9
|
+
// one agent here works as written, and every other knob is in `docs/`.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const INSTRUCTIONS_MD = `# House rules
|
|
12
|
+
|
|
13
|
+
Everything in this file is prepended to every agent's prompt, so it is the
|
|
14
|
+
place for the things that are true regardless of who is answering: tone,
|
|
15
|
+
constraints, what to do when the answer is not knowable.
|
|
16
|
+
|
|
17
|
+
Replace this with yours.
|
|
18
|
+
`;
|
|
19
|
+
/**
|
|
20
|
+
* The `model:` section, which is one line until it has to say more.
|
|
21
|
+
*
|
|
22
|
+
* A shorthand cannot carry options, and the object form cannot carry a
|
|
23
|
+
* shorthand — its `model:` is the bare id the API is sent — so asking for
|
|
24
|
+
* reasoning means splitting the ref back into the two fields and giving the
|
|
25
|
+
* configuration a name to be referred to by.
|
|
26
|
+
*/
|
|
27
|
+
const MODEL_SECTION = (ref, options) => {
|
|
28
|
+
const colon = ref.indexOf(':');
|
|
29
|
+
if (!options || colon < 0) {
|
|
30
|
+
return ('# The model an agent uses when it does not pin its own. Change it here and the\n' +
|
|
31
|
+
'# whole project moves. The prefix is the *provider* name, not the vendor — drop\n' +
|
|
32
|
+
'# it and the id goes to the default provider, whatever the id looks like.\n' +
|
|
33
|
+
`model: ${ref}`);
|
|
34
|
+
}
|
|
35
|
+
const indented = options
|
|
36
|
+
.trimEnd()
|
|
37
|
+
.split('\n')
|
|
38
|
+
.map((line) => (line ? ` ${line}` : ''))
|
|
39
|
+
.join('\n');
|
|
40
|
+
return ('# The model an agent uses when it does not pin its own. Change it here and the\n' +
|
|
41
|
+
'# whole project moves. A named configuration is what gives the knobs below\n' +
|
|
42
|
+
'# somewhere to live; a bare `model: <provider>:<id>` works when there are none.\n' +
|
|
43
|
+
'models:\n' +
|
|
44
|
+
' main:\n' +
|
|
45
|
+
` provider: ${ref.slice(0, colon)}\n` +
|
|
46
|
+
` model: ${ref.slice(colon + 1)}\n` +
|
|
47
|
+
`${indented}\n` +
|
|
48
|
+
'\n' +
|
|
49
|
+
'model: main');
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Added above the tool list when the project is scaffolded with web access.
|
|
53
|
+
* The group is registered whether or not a key exists, so this only ever
|
|
54
|
+
* changes what the agent is allowed to reach for.
|
|
55
|
+
*/
|
|
56
|
+
const EXA_NOTE = `
|
|
57
|
+
#
|
|
58
|
+
# exa:* is web search and page reading, here because this machine has an
|
|
59
|
+
# Exa key. The key is read from the environment when a tool is called,
|
|
60
|
+
# so a clone of this project without one still loads and only the call
|
|
61
|
+
# fails.`;
|
|
62
|
+
const AGENTS_YAML = (model, options, web) => `# Who exists, and what they may reach for.
|
|
63
|
+
#
|
|
64
|
+
version: 1
|
|
65
|
+
|
|
66
|
+
${MODEL_SECTION(model, options)}
|
|
67
|
+
|
|
68
|
+
# The container \`sandbox:*\` commands run in. \`persist: true\` keeps it between
|
|
69
|
+
# runs instead of throwing it away, so what the agent installs is still there
|
|
70
|
+
# next time — otherwise only /workspace and its home directory survive, and an
|
|
71
|
+
# \`apt-get\` or a root \`pip install\` is repeated on every run. \`zen sandbox
|
|
72
|
+
# clean\` removes the ones left behind. Everything else has a default; see the
|
|
73
|
+
# sandbox: block in docs/agents-yaml.md to size or pin the image.
|
|
74
|
+
sandbox:
|
|
75
|
+
persist: true
|
|
76
|
+
|
|
77
|
+
agents:
|
|
78
|
+
- name: default
|
|
79
|
+
description: The entry point.
|
|
80
|
+
# Instructions live in agents/prompts/<name>.md and are picked up by
|
|
81
|
+
# convention — no need to name the file here.
|
|
82
|
+
#
|
|
83
|
+
# workspace:* is every file tool at once, sandbox:* is the shell. Name
|
|
84
|
+
# them one by one to be narrower, or subtract: [workspace:*, -delete_file]
|
|
85
|
+
#
|
|
86
|
+
# sandbox:* runs commands in a container, not on this machine, so it
|
|
87
|
+
# needs podman — \`zen run\` installs and starts what it can on its own,
|
|
88
|
+
# and \`zen sandbox status\` says where that got to. Drop the line if you
|
|
89
|
+
# would rather this agent never reached a shell.${web ? EXA_NOTE : ''}
|
|
90
|
+
tools:
|
|
91
|
+
- workspace:*
|
|
92
|
+
- sandbox:*${web ? '\n - exa:*' : ''}
|
|
93
|
+
`;
|
|
94
|
+
const PROMPT = `You are a helpful assistant working inside a project workspace.
|
|
95
|
+
|
|
96
|
+
You have tools to read, search and edit files. The workspace is the only
|
|
97
|
+
place you can see; paths are relative to its root.
|
|
98
|
+
|
|
99
|
+
You can also run shell commands. They run in a container over the same
|
|
100
|
+
workspace, not on the user's machine, so a command that fails there has cost
|
|
101
|
+
them nothing — but it is still their work in the directory, so read before you
|
|
102
|
+
overwrite and say what you ran.
|
|
103
|
+
|
|
104
|
+
Read a file before you change it: \`apply_patch\` matches the surrounding text
|
|
105
|
+
exactly, so a patch written from memory will not apply. Use \`apply_patch\` to
|
|
106
|
+
change part of a file and \`write_file\` only for a new one.
|
|
107
|
+
|
|
108
|
+
Say what you changed.
|
|
109
|
+
`;
|
|
110
|
+
const GITIGNORE = `# Sessions hold run state, memory, blobs and whatever the agent wrote.
|
|
111
|
+
# None of it is source.
|
|
112
|
+
sessions/
|
|
113
|
+
`;
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Telling the editor which instructions are not for it
|
|
116
|
+
//
|
|
117
|
+
// The project's house rules are `INSTRUCTIONS.md`, deliberately not
|
|
118
|
+
// `AGENTS.md`: every coding assistant now reads that name out of an open
|
|
119
|
+
// folder and feeds it to itself as always-on instructions, and `zen open`
|
|
120
|
+
// opens exactly this directory. A name nobody else claims means the two are
|
|
121
|
+
// never confused, and `chat.useAgentsMdFile` no longer has to be switched off
|
|
122
|
+
// to keep them apart.
|
|
123
|
+
//
|
|
124
|
+
// `chat.useNestedAgentsMdFiles` is still written. It is already false by
|
|
125
|
+
// default, but it is opt-in globally, and this is a directory the agent itself
|
|
126
|
+
// writes into — someone who turned it on would otherwise have the editor pick
|
|
127
|
+
// up whatever `AGENTS.md` a run happened to leave behind. It is a *restricted*
|
|
128
|
+
// setting, so it applies only in a trusted workspace; that is the right way
|
|
129
|
+
// round, since an untrusted folder is not one to run agents in either.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
const VSCODE_SETTINGS = `{
|
|
132
|
+
"chat.useNestedAgentsMdFiles": false,
|
|
133
|
+
"chat.tools.terminal.autoApprove": {
|
|
134
|
+
"zen": true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
/**
|
|
139
|
+
* Writes `.vscode/settings.json` under `dir`, replacing what is there. The file
|
|
140
|
+
* is ours: it says how the editor is to treat a directory the agents write
|
|
141
|
+
* into, and a stale copy of that answer is worse than none. Returns the
|
|
142
|
+
* relative path.
|
|
143
|
+
*/
|
|
144
|
+
export function editorSettings(dir) {
|
|
145
|
+
const rel = join('.vscode', 'settings.json');
|
|
146
|
+
mkdirSync(join(dir, '.vscode'), { recursive: true });
|
|
147
|
+
writeFileSync(join(dir, rel), VSCODE_SETTINGS);
|
|
148
|
+
return rel;
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// The other half of the editor story
|
|
152
|
+
//
|
|
153
|
+
// `INSTRUCTIONS.md` addresses the *project's* agents. The editor's assistant
|
|
154
|
+
// still needs a brief of its own, and what it needs to know is how this kind
|
|
155
|
+
// of project is put together — the file formats, how a prompt is written, when
|
|
156
|
+
// to add a skill rather than an agent. That is a whole `.github/` tree — the
|
|
157
|
+
// standing brief, plus the prompt files and skills the editor picks up from
|
|
158
|
+
// the same place — kept as files rather than template literals in here: they
|
|
159
|
+
// are full of backticks and `${...}` examples, which a TS template literal
|
|
160
|
+
// cannot hold without escaping every one of them into illegibility.
|
|
161
|
+
//
|
|
162
|
+
// `templates/.github/` mirrors what lands in the project one for one, so
|
|
163
|
+
// adding a skill or a prompt file is adding a file there and nothing else.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
const GITHUB_TEMPLATE = fileURLToPath(new URL('../templates/.github', import.meta.url));
|
|
166
|
+
/**
|
|
167
|
+
* Writes the `.github/` tree under `dir`, replacing what is there — it
|
|
168
|
+
* describes the file formats of the version of `zen` in hand, so the current
|
|
169
|
+
* one is the only one worth having. Returns the relative paths written.
|
|
170
|
+
*/
|
|
171
|
+
export function copilotInstructions(dir) {
|
|
172
|
+
return copyTree(GITHUB_TEMPLATE, dir, '.github');
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Copies one template directory into `dir` at `rel`, depth first, sorted so
|
|
176
|
+
* the list it returns is the same on every machine.
|
|
177
|
+
*/
|
|
178
|
+
function copyTree(from, dir, rel) {
|
|
179
|
+
const written = [];
|
|
180
|
+
mkdirSync(join(dir, rel), { recursive: true });
|
|
181
|
+
const entries = readdirSync(from, { withFileTypes: true });
|
|
182
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
const child = join(rel, entry.name);
|
|
185
|
+
if (entry.isDirectory()) {
|
|
186
|
+
written.push(...copyTree(join(from, entry.name), dir, child));
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
writeFileSync(join(dir, child), readFileSync(join(from, entry.name)));
|
|
190
|
+
written.push(child);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return written;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Writes a project. Never overwrites the project's own files: the caller
|
|
197
|
+
* decides whether it may. The editor files are the exception — they are ours,
|
|
198
|
+
* and are replaced.
|
|
199
|
+
*/
|
|
200
|
+
export function scaffold(opts) {
|
|
201
|
+
const written = [];
|
|
202
|
+
const put = (rel, body) => {
|
|
203
|
+
const path = join(opts.dir, rel);
|
|
204
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
205
|
+
writeFileSync(path, body, { flag: 'wx' });
|
|
206
|
+
written.push(rel);
|
|
207
|
+
};
|
|
208
|
+
mkdirSync(join(opts.dir, 'agents', 'prompts'), { recursive: true });
|
|
209
|
+
mkdirSync(join(opts.dir, 'agents', 'skills'), { recursive: true });
|
|
210
|
+
mkdirSync(join(opts.dir, 'sessions'), { recursive: true });
|
|
211
|
+
put('INSTRUCTIONS.md', INSTRUCTIONS_MD);
|
|
212
|
+
put('agents.yaml', AGENTS_YAML(opts.model, opts.modelOptions, opts.web));
|
|
213
|
+
put(join('agents', 'prompts', 'default.md'), PROMPT);
|
|
214
|
+
put('.gitignore', GITIGNORE);
|
|
215
|
+
// The project directory is what `zen open` opens, so this is where the
|
|
216
|
+
// editor actually reads them.
|
|
217
|
+
written.push(editorSettings(opts.dir), ...copilotInstructions(opts.dir));
|
|
218
|
+
return written;
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=scaffold.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface SessionPaths {
|
|
2
|
+
id: string;
|
|
3
|
+
dir: string;
|
|
4
|
+
/** what the agent can see and write */
|
|
5
|
+
workspace: string;
|
|
6
|
+
data: string;
|
|
7
|
+
/** the live, resumable state — rewritten after every run */
|
|
8
|
+
state: string;
|
|
9
|
+
memory: string;
|
|
10
|
+
blobs: string;
|
|
11
|
+
runs: string;
|
|
12
|
+
lock: string;
|
|
13
|
+
meta: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function sessionPaths(projectDir: string, id: string): SessionPaths;
|
|
16
|
+
export interface SessionMeta {
|
|
17
|
+
version: 1;
|
|
18
|
+
id: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path the agent's file tools are rooted at. Recorded so resuming
|
|
22
|
+
* never re-asks and never silently moves — a session that quietly changed
|
|
23
|
+
* what "the workspace" meant between turns would be unexplainable.
|
|
24
|
+
*/
|
|
25
|
+
workspace: string;
|
|
26
|
+
lastRunAt?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function createSession(projectDir: string, id: string, workspace: string): SessionPaths;
|
|
30
|
+
export declare function readSessionMeta(p: SessionPaths): Promise<SessionMeta>;
|
|
31
|
+
export declare function writeSessionMeta(p: SessionPaths, meta: SessionMeta): void;
|
|
32
|
+
export interface SessionSummary {
|
|
33
|
+
id: string;
|
|
34
|
+
createdAt?: string;
|
|
35
|
+
runs: number;
|
|
36
|
+
lastRunAt?: string;
|
|
37
|
+
busy: boolean;
|
|
38
|
+
title?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Newest first — the order a picker wants. */
|
|
41
|
+
export declare function listSessions(projectDir: string): Promise<SessionSummary[]>;
|
|
42
|
+
export declare function newestSession(projectDir: string): string | undefined;
|
|
43
|
+
export declare function requireSession(projectDir: string, id: string): SessionPaths;
|
|
44
|
+
export interface Held {
|
|
45
|
+
release(): void;
|
|
46
|
+
}
|
|
47
|
+
export declare function acquire(p: SessionPaths): Held;
|
|
48
|
+
export interface RunPaths {
|
|
49
|
+
id: string;
|
|
50
|
+
dir: string;
|
|
51
|
+
input: string;
|
|
52
|
+
output: string;
|
|
53
|
+
state: string;
|
|
54
|
+
report: string;
|
|
55
|
+
meta: string;
|
|
56
|
+
}
|
|
57
|
+
export declare function runPaths(session: SessionPaths, id: string): RunPaths;
|
|
58
|
+
export declare function createRun(session: SessionPaths): RunPaths;
|
|
59
|
+
export interface RunMeta {
|
|
60
|
+
version: 1;
|
|
61
|
+
id: string;
|
|
62
|
+
session: string;
|
|
63
|
+
startedAt: string;
|
|
64
|
+
finishedAt: string;
|
|
65
|
+
durationMs: number;
|
|
66
|
+
agent: string;
|
|
67
|
+
stopReason: string;
|
|
68
|
+
turns: number;
|
|
69
|
+
usage: unknown;
|
|
70
|
+
workspace: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
export declare function writeRunMeta(p: RunPaths, meta: RunMeta): void;
|
|
74
|
+
export declare function newestRun(session: SessionPaths): string | undefined;
|
|
75
|
+
/** A path to show a human: relative when that is shorter, absolute otherwise. */
|
|
76
|
+
export declare function display(path: string, from?: string): string;
|
|
77
|
+
//# sourceMappingURL=session.d.ts.map
|