@zenera/cli 1.1.2 → 1.1.3
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 +88 -11
- package/dist/audit.d.ts +8 -6
- package/dist/audit.js +14 -22
- package/dist/commands/check.js +34 -7
- package/dist/commands/init.js +71 -11
- package/dist/commands/key.js +126 -36
- package/dist/commands/models.js +3 -3
- package/dist/commands/open.js +2 -2
- package/dist/commands/run.js +3 -0
- package/dist/engine.d.ts +2 -0
- package/dist/engine.js +1 -0
- package/dist/keys.d.ts +95 -12
- package/dist/keys.js +175 -34
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +1 -1
- package/dist/liveness.d.ts +16 -6
- package/dist/liveness.js +74 -23
- package/dist/sandbox.d.ts +2 -0
- package/dist/sandbox.js +58 -7
- package/dist/scaffold.d.ts +21 -21
- package/dist/scaffold.js +132 -204
- package/dist/validate.js +3 -3
- package/package.json +2 -18
- package/templates/{.github → editor/.github}/copilot-instructions.md +7 -6
- package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
- package/templates/editor/.github/skills/zen-cli/references/check.md +92 -0
- package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
- package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
- package/templates/editor/.github/skills/zen-cli/references/keys.md +114 -0
- package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
- package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
- package/templates/editor/.vscode/settings.json +6 -0
- package/templates/parts/exa.yaml.tmpl +5 -0
- package/templates/parts/model.yaml.tmpl +4 -0
- package/templates/parts/models.yaml.tmpl +10 -0
- package/templates/project/INSTRUCTIONS.md +7 -0
- package/templates/project/SPECIFICATION.md +6 -0
- package/templates/project/agents/prompts/default.md +15 -0
- package/templates/project/agents.yaml.tmpl +44 -0
- package/templates/project/assets/README.md +12 -0
- package/templates/project/gitignore +9 -0
- package/templates/{sandbox → project/sandbox}/Dockerfile +2 -0
- package/templates/.github/skills/zen-cli/SKILL.md +0 -110
- /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
- /package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +0 -0
- /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/dist/keys.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
3
1
|
import { EXA_API_KEY_ENV } from '@zenera/neo';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
5
|
import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
|
|
5
6
|
import { CliError, EXIT, credentialError, usageError } from "./term.js";
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -33,55 +34,108 @@ export const SERVICES = ['exa'];
|
|
|
33
34
|
/** Anything the keyring can hold a credential for. */
|
|
34
35
|
export const OWNERS = [...PROVIDERS, ...SERVICES];
|
|
35
36
|
/**
|
|
36
|
-
* Vertex is the odd one
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Vertex is the odd one, and it is odd twice.
|
|
38
|
+
*
|
|
39
|
+
* Its usual credential is not a key at all: the GenAI SDK resolves Application
|
|
40
|
+
* Default Credentials itself, so what is stored is a service-account *file* and
|
|
41
|
+
* what is exported is a path. But it also accepts an express-mode api key,
|
|
42
|
+
* which is an ordinary secret under an entirely different variable. The two are
|
|
43
|
+
* alternatives — express mode addresses no project — so which form a credential
|
|
44
|
+
* is gets decided per entry rather than per provider.
|
|
40
45
|
*/
|
|
41
46
|
export const SHAPES = {
|
|
42
47
|
openai: {
|
|
43
48
|
kind: 'model',
|
|
44
|
-
env: 'OPENAI_API_KEY',
|
|
45
|
-
holds: 'secret',
|
|
46
49
|
label: 'OpenAI',
|
|
47
|
-
|
|
50
|
+
forms: [
|
|
51
|
+
{
|
|
52
|
+
holds: 'secret',
|
|
53
|
+
env: 'OPENAI_API_KEY',
|
|
54
|
+
where: 'https://platform.openai.com/api-keys',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
48
57
|
},
|
|
49
58
|
anthropic: {
|
|
50
59
|
kind: 'model',
|
|
51
|
-
env: 'ANTHROPIC_API_KEY',
|
|
52
|
-
holds: 'secret',
|
|
53
60
|
label: 'Anthropic',
|
|
54
|
-
|
|
61
|
+
forms: [
|
|
62
|
+
{
|
|
63
|
+
holds: 'secret',
|
|
64
|
+
env: 'ANTHROPIC_API_KEY',
|
|
65
|
+
where: 'https://console.anthropic.com/settings/keys',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
55
68
|
},
|
|
56
69
|
google: {
|
|
57
70
|
kind: 'model',
|
|
58
|
-
env: 'GEMINI_API_KEY',
|
|
59
|
-
holds: 'secret',
|
|
60
71
|
label: 'Google AI Studio',
|
|
61
|
-
|
|
72
|
+
forms: [
|
|
73
|
+
{
|
|
74
|
+
holds: 'secret',
|
|
75
|
+
env: 'GEMINI_API_KEY',
|
|
76
|
+
where: 'https://aistudio.google.com/apikey',
|
|
77
|
+
},
|
|
78
|
+
],
|
|
62
79
|
},
|
|
63
80
|
vertex: {
|
|
64
81
|
kind: 'model',
|
|
65
|
-
env: 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
66
|
-
holds: 'file',
|
|
67
82
|
label: 'Vertex AI',
|
|
68
|
-
|
|
83
|
+
forms: [
|
|
84
|
+
{
|
|
85
|
+
holds: 'file',
|
|
86
|
+
env: 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
87
|
+
where: 'a service-account JSON key from the GCP console',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
holds: 'secret',
|
|
91
|
+
env: 'VERTEX_API_KEY',
|
|
92
|
+
where: 'an express-mode key from https://console.cloud.google.com/vertex-ai',
|
|
93
|
+
},
|
|
94
|
+
],
|
|
69
95
|
},
|
|
70
96
|
openrouter: {
|
|
71
97
|
kind: 'model',
|
|
72
|
-
env: 'OPENROUTER_API_KEY',
|
|
73
|
-
holds: 'secret',
|
|
74
98
|
label: 'OpenRouter',
|
|
75
|
-
|
|
99
|
+
forms: [
|
|
100
|
+
{
|
|
101
|
+
holds: 'secret',
|
|
102
|
+
env: 'OPENROUTER_API_KEY',
|
|
103
|
+
where: 'https://openrouter.ai/settings/keys',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
76
106
|
},
|
|
77
107
|
exa: {
|
|
78
108
|
kind: 'service',
|
|
79
|
-
env: EXA_API_KEY_ENV,
|
|
80
|
-
holds: 'secret',
|
|
81
109
|
label: 'Exa',
|
|
82
|
-
|
|
110
|
+
forms: [
|
|
111
|
+
{
|
|
112
|
+
holds: 'secret',
|
|
113
|
+
env: EXA_API_KEY_ENV,
|
|
114
|
+
where: 'https://dashboard.exa.ai/api-keys',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
83
117
|
},
|
|
84
118
|
};
|
|
119
|
+
/**
|
|
120
|
+
* The form a provider is usually reached by — for the questions that have to
|
|
121
|
+
* have one answer, like which variable to name when nothing is set yet.
|
|
122
|
+
*/
|
|
123
|
+
export function form(provider) {
|
|
124
|
+
return SHAPES[provider].forms[0];
|
|
125
|
+
}
|
|
126
|
+
/** Every variable a provider's credential could arrive in, usual one first. */
|
|
127
|
+
export function envNames(provider) {
|
|
128
|
+
return SHAPES[provider].forms.map((f) => f.env);
|
|
129
|
+
}
|
|
130
|
+
/** The variable this particular credential occupies. */
|
|
131
|
+
export function envOf(entry) {
|
|
132
|
+
if (entry.env) {
|
|
133
|
+
return entry.env;
|
|
134
|
+
}
|
|
135
|
+
// Entries written before a provider had two forms carry no `env` of their own.
|
|
136
|
+
const forms = SHAPES[entry.provider].forms;
|
|
137
|
+
return (forms.find((f) => f.holds === entry.holds) ?? forms[0]).env;
|
|
138
|
+
}
|
|
85
139
|
export function isProvider(name) {
|
|
86
140
|
return PROVIDERS.includes(name);
|
|
87
141
|
}
|
|
@@ -114,6 +168,15 @@ export function parseRef(ref) {
|
|
|
114
168
|
}
|
|
115
169
|
return { provider, name };
|
|
116
170
|
}
|
|
171
|
+
/** Which of a provider's forms a raw value is, decided by the value itself. */
|
|
172
|
+
function formOf(provider, raw) {
|
|
173
|
+
const { forms } = SHAPES[provider];
|
|
174
|
+
if (forms.length === 1) {
|
|
175
|
+
return forms[0];
|
|
176
|
+
}
|
|
177
|
+
const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
|
|
178
|
+
return forms.find((f) => f.holds === wanted) ?? forms[0];
|
|
179
|
+
}
|
|
117
180
|
// ---------------------------------------------------------------------------
|
|
118
181
|
// Store
|
|
119
182
|
// ---------------------------------------------------------------------------
|
|
@@ -160,17 +223,27 @@ export class KeyStore {
|
|
|
160
223
|
* directory: the point of a store is that the credential survives the
|
|
161
224
|
* original being moved, renamed or cleaned up, and a stored path that
|
|
162
225
|
* silently stops resolving is worse than no store at all.
|
|
226
|
+
*
|
|
227
|
+
* Which form a value is, when the provider accepts two, is read off the
|
|
228
|
+
* value: a path that is there is a credentials file, and anything else is a
|
|
229
|
+
* secret. Asking would be a flag to get wrong, and a service-account key
|
|
230
|
+
* and an api key are not mistakable for one another.
|
|
163
231
|
*/
|
|
164
|
-
add(provider, name, raw) {
|
|
232
|
+
add(provider, name, raw, meta = {}) {
|
|
165
233
|
if (!NAME.test(name)) {
|
|
166
234
|
throw usageError(`"${name}" is not a usable key name`, 'letters, digits, - and _ only');
|
|
167
235
|
}
|
|
168
|
-
const
|
|
236
|
+
const form = formOf(provider, raw);
|
|
169
237
|
const entry = {
|
|
170
238
|
provider,
|
|
171
239
|
name,
|
|
172
|
-
holds:
|
|
173
|
-
value:
|
|
240
|
+
holds: form.holds,
|
|
241
|
+
value: form.holds === 'file' ? this.#absorb(provider, name, raw) : raw,
|
|
242
|
+
env: form.env,
|
|
243
|
+
// Express mode addresses no project, so carrying one would only ever
|
|
244
|
+
// be a way to build the combination the service refuses.
|
|
245
|
+
...(form.holds === 'file' && meta.project ? { project: meta.project } : {}),
|
|
246
|
+
...(form.holds === 'file' && meta.location ? { location: meta.location } : {}),
|
|
174
247
|
addedAt: new Date().toISOString(),
|
|
175
248
|
};
|
|
176
249
|
const at = this.#file.entries.findIndex((e) => e.provider === provider && e.name === name);
|
|
@@ -216,9 +289,12 @@ export class KeyStore {
|
|
|
216
289
|
ensureDir(paths.home());
|
|
217
290
|
writeJson(paths.keys(), this.#file);
|
|
218
291
|
}
|
|
219
|
-
/**
|
|
292
|
+
/**
|
|
293
|
+
* Absolute path behind a file-shaped entry. Stored entries name a file in
|
|
294
|
+
* the key directory; an ambient one already knows where it is.
|
|
295
|
+
*/
|
|
220
296
|
fileOf(entry) {
|
|
221
|
-
return join(paths.keyDir(), entry.value);
|
|
297
|
+
return isAbsolute(entry.value) ? entry.value : join(paths.keyDir(), entry.value);
|
|
222
298
|
}
|
|
223
299
|
/** The plaintext an entry stands for — the only way out of the store. */
|
|
224
300
|
reveal(entry) {
|
|
@@ -239,11 +315,18 @@ export class KeyStore {
|
|
|
239
315
|
if (!entry) {
|
|
240
316
|
continue;
|
|
241
317
|
}
|
|
242
|
-
|
|
243
|
-
|
|
318
|
+
// Either variable being set means this provider is already answered
|
|
319
|
+
// for, so the stored alternative must not be exported alongside it.
|
|
320
|
+
if (envNames(provider).some((name) => process.env[name])) {
|
|
244
321
|
continue;
|
|
245
322
|
}
|
|
246
|
-
env[
|
|
323
|
+
env[envOf(entry)] = this.reveal(entry);
|
|
324
|
+
if (entry.project && !process.env.GOOGLE_CLOUD_PROJECT) {
|
|
325
|
+
env.GOOGLE_CLOUD_PROJECT = entry.project;
|
|
326
|
+
}
|
|
327
|
+
if (entry.location && !process.env.GOOGLE_CLOUD_LOCATION) {
|
|
328
|
+
env.GOOGLE_CLOUD_LOCATION = entry.location;
|
|
329
|
+
}
|
|
247
330
|
}
|
|
248
331
|
return env;
|
|
249
332
|
}
|
|
@@ -284,6 +367,64 @@ export function mask(secret) {
|
|
|
284
367
|
export function describe(store, entry) {
|
|
285
368
|
return entry.holds === 'file' ? store.fileOf(entry) : mask(entry.value);
|
|
286
369
|
}
|
|
370
|
+
/** How an ambient credential is named on the command line: it has no key name. */
|
|
371
|
+
export function ambientId(cred) {
|
|
372
|
+
return cred.env ? `${cred.provider}/$${cred.env}` : `${cred.provider}/adc`;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Where `gcloud auth application-default login` leaves its credentials. The
|
|
376
|
+
* GenAI SDK reads this without being told to, so it counts even though nothing
|
|
377
|
+
* in the environment mentions it.
|
|
378
|
+
*/
|
|
379
|
+
export function gcloudAdc() {
|
|
380
|
+
const dir = process.env.CLOUDSDK_CONFIG ?? join(homedir(), '.config', 'gcloud');
|
|
381
|
+
const path = join(dir, 'application_default_credentials.json');
|
|
382
|
+
return existsSync(path) ? path : undefined;
|
|
383
|
+
}
|
|
384
|
+
export function ambient(store, only) {
|
|
385
|
+
const found = [];
|
|
386
|
+
for (const provider of only ?? OWNERS) {
|
|
387
|
+
for (const form of SHAPES[provider].forms) {
|
|
388
|
+
const value = process.env[form.env];
|
|
389
|
+
if (!value) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
// `materialize()` puts the store's own entries here. Reporting one
|
|
393
|
+
// of those as ambient would double-count it, and would claim the
|
|
394
|
+
// environment as a source that would survive removing the entry.
|
|
395
|
+
const active = store.active(provider);
|
|
396
|
+
if (active && envOf(active) === form.env && store.reveal(active) === value) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
found.push({ provider, env: form.env, holds: form.holds, value });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const adc = (only ?? OWNERS).includes('vertex') ? gcloudAdc() : undefined;
|
|
403
|
+
if (adc && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
404
|
+
found.push({ provider: 'vertex', holds: 'file', value: adc });
|
|
405
|
+
}
|
|
406
|
+
return found;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Every credential variable this process is carrying, whatever put it there.
|
|
410
|
+
*
|
|
411
|
+
* Read off the environment rather than off the store, and deliberately: by the
|
|
412
|
+
* time anyone asks, `materialize()` has already run, so the environment is the
|
|
413
|
+
* union of the keyring and whatever the shell brought — which is exactly the
|
|
414
|
+
* set of credentials the run is actually using.
|
|
415
|
+
*/
|
|
416
|
+
export function credentials() {
|
|
417
|
+
const found = [];
|
|
418
|
+
for (const provider of OWNERS) {
|
|
419
|
+
for (const form of SHAPES[provider].forms) {
|
|
420
|
+
const value = process.env[form.env];
|
|
421
|
+
if (value) {
|
|
422
|
+
found.push({ env: form.env, holds: form.holds, value });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return found;
|
|
427
|
+
}
|
|
287
428
|
// ---------------------------------------------------------------------------
|
|
288
429
|
// Gate
|
|
289
430
|
// ---------------------------------------------------------------------------
|
|
@@ -296,7 +437,7 @@ export function describe(store, entry) {
|
|
|
296
437
|
* one clear error here for an obscure one on the first turn.
|
|
297
438
|
*/
|
|
298
439
|
export function assertUsable(store) {
|
|
299
|
-
const reachable = PROVIDERS.filter((p) => process.env[
|
|
440
|
+
const reachable = PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name]) || store.active(p) !== undefined);
|
|
300
441
|
if (reachable.length === 0) {
|
|
301
442
|
throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
|
|
302
443
|
}
|
package/dist/lib.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { extract, invokedAs, one, parse, split, type Parsed, type Split } from '
|
|
|
2
2
|
export { printBanner, type BannerText } from './banner.ts';
|
|
3
3
|
export type { Command, Context } from './command.ts';
|
|
4
4
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
|
|
5
|
-
export { assertNotEmpty, assertOwner, assertUsable, describe, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, type KeyCheck, type KeyEntry, type KeyOwner, type Liveness, type Provider, type Service, } from './keys.ts';
|
|
5
|
+
export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, type CredentialForm, type KeyCheck, type KeyEntry, type KeyOwner, type Liveness, type Provider, type Service, } from './keys.ts';
|
|
6
6
|
export { probe, probeAll } from './liveness.ts';
|
|
7
7
|
export { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, type DiskLine, type EngineDisk, type OwnedContainer, type PodmanOptions, type PodmanStatus, } from './podman.ts';
|
|
8
8
|
export { ago, bold, CliError, count, credentialError, cyan, dim, EXIT, fail, green, invalidError, isInteractive, json, note, pad, red, table, usageError, warn, write, writeAll, yellow, type ExitCode, } from './term.ts';
|
package/dist/lib.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
export { extract, invokedAs, one, parse, split } from "./args.js";
|
|
25
25
|
export { printBanner } from "./banner.js";
|
|
26
26
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
|
|
27
|
-
export { assertNotEmpty, assertOwner, assertUsable, describe, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
|
|
27
|
+
export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
|
|
28
28
|
export { probe, probeAll } from "./liveness.js";
|
|
29
29
|
export { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "./podman.js";
|
|
30
30
|
export { ago, bold, CliError, count, credentialError, cyan, dim, EXIT, fail, green, invalidError, isInteractive, json, note, pad, red, table, usageError, warn, write, writeAll, yellow, } from "./term.js";
|
package/dist/liveness.d.ts
CHANGED
|
@@ -6,18 +6,28 @@ import { type KeyCheck, type KeyEntry, type KeyStore } from './keys.ts';
|
|
|
6
6
|
* The client is built through the library's own registry rather than by
|
|
7
7
|
* requiring the SDKs directly, so a missing optional dependency produces the
|
|
8
8
|
* library's "run: npm i openai" message instead of a raw MODULE_NOT_FOUND.
|
|
9
|
+
*
|
|
10
|
+
* A secret is handed to the registry as a value rather than exported first,
|
|
11
|
+
* which is what lets several of these run at once: two probes sharing
|
|
12
|
+
* `process.env` would each read the other's key.
|
|
9
13
|
*/
|
|
10
14
|
export declare function probe(store: KeyStore, entry: KeyEntry): Promise<KeyCheck>;
|
|
11
15
|
/**
|
|
12
|
-
* Probes many entries,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Probes many entries together, because they are independent questions asked
|
|
17
|
+
* of different vendors and the answer is a round trip apiece: in sequence,
|
|
18
|
+
* five keys is five deadlines end to end, and `zen init` spends them all
|
|
19
|
+
* before it writes a file.
|
|
20
|
+
*
|
|
21
|
+
* The exception is a credential the SDK can only be given through the
|
|
22
|
+
* environment — the vertex service-account file — since two of those in flight
|
|
23
|
+
* would each read the other's path. Those go one at a time, after the rest.
|
|
24
|
+
*
|
|
25
|
+
* `onProbe` therefore reports what has *finished* rather than what is being
|
|
26
|
+
* waited on; with several in the air there is no single one to name.
|
|
17
27
|
*
|
|
18
28
|
* Pairs rather than a map, because the caller needs the entry itself to record
|
|
19
29
|
* the result against, and a map keyed by a string would only have to be
|
|
20
30
|
* un-joined again.
|
|
21
31
|
*/
|
|
22
|
-
export declare function probeAll(store: KeyStore, entries: readonly KeyEntry[], onProbe?: (entry: KeyEntry,
|
|
32
|
+
export declare function probeAll(store: KeyStore, entries: readonly KeyEntry[], onProbe?: (entry: KeyEntry, done: number, total: number) => void): Promise<[KeyEntry, KeyCheck][]>;
|
|
23
33
|
//# sourceMappingURL=liveness.d.ts.map
|
package/dist/liveness.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EXA_BASE_URL, ModelRegistry } from '@zenera/neo';
|
|
2
|
-
import { SHAPES, } from "./keys.js";
|
|
2
|
+
import { envOf, SHAPES, } from "./keys.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Liveness
|
|
5
5
|
//
|
|
@@ -94,18 +94,61 @@ async function within(work) {
|
|
|
94
94
|
* The client is built through the library's own registry rather than by
|
|
95
95
|
* requiring the SDKs directly, so a missing optional dependency produces the
|
|
96
96
|
* library's "run: npm i openai" message instead of a raw MODULE_NOT_FOUND.
|
|
97
|
+
*
|
|
98
|
+
* A secret is handed to the registry as a value rather than exported first,
|
|
99
|
+
* which is what lets several of these run at once: two probes sharing
|
|
100
|
+
* `process.env` would each read the other's key.
|
|
97
101
|
*/
|
|
98
102
|
export async function probe(store, entry) {
|
|
99
103
|
const shape = SHAPES[entry.provider];
|
|
100
104
|
if (shape.kind === 'service') {
|
|
101
105
|
return probeService(entry.provider, store.reveal(entry));
|
|
102
106
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
107
|
+
const provider = entry.provider;
|
|
108
|
+
const credential = store.reveal(entry);
|
|
109
|
+
// What the entry holds, not what its provider usually holds: a Vertex
|
|
110
|
+
// express key is a secret like any other, and passing it as a value is what
|
|
111
|
+
// lets it be asked at the same time as the rest.
|
|
112
|
+
if (entry.holds !== 'file') {
|
|
113
|
+
return ask(provider, { kind: provider, apiKey: credential });
|
|
114
|
+
}
|
|
115
|
+
// A service-account file is the exception, and `probeAll` knows it:
|
|
116
|
+
// Application Default Credentials are found through the environment or not
|
|
117
|
+
// at all. So this one is exported, asked, and put back — alone.
|
|
118
|
+
const name = envOf(entry);
|
|
119
|
+
const restore = exportTemporarily({
|
|
120
|
+
[name]: credential,
|
|
121
|
+
...(entry.project ? { GOOGLE_CLOUD_PROJECT: entry.project } : {}),
|
|
122
|
+
...(entry.location ? { GOOGLE_CLOUD_LOCATION: entry.location } : {}),
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
return await ask(provider, { kind: provider });
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
restore();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Sets variables, and hands back the undo. */
|
|
132
|
+
function exportTemporarily(vars) {
|
|
133
|
+
const previous = new Map(Object.keys(vars).map((name) => [name, process.env[name]]));
|
|
134
|
+
Object.assign(process.env, vars);
|
|
135
|
+
return () => {
|
|
136
|
+
for (const [name, value] of previous) {
|
|
137
|
+
if (value === undefined) {
|
|
138
|
+
delete process.env[name];
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
process.env[name] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** One authenticated round trip, and what its silence or refusal means. */
|
|
147
|
+
async function ask(provider, spec) {
|
|
105
148
|
try {
|
|
106
149
|
const registry = new ModelRegistry();
|
|
107
|
-
registry.provider('probe',
|
|
108
|
-
await within(authenticate(
|
|
150
|
+
registry.provider('probe', spec);
|
|
151
|
+
await within(authenticate(provider, registry.client('probe')));
|
|
109
152
|
return { state: 'live', at: new Date().toISOString() };
|
|
110
153
|
}
|
|
111
154
|
catch (err) {
|
|
@@ -118,14 +161,6 @@ export async function probe(store, entry) {
|
|
|
118
161
|
}
|
|
119
162
|
return classify(err);
|
|
120
163
|
}
|
|
121
|
-
finally {
|
|
122
|
-
if (previous === undefined) {
|
|
123
|
-
delete process.env[shape.env];
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
process.env[shape.env] = previous;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
164
|
}
|
|
130
165
|
/**
|
|
131
166
|
* A service has no model catalog to list, and its cheapest endpoint is one that
|
|
@@ -200,22 +235,38 @@ async function authenticate(provider, client) {
|
|
|
200
235
|
void result;
|
|
201
236
|
}
|
|
202
237
|
/**
|
|
203
|
-
* Probes many entries,
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
238
|
+
* Probes many entries together, because they are independent questions asked
|
|
239
|
+
* of different vendors and the answer is a round trip apiece: in sequence,
|
|
240
|
+
* five keys is five deadlines end to end, and `zen init` spends them all
|
|
241
|
+
* before it writes a file.
|
|
242
|
+
*
|
|
243
|
+
* The exception is a credential the SDK can only be given through the
|
|
244
|
+
* environment — the vertex service-account file — since two of those in flight
|
|
245
|
+
* would each read the other's path. Those go one at a time, after the rest.
|
|
246
|
+
*
|
|
247
|
+
* `onProbe` therefore reports what has *finished* rather than what is being
|
|
248
|
+
* waited on; with several in the air there is no single one to name.
|
|
208
249
|
*
|
|
209
250
|
* Pairs rather than a map, because the caller needs the entry itself to record
|
|
210
251
|
* the result against, and a map keyed by a string would only have to be
|
|
211
252
|
* un-joined again.
|
|
212
253
|
*/
|
|
213
254
|
export async function probeAll(store, entries, onProbe) {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
255
|
+
const exclusive = (entry) => entry.holds === 'file';
|
|
256
|
+
const results = new Map();
|
|
257
|
+
let done = 0;
|
|
258
|
+
const record = (entry, check) => {
|
|
259
|
+
results.set(entry, check);
|
|
260
|
+
onProbe?.(entry, ++done, entries.length);
|
|
261
|
+
};
|
|
262
|
+
await Promise.all(entries
|
|
263
|
+
.filter((entry) => !exclusive(entry))
|
|
264
|
+
.map(async (entry) => record(entry, await probe(store, entry))));
|
|
265
|
+
for (const entry of entries.filter(exclusive)) {
|
|
266
|
+
record(entry, await probe(store, entry));
|
|
218
267
|
}
|
|
219
|
-
|
|
268
|
+
// Back into the caller's order: which one answered first is an accident of
|
|
269
|
+
// the network, and a list that reshuffles itself between runs is unreadable.
|
|
270
|
+
return entries.map((entry) => [entry, results.get(entry)]);
|
|
220
271
|
}
|
|
221
272
|
//# sourceMappingURL=liveness.js.map
|
package/dist/sandbox.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface SandboxInputs {
|
|
|
26
26
|
mounts?: readonly SandboxMount[];
|
|
27
27
|
/** `--image` */
|
|
28
28
|
image?: string;
|
|
29
|
+
/** `--no-keys`: refuse to forward credentials, whatever the config says */
|
|
30
|
+
keys?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
|
|
31
33
|
/**
|
package/dist/sandbox.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
4
|
import { resolveBuild } from "./image.js";
|
|
5
|
+
import { credentials } from "./keys.js";
|
|
5
6
|
import { ensurePodmanReady } from "./podman.js";
|
|
6
7
|
import { warn } from "./term.js";
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -23,26 +24,75 @@ import { warn } from "./term.js";
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
/** Where the persistent home lives, inside the container. */
|
|
25
26
|
const HOME = '/home/agent';
|
|
27
|
+
/** Where a credential *file* is mounted, inside the container. */
|
|
28
|
+
const KEY_MOUNT = '/run/zenera/keys';
|
|
29
|
+
/**
|
|
30
|
+
* The credentials the run is using, in the form a container takes.
|
|
31
|
+
*
|
|
32
|
+
* Two shapes, two mechanisms. A secret is forwarded by *name* — podman reads
|
|
33
|
+
* the value from its own environment, so nothing is written into an argv that
|
|
34
|
+
* `ps` will show. A service-account file has to be present as a file, so the
|
|
35
|
+
* one file is bind-mounted read-only and the variable is rewritten to where it
|
|
36
|
+
* landed; mounting the directory it came from would drag in whatever else the
|
|
37
|
+
* user keeps beside it.
|
|
38
|
+
*
|
|
39
|
+
* This is the part of the design that gives something away, and it should be
|
|
40
|
+
* said plainly: the sandbox runs code the model wrote, and a key it can read
|
|
41
|
+
* is a key it can send. `keys: false`, or `network: none`, is the answer for a
|
|
42
|
+
* project where that is not an acceptable trade.
|
|
43
|
+
*/
|
|
44
|
+
function forwarded() {
|
|
45
|
+
const secrets = [];
|
|
46
|
+
const env = {};
|
|
47
|
+
const mounts = [];
|
|
48
|
+
for (const cred of credentials()) {
|
|
49
|
+
if (cred.holds === 'file') {
|
|
50
|
+
const at = `${KEY_MOUNT}/${basename(cred.value)}`;
|
|
51
|
+
mounts.push({ host: cred.value, at, readOnly: true });
|
|
52
|
+
env[cred.env] = at;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
secrets.push(cred.env);
|
|
56
|
+
}
|
|
57
|
+
// Not secrets, and useless without one: a service account says which
|
|
58
|
+
// project it belongs to but never which region to call.
|
|
59
|
+
for (const name of ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION']) {
|
|
60
|
+
const value = process.env[name];
|
|
61
|
+
if (value) {
|
|
62
|
+
env[name] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { secrets, env, mounts };
|
|
66
|
+
}
|
|
26
67
|
export function buildSandbox(opts) {
|
|
27
68
|
// An explicit --image is an answer, so there is nothing left to build.
|
|
28
69
|
const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
|
|
29
70
|
const base = {
|
|
30
71
|
...(opts.config.sandbox ?? {}),
|
|
31
72
|
...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
|
|
73
|
+
...(opts.keys === false ? { keys: false } : {}),
|
|
32
74
|
};
|
|
33
75
|
const home = join(opts.session.data, 'sandbox', 'home');
|
|
34
76
|
const mounts = [{ host: home, at: HOME }, ...(opts.mounts ?? [])];
|
|
77
|
+
const keys = base.keys === false ? undefined : forwarded();
|
|
78
|
+
if (keys) {
|
|
79
|
+
mounts.push(...keys.mounts);
|
|
80
|
+
}
|
|
35
81
|
// Skills and assets are mounted read-only, and a python script run from a
|
|
36
82
|
// read-only directory fails on writing its own `__pycache__` — a confusing
|
|
37
83
|
// error about a file nobody asked for.
|
|
38
|
-
const
|
|
84
|
+
const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(keys?.env ?? {}) };
|
|
85
|
+
const spec = toSpec(base, extra, keys?.secrets);
|
|
39
86
|
const agents = {};
|
|
40
87
|
for (const agent of opts.config.agents) {
|
|
41
88
|
if (agent.sandbox) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
89
|
+
const merged = merge(base, agent.sandbox);
|
|
90
|
+
// An agent that opts out gets neither the variables nor the mount,
|
|
91
|
+
// but the mount is on the pool and cannot be taken back per agent —
|
|
92
|
+
// so the variable is what actually decides, and without it the file
|
|
93
|
+
// sitting there is not a credential anything will look for.
|
|
94
|
+
const own = merged.keys === false ? undefined : keys;
|
|
95
|
+
agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(own?.env ?? {}) }, own?.secrets);
|
|
46
96
|
}
|
|
47
97
|
}
|
|
48
98
|
const pool = new SandboxPool({
|
|
@@ -76,7 +126,7 @@ function merge(base, agent) {
|
|
|
76
126
|
* is simply not forwarded — an empty string in the container is a different
|
|
77
127
|
* thing from an absent one, and tools test for absence.
|
|
78
128
|
*/
|
|
79
|
-
function toSpec(config, extra) {
|
|
129
|
+
function toSpec(config, extra, secrets) {
|
|
80
130
|
const env = { ...extra };
|
|
81
131
|
for (const name of config.env ?? []) {
|
|
82
132
|
const value = process.env[name];
|
|
@@ -94,6 +144,7 @@ function toSpec(config, extra) {
|
|
|
94
144
|
user: config.user,
|
|
95
145
|
persist: config.persist,
|
|
96
146
|
env,
|
|
147
|
+
secrets,
|
|
97
148
|
};
|
|
98
149
|
}
|
|
99
150
|
/**
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Writes
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Writes the editor's files under `dir`, replacing what is there. They are
|
|
3
|
+
* ours: they say how the editor is to treat a directory the agents write into,
|
|
4
|
+
* and they describe the file formats of the version of `zen` in hand, so the
|
|
5
|
+
* current answer is the only one worth having and a stale one is worse than
|
|
6
|
+
* none. Returns the relative paths written.
|
|
6
7
|
*/
|
|
7
|
-
export declare function
|
|
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
|
-
/**
|
|
15
|
-
* Writes `sandbox/`, the Dockerfile the scaffolded `agents.yaml` builds. Unlike
|
|
16
|
-
* the editor files this becomes the project's own — it is meant to be edited —
|
|
17
|
-
* so anything already there is left alone.
|
|
18
|
-
*/
|
|
19
|
-
export declare function sandboxTemplate(dir: string): string[];
|
|
8
|
+
export declare function editorFiles(dir: string): string[];
|
|
20
9
|
export interface ScaffoldOptions {
|
|
21
10
|
/** the project directory */
|
|
22
11
|
dir: string;
|
|
@@ -26,10 +15,21 @@ export interface ScaffoldOptions {
|
|
|
26
15
|
/** give the default agent `exa:*` — set when a key for it is on hand */
|
|
27
16
|
web?: boolean;
|
|
28
17
|
}
|
|
18
|
+
export interface Scaffolded {
|
|
19
|
+
/** the project's own files, in the order they were written */
|
|
20
|
+
files: string[];
|
|
21
|
+
/** `.vscode/` and `.github/` — written alongside, and nobody's to edit */
|
|
22
|
+
editor: string[];
|
|
23
|
+
}
|
|
29
24
|
/**
|
|
30
|
-
* Writes a project. Never overwrites the project's own files
|
|
31
|
-
*
|
|
32
|
-
* and are replaced.
|
|
25
|
+
* Writes a project. Never overwrites the project's own files — a second `init`
|
|
26
|
+
* over a directory fills in what is missing and leaves the rest alone — but the
|
|
27
|
+
* editor files are ours, and are replaced.
|
|
28
|
+
*
|
|
29
|
+
* The two are returned apart because they are read differently: the project's
|
|
30
|
+
* files are the thing that was just made, and worth listing; the editor's are
|
|
31
|
+
* plumbing for a tool that may not even be installed, and listing them buries
|
|
32
|
+
* the first set under twice as many lines about the second.
|
|
33
33
|
*/
|
|
34
|
-
export declare function scaffold(opts: ScaffoldOptions):
|
|
34
|
+
export declare function scaffold(opts: ScaffoldOptions): Scaffolded;
|
|
35
35
|
//# sourceMappingURL=scaffold.d.ts.map
|