@zenera/cli 1.1.9 → 1.1.11
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 +49 -15
- package/dist/cache.d.ts +98 -0
- package/dist/cache.js +301 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +35 -11
- package/dist/commands/cache.d.ts +7 -0
- package/dist/commands/cache.js +245 -0
- package/dist/commands/check.js +6 -3
- package/dist/commands/index.js +3 -1
- package/dist/commands/key.js +68 -14
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/history.d.ts +18 -0
- package/dist/history.js +93 -0
- package/dist/home.d.ts +2 -2
- package/dist/home.js +2 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +105 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.js +11 -0
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/term.d.ts +25 -2
- package/dist/term.js +224 -10
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +542 -58
- package/dist/tui/theme.d.ts +6 -2
- package/dist/tui/theme.js +14 -8
- package/dist/tui/wrap.d.ts +92 -0
- package/dist/tui/wrap.js +147 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +87 -2
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +50 -13
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +202 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
- package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
- package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
- package/templates/editor/.github/skills/zen-cli/references/rag.md +104 -0
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
- package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +28 -20
- package/templates/editor/.vscode/settings.json +1 -1
package/dist/commands/sandbox.js
CHANGED
|
@@ -2,8 +2,7 @@ import { readProjectConfig } from '@zenera/neo';
|
|
|
2
2
|
import { parse } from "../args.js";
|
|
3
3
|
import { resolveBuild } from "../image.js";
|
|
4
4
|
import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
|
|
5
|
-
import { dirSize, isProjectDir, Registry, sessionIds } from "../projects.js";
|
|
6
|
-
import { project as findProject } from "../resolve.js";
|
|
5
|
+
import { current as currentProject, dirSize, isProjectDir, open as openProject, Registry, sessionIds, } from "../projects.js";
|
|
7
6
|
import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
8
7
|
const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
|
|
9
8
|
/** Enough to see the pattern; the rest are a number. */
|
|
@@ -29,9 +28,13 @@ export const sandbox = {
|
|
|
29
28
|
' clean Remove every container this CLI created.',
|
|
30
29
|
' disk What the engine and every known project occupy.',
|
|
31
30
|
'',
|
|
32
|
-
' --project <name|dir> Which project the image
|
|
31
|
+
' --project <name|dir> Which project the image and containers belong to.',
|
|
33
32
|
' --image <ref> Use this image instead of the project\u2019s.',
|
|
34
33
|
'',
|
|
34
|
+
'Without --project this is the project you are standing in, and standing',
|
|
35
|
+
'nowhere is an answer: `status` then reports the engine and every container',
|
|
36
|
+
'on the machine, since that is all there is to say.',
|
|
37
|
+
'',
|
|
35
38
|
'None of this is required. A run does all of it on its own, the first',
|
|
36
39
|
'time an agent that can reach a shell is about to start one.',
|
|
37
40
|
],
|
|
@@ -47,18 +50,21 @@ export const sandbox = {
|
|
|
47
50
|
if (positionals.length > 1) {
|
|
48
51
|
throw usageError('one subcommand at a time', USAGE);
|
|
49
52
|
}
|
|
50
|
-
// `clean` and `disk` are machine-wide questions, so
|
|
51
|
-
// project they
|
|
53
|
+
// `clean` and `disk` are machine-wide questions, so looking for a
|
|
54
|
+
// project they do not use would be reading something for nothing.
|
|
52
55
|
const scoped = what === 'status' || what === 'up' || what === 'pull';
|
|
53
|
-
const found =
|
|
56
|
+
const found = scoped ? await projectSandbox(ctx.cwd, values) : undefined;
|
|
54
57
|
const image = values.image ?? found?.image;
|
|
55
|
-
const build = found?.build;
|
|
58
|
+
const build = values.image ? undefined : found?.build;
|
|
56
59
|
switch (what) {
|
|
57
60
|
case 'status':
|
|
58
|
-
return status(
|
|
61
|
+
return status(found, image, ctx.json);
|
|
59
62
|
case 'up':
|
|
60
63
|
return up(image, build, ctx.json, ctx.json);
|
|
61
64
|
case 'pull':
|
|
65
|
+
if (!image) {
|
|
66
|
+
throw usageError('no project here, so there is no image to pull', 'name one with --project, or an image with --image');
|
|
67
|
+
}
|
|
62
68
|
return up(image, build, true, ctx.json, true);
|
|
63
69
|
case 'clean':
|
|
64
70
|
return clean(ctx.json);
|
|
@@ -68,27 +74,51 @@ export const sandbox = {
|
|
|
68
74
|
},
|
|
69
75
|
};
|
|
70
76
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
77
|
+
* Which project this is about: the flag, or the directory the command was run
|
|
78
|
+
* in. It deliberately never asks. `zen sandbox status` run from anywhere at
|
|
79
|
+
* all is a useful thing, so standing outside every project is an answer — the
|
|
80
|
+
* engine is still there to report on — and a question with a list of every
|
|
81
|
+
* project on the machine is not one a *reading* has any business asking.
|
|
82
|
+
*
|
|
83
|
+
* Notably it also does not go through `target`: reading a setting must not
|
|
84
|
+
* create a session.
|
|
75
85
|
*/
|
|
76
86
|
async function projectSandbox(cwd, values) {
|
|
87
|
+
const found = values.project ? await openProject(values.project) : await currentProject(cwd);
|
|
88
|
+
if (!found) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
77
91
|
try {
|
|
78
|
-
const found = await findProject({ cwd, project: values.project, yes: true });
|
|
79
92
|
const { root, config } = readProjectConfig(found.dir);
|
|
80
93
|
const build = resolveBuild(root, config.sandbox);
|
|
81
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
dir: found.dir,
|
|
96
|
+
name: found.name,
|
|
97
|
+
image: build?.tag ?? config.sandbox?.image,
|
|
98
|
+
build,
|
|
99
|
+
};
|
|
82
100
|
}
|
|
83
101
|
catch {
|
|
84
|
-
|
|
102
|
+
// A project whose configuration does not read is `zen check`'s to
|
|
103
|
+
// report; here it only means there is no image to name.
|
|
104
|
+
return { dir: found.dir, name: found.name };
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
|
-
async function status(
|
|
107
|
+
async function status(project, image, asJson) {
|
|
108
|
+
const build = project?.build;
|
|
88
109
|
const found = await podmanStatus({ image });
|
|
89
|
-
const
|
|
110
|
+
const all = found.ready ? await ownedContainers(found.engine) : [];
|
|
111
|
+
// A container belongs to a session, and a session belongs to a project, so
|
|
112
|
+
// a report about one project must not list another's — or the faker's,
|
|
113
|
+
// which wears the same label and belongs to no project at all.
|
|
114
|
+
const containers = project ? ofProject(project.dir, all) : all;
|
|
90
115
|
if (asJson) {
|
|
91
|
-
json({
|
|
116
|
+
json({
|
|
117
|
+
...found,
|
|
118
|
+
project: project?.name ?? null,
|
|
119
|
+
dockerfile: build?.dockerfile ?? null,
|
|
120
|
+
containers,
|
|
121
|
+
});
|
|
92
122
|
return;
|
|
93
123
|
}
|
|
94
124
|
const mark = (ok) => (ok ? green('ok') : red('no'));
|
|
@@ -98,18 +128,31 @@ async function status(image, build, asJson) {
|
|
|
98
128
|
write(`${bold('machine')} ${found.machine.name} ${state}`);
|
|
99
129
|
}
|
|
100
130
|
write(`${bold('responds')} ${mark(found.ready)}`);
|
|
131
|
+
if (project) {
|
|
132
|
+
write(`${bold('project')} ${project.name} ${dim(project.dir)}`);
|
|
133
|
+
}
|
|
101
134
|
if (found.image) {
|
|
102
135
|
write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
|
|
103
136
|
}
|
|
104
137
|
if (build) {
|
|
105
138
|
write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
|
|
106
139
|
}
|
|
107
|
-
writeAll(containerLines(containers));
|
|
140
|
+
writeAll(containerLines(containers, project?.name));
|
|
108
141
|
if (!found.installed || !found.ready) {
|
|
109
142
|
note('');
|
|
110
143
|
note(dim('run `zen sandbox up` to fix what can be fixed.'));
|
|
111
144
|
}
|
|
112
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* The containers this project's sessions made. A container carries the session
|
|
148
|
+
* id that made it in a label, and a session id is a directory name under the
|
|
149
|
+
* project — the same attribution `disk` does for every project at once, so the
|
|
150
|
+
* two cannot disagree.
|
|
151
|
+
*/
|
|
152
|
+
function ofProject(dir, containers) {
|
|
153
|
+
const sessions = new Set(sessionIds(dir));
|
|
154
|
+
return containers.filter((c) => c.key !== undefined && sessions.has(c.key));
|
|
155
|
+
}
|
|
113
156
|
/**
|
|
114
157
|
* One per line rather than one long line, because there is normally more than
|
|
115
158
|
* one and the interesting part — how old, and whether anything is still up —
|
|
@@ -117,11 +160,15 @@ async function status(image, build, asJson) {
|
|
|
117
160
|
*
|
|
118
161
|
* The trailing note is there because the count surprises people: a container
|
|
119
162
|
* is per *session*, not per project, and `persist: true` is what leaves the
|
|
120
|
-
* stopped ones behind.
|
|
163
|
+
* stopped ones behind. `scope` names the project these belong to, and saying
|
|
164
|
+
* so is half the answer — the other half is that there are more elsewhere.
|
|
121
165
|
*/
|
|
122
|
-
function containerLines(containers) {
|
|
166
|
+
function containerLines(containers, scope) {
|
|
167
|
+
const tail = scope
|
|
168
|
+
? `one per session in ${scope}, kept by \`persist: true\` — all of them: zen sandbox disk`
|
|
169
|
+
: 'one per session, kept by `persist: true` — see: zen sandbox disk';
|
|
123
170
|
if (containers.length === 0) {
|
|
124
|
-
return [`${bold('containers')} ${dim('none')}`];
|
|
171
|
+
return [`${bold('containers')} ${dim(scope ? `none in ${scope}` : 'none')}`];
|
|
125
172
|
}
|
|
126
173
|
const running = containers.filter((c) => c.state === 'running').length;
|
|
127
174
|
const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
|
|
@@ -138,7 +185,7 @@ function containerLines(containers) {
|
|
|
138
185
|
head,
|
|
139
186
|
...table(rows),
|
|
140
187
|
...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
|
|
141
|
-
`${INDENT}${dim(
|
|
188
|
+
`${INDENT}${dim(tail)}`,
|
|
142
189
|
];
|
|
143
190
|
}
|
|
144
191
|
async function up(image, build, yes, asJson, rebuild = false) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Entries kept in memory and after a trim. */
|
|
2
|
+
export declare const MAX_ENTRIES = 500;
|
|
3
|
+
/** Beside the sessions, so it outlives any one of them and is already ignored by git. */
|
|
4
|
+
export declare const historyPath: (projectDir: string) => string;
|
|
5
|
+
export declare class History {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(path: string);
|
|
8
|
+
static open(projectDir: string): History;
|
|
9
|
+
/** Oldest first, which is the order the arrow keys walk. */
|
|
10
|
+
get entries(): readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Records one line. Blanks and a repeat of the line just before it are
|
|
13
|
+
* dropped — holding the arrow key down to get past six identical retries
|
|
14
|
+
* is the thing that makes a history annoying rather than useful.
|
|
15
|
+
*/
|
|
16
|
+
add(text: string): void;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=history.d.ts.map
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { sessionsDir } from "./projects.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The prompt's history
|
|
7
|
+
//
|
|
8
|
+
// The same contract a shell offers: what you asked before is one arrow key
|
|
9
|
+
// away, and it is still there tomorrow. It belongs to the **project**, not to
|
|
10
|
+
// a session — the question you want back is usually the one you asked in the
|
|
11
|
+
// session you just finished, while starting the next one.
|
|
12
|
+
//
|
|
13
|
+
// The file is what `.bash_history` is: one line per entry, oldest last,
|
|
14
|
+
// appended to. A prompt line cannot contain a newline, so nothing has to be
|
|
15
|
+
// escaped and the file stays greppable and hand-editable.
|
|
16
|
+
//
|
|
17
|
+
// Nothing here may ever stop someone typing. Every filesystem call is guarded
|
|
18
|
+
// and a failure degrades to an in-memory history for the rest of the run.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Entries kept in memory and after a trim. */
|
|
21
|
+
export const MAX_ENTRIES = 500;
|
|
22
|
+
/** Lines tolerated in the file before it is rewritten, so appending stays the common case. */
|
|
23
|
+
const TRIM_AT = MAX_ENTRIES * 2;
|
|
24
|
+
const FILE_MODE = 0o600;
|
|
25
|
+
/** Beside the sessions, so it outlives any one of them and is already ignored by git. */
|
|
26
|
+
export const historyPath = (projectDir) => join(sessionsDir(projectDir), '.history');
|
|
27
|
+
export class History {
|
|
28
|
+
#path;
|
|
29
|
+
#entries;
|
|
30
|
+
constructor(path) {
|
|
31
|
+
this.#path = path;
|
|
32
|
+
this.#entries = read(path);
|
|
33
|
+
}
|
|
34
|
+
static open(projectDir) {
|
|
35
|
+
return new History(historyPath(projectDir));
|
|
36
|
+
}
|
|
37
|
+
/** Oldest first, which is the order the arrow keys walk. */
|
|
38
|
+
get entries() {
|
|
39
|
+
return this.#entries;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Records one line. Blanks and a repeat of the line just before it are
|
|
43
|
+
* dropped — holding the arrow key down to get past six identical retries
|
|
44
|
+
* is the thing that makes a history annoying rather than useful.
|
|
45
|
+
*/
|
|
46
|
+
add(text) {
|
|
47
|
+
const line = text.trim();
|
|
48
|
+
if (!line || line.includes('\n') || this.#entries.at(-1) === line) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.#entries.push(line);
|
|
52
|
+
if (this.#entries.length > MAX_ENTRIES) {
|
|
53
|
+
this.#entries.shift();
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(this.#path), { recursive: true });
|
|
57
|
+
appendFileSync(this.#path, `${line}\n`, { mode: FILE_MODE });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A prompt that cannot remember is still a prompt.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function read(path) {
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
text = readFileSync(path, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const lines = text
|
|
73
|
+
.split('\n')
|
|
74
|
+
.map((line) => line.trimEnd())
|
|
75
|
+
.filter((line) => line.length > 0);
|
|
76
|
+
const kept = lines.slice(-MAX_ENTRIES);
|
|
77
|
+
if (lines.length > TRIM_AT) {
|
|
78
|
+
rewrite(path, kept);
|
|
79
|
+
}
|
|
80
|
+
return kept;
|
|
81
|
+
}
|
|
82
|
+
/** Through a sibling and a rename: a crash leaves the old history, never half of one. */
|
|
83
|
+
function rewrite(path, lines) {
|
|
84
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(tmp, lines.map((line) => `${line}\n`).join(''), { mode: FILE_MODE });
|
|
87
|
+
renameSync(tmp, path);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
rmSync(tmp, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=history.js.map
|
package/dist/home.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export declare const paths: {
|
|
|
5
5
|
keys: () => string;
|
|
6
6
|
keyDir: () => string;
|
|
7
7
|
faker: () => string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/** work already done and worth keeping: vectors, parses, model listings */
|
|
9
|
+
cache: () => string;
|
|
10
10
|
};
|
|
11
11
|
/** Creates a directory owner-only, and leaves an existing one's mode alone. */
|
|
12
12
|
export declare function ensureDir(dir: string, mode?: number): string;
|
package/dist/home.js
CHANGED
|
@@ -26,8 +26,8 @@ export const paths = {
|
|
|
26
26
|
keys: () => join(home(), 'keys.json'),
|
|
27
27
|
keyDir: () => join(home(), 'keys'),
|
|
28
28
|
faker: () => join(home(), 'faker'),
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** work already done and worth keeping: vectors, parses, model listings */
|
|
30
|
+
cache: () => join(home(), 'cache'),
|
|
31
31
|
};
|
|
32
32
|
/** Creates a directory owner-only, and leaves an existing one's mode alone. */
|
|
33
33
|
export function ensureDir(dir, mode = DIR_MODE) {
|
package/dist/keys.d.ts
CHANGED
|
@@ -103,6 +103,17 @@ export declare function parseRef(ref: string): {
|
|
|
103
103
|
provider: KeyOwner;
|
|
104
104
|
name?: string;
|
|
105
105
|
};
|
|
106
|
+
/** Which of a provider's forms a raw value is, decided by the value itself. */
|
|
107
|
+
export declare function formOf(provider: KeyOwner, raw: string): CredentialForm;
|
|
108
|
+
/**
|
|
109
|
+
* Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
|
|
110
|
+
* or `us-central` with its digit missing — because those are 404s at the first
|
|
111
|
+
* call, a long way from the command that caused them. Returns false for a
|
|
112
|
+
* well-formed name that is merely unrecognised, which the caller reports and
|
|
113
|
+
* stores anyway: Google adds regions, and a list compiled here would be wrong
|
|
114
|
+
* before it was old.
|
|
115
|
+
*/
|
|
116
|
+
export declare function checkRegion(region: string): boolean;
|
|
106
117
|
/** What a credential cannot say about itself. */
|
|
107
118
|
export interface KeyMeta {
|
|
108
119
|
/** GCP project id, for a Vertex service account */
|
|
@@ -144,6 +155,17 @@ export declare class KeyStore {
|
|
|
144
155
|
fileOf(entry: KeyEntry): string;
|
|
145
156
|
/** The plaintext an entry stands for — the only way out of the store. */
|
|
146
157
|
reveal(entry: KeyEntry): string;
|
|
158
|
+
/**
|
|
159
|
+
* Which GCP project a Vertex entry addresses, and how that was decided —
|
|
160
|
+
* the same order the library resolves it in, so what is shown is what will
|
|
161
|
+
* be called. A service-account file states its own project, and nothing was
|
|
162
|
+
* ever stored for it, so an entry with no `project` is not an entry with no
|
|
163
|
+
* project: it is one whose project is only knowable by reading the file.
|
|
164
|
+
*/
|
|
165
|
+
projectOf(entry: KeyEntry): {
|
|
166
|
+
id: string;
|
|
167
|
+
from: 'env' | 'stored' | 'file';
|
|
168
|
+
} | undefined;
|
|
147
169
|
/**
|
|
148
170
|
* What the library would see. Real environment variables win, so CI,
|
|
149
171
|
* `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
|
package/dist/keys.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EXA_API_KEY_ENV } from '@zenera/neo';
|
|
2
|
-
import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
|
|
@@ -169,7 +169,7 @@ export function parseRef(ref) {
|
|
|
169
169
|
return { provider, name };
|
|
170
170
|
}
|
|
171
171
|
/** Which of a provider's forms a raw value is, decided by the value itself. */
|
|
172
|
-
function formOf(provider, raw) {
|
|
172
|
+
export function formOf(provider, raw) {
|
|
173
173
|
const { forms } = SHAPES[provider];
|
|
174
174
|
if (forms.length === 1) {
|
|
175
175
|
return forms[0];
|
|
@@ -177,6 +177,71 @@ function formOf(provider, raw) {
|
|
|
177
177
|
const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
|
|
178
178
|
return forms.find((f) => f.holds === wanted) ?? forms[0];
|
|
179
179
|
}
|
|
180
|
+
const REGION = /^[a-z]+-[a-z]+[0-9]+$/;
|
|
181
|
+
const REGIONS = new Set([
|
|
182
|
+
'africa-south1',
|
|
183
|
+
'asia-east1',
|
|
184
|
+
'asia-east2',
|
|
185
|
+
'asia-northeast1',
|
|
186
|
+
'asia-northeast2',
|
|
187
|
+
'asia-northeast3',
|
|
188
|
+
'asia-south1',
|
|
189
|
+
'asia-south2',
|
|
190
|
+
'asia-southeast1',
|
|
191
|
+
'asia-southeast2',
|
|
192
|
+
'asia-southeast3',
|
|
193
|
+
'australia-southeast1',
|
|
194
|
+
'australia-southeast2',
|
|
195
|
+
'europe-central2',
|
|
196
|
+
'europe-north1',
|
|
197
|
+
'europe-southwest1',
|
|
198
|
+
'europe-west1',
|
|
199
|
+
'europe-west2',
|
|
200
|
+
'europe-west3',
|
|
201
|
+
'europe-west4',
|
|
202
|
+
'europe-west6',
|
|
203
|
+
'europe-west8',
|
|
204
|
+
'europe-west9',
|
|
205
|
+
'europe-west10',
|
|
206
|
+
'europe-west12',
|
|
207
|
+
'me-central1',
|
|
208
|
+
'me-central2',
|
|
209
|
+
'me-west1',
|
|
210
|
+
'northamerica-northeast1',
|
|
211
|
+
'northamerica-northeast2',
|
|
212
|
+
'northamerica-south1',
|
|
213
|
+
'southamerica-east1',
|
|
214
|
+
'southamerica-west1',
|
|
215
|
+
'us-central1',
|
|
216
|
+
'us-east1',
|
|
217
|
+
'us-east4',
|
|
218
|
+
'us-east5',
|
|
219
|
+
'us-south1',
|
|
220
|
+
'us-west1',
|
|
221
|
+
'us-west2',
|
|
222
|
+
'us-west3',
|
|
223
|
+
'us-west4',
|
|
224
|
+
]);
|
|
225
|
+
/**
|
|
226
|
+
* Endpoints that route across regions rather than naming one. `us` and `eu` keep
|
|
227
|
+
* processing inside their territory; `global` does not promise that, and takes
|
|
228
|
+
* whatever capacity is free.
|
|
229
|
+
*/
|
|
230
|
+
const ENDPOINTS = new Set(['global', 'us', 'eu']);
|
|
231
|
+
/**
|
|
232
|
+
* Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
|
|
233
|
+
* or `us-central` with its digit missing — because those are 404s at the first
|
|
234
|
+
* call, a long way from the command that caused them. Returns false for a
|
|
235
|
+
* well-formed name that is merely unrecognised, which the caller reports and
|
|
236
|
+
* stores anyway: Google adds regions, and a list compiled here would be wrong
|
|
237
|
+
* before it was old.
|
|
238
|
+
*/
|
|
239
|
+
export function checkRegion(region) {
|
|
240
|
+
if (!ENDPOINTS.has(region) && !REGION.test(region)) {
|
|
241
|
+
throw usageError(`"${region}" is not a region`, 'give a region such as us-central1, or the endpoints global, us or eu');
|
|
242
|
+
}
|
|
243
|
+
return ENDPOINTS.has(region) || REGIONS.has(region);
|
|
244
|
+
}
|
|
180
245
|
// ---------------------------------------------------------------------------
|
|
181
246
|
// Store
|
|
182
247
|
// ---------------------------------------------------------------------------
|
|
@@ -300,6 +365,30 @@ export class KeyStore {
|
|
|
300
365
|
reveal(entry) {
|
|
301
366
|
return entry.holds === 'file' ? this.fileOf(entry) : entry.value;
|
|
302
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Which GCP project a Vertex entry addresses, and how that was decided —
|
|
370
|
+
* the same order the library resolves it in, so what is shown is what will
|
|
371
|
+
* be called. A service-account file states its own project, and nothing was
|
|
372
|
+
* ever stored for it, so an entry with no `project` is not an entry with no
|
|
373
|
+
* project: it is one whose project is only knowable by reading the file.
|
|
374
|
+
*/
|
|
375
|
+
projectOf(entry) {
|
|
376
|
+
if (entry.provider !== 'vertex') {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
const fromEnv = process.env.GOOGLE_CLOUD_PROJECT;
|
|
380
|
+
if (fromEnv) {
|
|
381
|
+
return { id: fromEnv, from: 'env' };
|
|
382
|
+
}
|
|
383
|
+
if (entry.project) {
|
|
384
|
+
return { id: entry.project, from: 'stored' };
|
|
385
|
+
}
|
|
386
|
+
if (entry.holds !== 'file') {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const id = projectIdOf(this.fileOf(entry));
|
|
390
|
+
return id ? { id, from: 'file' } : undefined;
|
|
391
|
+
}
|
|
303
392
|
/**
|
|
304
393
|
* What the library would see. Real environment variables win, so CI,
|
|
305
394
|
* `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
|
|
@@ -350,6 +439,20 @@ export class KeyStore {
|
|
|
350
439
|
return target;
|
|
351
440
|
}
|
|
352
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* `project_id` out of a service-account file. Express-mode keys, gcloud user
|
|
444
|
+
* ADC and metadata credentials carry none, so finding nothing is an answer.
|
|
445
|
+
*/
|
|
446
|
+
function projectIdOf(path) {
|
|
447
|
+
try {
|
|
448
|
+
const key = JSON.parse(readFileSync(path, 'utf8'));
|
|
449
|
+
const id = key.project_id;
|
|
450
|
+
return typeof id === 'string' && id.trim() ? id.trim() : undefined;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
353
456
|
// ---------------------------------------------------------------------------
|
|
354
457
|
// Display
|
|
355
458
|
// ---------------------------------------------------------------------------
|
package/dist/lib.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { extract, invokedAs, one, parse, split, type Parsed, type Split } from './args.ts';
|
|
2
2
|
export { printBanner, type BannerText } from './banner.ts';
|
|
3
|
+
export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, type CacheEntry, type CacheItem, type CacheKind, type CacheOptions, type CacheStore, type Swept, } from './cache.ts';
|
|
3
4
|
export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, type Catalog, type CatalogEntry, type CatalogOptions, type Filters, type Role, } from './catalog.ts';
|
|
4
5
|
export type { Command, Context } from './command.ts';
|
|
5
6
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
|
package/dist/lib.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
export { extract, invokedAs, one, parse, split } from "./args.js";
|
|
25
25
|
export { printBanner } from "./banner.js";
|
|
26
|
+
export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, } from "./cache.js";
|
|
26
27
|
export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, } from "./catalog.js";
|
|
27
28
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
|
|
28
29
|
export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
|
package/dist/liveness.js
CHANGED
|
@@ -107,6 +107,17 @@ export function classify(err) {
|
|
|
107
107
|
if (UNREACHED.some((needle) => haystack.includes(needle))) {
|
|
108
108
|
return { state: 'unknown', at, detail: 'could not reach the provider' };
|
|
109
109
|
}
|
|
110
|
+
// A json api that answers in html was never reached: the hostname was
|
|
111
|
+
// wrong. Vertex builds its hostname out of the location, so a misspelt
|
|
112
|
+
// region lands here — as `<!DOCTYPE html>`, which explains nothing.
|
|
113
|
+
if (haystack.includes('<!doctype html') || haystack.includes('<html')) {
|
|
114
|
+
return {
|
|
115
|
+
state: 'unknown',
|
|
116
|
+
at,
|
|
117
|
+
detail: 'a web page came back, not the api — the endpoint does not exist',
|
|
118
|
+
fix: 'check the location: vertex builds its hostname from it',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
110
121
|
// 429 means the credential authenticated and then got rate limited, which
|
|
111
122
|
// is a live key having a bad day.
|
|
112
123
|
if (status === 429) {
|
package/dist/resolve.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface Target {
|
|
|
13
13
|
session: SessionPaths;
|
|
14
14
|
/** true when this call created the session */
|
|
15
15
|
created: boolean;
|
|
16
|
+
/** absolute path the agent's file tools are rooted at */
|
|
17
|
+
workspace: string;
|
|
18
|
+
/** true when that directory is the session's own, made by this call */
|
|
19
|
+
freshWorkspace: boolean;
|
|
16
20
|
}
|
|
17
21
|
export declare function project(want: Wanted): Promise<Projects.Project>;
|
|
18
22
|
export declare function target(want: Wanted): Promise<Target>;
|
package/dist/resolve.js
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { stamp } from "./ids.js";
|
|
4
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";
|
|
5
|
+
import { createSession, listSessions, readSessionMeta, requireSession, sessionPaths, } from "./session.js";
|
|
6
|
+
import { ago, bold, choose, confirm, cyan, dim, isInteractive, usageError, warn, yellow, } from "./term.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Project
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -36,44 +36,70 @@ export async function target(want) {
|
|
|
36
36
|
throw usageError('--session and --new contradict each other');
|
|
37
37
|
}
|
|
38
38
|
if (want.session) {
|
|
39
|
-
return
|
|
40
|
-
project: found,
|
|
41
|
-
session: requireSession(dir, want.session),
|
|
42
|
-
created: false,
|
|
43
|
-
};
|
|
39
|
+
return await resumed(found, requireSession(dir, want.session));
|
|
44
40
|
}
|
|
45
41
|
if (!want.fresh) {
|
|
46
42
|
const existing = await pickExisting(dir);
|
|
47
43
|
if (existing) {
|
|
48
|
-
return
|
|
44
|
+
return await resumed(found, existing);
|
|
49
45
|
}
|
|
50
46
|
}
|
|
51
|
-
|
|
47
|
+
const made = await create(dir, want);
|
|
48
|
+
return {
|
|
49
|
+
project: found,
|
|
50
|
+
session: made.session,
|
|
51
|
+
created: true,
|
|
52
|
+
workspace: made.workspace,
|
|
53
|
+
freshWorkspace: contains(made.session.dir, made.workspace),
|
|
54
|
+
};
|
|
52
55
|
}
|
|
56
|
+
/** A session that already exists brings its own workspace; it is never re-asked. */
|
|
57
|
+
async function resumed(project, session) {
|
|
58
|
+
const meta = await readSessionMeta(session);
|
|
59
|
+
return {
|
|
60
|
+
project,
|
|
61
|
+
session,
|
|
62
|
+
created: false,
|
|
63
|
+
workspace: resolve(meta.workspace),
|
|
64
|
+
freshWorkspace: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Past this many, you are looking for a session by name rather than scrolling
|
|
69
|
+
* to it — and `--session <id>` is how you say a name.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_SESSIONS = 25;
|
|
53
72
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
73
|
+
* Starting fresh is first, and it is the default: `zen run` followed by Enter
|
|
74
|
+
* should be a new conversation in a new directory, because that is what the
|
|
75
|
+
* command is asked for most often and the only answer that cannot go wrong.
|
|
76
|
+
* Everything already here is offered under it, newest first.
|
|
57
77
|
*
|
|
58
78
|
* A session that has never run has nothing to continue — it is indistinguishable
|
|
59
79
|
* from a fresh one, so it is left out rather than offered as a choice.
|
|
60
80
|
*/
|
|
61
81
|
async function pickExisting(projectDir) {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
82
|
+
const all = (await listSessions(projectDir)).filter((s) => s.runs > 0 || s.busy);
|
|
83
|
+
if (all.length === 0) {
|
|
64
84
|
return undefined;
|
|
65
85
|
}
|
|
66
86
|
if (!isInteractive()) {
|
|
67
|
-
return sessionPaths(projectDir,
|
|
87
|
+
return sessionPaths(projectDir, all[0].id);
|
|
68
88
|
}
|
|
89
|
+
const sessions = all.slice(0, MAX_SESSIONS);
|
|
69
90
|
const choice = await choose('Session', [
|
|
91
|
+
{
|
|
92
|
+
key: '0',
|
|
93
|
+
label: bold(cyan('New session…')),
|
|
94
|
+
detail: dim('a fresh conversation, in a directory of its own'),
|
|
95
|
+
value: undefined,
|
|
96
|
+
},
|
|
70
97
|
...sessions.map((s) => ({
|
|
71
98
|
label: s.title ?? s.id,
|
|
72
99
|
detail: `${s.id} ${ago(s.lastRunAt ?? s.createdAt)} ` +
|
|
73
100
|
`${s.runs} run${s.runs === 1 ? '' : 's'}${s.busy ? yellow(' running') : ''}`,
|
|
74
101
|
value: s.id,
|
|
75
102
|
})),
|
|
76
|
-
{ label: dim('New session…'), value: undefined },
|
|
77
103
|
]);
|
|
78
104
|
return choice ? sessionPaths(projectDir, choice) : undefined;
|
|
79
105
|
}
|
|
@@ -86,7 +112,7 @@ async function create(projectDir, want) {
|
|
|
86
112
|
const id = stamp();
|
|
87
113
|
const planned = sessionPaths(projectDir, id);
|
|
88
114
|
const workspace = await chooseWorkspace(planned, want);
|
|
89
|
-
return createSession(projectDir, id, workspace);
|
|
115
|
+
return { session: createSession(projectDir, id, workspace), workspace };
|
|
90
116
|
}
|
|
91
117
|
// ---------------------------------------------------------------------------
|
|
92
118
|
// Workspace
|