@zenera/cli 1.1.0 → 1.1.2
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/dist/commands/check.js +47 -14
- package/dist/commands/sandbox.js +226 -22
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +9 -2
- package/dist/image.d.ts +16 -0
- package/dist/image.js +85 -0
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +1 -1
- package/dist/main.js +0 -0
- package/dist/podman.d.ts +57 -1
- package/dist/podman.js +177 -12
- package/dist/projects.d.ts +18 -0
- package/dist/projects.js +60 -1
- package/dist/sandbox.d.ts +12 -1
- package/dist/sandbox.js +35 -6
- package/dist/scaffold.d.ts +6 -0
- package/dist/scaffold.js +49 -11
- package/dist/term.d.ts +2 -0
- package/dist/term.js +14 -0
- package/dist/validate.d.ts +20 -3
- package/dist/validate.js +306 -11
- package/package.json +2 -2
- package/templates/.github/copilot-instructions.md +154 -42
- package/templates/.github/prompts/new-skill.prompt.md +13 -6
- package/templates/sandbox/Dockerfile +19 -0
package/dist/term.js
CHANGED
|
@@ -239,4 +239,18 @@ export function ago(iso) {
|
|
|
239
239
|
export function count(n, singular, plural = `${singular}s`) {
|
|
240
240
|
return `${n} ${n === 1 ? singular : plural}`;
|
|
241
241
|
}
|
|
242
|
+
/** Powers of 1000, as the container engine prints them, so the two agree. */
|
|
243
|
+
export function bytes(n) {
|
|
244
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
245
|
+
return '0 B';
|
|
246
|
+
}
|
|
247
|
+
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
|
248
|
+
let value = n;
|
|
249
|
+
let unit = 0;
|
|
250
|
+
while (value >= 1000 && unit < units.length - 1) {
|
|
251
|
+
value /= 1000;
|
|
252
|
+
unit++;
|
|
253
|
+
}
|
|
254
|
+
return `${unit > 0 && value < 100 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
255
|
+
}
|
|
242
256
|
//# sourceMappingURL=term.js.map
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AnyTool, type ProjectConfig } from '@zenera/neo';
|
|
1
|
+
import { type AnyTool, type ProjectConfig, type Runner } from '@zenera/neo';
|
|
2
2
|
import { type DeclaredRole } from './audit.ts';
|
|
3
3
|
import { type KeyStore } from './keys.ts';
|
|
4
4
|
/**
|
|
@@ -65,8 +65,8 @@ export interface SkillReport {
|
|
|
65
65
|
/** the SKILL.md, relative to the project root */
|
|
66
66
|
path: string;
|
|
67
67
|
tools?: string[];
|
|
68
|
-
/** other files in the skill folder, which the agent
|
|
69
|
-
|
|
68
|
+
/** other files in the skill folder, which the agent can reach under /skills */
|
|
69
|
+
files?: string[];
|
|
70
70
|
/** agents whose binding can see it */
|
|
71
71
|
usedBy: string[];
|
|
72
72
|
}
|
|
@@ -111,8 +111,12 @@ export interface Report {
|
|
|
111
111
|
models: ModelReport[];
|
|
112
112
|
sandbox: {
|
|
113
113
|
image: string | null;
|
|
114
|
+
/** the Dockerfile that image is built from, when it is built rather than pulled */
|
|
115
|
+
dockerfile: string | null;
|
|
114
116
|
declared: boolean;
|
|
115
117
|
used: boolean;
|
|
118
|
+
/** whether the image was actually built and a container started */
|
|
119
|
+
probed: boolean;
|
|
116
120
|
};
|
|
117
121
|
findings: Finding[];
|
|
118
122
|
counts: {
|
|
@@ -139,6 +143,19 @@ export interface ValidateOptions {
|
|
|
139
143
|
* pay for it.
|
|
140
144
|
*/
|
|
141
145
|
keys?: KeyStore;
|
|
146
|
+
/**
|
|
147
|
+
* Whether to build the image and start a container. Everything else here
|
|
148
|
+
* is a reading of files; this is the one part that runs something, so it
|
|
149
|
+
* is named separately and can be turned off.
|
|
150
|
+
*/
|
|
151
|
+
sandbox?: {
|
|
152
|
+
enabled: boolean;
|
|
153
|
+
engine?: string;
|
|
154
|
+
/** the test seam, as elsewhere — a check can be watched without an engine */
|
|
155
|
+
exec?: Runner;
|
|
156
|
+
/** called with each slow step, so the caller can narrate one */
|
|
157
|
+
onProgress?: (what: string) => void;
|
|
158
|
+
};
|
|
142
159
|
}
|
|
143
160
|
export declare function validateProject(opts: ValidateOptions): Promise<Report>;
|
|
144
161
|
export declare function availableTools(root: string, config: ProjectConfig): AnyTool<unknown>[];
|
package/dist/validate.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { EXA_GROUP, FileSkillProvider, SANDBOX_MOUNT, Sandbox, exaTools, projectRegistry, readProjectConfig, sandboxTools, selectTools, workspaceTools, } from '@zenera/neo';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
5
|
import { auditModels, credentialFor } from "./audit.js";
|
|
6
|
+
import { resolveBuild } from "./image.js";
|
|
5
7
|
import { SHAPES } from "./keys.js";
|
|
8
|
+
import { BuildError, ensurePodmanReady } from "./podman.js";
|
|
6
9
|
// Mirrors the loader's own constants (`packages/neo/src/project/load.ts`).
|
|
7
10
|
// Duplicated rather than exported, because a check that agreed with the loader
|
|
8
11
|
// by construction could not report that the two had diverged.
|
|
@@ -10,6 +13,8 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
|
|
|
10
13
|
const HOUSE_RULES = 'INSTRUCTIONS.md';
|
|
11
14
|
const PROMPTS_DIR = 'agents/prompts';
|
|
12
15
|
const SKILLS_DIR = 'agents/skills';
|
|
16
|
+
const SKILL_FILE = 'SKILL.md';
|
|
17
|
+
const ASSETS_DIR = 'assets';
|
|
13
18
|
/** What `allow:` and `preload:` accept, so a skill outside it cannot be named. */
|
|
14
19
|
const REFERABLE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
15
20
|
export async function validateProject(opts) {
|
|
@@ -27,6 +32,8 @@ export async function validateProject(opts) {
|
|
|
27
32
|
let entry = null;
|
|
28
33
|
let registered = opts.registered ?? false;
|
|
29
34
|
let name = opts.name ?? null;
|
|
35
|
+
let build;
|
|
36
|
+
let probed = false;
|
|
30
37
|
const add = (f) => {
|
|
31
38
|
findings.push(f);
|
|
32
39
|
};
|
|
@@ -54,7 +61,7 @@ export async function validateProject(opts) {
|
|
|
54
61
|
skillDirs,
|
|
55
62
|
providers,
|
|
56
63
|
models,
|
|
57
|
-
sandbox: sandboxSummary(config, agents),
|
|
64
|
+
sandbox: sandboxSummary(config, agents, build, probed),
|
|
58
65
|
findings,
|
|
59
66
|
});
|
|
60
67
|
// -----------------------------------------------------------------------
|
|
@@ -181,12 +188,26 @@ export async function validateProject(opts) {
|
|
|
181
188
|
skills.push(...catalog.entries);
|
|
182
189
|
bindSkills(config, skills, catalog.names, add);
|
|
183
190
|
// -----------------------------------------------------------------------
|
|
191
|
+
// Assets
|
|
192
|
+
// -----------------------------------------------------------------------
|
|
193
|
+
checkAssets(root, config, record, add);
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// The sandbox
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
build = checkSandbox(root, config, record, add);
|
|
198
|
+
// -----------------------------------------------------------------------
|
|
184
199
|
// Models and credentials
|
|
185
200
|
// -----------------------------------------------------------------------
|
|
186
201
|
const resolved = checkModels(root, config, opts.keys, add);
|
|
187
202
|
providers = resolved.providers;
|
|
188
203
|
models.push(...resolved.models);
|
|
189
204
|
checkServices(agents, available, opts.keys, add);
|
|
205
|
+
// Last, because it is the only step that starts anything: everything a
|
|
206
|
+
// reading of the files can tell you is already on the report by now, so an
|
|
207
|
+
// interrupted check is still a useful one.
|
|
208
|
+
if (opts.sandbox?.enabled && sandboxSummary(config, agents).used) {
|
|
209
|
+
probed = await probeSandbox(config, build, opts.sandbox, add);
|
|
210
|
+
}
|
|
190
211
|
return done();
|
|
191
212
|
}
|
|
192
213
|
/**
|
|
@@ -562,9 +583,9 @@ async function checkSkills(root, config, available, record, add) {
|
|
|
562
583
|
try {
|
|
563
584
|
const skill = await provider.load(summary.name);
|
|
564
585
|
report.path = display(root, skill.file ?? '');
|
|
565
|
-
const
|
|
566
|
-
if (
|
|
567
|
-
report.
|
|
586
|
+
const files = siblings(skill.file);
|
|
587
|
+
if (files.length) {
|
|
588
|
+
report.files = files;
|
|
568
589
|
}
|
|
569
590
|
if (skill.tools?.length) {
|
|
570
591
|
report.tools = skill.tools.map((t) => t.name);
|
|
@@ -625,7 +646,7 @@ function locate(root, dirs, name) {
|
|
|
625
646
|
}
|
|
626
647
|
return undefined;
|
|
627
648
|
}
|
|
628
|
-
/**
|
|
649
|
+
/** Anything in a catalog that is not a `<name>/SKILL.md` folder. */ function ignored(root, dirs, known) {
|
|
629
650
|
const out = [];
|
|
630
651
|
for (const dir of dirs) {
|
|
631
652
|
let items;
|
|
@@ -636,10 +657,25 @@ function locate(root, dirs, name) {
|
|
|
636
657
|
continue;
|
|
637
658
|
}
|
|
638
659
|
for (const item of items) {
|
|
660
|
+
// A bare `<name>.md` is still indexed, so this is not a skill that
|
|
661
|
+
// went missing — it is one that can never grow a file of its own.
|
|
662
|
+
if (item.isFile() && item.name.endsWith('.md')) {
|
|
663
|
+
const folder = join(dir, item.name.slice(0, -'.md'.length));
|
|
664
|
+
out.push({
|
|
665
|
+
severity: 'error',
|
|
666
|
+
code: 'skill.flat',
|
|
667
|
+
where: join(dir, item.name),
|
|
668
|
+
message: 'a skill is a folder holding SKILL.md — a bare markdown file has no ' +
|
|
669
|
+
'directory of its own, so it can never ship the table, example or ' +
|
|
670
|
+
'script that is half of what a skill is for',
|
|
671
|
+
fix: `move it to ${join(folder, SKILL_FILE)}`,
|
|
672
|
+
});
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
639
675
|
if (!item.isDirectory() || known.has(item.name)) {
|
|
640
676
|
continue;
|
|
641
677
|
}
|
|
642
|
-
if (existsSync(join(root, dir, item.name,
|
|
678
|
+
if (existsSync(join(root, dir, item.name, SKILL_FILE))) {
|
|
643
679
|
// It has one; it is in the catalog under a frontmatter name.
|
|
644
680
|
continue;
|
|
645
681
|
}
|
|
@@ -745,6 +781,262 @@ function bindSkills(config, entries, known, add) {
|
|
|
745
781
|
}
|
|
746
782
|
}
|
|
747
783
|
// ---------------------------------------------------------------------------
|
|
784
|
+
// Assets
|
|
785
|
+
//
|
|
786
|
+
// The folder every agent can read and none can write. It is a convention, so
|
|
787
|
+
// not having one is not a finding — but *naming* one that is not there is, and
|
|
788
|
+
// so is naming one so wide that the run's own working files are inside it.
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
function checkAssets(root, config, record, add) {
|
|
791
|
+
const named = config.assets !== undefined;
|
|
792
|
+
const ref = config.assets ?? ASSETS_DIR;
|
|
793
|
+
const rel = normalise(root, ref);
|
|
794
|
+
if (rel === undefined) {
|
|
795
|
+
add({
|
|
796
|
+
severity: 'error',
|
|
797
|
+
code: 'assets.outside',
|
|
798
|
+
where: 'assets',
|
|
799
|
+
message: `"${ref}" resolves outside the project root, which is refused`,
|
|
800
|
+
fix: 'keep the material inside the project, or copy it in',
|
|
801
|
+
});
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// The conventional folder is optional; a project with nothing to share has
|
|
805
|
+
// nothing to say about it.
|
|
806
|
+
if (!named && !existsSync(join(root, rel))) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const role = 'reference material, mounted read-only at /assets';
|
|
810
|
+
if (!record(rel, role, 'directory', named, named ? 'assets' : undefined)) {
|
|
811
|
+
add({
|
|
812
|
+
severity: 'error',
|
|
813
|
+
code: 'assets.missing',
|
|
814
|
+
where: 'assets',
|
|
815
|
+
message: `no such directory: ${rel} — the project will not load`,
|
|
816
|
+
fix: `create ${rel}, or drop the \`assets:\` key`,
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const at = join(root, rel);
|
|
821
|
+
// Mounting the project root, or anything holding the sessions directory,
|
|
822
|
+
// hands every agent the transcripts of every run — including this one.
|
|
823
|
+
if (rel === '' || rel === '.' || existsSync(join(at, 'sessions'))) {
|
|
824
|
+
add({
|
|
825
|
+
severity: 'warning',
|
|
826
|
+
code: 'assets.overbroad',
|
|
827
|
+
where: 'assets',
|
|
828
|
+
message: `${rel || '.'} contains the project's own files, so every run's ` +
|
|
829
|
+
'transcripts, prompts and configuration are readable by every agent',
|
|
830
|
+
fix: 'point `assets:` at a directory that holds only what agents should read',
|
|
831
|
+
});
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (contents(at).length === 0) {
|
|
835
|
+
add({
|
|
836
|
+
severity: 'note',
|
|
837
|
+
code: 'assets.empty',
|
|
838
|
+
where: 'assets',
|
|
839
|
+
message: `${rel} is empty, so /assets is mounted with nothing in it`,
|
|
840
|
+
fix: 'put something in it, or remove it',
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function contents(dir) {
|
|
845
|
+
try {
|
|
846
|
+
return readdirSync(dir);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return [];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// The sandbox
|
|
854
|
+
//
|
|
855
|
+
// Two halves. The first reads the `build:` block and says whether the files it
|
|
856
|
+
// names are there — cheap, and true on any machine. The second builds the
|
|
857
|
+
// image and runs one command in it, which is the only way to find out whether
|
|
858
|
+
// the Dockerfile works, and is the only thing in this module that starts
|
|
859
|
+
// anything.
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
/**
|
|
862
|
+
* Resolves `sandbox.build:` and records the files it names. Returns what the
|
|
863
|
+
* probe would build, or nothing when the project pulls an image instead.
|
|
864
|
+
*/
|
|
865
|
+
function checkSandbox(root, config, record, add) {
|
|
866
|
+
const spec = config.sandbox?.build;
|
|
867
|
+
if (!spec) {
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
const file = normalise(root, spec.dockerfile);
|
|
871
|
+
const context = spec.context === undefined ? undefined : normalise(root, spec.context);
|
|
872
|
+
if (file === undefined || (spec.context !== undefined && context === undefined)) {
|
|
873
|
+
add({
|
|
874
|
+
severity: 'error',
|
|
875
|
+
code: 'sandbox.outside',
|
|
876
|
+
where: 'sandbox.build',
|
|
877
|
+
message: 'the build resolves outside the project root, which is refused',
|
|
878
|
+
fix: 'keep the Dockerfile and its context inside the project',
|
|
879
|
+
});
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
let ok = record(file, 'the sandbox image is built from this', 'file', true, 'sandbox.build');
|
|
883
|
+
if (!ok) {
|
|
884
|
+
add({
|
|
885
|
+
severity: 'error',
|
|
886
|
+
code: 'sandbox.dockerfile.missing',
|
|
887
|
+
where: 'sandbox.build.dockerfile',
|
|
888
|
+
message: `no such file: ${file} — the project will not load`,
|
|
889
|
+
fix: `create ${file}, or replace \`build:\` with \`image: <ref>\``,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
if (context !== undefined) {
|
|
893
|
+
const found = record(context, 'what the sandbox build may COPY from', 'directory', true, 'sandbox.build.context');
|
|
894
|
+
if (!found) {
|
|
895
|
+
add({
|
|
896
|
+
severity: 'error',
|
|
897
|
+
code: 'sandbox.context.missing',
|
|
898
|
+
where: 'sandbox.build.context',
|
|
899
|
+
message: `no such directory: ${context} — the project will not load`,
|
|
900
|
+
fix: `create ${context}, or drop \`context:\` to use the Dockerfile's own folder`,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
ok &&= found;
|
|
904
|
+
}
|
|
905
|
+
if (!ok) {
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
return resolveBuild(root, config.sandbox);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
add({
|
|
913
|
+
severity: 'error',
|
|
914
|
+
code: 'sandbox.build.invalid',
|
|
915
|
+
where: 'sandbox.build',
|
|
916
|
+
message: err instanceof Error ? err.message : String(err),
|
|
917
|
+
});
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/** Written by the smoke test and read back, to prove the mount is real. */
|
|
922
|
+
const SMOKE = 'zen-check';
|
|
923
|
+
/**
|
|
924
|
+
* Builds the image and runs one command in it.
|
|
925
|
+
*
|
|
926
|
+
* The container is given a temporary directory rather than anybody's
|
|
927
|
+
* workspace: this is a check, and a check that can write to the work is not
|
|
928
|
+
* one. `persist` is off, so closing it removes it — a `zen check` that left a
|
|
929
|
+
* container behind would be a slow leak nobody attributed to it.
|
|
930
|
+
*/
|
|
931
|
+
async function probeSandbox(config, build, opts, add) {
|
|
932
|
+
const image = build?.tag ?? config.sandbox?.image;
|
|
933
|
+
const engine = opts.engine ?? 'podman';
|
|
934
|
+
// The pre-flight narrates the build and the pull itself, so this only has
|
|
935
|
+
// to cover the silence before it: installing, and starting a machine.
|
|
936
|
+
opts.onProgress?.('checking the sandbox');
|
|
937
|
+
try {
|
|
938
|
+
await ensurePodmanReady({ image, build, engine, exec: opts.exec, yes: true });
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
if (err instanceof BuildError) {
|
|
942
|
+
const rel = config.sandbox?.build?.dockerfile ?? 'the Dockerfile';
|
|
943
|
+
add({
|
|
944
|
+
severity: 'error',
|
|
945
|
+
code: 'sandbox.build',
|
|
946
|
+
where: 'sandbox.build.dockerfile',
|
|
947
|
+
message: `${rel} does not build: ${err.hint ?? reason(err)}`,
|
|
948
|
+
fix: 'run the build by hand to see the whole log',
|
|
949
|
+
});
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
// A laptop with no container engine is not a broken project, and this
|
|
953
|
+
// report is most valuable on exactly that laptop.
|
|
954
|
+
add({
|
|
955
|
+
severity: 'warning',
|
|
956
|
+
code: 'sandbox.unchecked',
|
|
957
|
+
where: 'sandbox',
|
|
958
|
+
message: `${reason(err)} — so the sandbox was not tried`,
|
|
959
|
+
fix: 'run `zen sandbox up`, or pass --no-sandbox to skip this',
|
|
960
|
+
});
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'zen-check-')));
|
|
964
|
+
const box = new Sandbox({
|
|
965
|
+
root,
|
|
966
|
+
key: 'check',
|
|
967
|
+
image,
|
|
968
|
+
cpus: config.sandbox?.cpus,
|
|
969
|
+
memory: config.sandbox?.memory,
|
|
970
|
+
network: config.sandbox?.network,
|
|
971
|
+
workdir: config.sandbox?.workdir,
|
|
972
|
+
user: config.sandbox?.user,
|
|
973
|
+
engine,
|
|
974
|
+
exec: opts.exec,
|
|
975
|
+
// Not the project's: a check forwards no host values, and keeping the
|
|
976
|
+
// container is the caller's choice about their work, not about this.
|
|
977
|
+
env: {},
|
|
978
|
+
persist: false,
|
|
979
|
+
});
|
|
980
|
+
opts.onProgress?.(`starting ${image ?? 'the container'}`);
|
|
981
|
+
try {
|
|
982
|
+
const res = await box.exec([
|
|
983
|
+
`printf '%s\\n' ${SMOKE} > ${SMOKE}.txt`,
|
|
984
|
+
`cat ${SMOKE}.txt`,
|
|
985
|
+
`rm ${SMOKE}.txt`,
|
|
986
|
+
'pwd',
|
|
987
|
+
].join('\n'), { timeout: 60 });
|
|
988
|
+
if (res.exit_code !== 0 || !res.stdout.includes(SMOKE)) {
|
|
989
|
+
add({
|
|
990
|
+
severity: 'error',
|
|
991
|
+
code: 'sandbox.smoke',
|
|
992
|
+
where: 'sandbox',
|
|
993
|
+
message: `${image} starts, but the workspace is not usable inside it: ` +
|
|
994
|
+
(res.stderr.trim().split('\n')[0] || `exit ${res.exit_code}`),
|
|
995
|
+
fix: 'check `workdir:` and `user:` — the image must be able to write there',
|
|
996
|
+
});
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
add({
|
|
1003
|
+
severity: 'error',
|
|
1004
|
+
code: 'sandbox.start',
|
|
1005
|
+
where: 'sandbox',
|
|
1006
|
+
message: `could not start a container from ${image}: ${reason(err)}`,
|
|
1007
|
+
fix: 'run `zen sandbox status` to see what the engine says',
|
|
1008
|
+
});
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
await box.dispose().catch(() => undefined);
|
|
1013
|
+
rmSync(root, { recursive: true, force: true });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function reason(err) {
|
|
1017
|
+
return err instanceof Error ? err.message : String(err);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* What else is in a skill's folder — the files the agent reaches under /skills,
|
|
1021
|
+
* and the reason a skill can ship a script instead of describing one. A bare
|
|
1022
|
+
* `<name>.md` has no folder of its own, and its neighbours are other skills.
|
|
1023
|
+
*/
|
|
1024
|
+
function siblings(skillFile) {
|
|
1025
|
+
if (!skillFile || basename(skillFile) !== SKILL_FILE) {
|
|
1026
|
+
return [];
|
|
1027
|
+
}
|
|
1028
|
+
const folder = dirname(skillFile);
|
|
1029
|
+
try {
|
|
1030
|
+
return readdirSync(folder, { withFileTypes: true })
|
|
1031
|
+
.filter((e) => e.name !== SKILL_FILE)
|
|
1032
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
1033
|
+
.sort();
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
return [];
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
748
1040
|
// Models
|
|
749
1041
|
// ---------------------------------------------------------------------------
|
|
750
1042
|
function checkModels(root, config, keys, add) {
|
|
@@ -890,12 +1182,15 @@ function whereFor(config, role, name, usedBy) {
|
|
|
890
1182
|
// ---------------------------------------------------------------------------
|
|
891
1183
|
// Odds and ends
|
|
892
1184
|
// ---------------------------------------------------------------------------
|
|
893
|
-
function sandboxSummary(config, agents) {
|
|
1185
|
+
function sandboxSummary(config, agents, build, probed = false) {
|
|
894
1186
|
const used = agents.some((a) => a.tools.some((t) => SANDBOX_TOOLS.has(t)));
|
|
895
1187
|
return {
|
|
896
|
-
image: config?.sandbox?.image ?? null,
|
|
1188
|
+
image: build?.tag ?? config?.sandbox?.image ?? null,
|
|
1189
|
+
// As written, not as resolved: this is read by someone about to edit it.
|
|
1190
|
+
dockerfile: config?.sandbox?.build?.dockerfile ?? null,
|
|
897
1191
|
declared: Boolean(config?.sandbox) || agents.some((a) => a.ownSandbox),
|
|
898
1192
|
used,
|
|
1193
|
+
probed,
|
|
899
1194
|
};
|
|
900
1195
|
}
|
|
901
1196
|
const SANDBOX_TOOLS = new Set([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@inkjs/ui": "^2.0.0",
|
|
52
52
|
"ink": "^7.1.1",
|
|
53
53
|
"react": "^19.2.8",
|
|
54
|
-
"@zenera/neo": "^1.1.
|
|
54
|
+
"@zenera/neo": "^1.1.2"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@anthropic-ai/sdk": "^0.120.0",
|