@zhihaohong52/sonata 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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -0
  3. package/dist/adapters/claude.js +83 -0
  4. package/dist/adapters/claude.js.map +1 -0
  5. package/dist/adapters/codex.js +382 -0
  6. package/dist/adapters/codex.js.map +1 -0
  7. package/dist/adapters/index.js +21 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/opencode.js +104 -0
  10. package/dist/adapters/opencode.js.map +1 -0
  11. package/dist/adapters/pi.js +152 -0
  12. package/dist/adapters/pi.js.map +1 -0
  13. package/dist/adapters/reasonix.js +358 -0
  14. package/dist/adapters/reasonix.js.map +1 -0
  15. package/dist/adapters/types.js +2 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/agent-markers.js +3 -0
  18. package/dist/agent-markers.js.map +1 -0
  19. package/dist/aipricing.js +109 -0
  20. package/dist/aipricing.js.map +1 -0
  21. package/dist/catalog.js +274 -0
  22. package/dist/catalog.js.map +1 -0
  23. package/dist/cli.js +766 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/commands/approve.js +23 -0
  26. package/dist/commands/approve.js.map +1 -0
  27. package/dist/commands/auth.js +41 -0
  28. package/dist/commands/auth.js.map +1 -0
  29. package/dist/commands/catalog.js +172 -0
  30. package/dist/commands/catalog.js.map +1 -0
  31. package/dist/commands/code.js +113 -0
  32. package/dist/commands/code.js.map +1 -0
  33. package/dist/commands/dispatch.js +148 -0
  34. package/dist/commands/dispatch.js.map +1 -0
  35. package/dist/commands/doctor.js +545 -0
  36. package/dist/commands/doctor.js.map +1 -0
  37. package/dist/commands/gc.js +25 -0
  38. package/dist/commands/gc.js.map +1 -0
  39. package/dist/commands/init-log.js +83 -0
  40. package/dist/commands/init-log.js.map +1 -0
  41. package/dist/commands/init.js +157 -0
  42. package/dist/commands/init.js.map +1 -0
  43. package/dist/commands/log.js +24 -0
  44. package/dist/commands/log.js.map +1 -0
  45. package/dist/commands/route.js +660 -0
  46. package/dist/commands/route.js.map +1 -0
  47. package/dist/commands/run.js +199 -0
  48. package/dist/commands/run.js.map +1 -0
  49. package/dist/commands/runs.js +37 -0
  50. package/dist/commands/runs.js.map +1 -0
  51. package/dist/commands/serve.js +818 -0
  52. package/dist/commands/serve.js.map +1 -0
  53. package/dist/commands/status.js +14 -0
  54. package/dist/commands/status.js.map +1 -0
  55. package/dist/commands/sync.js +258 -0
  56. package/dist/commands/sync.js.map +1 -0
  57. package/dist/commands/tail.js +229 -0
  58. package/dist/commands/tail.js.map +1 -0
  59. package/dist/commands/usage.js +85 -0
  60. package/dist/commands/usage.js.map +1 -0
  61. package/dist/commands/verify.js +69 -0
  62. package/dist/commands/verify.js.map +1 -0
  63. package/dist/commands/wait.js +38 -0
  64. package/dist/commands/wait.js.map +1 -0
  65. package/dist/config.js +588 -0
  66. package/dist/config.js.map +1 -0
  67. package/dist/detect.js +445 -0
  68. package/dist/detect.js.map +1 -0
  69. package/dist/filelock.js +83 -0
  70. package/dist/filelock.js.map +1 -0
  71. package/dist/init/apply.js +96 -0
  72. package/dist/init/apply.js.map +1 -0
  73. package/dist/init/candidates.js +115 -0
  74. package/dist/init/candidates.js.map +1 -0
  75. package/dist/init/discover.js +216 -0
  76. package/dist/init/discover.js.map +1 -0
  77. package/dist/init/helpers.js +429 -0
  78. package/dist/init/helpers.js.map +1 -0
  79. package/dist/init/interactive-state.js +128 -0
  80. package/dist/init/interactive-state.js.map +1 -0
  81. package/dist/init/plan.js +225 -0
  82. package/dist/init/plan.js.map +1 -0
  83. package/dist/init/scripted-state.js +130 -0
  84. package/dist/init/scripted-state.js.map +1 -0
  85. package/dist/init/toml.js +83 -0
  86. package/dist/init/toml.js.map +1 -0
  87. package/dist/init/validate.js +149 -0
  88. package/dist/init/validate.js.map +1 -0
  89. package/dist/ledger.js +123 -0
  90. package/dist/ledger.js.map +1 -0
  91. package/dist/mcp/protocol.js +64 -0
  92. package/dist/mcp/protocol.js.map +1 -0
  93. package/dist/mcp/server.js +96 -0
  94. package/dist/mcp/server.js.map +1 -0
  95. package/dist/mcp/tools.js +210 -0
  96. package/dist/mcp/tools.js.map +1 -0
  97. package/dist/mode.js +64 -0
  98. package/dist/mode.js.map +1 -0
  99. package/dist/native/claude-shim.js +228 -0
  100. package/dist/native/claude-shim.js.map +1 -0
  101. package/dist/native/codex-auth.js +177 -0
  102. package/dist/native/codex-auth.js.map +1 -0
  103. package/dist/native/copilot-auth.js +105 -0
  104. package/dist/native/copilot-auth.js.map +1 -0
  105. package/dist/native/credentials.js +83 -0
  106. package/dist/native/credentials.js.map +1 -0
  107. package/dist/native/hosts.js +60 -0
  108. package/dist/native/hosts.js.map +1 -0
  109. package/dist/native/litellm.js +103 -0
  110. package/dist/native/litellm.js.map +1 -0
  111. package/dist/native/models.js +96 -0
  112. package/dist/native/models.js.map +1 -0
  113. package/dist/native/oauth-login.js +112 -0
  114. package/dist/native/oauth-login.js.map +1 -0
  115. package/dist/native/router.js +469 -0
  116. package/dist/native/router.js.map +1 -0
  117. package/dist/native/tls.js +73 -0
  118. package/dist/native/tls.js.map +1 -0
  119. package/dist/native/usage.js +119 -0
  120. package/dist/native/usage.js.map +1 -0
  121. package/dist/normalize.js +98 -0
  122. package/dist/normalize.js.map +1 -0
  123. package/dist/pricing.js +86 -0
  124. package/dist/pricing.js.map +1 -0
  125. package/dist/roles.js +49 -0
  126. package/dist/roles.js.map +1 -0
  127. package/dist/sessions.js +62 -0
  128. package/dist/sessions.js.map +1 -0
  129. package/dist/settings.js +131 -0
  130. package/dist/settings.js.map +1 -0
  131. package/dist/store.js +70 -0
  132. package/dist/store.js.map +1 -0
  133. package/dist/tmux.js +89 -0
  134. package/dist/tmux.js.map +1 -0
  135. package/dist/tui-ink/app-state.js +340 -0
  136. package/dist/tui-ink/app-state.js.map +1 -0
  137. package/dist/tui-ink/app.js +156 -0
  138. package/dist/tui-ink/app.js.map +1 -0
  139. package/dist/tui-ink/components/byok-step.js +102 -0
  140. package/dist/tui-ink/components/byok-step.js.map +1 -0
  141. package/dist/tui-ink/components/login-screen.js +80 -0
  142. package/dist/tui-ink/components/login-screen.js.map +1 -0
  143. package/dist/tui-ink/components/models-step.js +92 -0
  144. package/dist/tui-ink/components/models-step.js.map +1 -0
  145. package/dist/tui-ink/components/multi-select-state.js +56 -0
  146. package/dist/tui-ink/components/multi-select-state.js.map +1 -0
  147. package/dist/tui-ink/components/multi-select.js +69 -0
  148. package/dist/tui-ink/components/multi-select.js.map +1 -0
  149. package/dist/tui-ink/components/providers-step.js +285 -0
  150. package/dist/tui-ink/components/providers-step.js.map +1 -0
  151. package/dist/tui-ink/components/ranked-select-state.js +41 -0
  152. package/dist/tui-ink/components/ranked-select-state.js.map +1 -0
  153. package/dist/tui-ink/components/ranked-select.js +61 -0
  154. package/dist/tui-ink/components/ranked-select.js.map +1 -0
  155. package/dist/tui-ink/components/search-select.js +47 -0
  156. package/dist/tui-ink/components/search-select.js.map +1 -0
  157. package/dist/tui-ink/components/text-input-state.js +42 -0
  158. package/dist/tui-ink/components/text-input-state.js.map +1 -0
  159. package/dist/tui-ink/components/text-input.js +48 -0
  160. package/dist/tui-ink/components/text-input.js.map +1 -0
  161. package/dist/tui-ink/run.js +38 -0
  162. package/dist/tui-ink/run.js.map +1 -0
  163. package/dist/tui-ink/test-compile.js +4 -0
  164. package/dist/tui-ink/test-compile.js.map +1 -0
  165. package/dist/tui-ink/types.js +2 -0
  166. package/dist/tui-ink/types.js.map +1 -0
  167. package/dist/tui.js +330 -0
  168. package/dist/tui.js.map +1 -0
  169. package/dist/types.js +2 -0
  170. package/dist/types.js.map +1 -0
  171. package/dist/watchdog.js +85 -0
  172. package/dist/watchdog.js.map +1 -0
  173. package/hooks/capture-mode.mjs +56 -0
  174. package/hooks/ensure-serve.mjs +140 -0
  175. package/hooks/hooks.json +12 -0
  176. package/hooks/route-session.mjs +47 -0
  177. package/hooks/route-subagent.mjs +54 -0
  178. package/package.json +64 -0
  179. package/roles/code.md +13 -0
  180. package/roles/explore.md +9 -0
  181. package/roles/plan.md +10 -0
  182. package/roles/review.md +11 -0
  183. package/skills/loop/SKILL.md +43 -0
package/dist/cli.js ADDED
@@ -0,0 +1,766 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { join } from 'node:path';
4
+ import { cmdRun } from './commands/run.js';
5
+ import { cmdDispatch } from './commands/dispatch.js';
6
+ import { cmdTail } from './commands/tail.js';
7
+ import { cmdWait } from './commands/wait.js';
8
+ import { loadConfig } from './config.js';
9
+ import { cmdApprove } from './commands/approve.js';
10
+ import { cmdSync } from './commands/sync.js';
11
+ import { cmdDoctor } from './commands/doctor.js';
12
+ import { cmdGc } from './commands/gc.js';
13
+ import { cmdInit, isCancellation } from './commands/init.js';
14
+ import { initLogDir } from './commands/init-log.js';
15
+ import { banner, isInteractive, confirm } from './tui.js';
16
+ import { pruneAgents } from './detect.js';
17
+ import { homedir } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
20
+ import { cmdAuthAdd, cmdAuthList, cmdAuthLogin, cmdAuthRemove } from './commands/auth.js';
21
+ import { cmdServe, cmdRestart, startServeDaemon, isSonataRouter } from './commands/serve.js';
22
+ import { cmdCode } from './commands/code.js';
23
+ import { recentRoutes } from './commands/status.js';
24
+ import { summarizeRuns } from './commands/runs.js';
25
+ import { cmdRoute, cmdRouteSession, cmdRouteSubagent } from './commands/route.js';
26
+ import { cmdCatalogUpdate } from './commands/catalog.js';
27
+ import { AA_ATTRIBUTION, aaCatalogPath, loadAaCatalog } from './catalog.js';
28
+ import { AI_PRICING_ATTRIBUTION } from './aipricing.js';
29
+ import { cmdUsage } from './commands/usage.js';
30
+ import { readRows } from './ledger.js';
31
+ const USAGE = `sonata — foreign-model subagents for Claude Code
32
+
33
+ sonata init set up sonata in this project (interactive)
34
+ sonata doctor check tmux, harnesses, auth and versions
35
+ sonata sync regenerate agent files from sonata.toml
36
+ sonata run launch a harness run, print its id
37
+ sonata dispatch run a ranked tier with harness fallback
38
+ sonata tail poll a run for progress
39
+ sonata wait resume a RUNNING or approved run and block for its next state
40
+ sonata approve answer a pending approval (requires --yes or --no)
41
+ sonata gc kill finished tmux sessions
42
+ sonata log print a run's whole transcript (tail returns only new lines)
43
+ sonata verify confirm a dispatch actually happened
44
+ sonata serve start the native routing proxy (router + litellm)
45
+ sonata restart kill any router holding the port and start a fresh daemon
46
+ sonata code launch a claude session routed through sonata serve
47
+ sonata route on|off|status — route plain claude sessions through sonata serve
48
+ auto|manual — route each session for its lifetime, keeping Remote Control
49
+ sonata auth manage gateway credentials (list/add/remove/login)
50
+ sonata catalog show or refresh the Artificial Analysis model catalog
51
+ sonata usage report native-path token and cost usage from the ledger
52
+ sonata status router health and the last hour of routes
53
+ sonata runs list every run, with state and whether it wrote a report
54
+
55
+ init flags (skip the prompts):
56
+ --yes accept defaults, no prompts
57
+ --providers opencode/openrouter,pi/opencode-go providers to draw models from
58
+ --models a,b models to enable (config keys)
59
+ --roles code,review roles to generate
60
+ --credential-source gateway=sonata|codex|opencode record a gateway credential source
61
+ --config-scope project|global where the config and its agents go
62
+ --scope project|global|skip where to install the permission hook
63
+ --prune delete stale sonata agent files
64
+ `;
65
+ /** `--wait` wins; otherwise use the configured tail window. */
66
+ export function tailWaitSeconds(flag, configured) {
67
+ if (flag === undefined)
68
+ return configured;
69
+ const parsed = Number.parseInt(flag, 10);
70
+ return Number.isFinite(parsed) ? parsed : configured;
71
+ }
72
+ /** Repository root, one level above the compiled dist/ or src/ directory. */
73
+ function packageRoot() {
74
+ return join(fileURLToPath(new URL('.', import.meta.url)), '..');
75
+ }
76
+ export async function main(argv) {
77
+ const [command, ...rest] = argv;
78
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
79
+ console.log(USAGE);
80
+ return command ? 0 : 2;
81
+ }
82
+ if (command === 'init') {
83
+ const { values } = parseArgs({
84
+ args: rest,
85
+ options: {
86
+ yes: { type: 'boolean', default: false },
87
+ providers: { type: 'string' },
88
+ models: { type: 'string' },
89
+ roles: { type: 'string' },
90
+ 'credential-source': { type: 'string', multiple: true },
91
+ 'config-scope': { type: 'string' },
92
+ scope: { type: 'string' },
93
+ routing: { type: 'string' },
94
+ // No default: `undefined` means "unanswered", which lets cmdInit fall
95
+ // through to the interactive prompt. `false` would suppress it.
96
+ prune: { type: 'boolean' },
97
+ },
98
+ });
99
+ const scope = values.scope;
100
+ if (scope && !['project', 'global', 'skip'].includes(scope)) {
101
+ throw new Error(`sonata init: --scope must be project, global or skip (got "${scope}")`);
102
+ }
103
+ const routing = values.routing;
104
+ if (routing && !['project', 'global', 'skip'].includes(routing)) {
105
+ throw new Error(`sonata init: --routing must be project, global or skip (got "${routing}")`);
106
+ }
107
+ const configScope = values['config-scope'];
108
+ if (configScope && !['project', 'global'].includes(configScope)) {
109
+ throw new Error(`sonata init: --config-scope must be project or global (got "${configScope}")`);
110
+ }
111
+ const split = (v) => v === undefined ? undefined : v.split(',').map((s) => s.trim()).filter(Boolean);
112
+ try {
113
+ const res = await cmdInit({
114
+ cwd: process.cwd(),
115
+ home: homedir(),
116
+ packageRoot: packageRoot(),
117
+ yes: values.yes,
118
+ providers: split(values.providers),
119
+ models: split(values.models),
120
+ roles: split(values.roles),
121
+ credentialSource: values['credential-source'],
122
+ scope,
123
+ routing,
124
+ configScope,
125
+ prune: values.prune,
126
+ });
127
+ if (res.cancelled)
128
+ return 1;
129
+ return res.problems.some((p) => p.severity === 'error') ? 1 : 0;
130
+ }
131
+ catch (err) {
132
+ // The wizard owns the screen, so a failure's own output may already be
133
+ // gone with the alternate buffer. Point at the log that survived it.
134
+ const logDir = initLogDir(homedir());
135
+ if (isCancellation(err)) {
136
+ console.log('\n Cancelled. Nothing written.');
137
+ console.log(` Log: ${logDir}`);
138
+ return 130;
139
+ }
140
+ console.error(`\n sonata init failed. Log: ${logDir}`);
141
+ throw err;
142
+ }
143
+ }
144
+ if (command === 'run') {
145
+ const { values } = parseArgs({
146
+ args: rest,
147
+ options: {
148
+ role: { type: 'string' },
149
+ model: { type: 'string' },
150
+ 'task-file': { type: 'string' },
151
+ 'roles-dir': { type: 'string' },
152
+ json: { type: 'boolean', default: false },
153
+ },
154
+ });
155
+ if (!values.role || !values.model || !values['task-file']) {
156
+ throw new Error('sonata run requires --role, --model and --task-file');
157
+ }
158
+ const res = await cmdRun({
159
+ cwd: process.cwd(),
160
+ role: values.role,
161
+ model: values.model,
162
+ taskFile: values['task-file'],
163
+ // fileURLToPath, not .pathname — the latter percent-encodes spaces and
164
+ // would break for anyone whose install path contains one.
165
+ rolesDir: values['roles-dir'] ?? join(packageRoot(), 'roles'),
166
+ sessionId: process.env.CLAUDE_CODE_SESSION_ID,
167
+ });
168
+ // The attach command is printed in full, because watching a foreign model
169
+ // work is the one thing sonata cannot stream into a Claude Code
170
+ // conversation — and reconstructing the session name by hand is the step
171
+ // that stops people from looking.
172
+ console.log(values.json
173
+ ? JSON.stringify(res)
174
+ : `run ${res.id} (tmux ${res.session})\n watch: tmux attach -r -t ${res.session}`);
175
+ return 0;
176
+ }
177
+ if (command === 'dispatch') {
178
+ const { values, positionals } = parseArgs({
179
+ args: rest,
180
+ allowPositionals: true,
181
+ options: {
182
+ tier: { type: 'string' },
183
+ model: { type: 'string' },
184
+ role: { type: 'string' },
185
+ 'task-file': { type: 'string' },
186
+ 'task-stdin': { type: 'boolean' },
187
+ 'roles-dir': { type: 'string' },
188
+ },
189
+ });
190
+ if ((values.tier === undefined) === (values.model === undefined)) {
191
+ throw new Error('sonata dispatch requires exactly one of --tier or --model');
192
+ }
193
+ if (values.role !== undefined && values.tier !== undefined) {
194
+ throw new Error('sonata dispatch: --role only applies to --model; --tier\'s alias already names a role');
195
+ }
196
+ if (values['task-file'] !== undefined && values['task-stdin']) {
197
+ throw new Error('sonata dispatch: --task-file and --task-stdin are mutually exclusive');
198
+ }
199
+ if ((values['task-file'] !== undefined || values['task-stdin']) && positionals.length > 0) {
200
+ throw new Error('sonata dispatch accepts positional task text only without --task-file/--task-stdin');
201
+ }
202
+ const task = values['task-stdin']
203
+ ? readFileSync(0, 'utf8')
204
+ : values['task-file'] !== undefined
205
+ ? readFileSync(values['task-file'], 'utf8')
206
+ : positionals.join(' ');
207
+ if (!task.trim())
208
+ throw new Error('sonata dispatch requires task text, --task-file, or --task-stdin');
209
+ const res = await cmdDispatch({
210
+ cwd: process.cwd(),
211
+ home: homedir(),
212
+ tier: values.tier,
213
+ model: values.model,
214
+ role: values.role,
215
+ task,
216
+ rolesDir: values['roles-dir'] ?? join(packageRoot(), 'roles'),
217
+ sessionId: process.env.CLAUDE_CODE_SESSION_ID,
218
+ });
219
+ console.log(`${res.state} model=${res.modelKey} id=${res.id}`);
220
+ if (res.report)
221
+ console.log(`\n${res.report}`);
222
+ if (res.prompt) {
223
+ console.log(`\nPROMPT: ${res.prompt}`);
224
+ console.log(`sonata approve ${res.id} --yes (or --no)`);
225
+ }
226
+ else if (res.state === 'RUNNING') {
227
+ console.log(`sonata wait ${res.id}`);
228
+ }
229
+ if (res.state === 'FAILED') {
230
+ for (const attempt of res.attempts) {
231
+ console.log(` ${attempt.modelKey}: ${attempt.state}${attempt.degraded ? ' (degraded)' : ''}${attempt.error ? ` — ${attempt.error}` : ''}`);
232
+ }
233
+ return 1;
234
+ }
235
+ if (res.state === 'STALLED')
236
+ return 3;
237
+ return 0;
238
+ }
239
+ if (command === 'tail') {
240
+ const { values, positionals } = parseArgs({
241
+ args: rest,
242
+ allowPositionals: true,
243
+ options: { wait: { type: 'string' }, json: { type: 'boolean', default: false } },
244
+ });
245
+ const id = positionals[0];
246
+ if (!id)
247
+ throw new Error('sonata tail requires a run id');
248
+ const res = await cmdTail({
249
+ cwd: process.cwd(),
250
+ id,
251
+ waitSeconds: tailWaitSeconds(values.wait, loadConfig(process.cwd()).run.tailWindowSeconds),
252
+ });
253
+ if (values.json) {
254
+ console.log(JSON.stringify(res));
255
+ }
256
+ else {
257
+ console.log(res.state);
258
+ for (const l of res.lines)
259
+ console.log(` ${l}`);
260
+ if (res.prompt)
261
+ console.log(` PROMPT: ${res.prompt}`);
262
+ if (res.report)
263
+ console.log(`\n${res.report}`);
264
+ }
265
+ return res.state === 'STALLED' ? 3 : 0;
266
+ }
267
+ if (command === 'wait') {
268
+ const { values, positionals } = parseArgs({
269
+ args: rest,
270
+ allowPositionals: true,
271
+ options: { json: { type: 'boolean', default: false } },
272
+ });
273
+ const id = positionals[0];
274
+ if (!id)
275
+ throw new Error('sonata wait requires a run id');
276
+ const res = await cmdWait({ cwd: process.cwd(), id });
277
+ if (values.json) {
278
+ console.log(JSON.stringify(res));
279
+ }
280
+ else {
281
+ console.log(res.state);
282
+ for (const l of res.lines)
283
+ console.log(` ${l}`);
284
+ if (res.prompt) {
285
+ console.log(` PROMPT: ${res.prompt}`);
286
+ console.log(`sonata approve ${id} --yes (or --no)`);
287
+ }
288
+ else if (res.state === 'RUNNING') {
289
+ console.log(`sonata wait ${id}`);
290
+ }
291
+ if (res.report)
292
+ console.log(`\n${res.report}`);
293
+ }
294
+ return res.state === 'STALLED' ? 3 : 0;
295
+ }
296
+ if (command === 'approve') {
297
+ const { values, positionals } = parseArgs({
298
+ args: rest,
299
+ allowPositionals: true,
300
+ options: { yes: { type: 'boolean', default: false }, no: { type: 'boolean', default: false } },
301
+ });
302
+ const id = positionals[0];
303
+ if (!id)
304
+ throw new Error('sonata approve requires a run id');
305
+ if (values.yes === values.no)
306
+ throw new Error('sonata approve requires exactly one of --yes or --no');
307
+ await cmdApprove({ cwd: process.cwd(), id, yes: values.yes });
308
+ console.log(`approved=${values.yes} run=${id}`);
309
+ return 0;
310
+ }
311
+ if (command === 'sync') {
312
+ const { values } = parseArgs({
313
+ args: rest,
314
+ options: { prune: { type: 'boolean', default: false } },
315
+ });
316
+ const agentsDir = join(process.cwd(), '.claude', 'agents');
317
+ const sync = cmdSync({
318
+ cwd: process.cwd(),
319
+ home: homedir(),
320
+ agentsDir,
321
+ });
322
+ for (const p of sync.written)
323
+ console.log(`wrote ${p}`);
324
+ if (sync.skipped.length > 0) {
325
+ for (const f of sync.skipped.slice(0, 5))
326
+ console.log(`skipped ${f} (exists, not sonata-owned)`);
327
+ if (sync.skipped.length > 5)
328
+ console.log(`skipped ... and ${sync.skipped.length - 5} more`);
329
+ }
330
+ if (sync.stale.length > 0) {
331
+ for (const f of sync.stale.slice(0, 5))
332
+ console.log(`stale ${f}`);
333
+ if (sync.stale.length > 5)
334
+ console.log(`stale ... and ${sync.stale.length - 5} more`);
335
+ const remove = values.prune || (isInteractive() && await confirm('Delete them?', true));
336
+ if (remove) {
337
+ const pruned = pruneAgents(agentsDir, sync.stale);
338
+ console.log(`removed ${pruned.length} stale agent file(s)`);
339
+ }
340
+ else {
341
+ console.log('delete them by hand, or re-run with --prune');
342
+ }
343
+ }
344
+ return 0;
345
+ }
346
+ if (command === 'doctor') {
347
+ // doctor is read, not piped — but only decorate a real terminal, so
348
+ // `sonata doctor > report.txt` stays plain.
349
+ if (isInteractive())
350
+ console.log(`\n${banner()}\n`);
351
+ const { ok, checks } = await cmdDoctor({
352
+ cwd: process.cwd(),
353
+ home: homedir(),
354
+ packageRoot: packageRoot(),
355
+ });
356
+ for (const c of checks)
357
+ console.log(`${c.ok ? 'ok ' : 'FAIL'} ${c.name}: ${c.detail}`);
358
+ return ok ? 0 : 1;
359
+ }
360
+ if (command === 'catalog') {
361
+ const action = rest[0];
362
+ if (action !== undefined && action !== 'update') {
363
+ throw new Error('sonata catalog requires update or no subcommand');
364
+ }
365
+ if (action === 'update') {
366
+ const result = await cmdCatalogUpdate(homedir());
367
+ if ('error' in result.aa) {
368
+ console.error(`Artificial Analysis catalog not updated: ${result.aa.error.message}`);
369
+ }
370
+ else {
371
+ console.log(`catalog updated: ${result.aa.models} models`);
372
+ console.log(` path: ${result.aa.path}`);
373
+ console.log(` fetched: ${result.aa.fetchedAt}`);
374
+ console.log(AA_ATTRIBUTION);
375
+ }
376
+ if ('error' in result.aiPricing) {
377
+ console.error(`ai-pricing catalog not updated: ${result.aiPricing.error.message}`);
378
+ }
379
+ else {
380
+ console.log(`ai-pricing catalog updated: ${result.aiPricing.models} models`);
381
+ console.log(` path: ${result.aiPricing.path}`);
382
+ console.log(` fetched: ${result.aiPricing.fetchedAt}`);
383
+ console.log(AI_PRICING_ATTRIBUTION);
384
+ }
385
+ return 'error' in result.aa || 'error' in result.aiPricing ? 1 : 0;
386
+ }
387
+ const path = aaCatalogPath(homedir());
388
+ const catalog = loadAaCatalog(homedir());
389
+ if (!catalog || !existsSync(path)) {
390
+ console.log('no catalog — run sonata catalog update');
391
+ return 0;
392
+ }
393
+ const age = Math.max(0, Date.now() - Date.parse(catalog.fetchedAt));
394
+ console.log(`catalog: ${catalog.models ? Object.keys(catalog.models).length : 0} models; age ${Math.floor(age / 1000)}s`);
395
+ console.log(` path: ${path}`);
396
+ return 0;
397
+ }
398
+ if (command === 'usage') {
399
+ // parseArgs is strict by default: an unrecognized flag throws rather than
400
+ // being silently ignored — for a cost report, quietly falling back to the
401
+ // default window on a misspelled `--since` would be materially misleading.
402
+ const { values } = parseArgs({
403
+ args: rest,
404
+ options: {
405
+ by: { type: 'string' },
406
+ since: { type: 'string' },
407
+ session: { type: 'string' },
408
+ json: { type: 'boolean', default: false },
409
+ },
410
+ });
411
+ const by = (values.by ?? 'model');
412
+ if (!['model', 'role', 'tier', 'gateway', 'session', 'project'].includes(by)) {
413
+ throw new Error('sonata usage --by must be one of: model | role | tier | gateway | session | project');
414
+ }
415
+ const report = await cmdUsage({
416
+ home: homedir(),
417
+ since: values.since ?? '7d',
418
+ by,
419
+ session: values.session,
420
+ json: values.json,
421
+ });
422
+ if (values.json) {
423
+ console.log(JSON.stringify(report, null, 2));
424
+ return 0;
425
+ }
426
+ // Widen to the longest label rather than a fixed 30: a model key like
427
+ // `openrouter-nemotron-3.5-lightning` overruns that, and every number on
428
+ // its row then shifts out of its column.
429
+ const labelWidth = Math.max(0, ...report.buckets.map((bucket) => bucket.label.length));
430
+ for (const bucket of report.buckets) {
431
+ console.log(`${bucket.label.padEnd(labelWidth)} ${String(bucket.requests).padStart(8)} ${String(bucket.input).padStart(12)} ${String(bucket.output).padStart(10)} ${bucket.costUsd === 0 && bucket.unpricedRequests === bucket.requests ? '—' : `$${bucket.costUsd.toFixed(4)}`}`);
432
+ }
433
+ console.log(`\npriced total $${report.pricedTotalUsd.toFixed(4)}`);
434
+ if (report.unpriced.requests > 0) {
435
+ console.log(`unpriced ${report.unpriced.requests} requests · ${report.unpriced.input} in · ${report.unpriced.output} out`);
436
+ }
437
+ console.log('native path only — `sonata dispatch` runs bypass the router and cannot be measured');
438
+ if (report.priceCacheAgeMs !== undefined) {
439
+ console.log(`prices: ai-pricing.fyi cache ${Math.floor(report.priceCacheAgeMs / 86_400_000)}d old`);
440
+ }
441
+ return 0;
442
+ }
443
+ if (command === 'status') {
444
+ // parseArgs is strict by default: an unknown flag throws rather than being
445
+ // silently ignored — a misspelled `--session` must not silently fall back
446
+ // to the most recent session's rows.
447
+ const { values } = parseArgs({
448
+ args: rest,
449
+ options: {
450
+ session: { type: 'string' },
451
+ all: { type: 'boolean', default: false },
452
+ },
453
+ });
454
+ if (values.session !== undefined && values.all) {
455
+ throw new Error('sonata status: --session and --all are mutually exclusive');
456
+ }
457
+ const home = homedir();
458
+ let port;
459
+ try {
460
+ port = loadConfig(process.cwd(), home).native?.ports.router;
461
+ }
462
+ catch { /* no config here — ledger rows below still report */ }
463
+ const up = port === undefined ? false : await isSonataRouter(port);
464
+ console.log(up ? `router: up on localhost:${port}` : port === undefined ? 'router: unknown (no sonata.toml here)' : 'router: down');
465
+ // The last hour of routes by default, narrowed to the most recent session
466
+ // — unless --all asks for every session or --session names one specifically.
467
+ let rows = readRows(home, Date.now() - 3_600_000);
468
+ if (values.all) {
469
+ // keep every session's rows
470
+ }
471
+ else if (values.session !== undefined) {
472
+ rows = rows.filter((row) => row.session === values.session);
473
+ }
474
+ else {
475
+ // The most recent session is the one whose newest row is newest.
476
+ const sessions = new Map();
477
+ for (const row of rows) {
478
+ if (row.session === undefined)
479
+ continue;
480
+ const ts = Date.parse(row.ts);
481
+ const prev = sessions.get(row.session);
482
+ if (prev === undefined || ts > prev)
483
+ sessions.set(row.session, ts);
484
+ }
485
+ let newest;
486
+ for (const [id, ts] of sessions) {
487
+ if (newest === undefined || ts > newest.ts)
488
+ newest = { id, ts };
489
+ }
490
+ rows = newest === undefined ? [] : rows.filter((row) => row.session === newest.id);
491
+ }
492
+ const recent = recentRoutes(rows, 10);
493
+ if (recent.length === 0) {
494
+ console.log('no routes in the last hour');
495
+ }
496
+ else {
497
+ for (const line of recent) {
498
+ const served = line.served ?? '(none — all candidates failed)';
499
+ console.log(`${line.status} ${line.alias.padEnd(24)} -> ${served.padEnd(20)} ${line.input} in / ${line.output} out`);
500
+ if (line.attempts.length > 0) {
501
+ for (const a of line.attempts)
502
+ console.log(` attempt ${a.key}: ${a.status}`);
503
+ }
504
+ }
505
+ }
506
+ console.log('reach and routing state live in `sonata route status`');
507
+ return 0;
508
+ }
509
+ if (command === 'runs') {
510
+ const { values } = parseArgs({
511
+ args: rest,
512
+ options: { json: { type: 'boolean', default: false } },
513
+ });
514
+ const runs = summarizeRuns(process.cwd());
515
+ if (values.json) {
516
+ console.log(JSON.stringify(runs, null, 2));
517
+ return 0;
518
+ }
519
+ if (runs.length === 0) {
520
+ console.log('no runs — `sonata run` or `sonata dispatch` to launch one');
521
+ return 0;
522
+ }
523
+ for (const r of runs) {
524
+ const flags = `${r.state}${r.degraded ? ' degraded' : ''}${r.report ? ' report' : ''}`;
525
+ console.log(`${r.id.padEnd(8)} ${flags.padEnd(24)} ${(r.role ?? '—').padEnd(8)} ${r.model ?? '—'} ${r.started ?? ''}`);
526
+ }
527
+ return 0;
528
+ }
529
+ if (command === 'auth') {
530
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
531
+ const [action, gateway] = positionals;
532
+ let gateways = [];
533
+ try {
534
+ gateways = Object.keys(loadConfig(process.cwd(), homedir()).native?.gateways ?? {});
535
+ }
536
+ catch { /* no config yet — add/remove/login do not need one */ }
537
+ if (action === 'list') {
538
+ console.log(cmdAuthList({ home: homedir(), gateways }).text);
539
+ return 0;
540
+ }
541
+ if (action === 'login') {
542
+ if (!gateway)
543
+ throw new Error('sonata auth login requires a gateway');
544
+ await cmdAuthLogin({ home: homedir(), cwd: process.cwd(), gateway, out: (l) => console.log(l) });
545
+ return 0;
546
+ }
547
+ if (action === 'remove') {
548
+ if (!gateway)
549
+ throw new Error('sonata auth remove requires a gateway');
550
+ cmdAuthRemove({ home: homedir(), gateway });
551
+ return 0;
552
+ }
553
+ if (action === 'add') {
554
+ if (!gateway)
555
+ throw new Error('sonata auth add requires a gateway');
556
+ const key = await readAuthKey();
557
+ if (!key)
558
+ throw new Error('sonata auth add requires a non-empty key');
559
+ cmdAuthAdd({ home: homedir(), gateway, key });
560
+ return 0;
561
+ }
562
+ throw new Error('sonata auth requires list, add <gateway>, remove <gateway> or login <gateway>');
563
+ }
564
+ if (command === 'serve') {
565
+ const { values } = parseArgs({
566
+ args: rest,
567
+ options: { daemon: { type: 'boolean', default: false } },
568
+ });
569
+ if (values.daemon) {
570
+ // Re-exec this same CLI in the foreground, detached. The flag used to be
571
+ // parsed, handed to cmdServe and ignored, so `--daemon` blocked exactly
572
+ // like the foreground command.
573
+ const self = fileURLToPath(import.meta.url);
574
+ const daemon = await startServeDaemon(homedir(), [process.execPath, self, 'serve']);
575
+ console.log(`sonata serve running in the background (pid ${daemon.pid}) on port ${daemon.port}`);
576
+ console.log(` log: ${daemon.logPath}`);
577
+ console.log(` stop: kill ${daemon.pid}`);
578
+ return 0;
579
+ }
580
+ const handle = await cmdServe({ cwd: process.cwd(), home: homedir(), daemon: values.daemon });
581
+ console.log(`router listening on ${handle.routerPort}; litellm listening on ${handle.litellmPort}`);
582
+ // serve runs until it is killed, so the signal handlers ARE its normal exit
583
+ // path — without them `stop()` never runs and the run's temp directory
584
+ // survives. That directory holds the generated master key, and for a
585
+ // codex-oauth gateway the ChatGPT credential too; one was found in the
586
+ // system temp directory after a Ctrl-C.
587
+ let stopping = false;
588
+ const shutdown = (signal) => {
589
+ if (stopping)
590
+ return;
591
+ stopping = true;
592
+ void handle.stop().finally(() => process.kill(process.pid, signal));
593
+ };
594
+ process.once('SIGINT', shutdown);
595
+ process.once('SIGTERM', shutdown);
596
+ process.once('SIGHUP', shutdown);
597
+ await new Promise(() => { });
598
+ return 0;
599
+ }
600
+ if (command === 'code') {
601
+ const passthrough = rest[0] === '--' ? rest.slice(1) : rest;
602
+ await cmdCode({ cwd: process.cwd(), home: homedir(), passthrough });
603
+ return 0;
604
+ }
605
+ if (command === 'route') {
606
+ const action = rest[0];
607
+ const global = rest.includes('--global');
608
+ const scope = global ? 'global' : 'project';
609
+ const opts = { cwd: process.cwd(), home: homedir(), packageRoot: packageRoot(), scope };
610
+ // The two session-<phase> actions are the body of the auto-mode hooks, not
611
+ // a surface for people: they take the session id the hook read from stdin.
612
+ if (action === 'session-start' || action === 'session-end') {
613
+ const id = rest[rest.indexOf('--id') + 1];
614
+ if (!rest.includes('--id') || !id)
615
+ throw new Error(`sonata route ${action} requires --id <session-id>`);
616
+ const self = fileURLToPath(import.meta.url);
617
+ const res = await cmdRouteSession(action === 'session-start' ? 'start' : 'end', id, { ...opts, serveArgv: [process.execPath, self, 'serve'] });
618
+ console.log(`routing ${res.routing}; ${res.sessions} session(s) routed`);
619
+ return 0;
620
+ }
621
+ // Also the body of a hook, not a surface for people: routing follows the
622
+ // foreign-model subagents that need it, turned on at SubagentStart and off
623
+ // when the last one stops.
624
+ if (action === 'subagent-start' || action === 'subagent-stop') {
625
+ const id = rest[rest.indexOf('--id') + 1];
626
+ if (!rest.includes('--id') || !id)
627
+ throw new Error(`sonata route ${action} requires --id <agent-id>`);
628
+ const res = await cmdRouteSubagent(action === 'subagent-start' ? 'start' : 'stop', id, opts);
629
+ console.log(`routing ${res.routing}; ${res.subagents} subagent(s) running`);
630
+ return 0;
631
+ }
632
+ if (!action || !['on', 'off', 'status', 'auto', 'manual'].includes(action)) {
633
+ throw new Error('sonata route requires one of: on | off | status | auto | manual');
634
+ }
635
+ const status = await cmdRoute(action, opts);
636
+ if (status) {
637
+ const scopeLabel = global ? 'globally' : 'in this project';
638
+ console.log(status.on
639
+ ? `claude sessions ${scopeLabel} route through sonata serve`
640
+ : `claude sessions ${scopeLabel} use their default API endpoint`);
641
+ if (status.on)
642
+ console.log(` router: http://localhost:${status.port}`);
643
+ if (action === 'status') {
644
+ const active = ['project', 'global'].filter((name) => status.scopes[name].on || status.scopes[name].auto);
645
+ console.log(` scopes: ${active.length > 0 ? active.join(', ') : 'none'}`);
646
+ console.log(' see `sonata status` for what the router has actually served recently');
647
+ }
648
+ if (status.auto) {
649
+ console.log(' auto: on — routing turns on only while a foreign-model subagent runs');
650
+ console.log(' (every session keeps Remote Control: none launches into a routed file)');
651
+ if (status.sessions > 0)
652
+ console.log(` live: ${status.sessions} routed session(s)`);
653
+ }
654
+ else if (action === 'manual') {
655
+ console.log(' auto: off — routing is whatever `sonata route on|off` last set');
656
+ }
657
+ }
658
+ return 0;
659
+ }
660
+ if (command === 'restart') {
661
+ // Kills whatever sonata router currently holds the configured port (a
662
+ // stale daemon or an in-process native router) using
663
+ // only the pid `cmdServe` recorded for itself, then starts a fresh
664
+ // daemon. Plain `sonata serve --daemon` cannot do this: it just times
665
+ // out against `EADDRINUSE` with "the daemon did not answer", which reads
666
+ // as a startup failure rather than "something else is already there".
667
+ const self = fileURLToPath(import.meta.url);
668
+ const restarted = await cmdRestart(homedir(), [process.execPath, self, 'serve'], { cwd: process.cwd() });
669
+ console.log(`sonata serve restarted (pid ${restarted.pid}) on port ${restarted.port}`);
670
+ console.log(` log: ${restarted.logPath}`);
671
+ console.log(` stop: kill ${restarted.pid}`);
672
+ return 0;
673
+ }
674
+ if (command === 'gc') {
675
+ const killed = await cmdGc({ cwd: process.cwd() });
676
+ console.log(killed.length ? `killed ${killed.join(', ')}` : 'nothing to clean up');
677
+ return 0;
678
+ }
679
+ if (command === 'log') {
680
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
681
+ const id = positionals[0];
682
+ if (!id)
683
+ throw new Error('sonata log requires a run id');
684
+ const { cmdLog } = await import('./commands/log.js');
685
+ const res = cmdLog({ cwd: process.cwd(), id });
686
+ console.log(res.text);
687
+ return res.ok ? 0 : 1;
688
+ }
689
+ if (command === 'verify') {
690
+ const { values, positionals } = parseArgs({
691
+ args: rest, allowPositionals: true, options: { model: { type: 'string' } },
692
+ });
693
+ const id = positionals[0];
694
+ if (!id)
695
+ throw new Error('sonata verify requires a run id');
696
+ const { cmdVerify } = await import('./commands/verify.js');
697
+ const res = cmdVerify({ cwd: process.cwd(), id, model: values.model });
698
+ console.log(`${res.ok ? 'ok ' : 'FAIL'} ${res.detail}`);
699
+ return res.ok ? 0 : 1;
700
+ }
701
+ console.error(`sonata: unknown command "${command ?? ''}"`);
702
+ return 2;
703
+ }
704
+ async function readAuthKey() {
705
+ if (!process.stdin.isTTY) {
706
+ return readFileSync(0, 'utf8').split(/\r?\n/, 1)[0].trim();
707
+ }
708
+ const stdin = process.stdin;
709
+ const stdout = process.stdout;
710
+ stdout.write('Key: ');
711
+ stdin.setRawMode?.(true);
712
+ stdin.resume();
713
+ let value = '';
714
+ try {
715
+ for await (const chunk of stdin.iterator({ destroyOnReturn: false })) {
716
+ const text = String(chunk);
717
+ for (const char of text) {
718
+ if (char === '\n' || char === '\r')
719
+ return value.trim();
720
+ if (char === '\u0003')
721
+ throw new Error('sonata auth add cancelled');
722
+ if (char === '\u007f' || char === '\b')
723
+ value = value.slice(0, -1);
724
+ else
725
+ value += char;
726
+ }
727
+ }
728
+ }
729
+ finally {
730
+ stdin.setRawMode?.(false);
731
+ stdin.pause();
732
+ stdout.write('\n');
733
+ }
734
+ return value.trim();
735
+ }
736
+ /**
737
+ * True when this module was executed as the program, rather than imported.
738
+ *
739
+ * Compares REAL paths. `sonata` on PATH is a symlink into node_modules, so
740
+ * node sets argv[1] to the symlink while import.meta.url resolves to the file
741
+ * it points at — a raw string comparison never matches, and every command
742
+ * exits 0 having done nothing. That shipped: the guard exists so a test can
743
+ * import this module without running the CLI, and the first version of it
744
+ * killed the CLI instead. Nothing caught it, because every test imports.
745
+ */
746
+ export function invokedAsProgram(argv1, moduleUrl) {
747
+ if (!argv1)
748
+ return false;
749
+ try {
750
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
751
+ }
752
+ catch {
753
+ // One of the paths does not resolve, so this is not the program. Staying
754
+ // silent beats running twice.
755
+ return false;
756
+ }
757
+ }
758
+ if (invokedAsProgram(process.argv[1], import.meta.url)) {
759
+ main(process.argv.slice(2))
760
+ .then((code) => process.exit(code))
761
+ .catch((err) => {
762
+ console.error(err instanceof Error ? err.message : String(err));
763
+ process.exit(1);
764
+ });
765
+ }
766
+ //# sourceMappingURL=cli.js.map