omnilane 0.8.3 → 0.9.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/omnilane-mcp CHANGED
@@ -9,6 +9,7 @@ const { spawn } = require('child_process');
9
9
  const ROOT = path.resolve(__dirname, '..');
10
10
  const DISPATCH_SCRIPT = path.join(ROOT, 'scripts', 'dispatch.sh');
11
11
  const JOBS_SCRIPT = path.join(ROOT, 'scripts', 'jobs.sh');
12
+ const DOCTOR_SCRIPT = path.join(ROOT, 'scripts', 'doctor.sh');
12
13
  const LATEST_PROTOCOL_VERSION = '2025-11-25';
13
14
  const SUPPORTED_PROTOCOL_VERSIONS = new Set([
14
15
  '2025-11-25',
@@ -146,6 +147,196 @@ const tools = [
146
147
  openWorldHint: false,
147
148
  },
148
149
  },
150
+ {
151
+ name: 'explain',
152
+ description: 'Explain one lane offline: every fallback candidate and the selected vendor, with no provider call and no job state.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ lane: {
157
+ type: 'string',
158
+ pattern: '^[a-z][a-z0-9-]*$',
159
+ description: 'Configured omnilane lane name.',
160
+ },
161
+ json: {
162
+ type: 'boolean',
163
+ default: false,
164
+ description: 'Return the versioned JSON envelope instead of the human table.',
165
+ },
166
+ },
167
+ required: ['lane'],
168
+ additionalProperties: false,
169
+ },
170
+ annotations: {
171
+ readOnlyHint: true,
172
+ destructiveHint: false,
173
+ idempotentHint: true,
174
+ openWorldHint: false,
175
+ },
176
+ },
177
+ {
178
+ name: 'validate',
179
+ description: 'Lint the effective routing table offline and report PASS/WARN/FAIL per lane, with no provider call and no job state.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ json: {
184
+ type: 'boolean',
185
+ default: false,
186
+ description: 'Return the versioned JSON envelope instead of the human report.',
187
+ },
188
+ },
189
+ additionalProperties: false,
190
+ },
191
+ annotations: {
192
+ readOnlyHint: true,
193
+ destructiveHint: false,
194
+ idempotentHint: true,
195
+ openWorldHint: false,
196
+ },
197
+ },
198
+ {
199
+ name: 'dry_run',
200
+ description: 'Resolve a dispatch plan (vendor, model, mode, timeouts, side-effect decision) without calling any provider or creating job state. Work mode requires an explicit workdir.',
201
+ inputSchema: {
202
+ type: 'object',
203
+ properties: {
204
+ lane: {
205
+ type: 'string',
206
+ pattern: '^[a-z][a-z0-9-]*$',
207
+ description: 'Configured omnilane lane name.',
208
+ },
209
+ task: { type: 'string', description: 'Task text used only to resolve the plan; it is never sent to a provider.' },
210
+ mode: {
211
+ type: 'string',
212
+ enum: ['advise', 'work'],
213
+ default: 'advise',
214
+ description: 'Read-only advice or write-enabled work.',
215
+ },
216
+ workdir: {
217
+ type: 'string',
218
+ minLength: 1,
219
+ description: 'Working directory. Required when mode is work.',
220
+ },
221
+ vendor: { type: 'string', description: 'Optional configured vendor override.' },
222
+ model: { type: 'string', description: 'Optional routed model override.' },
223
+ effort: { type: 'string', description: 'Optional routed effort override.' },
224
+ timeout: {
225
+ type: 'integer',
226
+ minimum: 1,
227
+ description: 'Per-call timeout in seconds forwarded to dispatch.sh.',
228
+ },
229
+ },
230
+ required: ['lane', 'task'],
231
+ additionalProperties: false,
232
+ allOf: [
233
+ {
234
+ if: { properties: { mode: { const: 'work' } }, required: ['mode'] },
235
+ then: { required: ['workdir'] },
236
+ },
237
+ ],
238
+ },
239
+ annotations: {
240
+ readOnlyHint: true,
241
+ destructiveHint: false,
242
+ idempotentHint: true,
243
+ openWorldHint: false,
244
+ },
245
+ },
246
+ {
247
+ name: 'jobs_list',
248
+ description: 'List omnilane background jobs. Metadata only; task and result bodies stay private.',
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ json: {
253
+ type: 'boolean',
254
+ default: false,
255
+ description: 'Return the versioned JSON envelope instead of the human list.',
256
+ },
257
+ },
258
+ additionalProperties: false,
259
+ },
260
+ annotations: {
261
+ readOnlyHint: true,
262
+ destructiveHint: false,
263
+ idempotentHint: true,
264
+ openWorldHint: false,
265
+ },
266
+ },
267
+ {
268
+ name: 'doctor',
269
+ description: 'Read-only local health report for routing, state, watchdog, and optional UI support.',
270
+ inputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ json: {
274
+ type: 'boolean',
275
+ default: false,
276
+ description: 'Return the versioned JSON envelope instead of the human report.',
277
+ },
278
+ },
279
+ additionalProperties: false,
280
+ },
281
+ annotations: {
282
+ readOnlyHint: true,
283
+ destructiveHint: false,
284
+ idempotentHint: true,
285
+ openWorldHint: false,
286
+ },
287
+ },
288
+ {
289
+ name: 'jobs_stats',
290
+ description: 'Aggregate local background-job outcomes and lane/vendor distribution from bounded public metadata. Never reads task or result bodies.',
291
+ inputSchema: {
292
+ type: 'object',
293
+ properties: {
294
+ last: {
295
+ type: 'integer',
296
+ minimum: 1,
297
+ description: 'Limit the aggregate to the most recent N jobs.',
298
+ },
299
+ json: {
300
+ type: 'boolean',
301
+ default: false,
302
+ description: 'Return the versioned JSON envelope instead of the human report.',
303
+ },
304
+ },
305
+ additionalProperties: false,
306
+ },
307
+ annotations: {
308
+ readOnlyHint: true,
309
+ destructiveHint: false,
310
+ idempotentHint: true,
311
+ openWorldHint: false,
312
+ },
313
+ },
314
+ {
315
+ name: 'jobs_audit',
316
+ description: 'Bounded read-only integrity and privacy scan of the local job store. Reports findings without printing task or result content.',
317
+ inputSchema: {
318
+ type: 'object',
319
+ properties: {
320
+ last: {
321
+ type: 'integer',
322
+ minimum: 1,
323
+ description: 'Limit the scan to the most recent N jobs.',
324
+ },
325
+ json: {
326
+ type: 'boolean',
327
+ default: false,
328
+ description: 'Return the versioned JSON envelope instead of the human report.',
329
+ },
330
+ },
331
+ additionalProperties: false,
332
+ },
333
+ annotations: {
334
+ readOnlyHint: true,
335
+ destructiveHint: false,
336
+ idempotentHint: true,
337
+ openWorldHint: false,
338
+ },
339
+ },
149
340
  ];
150
341
 
151
342
  class RpcError extends Error {
@@ -237,6 +428,60 @@ function validateJobArguments(args) {
237
428
  return null;
238
429
  }
239
430
 
431
+ function validateJsonOnlyArguments(args) {
432
+ const error = validationError(args, ['json']);
433
+ if (error) return error;
434
+ if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
435
+ return null;
436
+ }
437
+
438
+ function validateJobsQueryArguments(args) {
439
+ const error = validationError(args, ['last', 'json']);
440
+ if (error) return error;
441
+ if (OWN(args, 'last') && (!Number.isInteger(args.last) || args.last < 1)) {
442
+ return 'last must be a positive integer';
443
+ }
444
+ if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
445
+ return null;
446
+ }
447
+
448
+ function validateExplainArguments(args) {
449
+ const error = validationError(args, ['lane', 'json']);
450
+ if (error) return error;
451
+ if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
452
+ return 'lane must match ^[a-z][a-z0-9-]*$';
453
+ }
454
+ if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
455
+ return null;
456
+ }
457
+
458
+ function validateDryRunArguments(args) {
459
+ const error = validationError(args, [
460
+ 'lane', 'task', 'mode', 'workdir', 'vendor', 'model', 'effort', 'timeout',
461
+ ]);
462
+ if (error) return error;
463
+ if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
464
+ return 'lane must match ^[a-z][a-z0-9-]*$';
465
+ }
466
+ if (typeof args.task !== 'string') return 'task must be a string';
467
+ if (OWN(args, 'mode') && args.mode !== 'advise' && args.mode !== 'work') {
468
+ return 'mode must be advise or work';
469
+ }
470
+ if (OWN(args, 'workdir') && (typeof args.workdir !== 'string' || args.workdir.length === 0)) {
471
+ return 'workdir must be a non-empty string';
472
+ }
473
+ for (const key of ['vendor', 'model', 'effort']) {
474
+ if (OWN(args, key) && typeof args[key] !== 'string') return `${key} must be a string`;
475
+ }
476
+ if (OWN(args, 'timeout') && (!Number.isInteger(args.timeout) || args.timeout < 1)) {
477
+ return 'timeout must be a positive integer';
478
+ }
479
+ if (args.mode === 'work' && !OWN(args, 'workdir')) {
480
+ return 'work mode requires an explicit workdir';
481
+ }
482
+ return null;
483
+ }
484
+
240
485
  function createCapture() {
241
486
  const chunks = { stdout: [], stderr: [] };
242
487
  let collected = 0;
@@ -363,6 +608,52 @@ async function callTool(name, args) {
363
608
  const command = name === 'jobs_status' ? 'status' : 'result';
364
609
  return runToolScript(name, JOBS_SCRIPT, [command, args.id]);
365
610
  }
611
+ if (name === 'jobs_list') {
612
+ const error = validateJsonOnlyArguments(args);
613
+ if (error) return toolError(error);
614
+ const argv = args.json === true ? ['--json', 'list'] : ['list'];
615
+ return runToolScript('jobs_list', JOBS_SCRIPT, argv);
616
+ }
617
+ if (name === 'explain') {
618
+ const error = validateExplainArguments(args);
619
+ if (error) return toolError(error);
620
+ const argv = ['--explain', args.lane];
621
+ if (args.json === true) argv.push('--json');
622
+ return runToolScript('explain', DISPATCH_SCRIPT, argv);
623
+ }
624
+ if (name === 'validate') {
625
+ const error = validateJsonOnlyArguments(args);
626
+ if (error) return toolError(error);
627
+ const argv = ['--validate'];
628
+ if (args.json === true) argv.push('--json');
629
+ return runToolScript('validate', DISPATCH_SCRIPT, argv);
630
+ }
631
+ if (name === 'dry_run') {
632
+ const error = validateDryRunArguments(args);
633
+ if (error) return toolError(error);
634
+ const mode = OWN(args, 'mode') ? args.mode : 'advise';
635
+ const argv = ['--dry-run', '--mode', mode];
636
+ for (const key of ['workdir', 'vendor', 'model', 'effort', 'timeout']) {
637
+ if (OWN(args, key)) argv.push(`--${key}`, String(args[key]));
638
+ }
639
+ argv.push(args.lane, args.task);
640
+ return runToolScript('dry_run', DISPATCH_SCRIPT, argv);
641
+ }
642
+ if (name === 'doctor') {
643
+ const error = validateJsonOnlyArguments(args);
644
+ if (error) return toolError(error);
645
+ const argv = args.json === true ? ['--json'] : [];
646
+ return runToolScript('doctor', DOCTOR_SCRIPT, argv);
647
+ }
648
+ if (name === 'jobs_stats' || name === 'jobs_audit') {
649
+ const error = validateJobsQueryArguments(args);
650
+ if (error) return toolError(error);
651
+ const argv = [];
652
+ if (args.json === true) argv.push('--json');
653
+ argv.push(name === 'jobs_stats' ? 'stats' : 'audit');
654
+ if (OWN(args, 'last')) argv.push('--last', String(args.last));
655
+ return runToolScript(name, JOBS_SCRIPT, argv);
656
+ }
366
657
  throw new RpcError(-32602, `Unknown tool: ${name}`);
367
658
  }
368
659
 
@@ -0,0 +1,50 @@
1
+ # Fish completion for omnilane. Static command/option completion only; it never
2
+ # invokes dispatch, provider CLIs, or the executable machine-local overlay.
3
+ # Dynamic lane-name and job-id completion remain Bash/Zsh-only for now.
4
+
5
+ # Top-level subcommands (offered only before a subcommand is chosen).
6
+ complete -c omnilane -f -n __fish_use_subcommand -a version -d 'installed release version'
7
+ complete -c omnilane -f -n __fish_use_subcommand -a list -d 'effective routing table'
8
+ complete -c omnilane -f -n __fish_use_subcommand -a route -d 'dispatch or consult a model'
9
+ complete -c omnilane -f -n __fish_use_subcommand -a dispatch -d 'dispatch or consult a model'
10
+ complete -c omnilane -f -n __fish_use_subcommand -a jobs -d 'inspect background jobs'
11
+ complete -c omnilane -f -n __fish_use_subcommand -a mcp -d 'MCP stdio server'
12
+ complete -c omnilane -f -n __fish_use_subcommand -a doctor -d 'read-only health report'
13
+ complete -c omnilane -f -n __fish_use_subcommand -a release-audit -d 'offline release gate'
14
+ complete -c omnilane -f -n __fish_use_subcommand -a ui -d 'Live Board server'
15
+ complete -c omnilane -f -n __fish_use_subcommand -a configure -d 'lane routing (menu or set/get/unset/list/diff)'
16
+ complete -c omnilane -f -n __fish_use_subcommand -a completion -d 'print a shell completion script'
17
+ complete -c omnilane -f -n __fish_use_subcommand -a help -d 'usage'
18
+
19
+ # route / dispatch options.
20
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l mode -x -a 'advise work' -d 'read-only advise or write-enabled work'
21
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l vendor -x -a 'codex claude grok gemini kimi qwen opencode openrouter deepseek zai mistral groq cerebras exec vote' -d 'pin one vendor, no fallback'
22
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l effort -x -a 'low medium high xhigh max' -d 'reasoning effort'
23
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l workdir -r -d 'working directory'
24
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l model -x -d 'routed model override'
25
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l timeout -x -d 'per-call timeout in seconds'
26
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l job-timeout -x -d 'whole-job timeout in seconds'
27
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l background -d 'run as a background job'
28
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l dry-run -d 'resolve the plan and stop'
29
+
30
+ # jobs subcommands (only before a jobs subcommand is chosen).
31
+ complete -c omnilane -f \
32
+ -n '__fish_seen_subcommand_from jobs; and not __fish_seen_subcommand_from list status result tail retry stats wait audit prune cancel rm help' \
33
+ -a 'list status result tail retry stats wait audit prune cancel rm help' -d 'jobs subcommand'
34
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs' -l json -d 'JSON output'
35
+ # jobs list / stats filters.
36
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs; and __fish_seen_subcommand_from list stats' -l lane -x -d 'filter by lane'
37
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs; and __fish_seen_subcommand_from list stats' -l vendor -x -a 'codex claude grok gemini kimi qwen opencode openrouter deepseek zai mistral groq cerebras exec' -d 'filter by vendor'
38
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs; and __fish_seen_subcommand_from list' -l status -x -a 'running done' -d 'filter by status'
39
+
40
+ # configure subcommands (non-interactive).
41
+ complete -c omnilane -f -n '__fish_seen_subcommand_from configure; and not __fish_seen_subcommand_from set get unset list diff' -a 'set get unset list diff' -d 'configure action'
42
+
43
+ # doctor / release-audit / ui / completion.
44
+ complete -c omnilane -n '__fish_seen_subcommand_from doctor' -l json -d 'JSON output'
45
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l target -x -d 'target version'
46
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l allow-dirty -d 'permit a dirty tree'
47
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l require-tag -d 'require an annotated tag'
48
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l json -d 'JSON output'
49
+ complete -c omnilane -f -n '__fish_seen_subcommand_from ui' -a 'start status url stop' -d 'Live Board action'
50
+ complete -c omnilane -f -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'target shell'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnilane",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "One routing table, every harness — classify subtasks into lanes and dispatch each lane to the best vendor's agentic CLI (Codex, Claude, Gemini, Grok) using your existing subscription logins.",
5
5
  "bin": {
6
6
  "omnilane": "bin/omnilane"
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ # omnilane self-check: run the CONTRIBUTING required checks in one command.
4
+ # Usage: check.sh [--quick] [REPO_DIR]
5
+ # --quick skip the two slow suite runs (python unittest, tests/run.sh)
6
+ # A SKIP (tool unavailable or target absent) is not a failure; the script exits
7
+ # non-zero only when a check actually FAILs. No -e: checks may fail and are
8
+ # tallied rather than aborting the run.
9
+
10
+ QUICK=0
11
+ REPO=""
12
+ while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --quick) QUICK=1; shift ;;
15
+ -h|--help) echo "usage: check.sh [--quick] [REPO_DIR]"; exit 0 ;;
16
+ --) shift; break ;;
17
+ -*) echo "check.sh: unknown option: $1" >&2; exit 2 ;;
18
+ *) if [[ -n "$REPO" ]]; then echo "check.sh: too many arguments" >&2; exit 2; fi
19
+ REPO="$1"; shift ;;
20
+ esac
21
+ done
22
+ if [[ $# -gt 0 ]]; then
23
+ if [[ -n "$REPO" ]]; then echo "check.sh: too many arguments" >&2; exit 2; fi
24
+ REPO="$1"
25
+ fi
26
+ [[ -n "$REPO" ]] || REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
27
+
28
+ PASS_N=0; FAIL_N=0; SKIP_N=0
29
+ _pass() { PASS_N=$((PASS_N + 1)); printf 'PASS %s\n' "$1"; }
30
+ _skip() { SKIP_N=$((SKIP_N + 1)); printf 'SKIP %s (%s)\n' "$1" "$2"; }
31
+ _fail() { FAIL_N=$((FAIL_N + 1)); printf 'FAIL %s\n' "$1"; [[ -z "${2:-}" ]] || printf ' %s\n' "$2"; }
32
+
33
+ # Shell files present in the repo (CONTRIBUTING's bash -n / shellcheck set).
34
+ shell_files=()
35
+ for pattern in 'bin/omnilane' 'scripts/*.sh' 'scripts/lib/*.sh' 'scripts/runners/*.sh' 'install.sh'; do
36
+ # shellcheck disable=SC2086 # $pattern is an intentional glob against $REPO
37
+ for f in $REPO/$pattern; do
38
+ [[ -f "$f" ]] && shell_files+=("$f")
39
+ done
40
+ done
41
+
42
+ # 1) bash -n on every tracked shell file
43
+ if [[ ${#shell_files[@]} -eq 0 ]]; then
44
+ _skip bash-syntax "no shell files found"
45
+ else
46
+ bad=""
47
+ for f in "${shell_files[@]}"; do
48
+ bash -n "$f" 2>/dev/null || bad="$bad ${f##*/}"
49
+ done
50
+ if [[ -z "$bad" ]]; then _pass bash-syntax; else _fail bash-syntax "invalid:$bad"; fi
51
+ fi
52
+
53
+ # 2) shellcheck -S warning (skip if unavailable)
54
+ if ! command -v shellcheck >/dev/null 2>&1; then
55
+ _skip shellcheck "not installed"
56
+ elif [[ ${#shell_files[@]} -eq 0 ]]; then
57
+ _skip shellcheck "no shell files"
58
+ elif shellcheck -S warning "${shell_files[@]}" >/dev/null 2>&1; then
59
+ _pass shellcheck
60
+ else
61
+ _fail shellcheck "shellcheck -S warning reported findings"
62
+ fi
63
+
64
+ # 3) perl -c on the job-timeout supervisor
65
+ perl_file="$REPO/scripts/lib/job-timeout.pl"
66
+ if ! command -v perl >/dev/null 2>&1; then
67
+ _skip perl-syntax "perl not installed"
68
+ elif [[ ! -f "$perl_file" ]]; then
69
+ _skip perl-syntax "job-timeout.pl absent"
70
+ elif perl -c "$perl_file" >/dev/null 2>&1; then
71
+ _pass perl-syntax
72
+ else
73
+ _fail perl-syntax "perl -c failed"
74
+ fi
75
+
76
+ # 4) python compile of the UI and test modules
77
+ py_files=()
78
+ for rel in scripts/ui.py tests/test_ui.py tests/test_ci_policy.py tests/ui_browser_harness.py; do
79
+ [[ -f "$REPO/$rel" ]] && py_files+=("$REPO/$rel")
80
+ done
81
+ if ! command -v python3 >/dev/null 2>&1; then
82
+ _skip py-compile "python3 not installed"
83
+ elif [[ ${#py_files[@]} -eq 0 ]]; then
84
+ _skip py-compile "no python files"
85
+ elif python3 -m py_compile "${py_files[@]}" >/dev/null 2>&1; then
86
+ _pass py-compile
87
+ else
88
+ _fail py-compile "py_compile failed"
89
+ fi
90
+
91
+ # 5) python unit tests (slow; --quick skips)
92
+ if [[ "$QUICK" -eq 1 ]]; then
93
+ _skip unittest "--quick"
94
+ elif ! command -v python3 >/dev/null 2>&1; then
95
+ _skip unittest "python3 not installed"
96
+ elif [[ ! -d "$REPO/tests" ]]; then
97
+ _skip unittest "no tests dir"
98
+ elif ( cd "$REPO" && python3 -m unittest discover -s tests -p 'test_*.py' >/dev/null 2>&1 ); then
99
+ _pass unittest
100
+ else
101
+ _fail unittest "python unittest suite failed"
102
+ fi
103
+
104
+ # 6) shell test suite (slow; --quick skips)
105
+ if [[ "$QUICK" -eq 1 ]]; then
106
+ _skip shell-suite "--quick"
107
+ elif [[ ! -f "$REPO/tests/run.sh" ]]; then
108
+ _skip shell-suite "tests/run.sh absent"
109
+ elif bash "$REPO/tests/run.sh" >/dev/null 2>&1; then
110
+ _pass shell-suite
111
+ else
112
+ _fail shell-suite "bash tests/run.sh failed"
113
+ fi
114
+
115
+ # 7) effective routing table resolves
116
+ if [[ ! -f "$REPO/scripts/dispatch.sh" ]]; then
117
+ _skip dispatch-list "dispatch.sh absent"
118
+ elif bash "$REPO/scripts/dispatch.sh" --list >/dev/null 2>&1; then
119
+ _pass dispatch-list
120
+ else
121
+ _fail dispatch-list "dispatch.sh --list failed"
122
+ fi
123
+
124
+ printf '\nsummary: %d passed, %d skipped, %d failed\n' "$PASS_N" "$SKIP_N" "$FAIL_N"
125
+ [[ "$FAIL_N" -eq 0 ]]
@@ -9,6 +9,142 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib/i18n.sh"
9
9
 
10
10
  LOCAL_FILE="$OMNILANE_HOME/routing.local.yaml"
11
11
 
12
+ # --- Non-interactive subcommands -------------------------------------------
13
+ # `configure set|get|unset|list` script the same routing.local.yaml the menu
14
+ # writes, so automation never needs a tty. No subcommand => interactive menu.
15
+
16
+ cfg_usage() {
17
+ cat >&2 <<'EOF'
18
+ usage: configure interactive lane menu
19
+ configure set LANE SPEC set/override one lane. SPEC = "vendor model effort [| ...]" or "off".
20
+ Quote the whole SPEC (and the model) when a model name has spaces.
21
+ configure get LANE show the effective routing line for LANE
22
+ configure unset LANE remove LANE's local override
23
+ configure list show current local overrides
24
+ configure diff show how local overrides change the effective table vs the defaults
25
+ EOF
26
+ }
27
+
28
+ # Reject shell-dangerous bytes while allowing the routing RHS grammar
29
+ # (chains with |, quoted "models with spaces", slashes, effort tokens).
30
+ cfg_spec_is_safe() {
31
+ case "$1" in
32
+ '') return 1 ;;
33
+ *'$'*|*'`'*|*'\'*|*'#'*|*';'*|*'&'*|*'<'*|*'>'*|*$'\r'*|*$'\n'*) return 1 ;;
34
+ *) return 0 ;;
35
+ esac
36
+ }
37
+
38
+ cfg_lane_is_known() {
39
+ local want="$1" line
40
+ while IFS= read -r line; do
41
+ case "$line" in "$want":*) return 0 ;; esac
42
+ done < "$OMNILANE_REPO/routing.yaml"
43
+ return 1
44
+ }
45
+
46
+ cfg_valid_lane() { [[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]; }
47
+
48
+ cfg_set() {
49
+ local lane="${1:-}"; shift || true
50
+ local spec="$*"
51
+ cfg_valid_lane "$lane" || { echo "omnilane: invalid lane '$lane'" >&2; exit 2; }
52
+ cfg_lane_is_known "$lane" || { echo "omnilane: unknown lane '$lane' (see: omnilane list)" >&2; exit 2; }
53
+ [[ -n "$spec" ]] || { echo "omnilane: missing routing spec for '$lane'" >&2; cfg_usage; exit 2; }
54
+ cfg_spec_is_safe "$spec" || { echo 'omnilane: unsafe routing spec (not allowed: $ ` \ # ; & < > newlines)' >&2; exit 2; }
55
+
56
+ mkdir -p "$OMNILANE_HOME"
57
+ local had_file=0
58
+ if [[ -f "$LOCAL_FILE" ]]; then had_file=1; cp "$LOCAL_FILE" "$LOCAL_FILE.bak"; fi
59
+ local tmp="$LOCAL_FILE.tmp.$$"
60
+ {
61
+ echo "# updated by 'configure set' on $(date +%F) — first match per lane wins"
62
+ echo "$lane: $spec"
63
+ [[ "$had_file" -eq 1 ]] && grep -v '^#' "$LOCAL_FILE.bak" | grep -v "^$lane:" || true
64
+ } > "$tmp"
65
+ mv "$tmp" "$LOCAL_FILE"
66
+
67
+ # Semantic guard: reject only a structural FAIL on THIS lane. Availability
68
+ # WARN (validate exit 4) is fine — the vendor CLI may be legitimately absent.
69
+ local validate_out
70
+ validate_out="$(OMNILANE_HOME="$OMNILANE_HOME" bash "$OMNILANE_REPO/scripts/dispatch.sh" --validate 2>&1)" || true
71
+ if printf '%s\n' "$validate_out" | grep -q "^FAIL $lane "; then
72
+ if [[ "$had_file" -eq 1 ]]; then mv "$LOCAL_FILE.bak" "$LOCAL_FILE"; else /bin/rm -f "$LOCAL_FILE"; fi
73
+ echo "omnilane: rejected — $(printf '%s\n' "$validate_out" | grep "^FAIL $lane " | head -1)" >&2
74
+ exit 2
75
+ fi
76
+ [[ "$had_file" -eq 1 ]] && /bin/rm -f "$LOCAL_FILE.bak"
77
+ echo "set $lane -> $spec"
78
+ }
79
+
80
+ cfg_get() {
81
+ local lane="${1:-}"
82
+ cfg_valid_lane "$lane" || { echo "omnilane: invalid lane '$lane'" >&2; exit 2; }
83
+ local line
84
+ line="$(OMNILANE_HOME="$OMNILANE_HOME" bash "$OMNILANE_REPO/scripts/dispatch.sh" --list 2>/dev/null | grep "^$lane:" | head -1 || true)"
85
+ [[ -n "$line" ]] || { echo "omnilane: unknown lane '$lane' (see: omnilane list)" >&2; exit 2; }
86
+ printf '%s\n' "$line"
87
+ }
88
+
89
+ cfg_unset() {
90
+ local lane="${1:-}"
91
+ cfg_valid_lane "$lane" || { echo "omnilane: invalid lane '$lane'" >&2; exit 2; }
92
+ if [[ ! -f "$LOCAL_FILE" ]] || ! grep -q "^$lane:" "$LOCAL_FILE"; then
93
+ echo "no local override for '$lane'"; return 0
94
+ fi
95
+ cp "$LOCAL_FILE" "$LOCAL_FILE.bak"
96
+ grep -v "^$lane:" "$LOCAL_FILE.bak" > "$LOCAL_FILE" || true
97
+ /bin/rm -f "$LOCAL_FILE.bak"
98
+ echo "unset $lane (local override removed)"
99
+ }
100
+
101
+ cfg_list() {
102
+ if [[ -f "$LOCAL_FILE" ]] && grep -qE '^[a-z]' "$LOCAL_FILE"; then
103
+ cat "$LOCAL_FILE"
104
+ else
105
+ echo "no local overrides in $LOCAL_FILE"
106
+ fi
107
+ }
108
+
109
+ # Diff the effective table (local wins) against a defaults-only resolution, so
110
+ # the user sees exactly which lanes their overrides change. Reuses dispatch.sh
111
+ # --list for both, so availability annotation and formatting stay consistent; a
112
+ # throwaway empty OMNILANE_HOME yields the defaults-only table.
113
+ cfg_diff() {
114
+ if [[ ! -f "$LOCAL_FILE" ]] || ! grep -qE '^[a-z]' "$LOCAL_FILE"; then
115
+ echo "no local overrides ($LOCAL_FILE); effective table equals the defaults"
116
+ return 0
117
+ fi
118
+ local empty eff def changed=0 lane eff_line def_line
119
+ empty="$(mktemp -d "${TMPDIR:-/tmp}/omnilane-diff.XXXXXX")"
120
+ eff="$(OMNILANE_HOME="$OMNILANE_HOME" bash "$OMNILANE_REPO/scripts/dispatch.sh" --list 2>/dev/null || true)"
121
+ def="$(OMNILANE_HOME="$empty" bash "$OMNILANE_REPO/scripts/dispatch.sh" --list 2>/dev/null || true)"
122
+ /bin/rm -rf "$empty"
123
+ while IFS= read -r eff_line; do
124
+ [[ "$eff_line" =~ ^([a-z][a-z0-9-]*): ]] || continue
125
+ lane="${BASH_REMATCH[1]}"
126
+ def_line="$(printf '%s\n' "$def" | grep "^$lane:" | head -1 || true)"
127
+ if [[ "$eff_line" != "$def_line" ]]; then
128
+ changed=1
129
+ printf 'default> %s\n' "${def_line:-($lane not in defaults)}"
130
+ printf 'local > %s\n' "$eff_line"
131
+ echo
132
+ fi
133
+ done <<DIFF_EFF
134
+ $eff
135
+ DIFF_EFF
136
+ [[ "$changed" -eq 1 ]] || echo "local overrides present, but the effective table matches the defaults"
137
+ }
138
+
139
+ case "${1:-}" in
140
+ set) shift; cfg_set "$@"; exit $? ;;
141
+ get) shift; cfg_get "$@"; exit $? ;;
142
+ unset) shift; cfg_unset "$@"; exit $? ;;
143
+ list) shift; cfg_list "$@"; exit $? ;;
144
+ diff) cfg_diff; exit $? ;;
145
+ -h|--help|help) cfg_usage; exit 0 ;;
146
+ esac
147
+
12
148
  # Curated suggestions only — "c" always allows free text so new models work.
13
149
  CODEX_MODELS=("gpt-5.6-sol" "gpt-5.6-terra" "gpt-5.6-luna")
14
150
  CODEX_EFFORTS=("xhigh" "max" "ultra" "high" "medium" "low" "minimal" "none")
@@ -21,6 +157,13 @@ QWEN_MODELS=("qwen3-coder-plus" "qwen3-coder-flash")
21
157
  # OpenCode models use provider/model form; OpenRouter models are catalog slugs.
22
158
  OPENCODE_MODELS=("openrouter/anthropic/claude-sonnet-5" "openrouter/openai/gpt-5.6-sol" "opencode/default (leave model to opencode)")
23
159
  OPENROUTER_MODELS=("anthropic/claude-sonnet-5" "openai/gpt-5.6-sol" "moonshotai/kimi-k3" "qwen/qwen3-coder-plus")
160
+ # Direct-API OpenAI-compatible vendors (curl + <VENDOR>_API_KEY); slugs are
161
+ # suggestions — "c" free text covers anything each provider's /models lists.
162
+ DEEPSEEK_MODELS=("deepseek-chat" "deepseek-reasoner" "deepseek-v4-flash")
163
+ ZAI_MODELS=("glm-4.6")
164
+ MISTRAL_MODELS=("devstral-latest" "codestral-latest" "mistral-medium-latest")
165
+ GROQ_MODELS=("openai/gpt-oss-120b" "qwen/qwen3.6-27b" "openai/gpt-oss-20b")
166
+ CEREBRAS_MODELS=("gpt-oss-120b" "qwen-3-32b" "llama-3.3-70b")
24
167
 
25
168
  custom_value_is_safe() {
26
169
  case "$1" in
@@ -79,7 +222,7 @@ while true; do
79
222
  echo "$(msgf cfg_pick_range "${#LANES[@]}")"; continue
80
223
  fi
81
224
  lane="${LANES[$((n - 1))]}"
82
- vendor="$(pick "$(msgf cfg_vendor_for "$lane")" codex claude grok gemini kimi qwen opencode openrouter "vote (multi-model panel)" "exec (your own script/gate)" off)"
225
+ vendor="$(pick "$(msgf cfg_vendor_for "$lane")" codex claude grok gemini kimi qwen opencode openrouter deepseek zai mistral groq cerebras "vote (multi-model panel)" "exec (your own script/gate)" off)"
83
226
  [[ "$vendor" == exec* ]] && vendor="exec"
84
227
  [[ "$vendor" == vote* ]] && vendor="vote"
85
228
  if [[ "$vendor" == "off" ]]; then
@@ -96,6 +239,11 @@ while true; do
96
239
  opencode) model="$(pick "$(msg cfg_model)" "${OPENCODE_MODELS[@]}")"; effort="-"
97
240
  [[ "$model" == "opencode/default"* ]] && model="-" ;;
98
241
  openrouter) model="$(pick "$(msg cfg_model)" "${OPENROUTER_MODELS[@]}")"; effort="-" ;;
242
+ deepseek) model="$(pick "$(msg cfg_model)" "${DEEPSEEK_MODELS[@]}")"; effort="-" ;;
243
+ zai) model="$(pick "$(msg cfg_model)" "${ZAI_MODELS[@]}")"; effort="-" ;;
244
+ mistral) model="$(pick "$(msg cfg_model)" "${MISTRAL_MODELS[@]}")"; effort="-" ;;
245
+ groq) model="$(pick "$(msg cfg_model)" "${GROQ_MODELS[@]}")"; effort="-" ;;
246
+ cerebras) model="$(pick "$(msg cfg_model)" "${CEREBRAS_MODELS[@]}")"; effort="-" ;;
99
247
  vote) while true; do
100
248
  count="$(pick "$(msg cfg_voters_count)" "1" "2" "3" "4")"
101
249
  [[ "$count" =~ ^[1-4]$ ]] && break