@zenera/cli 1.1.0 → 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.
Files changed (61) hide show
  1. package/README.md +88 -11
  2. package/dist/audit.d.ts +8 -6
  3. package/dist/audit.js +14 -22
  4. package/dist/commands/check.js +79 -19
  5. package/dist/commands/init.js +71 -11
  6. package/dist/commands/key.js +126 -36
  7. package/dist/commands/models.js +3 -3
  8. package/dist/commands/open.js +2 -2
  9. package/dist/commands/run.js +3 -0
  10. package/dist/commands/sandbox.js +226 -22
  11. package/dist/engine.d.ts +3 -1
  12. package/dist/engine.js +10 -2
  13. package/dist/image.d.ts +16 -0
  14. package/dist/image.js +85 -0
  15. package/dist/keys.d.ts +95 -12
  16. package/dist/keys.js +175 -34
  17. package/dist/lib.d.ts +2 -2
  18. package/dist/lib.js +2 -2
  19. package/dist/liveness.d.ts +16 -6
  20. package/dist/liveness.js +74 -23
  21. package/dist/main.js +0 -0
  22. package/dist/podman.d.ts +57 -1
  23. package/dist/podman.js +177 -12
  24. package/dist/projects.d.ts +18 -0
  25. package/dist/projects.js +60 -1
  26. package/dist/sandbox.d.ts +14 -1
  27. package/dist/sandbox.js +88 -8
  28. package/dist/scaffold.d.ts +21 -15
  29. package/dist/scaffold.js +133 -167
  30. package/dist/term.d.ts +2 -0
  31. package/dist/term.js +14 -0
  32. package/dist/validate.d.ts +20 -3
  33. package/dist/validate.js +309 -14
  34. package/package.json +2 -18
  35. package/templates/{.github → editor/.github}/copilot-instructions.md +161 -48
  36. package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +13 -6
  37. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  38. package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
  39. package/templates/editor/.github/skills/zen-cli/references/check.md +92 -0
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  41. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  42. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  43. package/templates/editor/.github/skills/zen-cli/references/keys.md +114 -0
  44. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  45. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  46. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  47. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  48. package/templates/editor/.vscode/settings.json +6 -0
  49. package/templates/parts/exa.yaml.tmpl +5 -0
  50. package/templates/parts/model.yaml.tmpl +4 -0
  51. package/templates/parts/models.yaml.tmpl +10 -0
  52. package/templates/project/INSTRUCTIONS.md +7 -0
  53. package/templates/project/SPECIFICATION.md +6 -0
  54. package/templates/project/agents/prompts/default.md +15 -0
  55. package/templates/project/agents.yaml.tmpl +44 -0
  56. package/templates/project/assets/README.md +12 -0
  57. package/templates/project/gitignore +9 -0
  58. package/templates/project/sandbox/Dockerfile +21 -0
  59. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  60. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  61. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/dist/validate.js CHANGED
@@ -1,8 +1,11 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { isAbsolute, join, relative, resolve } from 'node:path';
3
- import { EXA_GROUP, FileSkillProvider, SANDBOX_MOUNT, exaTools, projectRegistry, readProjectConfig, sandboxTools, selectTools, workspaceTools, } from '@zenera/neo';
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";
5
- import { SHAPES } from "./keys.js";
6
+ import { resolveBuild } from "./image.js";
7
+ import { SHAPES, envNames, form } 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
  /**
@@ -209,7 +230,7 @@ function checkServices(agents, available, keys, add) {
209
230
  continue;
210
231
  }
211
232
  const shape = SHAPES[service];
212
- if (process.env[shape.env] || keys.active(service)) {
233
+ if (envNames(service).some((name) => process.env[name]) || keys.active(service)) {
213
234
  continue;
214
235
  }
215
236
  add({
@@ -218,7 +239,7 @@ function checkServices(agents, available, keys, add) {
218
239
  where: users.map((a) => `agents.${a.name}`).join(', '),
219
240
  message: `uses the ${shape.label} tools, and nothing on this machine holds a ` +
220
241
  `${shape.label} key — those tools will refuse every call`,
221
- fix: `zen key add ${service}, or set $${shape.env}`,
242
+ fix: `zen key add ${service}, or set $${form(service).env}`,
222
243
  });
223
244
  }
224
245
  }
@@ -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 resources = Object.keys(skill.resources ?? {});
566
- if (resources.length) {
567
- report.resources = resources;
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
- /** Directories in a catalog that hold no `SKILL.md`, and are therefore not skills. */ function ignored(root, dirs, known) {
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, 'SKILL.md'))) {
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.0",
3
+ "version": "1.1.3",
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,26 +51,10 @@
51
51
  "@inkjs/ui": "^2.0.0",
52
52
  "ink": "^7.1.1",
53
53
  "react": "^19.2.8",
54
- "@zenera/neo": "^1.1.0"
55
- },
56
- "peerDependencies": {
54
+ "@zenera/neo": "^1.1.3",
57
55
  "@anthropic-ai/sdk": "^0.120.0",
58
56
  "@google/genai": "^2.18.0",
59
57
  "@openrouter/sdk": "^1.2.80",
60
58
  "openai": "^6.0.0"
61
- },
62
- "peerDependenciesMeta": {
63
- "@anthropic-ai/sdk": {
64
- "optional": true
65
- },
66
- "@google/genai": {
67
- "optional": true
68
- },
69
- "@openrouter/sdk": {
70
- "optional": true
71
- },
72
- "openai": {
73
- "optional": true
74
- }
75
59
  }
76
60
  }