@0xgeorgemathew/zcode 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/zcode.js CHANGED
@@ -1,108 +1,130 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // zcode — CLI entry: argv parsing + command dispatch (DESIGN §10).
3
+ // zcode — CLI entry: argv parsing + command dispatch. Parsing rejects bad
4
+ // input (exit 2) before any engine spawn or session record exists.
4
5
  const { normalizeModel, unknownModelMessage, normalizeEffort, EFFORT_LEVELS, DEFAULT_MODEL } = require('../lib/models.js');
5
6
 
6
- const EXIT = {
7
- OK: 0,
8
- INTERNAL: 1,
9
- USAGE: 2,
10
- NO_KEY: 3,
11
- NO_ENGINE: 4,
12
- TURN_FAILED: 5,
13
- NO_SUBAGENT: 6,
14
- TIMEOUT: 124,
15
- };
16
-
17
- const USAGE = `usage: zcode [options] [prompt…] one-shot turn (prompt via stdin when piped)
18
- zcode agent [--profile <name>] <instructions…>
19
- turn that MUST dispatch a subagent
20
- zcode login read API key from stdin → credentials
21
- zcode doctor [--json] [--fresh] engine + isolation + key report
22
-
23
- options:
24
- --model <id> glm-5.3 | glm-5.3-flash (+aliases). default: glm-5.3-flash
25
- --effort <level> low | medium | high | xhigh | max. default: max
26
- --cwd <dir> workspace for the session (default: process.cwd())
27
- --profile <name> subagent profile (agent command; default: general-purpose)
28
- --session <id> continue a previous session (sessionId from a prior result)
29
- --mcp <json|@f> add MCP servers (repeatable). JSON maps names to defs:
30
- {"name":{"type":"stdio","command","args","env"}} or
31
- {"name":{"type":"http","url","headers"}}; @file reads a file.
32
- Hermetic default: only these run (deterministic, fast)
33
- --mcp-inherit also inherit the desktop's enabled MCP servers
34
- --mcp-list print the effective MCP server set as JSON and exit
35
- --skills <dir> add skills (repeatable): one skill dir (has SKILL.md) or a
36
- root of <name>/SKILL.md dirs. project <cwd>/.zcode/skills
37
- is picked up by the engine without any flag
38
- --no-skills run with an empty skill set (nothing inherited, nothing injected)
39
- --include-reasoning put the full reasoning text in the final JSON result
40
- --quiet do not mirror reasoning to stderr while running
41
- --json NDJSON events on stdout; final line {"type":"result",…}
42
- (stream events include reasoning_delta = thinking)
43
- --timeout-ms <n> default 600000
44
- --fresh rebuild the scratch HOME first
45
- -p, --prompt <t> prompt text (alias for positional prompt)
46
- --version, --help`;
7
+ const USAGE = `usage:
8
+ zcode task [options] <objective…> execute a task contract, verify it, hand back
9
+ zcode [run] [options] <prompt…> one-shot turn (prompt via stdin when piped)
10
+ zcode agent [options] <instructions…> turn that MUST dispatch a subagent
11
+ zcode resume <id> [message…] continue a session (answer, follow-up, retry)
12
+ zcode status [<id>] [--json] one session in detail, or all sessions
13
+ zcode wait <id…> [--json] block until sessions finish; print handbacks
14
+ zcode cancel <id> [--json] stop a running session
15
+ zcode login read API key from stdin → credentials
16
+ zcode doctor [--json] engine + isolation + key report
17
+
18
+ session:
19
+ --id <id> name the session (letters, digits, . _ -); default zc-<random>.
20
+ An existing id is never re-run: use resume.
21
+ --label <text> free-form label shown in status
22
+ --detach start in the background, print the id, exit 0
23
+ --cwd <dir> workspace (default: current dir)
24
+ --owns <path|glob> repeatable: paths this session may change (relative to
25
+ --cwd). Default: the whole workspace. Overlapping
26
+ ownership with a running session is refused (exit 7)
27
+ --allow-overlap start despite overlapping ownership
28
+
29
+ task contract (task only):
30
+ --task <file|-> JSON contract or markdown brief (- = stdin)
31
+ --accept <text> repeatable acceptance criterion
32
+ --check <command> repeatable shell check zcode runs after the work
33
+ --file <path> repeatable relevant file to read first
34
+ --context <text|@f> repeatable extra context (design decisions, references)
35
+ --max-repairs <n> repair turns after failing checks (default 2)
36
+ --check-timeout-ms <n> per-check timeout (default 300000)
37
+
38
+ execution:
39
+ --model <id> glm-5.3 | glm-5.3-flash (+aliases). default: glm-5.3-flash
40
+ --effort <level> low | medium | high | xhigh | max. default: max
41
+ --timeout-ms <n> whole invocation incl. checks/repairs (default 600000;
42
+ task default 1800000)
43
+ --profile <name> subagent profile (agent command; default general-purpose)
44
+ --mcp <json|@f> add MCP servers (repeatable); hermetic by default
45
+ --mcp-inherit also inherit the desktop's enabled MCP servers
46
+ --mcp-list print the effective MCP server set as JSON and exit
47
+ --skills <dir> add skills (repeatable): one skill dir or a root of them
48
+ --no-skills empty skill set
49
+
50
+ output:
51
+ --json NDJSON events on stdout; final line {"type":"result",…}
52
+ --quiet no progress: no stderr feed; with --json only the result line
53
+ --thinking / --no-thinking mirror reasoning to stderr (run: on, task: off)
54
+ --stream task --json: include text/reasoning deltas in the events
55
+ --include-reasoning full reasoning text in the result
56
+ -p, --prompt <t> prompt text (alias for positional prompt)
57
+ --session <id> run: continue a session (zcode id or engine sess_ id)
58
+ --version, --help
59
+
60
+ exit codes: 0 completed · 2 usage · 3 no key · 4 no engine · 5 failed ·
61
+ 6 agent: no subagent · 7 workspace conflict / session busy · 8 partial or
62
+ checks failing · 9 unverified · 10 blocked · 124 timeout · 130 cancelled`;
47
63
 
48
64
  function fail(code, message) {
49
65
  process.stderr.write(`zcode: ${message}\n`);
50
66
  process.exit(code);
51
67
  }
52
68
 
53
- function isDirectory(p) {
69
+ function readFileOrFail(file, flag) {
54
70
  try {
55
- return require('node:fs').statSync(p).isDirectory();
71
+ return require('node:fs').readFileSync(file, 'utf8');
56
72
  } catch {
57
- return false;
73
+ return fail(2, `${flag} file "${file}" could not be read.`);
58
74
  }
59
75
  }
60
76
 
61
- // --mcp accepts inline JSON or @file; the JSON must map server names to
62
- // definition objects ({type:"stdio"|"http"|"sse", ...} per the engine's
63
- // config schema). Fails (exit 2) before any engine spawn.
77
+ // --mcp accepts inline JSON or @file mapping server names to definitions.
64
78
  function parseMcpSpec(value) {
65
- let text = value;
66
- if (value.startsWith('@')) {
67
- try {
68
- text = require('node:fs').readFileSync(value.slice(1), 'utf8');
69
- } catch {
70
- fail(EXIT.USAGE, `--mcp file "${value.slice(1)}" could not be read.`);
71
- }
72
- }
79
+ const text = value.startsWith('@') ? readFileOrFail(value.slice(1), '--mcp') : value;
73
80
  let parsed;
74
81
  try {
75
82
  parsed = JSON.parse(text);
76
83
  } catch (err) {
77
- fail(EXIT.USAGE, `--mcp spec must be valid JSON mapping server names to definitions: ${err.message}`);
84
+ fail(2, `--mcp spec must be valid JSON mapping server names to definitions: ${err.message}`);
78
85
  }
79
86
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
80
- fail(EXIT.USAGE, `--mcp spec must be valid JSON mapping server names to definitions, got ${Array.isArray(parsed) ? 'an array' : `a ${typeof parsed}`}.`);
87
+ fail(2, `--mcp spec must be valid JSON mapping server names to definitions, got ${Array.isArray(parsed) ? 'an array' : `a ${typeof parsed}`}.`);
81
88
  }
82
89
  for (const [name, def] of Object.entries(parsed)) {
83
90
  if (!def || typeof def !== 'object' || Array.isArray(def)) {
84
- fail(EXIT.USAGE, `--mcp server "${name}" must be an object (e.g. {"type":"stdio","command":...,"args":[...]}).`);
91
+ fail(2, `--mcp server "${name}" must be an object (e.g. {"type":"stdio","command":...,"args":[...]}).`);
85
92
  }
86
93
  }
87
94
  return parsed;
88
95
  }
89
96
 
90
- // Parses argv (without node/script) into {command, prompts, flags}.
91
- // Exits 2 on usage errors. Rejection happens here, before any engine spawn.
97
+ const COMMANDS = new Set(['run', 'task', 'agent', 'resume', 'status', 'list', 'wait', 'cancel', 'login', 'doctor', '__exec']);
98
+ const TASK_ONLY = new Set(['task', 'accept', 'check', 'file', 'context', 'maxRepairs', 'checkTimeoutMs']);
99
+ const START_ONLY = new Set(['id', 'label', 'owns', 'allowOverlap', 'mcpSpecs', 'mcpInherit', 'skillsDirs', 'noSkills', 'profile', 'cwd']);
100
+
92
101
  function parseArgv(argv) {
93
102
  const out = {
94
103
  command: 'run',
95
104
  prompts: [],
105
+ given: new Set(),
96
106
  flags: {
97
107
  model: DEFAULT_MODEL,
98
108
  effort: 'max',
99
109
  cwd: process.cwd(),
100
110
  json: false,
101
111
  fresh: false,
102
- timeoutMs: 600000,
112
+ timeoutMs: undefined,
103
113
  profile: 'general-purpose',
104
114
  prompt: undefined,
105
115
  session: undefined,
116
+ id: undefined,
117
+ label: undefined,
118
+ detach: false,
119
+ task: undefined,
120
+ accept: [],
121
+ check: [],
122
+ file: [],
123
+ context: [],
124
+ owns: [],
125
+ allowOverlap: false,
126
+ maxRepairs: undefined,
127
+ checkTimeoutMs: undefined,
106
128
  mcpSpecs: [],
107
129
  mcpInherit: false,
108
130
  mcpList: false,
@@ -110,130 +132,149 @@ function parseArgv(argv) {
110
132
  noSkills: false,
111
133
  includeReasoning: false,
112
134
  quiet: false,
135
+ thinking: undefined,
136
+ stream: false,
113
137
  },
114
138
  };
115
139
  const valueFlags = new Map([
116
- ['--model', 'model'],
117
- ['--effort', 'effort'],
118
- ['--cwd', 'cwd'],
119
- ['--profile', 'profile'],
120
- ['--timeout-ms', 'timeoutMs'],
121
- ['-p', 'prompt'],
122
- ['--prompt', 'prompt'],
123
- ['--session', 'session'],
140
+ ['--model', 'model'], ['--effort', 'effort'], ['--cwd', 'cwd'], ['--profile', 'profile'],
141
+ ['--timeout-ms', 'timeoutMs'], ['-p', 'prompt'], ['--prompt', 'prompt'], ['--session', 'session'],
142
+ ['--id', 'id'], ['--label', 'label'], ['--task', 'task'], ['--max-repairs', 'maxRepairs'],
143
+ ['--check-timeout-ms', 'checkTimeoutMs'],
124
144
  ]);
125
145
  const listFlags = new Map([
126
- ['--mcp', 'mcpSpecs'],
127
- ['--skills', 'skillsDirs'],
146
+ ['--mcp', 'mcpSpecs'], ['--skills', 'skillsDirs'], ['--accept', 'accept'], ['--check', 'check'],
147
+ ['--file', 'file'], ['--context', 'context'], ['--owns', 'owns'],
128
148
  ]);
129
149
  const boolFlags = new Map([
130
- ['--json', 'json'],
131
- ['--fresh', 'fresh'],
132
- ['--mcp-inherit', 'mcpInherit'],
133
- ['--mcp-list', 'mcpList'],
134
- ['--no-skills', 'noSkills'],
135
- ['--include-reasoning', 'includeReasoning'],
136
- ['--quiet', 'quiet'],
150
+ ['--json', ['json', true]], ['--fresh', ['fresh', true]], ['--mcp-inherit', ['mcpInherit', true]],
151
+ ['--mcp-list', ['mcpList', true]], ['--no-skills', ['noSkills', true]],
152
+ ['--include-reasoning', ['includeReasoning', true]], ['--quiet', ['quiet', true]],
153
+ ['--detach', ['detach', true]], ['--allow-overlap', ['allowOverlap', true]],
154
+ ['--thinking', ['thinking', true]], ['--no-thinking', ['thinking', false]], ['--stream', ['stream', true]],
137
155
  ]);
138
- const commands = new Set(['run', 'agent', 'login', 'doctor']);
156
+ const numeric = (flag, value, { min = 1 } = {}) => {
157
+ const n = Number(value);
158
+ if (!Number.isFinite(n) || n < min) fail(2, `${flag} must be a number >= ${min}, got "${value}".`);
159
+ return n;
160
+ };
139
161
 
140
162
  for (let i = 0; i < argv.length; i++) {
141
163
  const arg = argv[i];
142
- if (valueFlags.has(arg) || boolFlags.has(arg) || listFlags.has(arg)) {
143
- let key;
144
- if (valueFlags.has(arg)) {
145
- key = valueFlags.get(arg);
146
- const value = argv[++i];
147
- if (value === undefined) fail(EXIT.USAGE, `option ${arg} requires a value.\n${USAGE}`);
148
- if (key === 'timeoutMs') {
149
- const n = Number(value);
150
- if (!Number.isFinite(n) || n <= 0) fail(EXIT.USAGE, `--timeout-ms must be a positive number, got "${value}".`);
151
- out.flags[key] = n;
152
- } else if (key === 'session') {
153
- if (!value.trim()) fail(EXIT.USAGE, '--session requires a session id.');
154
- out.flags[key] = value.trim();
155
- } else {
156
- out.flags[key] = value;
157
- }
158
- } else if (listFlags.has(arg)) {
159
- key = listFlags.get(arg);
160
- const value = argv[++i];
161
- if (value === undefined) fail(EXIT.USAGE, `option ${arg} requires a value.\n${USAGE}`);
162
- if (key === 'mcpSpecs') {
163
- out.flags.mcpSpecs.push(parseMcpSpec(value, arg));
164
- } else {
165
- if (!isDirectory(value)) fail(EXIT.USAGE, `--skills requires an existing directory, got "${value}".`);
166
- out.flags.skillsDirs.push(value);
167
- }
168
- } else {
169
- key = boolFlags.get(arg);
170
- out.flags[key] = true;
171
- }
164
+ if (valueFlags.has(arg) || listFlags.has(arg)) {
165
+ const key = valueFlags.get(arg) || listFlags.get(arg);
166
+ const value = argv[++i];
167
+ if (value === undefined) fail(2, `option ${arg} requires a value.\n${USAGE}`);
168
+ out.given.add(key);
169
+ if (key === 'timeoutMs' || key === 'checkTimeoutMs') out.flags[key] = numeric(arg, value);
170
+ else if (key === 'maxRepairs') out.flags[key] = numeric(arg, value, { min: 0 });
171
+ else if (key === 'mcpSpecs') out.flags.mcpSpecs.push(parseMcpSpec(value));
172
+ else if (key === 'skillsDirs') {
173
+ if (!isDirectory(value)) fail(2, `--skills requires an existing directory, got "${value}".`);
174
+ out.flags.skillsDirs.push(value);
175
+ } else if (key === 'context') {
176
+ out.flags.context.push(value.startsWith('@') ? readFileOrFail(value.slice(1), '--context') : value);
177
+ } else if (listFlags.has(arg)) out.flags[key].push(value);
178
+ else if (!value.trim()) fail(2, `${arg} requires a non-empty value.`);
179
+ else out.flags[key] = value.trim();
180
+ continue;
181
+ }
182
+ if (boolFlags.has(arg)) {
183
+ const [key, v] = boolFlags.get(arg);
184
+ out.given.add(key);
185
+ out.flags[key] = v;
172
186
  continue;
173
187
  }
174
188
  if (arg === '--help' || arg === '-h') {
175
189
  process.stdout.write(USAGE + '\n');
176
- process.exit(EXIT.OK);
190
+ process.exit(0);
177
191
  }
178
192
  if (arg === '--version') {
179
193
  process.stdout.write(require('../package.json').version + '\n');
180
- process.exit(EXIT.OK);
194
+ process.exit(0);
181
195
  }
182
196
  if (arg === '--') {
183
197
  out.prompts.push(...argv.slice(i + 1));
184
198
  break;
185
199
  }
186
- if (arg.startsWith('--') && arg.length > 2) {
187
- fail(EXIT.USAGE, `unknown option "${arg}".\n${USAGE}`);
188
- }
189
- if (out.prompts.length === 0 && commands.has(arg)) {
190
- out.command = arg;
200
+ if (arg.startsWith('--') && arg.length > 2) fail(2, `unknown option "${arg}".\n${USAGE}`);
201
+ if (out.prompts.length === 0 && out.command === 'run' && !out.commandSet && COMMANDS.has(arg)) {
202
+ out.command = arg === 'list' ? 'status' : arg;
203
+ out.commandSet = true;
191
204
  continue;
192
205
  }
193
206
  out.prompts.push(arg);
194
207
  }
195
208
 
196
- if (out.command === 'run' && out.flags.prompt !== undefined) {
197
- out.prompts.unshift(out.flags.prompt);
209
+ const cmd = out.command;
210
+ if (out.flags.prompt !== undefined) out.prompts.unshift(out.flags.prompt);
211
+ if (cmd !== 'task') {
212
+ for (const key of TASK_ONLY) {
213
+ if (out.given.has(key) && !(cmd === 'resume' && (key === 'maxRepairs' || key === 'checkTimeoutMs'))) {
214
+ fail(2, `--${flagName(key)} is only valid with "zcode task".`);
215
+ }
216
+ }
198
217
  }
218
+ if (cmd === 'resume' || cmd === '__exec') {
219
+ for (const key of START_ONLY) {
220
+ if (out.given.has(key)) fail(2, `--${flagName(key)} cannot change on resume; it is fixed when the session starts.`);
221
+ }
222
+ }
223
+ if (out.given.has('session') && cmd !== 'run') fail(2, '--session is only valid with "zcode run"; use "zcode resume <id>".');
224
+ if (out.given.has('id') && !require('../lib/store.js').validId(out.flags.id)) {
225
+ fail(2, `--id "${out.flags.id}" must be 1-64 chars of letters, digits, ".", "_" or "-", starting with a letter or digit.`);
226
+ }
227
+ if (!isDirectory(out.flags.cwd)) fail(2, `--cwd must be an existing directory, got "${out.flags.cwd}".`);
199
228
 
200
229
  const model = normalizeModel(out.flags.model);
201
- if (!model) fail(EXIT.USAGE, unknownModelMessage(out.flags.model));
230
+ if (!model) fail(2, unknownModelMessage(out.flags.model));
202
231
  out.flags.model = model;
203
-
204
232
  const effort = normalizeEffort(out.flags.effort);
205
- if (!effort) {
206
- fail(
207
- EXIT.USAGE,
208
- `unknown effort "${out.flags.effort}". --effort accepts ${EFFORT_LEVELS.join(' | ')}.`
209
- );
210
- }
233
+ if (!effort) fail(2, `unknown effort "${out.flags.effort}". --effort accepts ${EFFORT_LEVELS.join(' | ')}.`);
211
234
  out.flags.effort = effort;
212
235
  return out;
213
236
  }
214
237
 
238
+ function flagName(key) {
239
+ return key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`).replace(/^mcp-specs$/, 'mcp').replace(/^skills-dirs$/, 'skills');
240
+ }
241
+
242
+ function isDirectory(p) {
243
+ try {
244
+ return require('node:fs').statSync(p).isDirectory();
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
215
250
  async function main() {
216
251
  const parsed = parseArgv(process.argv.slice(2));
217
- const { command, flags } = parsed;
218
- if (flags.mcpList) {
219
- return require('../lib/commands/mcp-list.js')(parsed);
220
- }
221
- const run = require('../lib/commands/run.js');
222
- switch (command) {
252
+ if (parsed.flags.mcpList) return require('../lib/commands/mcp-list.js')(parsed);
253
+ switch (parsed.command) {
223
254
  case 'run':
224
- return run(parsed);
255
+ case 'task':
225
256
  case 'agent':
226
- return require('../lib/commands/agent.js')(parsed);
257
+ case 'resume':
258
+ case '__exec':
259
+ return require('../lib/commands/exec.js')(parsed);
260
+ case 'status':
261
+ case 'wait':
262
+ case 'cancel':
263
+ return require('../lib/commands/sessions.js')(parsed);
227
264
  case 'login':
228
265
  return require('../lib/commands/login.js')(parsed);
229
266
  case 'doctor':
230
267
  return require('../lib/commands/doctor.js')(parsed);
231
268
  default:
232
- fail(EXIT.USAGE, `unknown command "${command}".\n${USAGE}`);
269
+ return fail(2, `unknown command "${parsed.command}".\n${USAGE}`);
233
270
  }
234
271
  }
235
272
 
236
- main().catch((err) => {
237
- process.stderr.write(`zcode: internal error: ${err && err.stack ? err.stack : err}\n`);
238
- process.exit(EXIT.INTERNAL);
239
- });
273
+ if (require.main === module) {
274
+ main().catch((err) => {
275
+ process.stderr.write(`zcode: internal error: ${err && err.stack ? err.stack : err}\n`);
276
+ process.exit(1);
277
+ });
278
+ }
279
+
280
+ module.exports = { parseArgv, USAGE };