@ocis/myagent-cli 0.2.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 (91) hide show
  1. package/README.md +357 -0
  2. package/dist/agent/context.d.ts +33 -0
  3. package/dist/agent/context.js +169 -0
  4. package/dist/agent/modes.d.ts +21 -0
  5. package/dist/agent/modes.js +84 -0
  6. package/dist/agent/prompt-builder.d.ts +9 -0
  7. package/dist/agent/prompt-builder.js +31 -0
  8. package/dist/agent/sessions.d.ts +38 -0
  9. package/dist/agent/sessions.js +130 -0
  10. package/dist/agent/todo.d.ts +18 -0
  11. package/dist/agent/todo.js +61 -0
  12. package/dist/agent/turn.d.ts +309 -0
  13. package/dist/agent/turn.js +1253 -0
  14. package/dist/approval/policy.d.ts +81 -0
  15. package/dist/approval/policy.js +157 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +156 -0
  18. package/dist/git/status.d.ts +89 -0
  19. package/dist/git/status.js +226 -0
  20. package/dist/headless.d.ts +72 -0
  21. package/dist/headless.js +330 -0
  22. package/dist/index.d.ts +60 -0
  23. package/dist/index.js +511 -0
  24. package/dist/protocol/client.d.ts +123 -0
  25. package/dist/protocol/client.js +250 -0
  26. package/dist/protocol/sse-frames.d.ts +6 -0
  27. package/dist/protocol/sse-frames.js +75 -0
  28. package/dist/protocol/types.d.ts +200 -0
  29. package/dist/protocol/types.js +8 -0
  30. package/dist/runtime.d.ts +38 -0
  31. package/dist/runtime.js +166 -0
  32. package/dist/sanitize.d.ts +1 -0
  33. package/dist/sanitize.js +21 -0
  34. package/dist/skills/discovery.d.ts +24 -0
  35. package/dist/skills/discovery.js +109 -0
  36. package/dist/tools/binary.d.ts +2 -0
  37. package/dist/tools/binary.js +22 -0
  38. package/dist/tools/diff.d.ts +1 -0
  39. package/dist/tools/diff.js +49 -0
  40. package/dist/tools/find.d.ts +2 -0
  41. package/dist/tools/find.js +61 -0
  42. package/dist/tools/fs.d.ts +2 -0
  43. package/dist/tools/fs.js +276 -0
  44. package/dist/tools/glob.d.ts +6 -0
  45. package/dist/tools/glob.js +131 -0
  46. package/dist/tools/grep.d.ts +3 -0
  47. package/dist/tools/grep.js +228 -0
  48. package/dist/tools/paths.d.ts +27 -0
  49. package/dist/tools/paths.js +124 -0
  50. package/dist/tools/registry.d.ts +13 -0
  51. package/dist/tools/registry.js +38 -0
  52. package/dist/tools/shell.d.ts +2 -0
  53. package/dist/tools/shell.js +136 -0
  54. package/dist/tools/skills.d.ts +2 -0
  55. package/dist/tools/skills.js +36 -0
  56. package/dist/tools/todo.d.ts +2 -0
  57. package/dist/tools/todo.js +43 -0
  58. package/dist/tools/transfer.d.ts +2 -0
  59. package/dist/tools/transfer.js +145 -0
  60. package/dist/tools/truncate.d.ts +12 -0
  61. package/dist/tools/truncate.js +46 -0
  62. package/dist/tools/types.d.ts +85 -0
  63. package/dist/tools/types.js +63 -0
  64. package/dist/ui/app.d.ts +39 -0
  65. package/dist/ui/app.js +1061 -0
  66. package/dist/ui/colors.d.ts +100 -0
  67. package/dist/ui/colors.js +169 -0
  68. package/dist/ui/components.d.ts +267 -0
  69. package/dist/ui/components.js +811 -0
  70. package/dist/ui/diff.d.ts +37 -0
  71. package/dist/ui/diff.js +143 -0
  72. package/dist/ui/format.d.ts +28 -0
  73. package/dist/ui/format.js +76 -0
  74. package/dist/ui/help.d.ts +6 -0
  75. package/dist/ui/help.js +45 -0
  76. package/dist/ui/highlight.d.ts +20 -0
  77. package/dist/ui/highlight.js +210 -0
  78. package/dist/ui/logo.d.ts +24 -0
  79. package/dist/ui/logo.js +106 -0
  80. package/dist/ui/model-list.d.ts +10 -0
  81. package/dist/ui/model-list.js +33 -0
  82. package/dist/ui/quit-confirm.d.ts +8 -0
  83. package/dist/ui/quit-confirm.js +40 -0
  84. package/dist/ui/select-popup.d.ts +30 -0
  85. package/dist/ui/select-popup.js +54 -0
  86. package/dist/ui/theme.d.ts +4 -0
  87. package/dist/ui/theme.js +41 -0
  88. package/dist/ui/tool-view.d.ts +20 -0
  89. package/dist/ui/tool-view.js +326 -0
  90. package/package.json +44 -0
  91. package/skills/git-commit/SKILL.md +27 -0
package/dist/index.js ADDED
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // myagent CLI — entry point.
4
+ //
5
+ // Interactive TUI by default; `-p`/`run`/piped stdin switch to headless mode.
6
+ // Credentials resolve flags > env > config file; the config wizard lives under
7
+ // `myagent config`.
8
+ // ---------------------------------------------------------------------------
9
+ import { createInterface } from "node:readline/promises";
10
+ import { stdin as input, stdout as output } from "node:process";
11
+ import { homedir } from "node:os";
12
+ import { resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { IntegrationClient } from "./protocol/client.js";
15
+ import { ToolRegistry } from "./tools/registry.js";
16
+ import { combinedIntegrationUrl, configDir, isConfigured, loadConfig, migrateLegacyConfigDir, resolveConfig, saveConfig, sessionsPath, splitIntegrationUrl, } from "./config.js";
17
+ import { SessionIndex } from "./agent/sessions.js";
18
+ import { DEFAULT_MODE } from "./agent/modes.js";
19
+ import { runTui } from "./ui/app.js";
20
+ import { failureDoneEvent, failureResult, runHeadless } from "./headless.js";
21
+ import { discoverSkills, formatSkillList, resolveSkillDirs } from "./skills/discovery.js";
22
+ import { sanitizeForTerminal } from "./sanitize.js";
23
+ import { isMainModule, readJsonFile, readStdinText } from "./runtime.js";
24
+ /**
25
+ * Package version, read from package.json so npm releases never drift from
26
+ * `--version`. Resolves to the package root from both src/ (dev) and dist/.
27
+ */
28
+ async function readVersion() {
29
+ try {
30
+ const pkg = await readJsonFile(fileURLToPath(new URL("../package.json", import.meta.url)));
31
+ return pkg.version ?? "0.0.0";
32
+ }
33
+ catch {
34
+ return "0.0.0";
35
+ }
36
+ }
37
+ const HELP = `myagent — a coding agent for your local repository, powered by MyAgent
38
+
39
+ Usage:
40
+ myagent [options] [prompt...] interactive TUI (or headless if not a TTY)
41
+ myagent run [options] [prompt...] one-shot; prompt from args or stdin
42
+ myagent -p "prompt" same as run
43
+ myagent --prompt "prompt" prompt mode: unattended one-shot (CI)
44
+ myagent config configure credentials interactively
45
+ myagent config show print the current config (redacted)
46
+ myagent skills list locally installed skills
47
+ myagent sessions list remote sessions
48
+
49
+ Connection:
50
+ --base-url <url> MyAgent base URL (env MYAGENT_BASE_URL)
51
+ --integration-id <id> integration id (env MYAGENT_INTEGRATION_ID)
52
+ --api-key <key> integration API key (env MYAGENT_API_KEY)
53
+
54
+ Session:
55
+ -c, --continue continue the most recent session for this directory
56
+ -r, --resume resume the most recent session (same as -c)
57
+ --session <thread-id> resume a specific remote session
58
+
59
+ Agent:
60
+ --plan start in plan mode (read-only)
61
+ --coding start in coding mode (software work; default)
62
+ --cowork start in cowork mode (general tasks)
63
+ --ask confirm mutating tools (interactive only; headless denies them)
64
+ --auto run tools unattended (default)
65
+ --model <id> model for new sessions (client-choice integrations; see /v1/models)
66
+ --thinking <level> off | minimal | low | medium | high | xhigh | max | default
67
+ --skills-dir <dir> extra skills directory (repeatable)
68
+ --allow-outside allow tools to touch paths outside the workspace
69
+ --no-context-files do not load AGENTS.md / CLAUDE.md
70
+
71
+ Prompt mode (CI):
72
+ --prompt <text|-> non-interactive one-shot: auto-approves every tool
73
+ (including paths outside the workspace) and prints
74
+ only the final assistant message. "-" reads stdin.
75
+ --timeout <sec> abort the run after N seconds (headless only)
76
+
77
+ Output:
78
+ --output-format <fmt> text | json | stream-json (headless only)
79
+ --no-logo skip the startup logo animation
80
+ -q, --quiet suppress tool/progress output (headless)
81
+
82
+ Exit codes:
83
+ 0 success · 1 run failed (incl. timeout) · 2 usage/config/connection error
84
+ 130/143 interrupted (SIGINT/SIGTERM)
85
+
86
+ Other:
87
+ -h, --help show this help
88
+ -v, --version show the version
89
+ `;
90
+ export function parseArgs(argv) {
91
+ const args = { command: "chat", flags: {}, skillsDirs: [], promptParts: [] };
92
+ const rest = [];
93
+ for (let i = 0; i < argv.length; i++) {
94
+ const arg = argv[i];
95
+ const next = (name) => {
96
+ const value = argv[++i];
97
+ if (value === undefined)
98
+ throw new Error(`Missing value for ${name}`);
99
+ return value;
100
+ };
101
+ switch (arg) {
102
+ case "run":
103
+ args.command = "run";
104
+ break;
105
+ case "config":
106
+ args.command = "config";
107
+ args.configSub = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : undefined;
108
+ break;
109
+ case "skills":
110
+ args.command = "skills";
111
+ break;
112
+ case "sessions":
113
+ args.command = "sessions";
114
+ break;
115
+ case "-h":
116
+ case "--help":
117
+ args.command = "help";
118
+ break;
119
+ case "-v":
120
+ case "--version":
121
+ args.command = "version";
122
+ break;
123
+ case "--base-url":
124
+ args.flags.baseUrl = next(arg);
125
+ break;
126
+ case "--integration-id":
127
+ args.flags.integrationId = next(arg);
128
+ break;
129
+ case "--api-key":
130
+ args.flags.apiKey = next(arg);
131
+ break;
132
+ case "--workspace":
133
+ case "-w":
134
+ args.flags.workspace = next(arg);
135
+ break;
136
+ case "-c":
137
+ case "--continue":
138
+ case "-r":
139
+ case "--resume":
140
+ args.flags.continue = true;
141
+ break;
142
+ case "--session":
143
+ args.flags.session = next(arg);
144
+ break;
145
+ case "--plan":
146
+ args.flags.mode = "plan";
147
+ break;
148
+ case "--coding":
149
+ args.flags.mode = "coding";
150
+ break;
151
+ case "--cowork":
152
+ args.flags.mode = "cowork";
153
+ break;
154
+ case "--ask":
155
+ args.flags.ask = true;
156
+ break;
157
+ case "--auto":
158
+ args.flags.auto = true;
159
+ break;
160
+ case "--thinking":
161
+ args.flags.thinking = next(arg);
162
+ break;
163
+ case "--model":
164
+ args.flags.model = next(arg);
165
+ break;
166
+ case "--skills-dir":
167
+ args.skillsDirs.push(next(arg));
168
+ break;
169
+ case "--allow-outside":
170
+ args.flags.allowOutside = true;
171
+ break;
172
+ case "--no-context-files":
173
+ args.flags.noContextFiles = true;
174
+ break;
175
+ case "--no-logo":
176
+ args.flags.noLogo = true;
177
+ break;
178
+ case "-q":
179
+ case "--quiet":
180
+ args.flags.quiet = true;
181
+ break;
182
+ case "--prompt":
183
+ args.flags.prompt = next(arg);
184
+ break;
185
+ case "--timeout": {
186
+ const raw = next(arg);
187
+ const value = Number(raw);
188
+ // setTimeout coerces delays above 2^31-1 ms to 1 ms — an oversized
189
+ // value would fire immediately, the opposite of the user's intent.
190
+ if (!Number.isFinite(value) || value <= 0 || value * 1000 > 2_147_483_647) {
191
+ throw new Error(`Invalid --timeout "${raw}" (positive seconds, max 2147483)`);
192
+ }
193
+ args.flags.timeout = value;
194
+ break;
195
+ }
196
+ case "--output-format": {
197
+ const value = next(arg);
198
+ if (value !== "text" && value !== "json" && value !== "stream-json") {
199
+ throw new Error(`Invalid --output-format "${value}" (text | json | stream-json)`);
200
+ }
201
+ args.flags.outputFormat = value;
202
+ break;
203
+ }
204
+ case "-p":
205
+ case "--print":
206
+ args.command = args.command === "chat" ? "run" : args.command;
207
+ break;
208
+ default:
209
+ if (arg.startsWith("-") && arg !== "-")
210
+ throw new Error(`Unknown option: ${arg}`);
211
+ rest.push(arg);
212
+ }
213
+ }
214
+ args.promptParts = rest;
215
+ return args;
216
+ }
217
+ /**
218
+ * Interactive means the TUI: a plain `chat` invocation with both streams on a
219
+ * TTY. `run`/`-p`, `--prompt`, `--output-format`, and piped stdin all mean
220
+ * headless.
221
+ */
222
+ export function isInteractiveMode(args, stdinIsTty, stdoutIsTty) {
223
+ return (args.command === "chat" &&
224
+ !args.flags.outputFormat &&
225
+ !args.flags.prompt &&
226
+ stdinIsTty &&
227
+ stdoutIsTty);
228
+ }
229
+ /**
230
+ * Whether stdin should be read as prompt text. Piped stdin is a prompt for
231
+ * every headless path (`echo fix | myagent`); an inline `--prompt` never reads
232
+ * it (a CI stdin may be an open pipe — reading it would hang), while
233
+ * `--prompt -` reads it explicitly.
234
+ */
235
+ export function shouldReadStdin(args, interactive) {
236
+ if (interactive)
237
+ return false;
238
+ // An inline --prompt is the whole prompt; `-` and no flag read stdin.
239
+ return args.flags.prompt === undefined || args.flags.prompt === "-";
240
+ }
241
+ /** Join the prompt sources: positional words, `--prompt` (or stdin), piped stdin. */
242
+ export function assemblePrompt(args, stdinText) {
243
+ const promptFromStdin = args.flags.prompt === "-";
244
+ return [
245
+ ...args.promptParts,
246
+ promptFromStdin ? "" : args.flags.prompt,
247
+ stdinText.trim(),
248
+ ].filter(Boolean).join("\n");
249
+ }
250
+ /**
251
+ * Report a pre-run failure (usage/config/connection) in the requested output
252
+ * format and return the exit code. `json` always emits a parseable object — a
253
+ * CI parser must never see an empty stdout; `stream-json` emits a terminal
254
+ * `done` event; text goes to stderr.
255
+ */
256
+ export function fail(format, message, errorCode) {
257
+ if (format === "json") {
258
+ process.stdout.write(failureResult(message, errorCode) + "\n");
259
+ }
260
+ else if (format === "stream-json") {
261
+ process.stdout.write(failureDoneEvent(message, errorCode) + "\n");
262
+ }
263
+ else {
264
+ process.stderr.write(message + "\n");
265
+ }
266
+ return 2;
267
+ }
268
+ /**
269
+ * Read piped stdin as prompt text. Bounded by the run's `--timeout` when one
270
+ * is set: a non-TTY stdin that never closes (a CI misconfiguration like
271
+ * `docker run -i` without `< /dev/null`) would otherwise hang before the
272
+ * run's own timeout control is even created. Returns null on timeout so the
273
+ * caller can fail with a clear message instead of running an empty prompt.
274
+ */
275
+ export async function readStdin(timeoutMs) {
276
+ if (process.stdin.isTTY)
277
+ return "";
278
+ const read = readStdinText().catch(() => "");
279
+ if (!timeoutMs)
280
+ return read;
281
+ let timer = null;
282
+ try {
283
+ return await Promise.race([
284
+ read,
285
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }),
286
+ ]);
287
+ }
288
+ finally {
289
+ if (timer)
290
+ clearTimeout(timer);
291
+ }
292
+ }
293
+ async function main() {
294
+ await migrateLegacyConfigDir();
295
+ let args;
296
+ try {
297
+ args = parseArgs(process.argv.slice(2));
298
+ }
299
+ catch (err) {
300
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n\n${HELP}`);
301
+ return 2;
302
+ }
303
+ if (args.command === "help") {
304
+ process.stdout.write(HELP);
305
+ return 0;
306
+ }
307
+ if (args.command === "version") {
308
+ process.stdout.write(`${await readVersion()}\n`);
309
+ return 0;
310
+ }
311
+ const stored = await loadConfig();
312
+ const config = resolveConfig(stored, args.flags);
313
+ if (args.command === "config") {
314
+ if (args.configSub === "show") {
315
+ process.stdout.write(`apiBaseUrl: ${combinedIntegrationUrl(config) || "(unset)"}\n` +
316
+ `apiKey: ${config.apiKey ? `${"•".repeat(8)}…${config.apiKey.slice(-4)}` : "(unset)"}\n` +
317
+ `approvalDefault: ${config.approvalDefault}\n` +
318
+ `config file: ${configDir()}/config.json\n`);
319
+ return 0;
320
+ }
321
+ // The wizard edits the FILE, so it must see the file's values — not the
322
+ // env/flag-merged runtime config. Otherwise pressing Enter through the
323
+ // prompts would persist a temporary MYAGENT_API_KEY / --base-url into
324
+ // config.json without ever saying so.
325
+ return await configWizard(stored);
326
+ }
327
+ const workspace = resolveWorkspace(args.flags.workspace);
328
+ const skillDirs = await resolveSkillDirs(workspace, configDir(), args.skillsDirs);
329
+ if (args.command === "skills") {
330
+ const skills = await discoverSkills(skillDirs);
331
+ process.stdout.write(`Search paths:\n${skillDirs.map((d) => ` ${d}`).join("\n")}\n\n${formatSkillList(skills)}\n`);
332
+ return 0;
333
+ }
334
+ if (!isConfigured(config)) {
335
+ return fail(args.flags.outputFormat, "Not configured. Run `myagent config` first, or pass --base-url/--integration-id/--api-key.\n" +
336
+ "Create an integration and API key on the MyAgent Integrations page.", "not_configured");
337
+ }
338
+ const client = new IntegrationClient({
339
+ baseUrl: config.baseUrl,
340
+ integrationId: config.integrationId,
341
+ apiKey: config.apiKey,
342
+ });
343
+ let status;
344
+ try {
345
+ // Bounded connect: a hung server must fail (exit 2) instead of hanging CI.
346
+ status = await client.getStatus({ signal: AbortSignal.timeout(30_000) });
347
+ }
348
+ catch (err) {
349
+ return fail(args.flags.outputFormat, `Could not reach ${config.baseUrl}: ${err instanceof Error ? err.message : String(err)}`, "connection_failed");
350
+ }
351
+ if (!status.enabled) {
352
+ return fail(args.flags.outputFormat, `Integration "${status.label}" is disabled on the server.`, "integration_disabled");
353
+ }
354
+ const sessionIndex = await SessionIndex.load(sessionsPath());
355
+ if (args.command === "sessions") {
356
+ const sessions = await client.listSessions();
357
+ return printSessions(sessions);
358
+ }
359
+ const mode = args.flags.mode ?? DEFAULT_MODE;
360
+ const promptMode = args.flags.prompt !== undefined;
361
+ // Prompt mode is unattended by contract: every tool runs, including paths
362
+ // outside the workspace — no one is present to answer an approval prompt.
363
+ const approval = promptMode
364
+ ? "auto"
365
+ : args.flags.ask
366
+ ? "ask"
367
+ : args.flags.auto
368
+ ? "auto"
369
+ : config.approvalDefault;
370
+ const allowOutside = promptMode ? true : (args.flags.allowOutside ?? false);
371
+ const resumeThreadId = args.flags.session
372
+ ?? (args.flags.continue ? sessionIndex.lastThread(workspace) : undefined);
373
+ // `--prompt` is unattended by contract, so an explicit `--ask` cannot be
374
+ // honored — say so instead of silently dropping the safer setting.
375
+ if (promptMode && args.flags.ask) {
376
+ process.stderr.write("Warning: --ask is ignored with --prompt (unattended mode auto-approves every tool, including paths outside the workspace).\n");
377
+ }
378
+ const interactive = isInteractiveMode(args, Boolean(process.stdin.isTTY), Boolean(process.stdout.isTTY));
379
+ const readsStdin = shouldReadStdin(args, interactive);
380
+ // A prompt is already complete, yet stdin is still read (it may carry extra
381
+ // context: `cat log | myagent run "summarize:"`). Reading blocks until EOF,
382
+ // so a non-TTY stdin that never closes (CI `docker run -i` without
383
+ // `< /dev/null`) would hang silently — announce the wait instead.
384
+ if (readsStdin && !process.stdin.isTTY && args.promptParts.length > 0 && !args.flags.timeout) {
385
+ process.stderr.write("Reading additional prompt text from stdin until EOF — pass `< /dev/null` or --timeout <sec> if nothing is piped.\n");
386
+ }
387
+ const stdinText = readsStdin
388
+ ? await readStdin(args.flags.timeout ? args.flags.timeout * 1000 : undefined)
389
+ : "";
390
+ if (stdinText === null) {
391
+ return fail(args.flags.outputFormat, `Timed out after ${args.flags.timeout}s waiting for stdin.`, "timeout");
392
+ }
393
+ const prompt = assemblePrompt(args, stdinText);
394
+ const registry = new ToolRegistry();
395
+ if (interactive) {
396
+ return await runTui({
397
+ client,
398
+ registry,
399
+ config,
400
+ sessionIndex,
401
+ workspace,
402
+ allowOutside: args.flags.allowOutside ?? false,
403
+ skillDirs,
404
+ noContextFiles: args.flags.noContextFiles,
405
+ initialMode: mode,
406
+ initialApproval: approval,
407
+ initialThinking: args.flags.thinking === "default" ? undefined : args.flags.thinking,
408
+ initialModel: args.flags.model,
409
+ initialPrompt: prompt || undefined,
410
+ resumeThreadId,
411
+ noLogo: args.flags.noLogo,
412
+ });
413
+ }
414
+ if (!prompt) {
415
+ return fail(args.flags.outputFormat, "No prompt provided. Usage: myagent -p \"...\" or pipe text via stdin.", "no_prompt");
416
+ }
417
+ return await runHeadless({
418
+ client,
419
+ registry,
420
+ sessionIndex,
421
+ workspace,
422
+ allowOutside,
423
+ skillDirs,
424
+ mode,
425
+ approval,
426
+ model: args.flags.model,
427
+ thinkingLevel: args.flags.thinking === "default" ? undefined : args.flags.thinking,
428
+ noContextFiles: args.flags.noContextFiles,
429
+ resumeThreadId,
430
+ prompt,
431
+ outputFormat: args.flags.outputFormat ?? "text",
432
+ quiet: args.flags.quiet,
433
+ lastMessageOnly: promptMode,
434
+ timeoutMs: args.flags.timeout ? args.flags.timeout * 1000 : undefined,
435
+ });
436
+ }
437
+ function printSessions(sessions) {
438
+ if (sessions.length === 0) {
439
+ process.stdout.write("No remote sessions.\n");
440
+ return 0;
441
+ }
442
+ for (const session of sessions) {
443
+ const updated = new Date(session.updated_at).toLocaleString();
444
+ // Remote free text (title, status) reaches the real terminal here — same
445
+ // sanitizing rule as the TUI transcript and the headless writer.
446
+ const line = `${session.thread_id} ${session.status.padEnd(10)} ${String(session.message_count).padStart(4)} msgs ${updated} ${session.title}`;
447
+ process.stdout.write(`${sanitizeForTerminal(line)}\n`);
448
+ }
449
+ return 0;
450
+ }
451
+ /** Resolve the workspace flag (with ~ expansion) against the process cwd. */
452
+ function resolveWorkspace(flag) {
453
+ if (!flag)
454
+ return process.cwd();
455
+ const expanded = flag.replace(/^~(?=$|\/)/, homedir());
456
+ return resolve(expanded);
457
+ }
458
+ async function configWizard(config) {
459
+ const rl = createInterface({ input, output });
460
+ try {
461
+ // The Integrations page shows one combined URL (`<host>/integrations/<id>`)
462
+ // — accept it here and derive the id, so pasting it just works.
463
+ const baseUrlDefault = combinedIntegrationUrl(config) || "https://...";
464
+ const baseUrlInput = (await rl.question(`API base URL [${baseUrlDefault}]: `)).trim() || config.baseUrl;
465
+ const { baseUrl, integrationId: derivedId } = splitIntegrationUrl(baseUrlInput);
466
+ const integrationIdDefault = derivedId ?? config.integrationId;
467
+ const integrationId = (await rl.question(`Integration ID [${integrationIdDefault || "..."}]: `)).trim() || integrationIdDefault;
468
+ const apiKey = (await rl.question(`API key [${config.apiKey ? "unchanged" : "iak_..."}]: `)).trim() || config.apiKey;
469
+ const next = { ...config, baseUrl, integrationId, apiKey };
470
+ if (baseUrl && integrationId && apiKey) {
471
+ const client = new IntegrationClient({ baseUrl, integrationId, apiKey });
472
+ try {
473
+ const status = await client.getStatus();
474
+ process.stdout.write(`Connected: ${status.label} (${status.enabled ? "enabled" : "DISABLED"})\n`);
475
+ }
476
+ catch (err) {
477
+ process.stdout.write(`Connection test failed: ${err instanceof Error ? err.message : String(err)}\n`);
478
+ const saveAnyway = (await rl.question("Save anyway? [y/N] ")).trim().toLowerCase();
479
+ if (saveAnyway !== "y" && saveAnyway !== "yes")
480
+ return 1;
481
+ }
482
+ }
483
+ // Informed consent for the approval default: AUTO runs workspace tool
484
+ // actions (shell, write, edit) unattended — only outside-workspace paths
485
+ // still prompt. The current value is the preselected answer.
486
+ const approvalHint = next.approvalDefault === "auto" ? "Y/n" : "y/N";
487
+ const approval = (await rl.question(`Auto-approve tool actions inside the workspace (shell/write/edit run unattended; outside-workspace paths always ask)? [${approvalHint}]: `)).trim().toLowerCase();
488
+ if (["y", "yes"].includes(approval))
489
+ next.approvalDefault = "auto";
490
+ if (["n", "no"].includes(approval))
491
+ next.approvalDefault = "ask";
492
+ if (!isConfigured(next)) {
493
+ process.stderr.write("Configuration incomplete — base URL, integration ID, and API key are all required. Nothing was saved.\n");
494
+ return 1;
495
+ }
496
+ await saveConfig(next);
497
+ process.stdout.write(`Saved to ${configDir()}/config.json\n`);
498
+ return 0;
499
+ }
500
+ finally {
501
+ rl.close();
502
+ }
503
+ }
504
+ if (isMainModule(import.meta)) {
505
+ main()
506
+ .then((code) => process.exit(code))
507
+ .catch((err) => {
508
+ process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
509
+ process.exit(1);
510
+ });
511
+ }
@@ -0,0 +1,123 @@
1
+ import type { AguiEvent, AguiInputMessage, AguiRunRequest, AguiToolDef, IntegrationStatus, ModelInfo, RunStreamResult, SessionDetail, SessionSummary, WorkspaceEntry, WorkspaceFile, WorkspaceWriteResult } from "./types.js";
2
+ export interface IntegrationClientOptions {
3
+ baseUrl: string;
4
+ integrationId: string;
5
+ apiKey: string;
6
+ /** Injectable fetch (tests). */
7
+ fetch?: typeof fetch;
8
+ }
9
+ /** Error thrown for non-2xx integration API responses. */
10
+ export declare class IntegrationApiError extends Error {
11
+ readonly status: number;
12
+ /** Machine-readable discriminator from the error body (e.g. `tool_call_expired`). */
13
+ readonly code?: string | undefined;
14
+ /** Additive machine-readable context (e.g. `{ expiredIds }`). */
15
+ readonly details?: Record<string, unknown> | undefined;
16
+ constructor(message: string, status: number,
17
+ /** Machine-readable discriminator from the error body (e.g. `tool_call_expired`). */
18
+ code?: string | undefined,
19
+ /** Additive machine-readable context (e.g. `{ expiredIds }`). */
20
+ details?: Record<string, unknown> | undefined);
21
+ }
22
+ /** Options for a run stream (AG-UI surface). */
23
+ export interface RunStreamOptions {
24
+ signal?: AbortSignal;
25
+ onEvent: (event: AguiEvent) => void;
26
+ /** Called as soon as the server reports the thread id (X-Thread-Id header). */
27
+ onThreadId?: (threadId: string) => void;
28
+ }
29
+ /** One client tool result as the local execution layer produces it. */
30
+ export interface ToolOutput {
31
+ tool_call_id: string;
32
+ output: string;
33
+ isError?: boolean;
34
+ images?: Array<{
35
+ data: string;
36
+ mimeType: string;
37
+ }>;
38
+ }
39
+ export declare class IntegrationClient {
40
+ readonly baseUrl: string;
41
+ readonly integrationId: string;
42
+ private readonly apiKey;
43
+ private readonly fetchImpl;
44
+ constructor(opts: IntegrationClientOptions);
45
+ private url;
46
+ private headers;
47
+ /**
48
+ * Start a run and stream its AG-UI events. The stream stays open through
49
+ * client-tool waits (heartbeats keep it alive), so it is the single line for
50
+ * the whole run: collect `CUSTOM_TOOL_CALL` events, execute the calls, then
51
+ * submit results with sendRunMessages — the continuation's events keep
52
+ * flowing on this same stream.
53
+ *
54
+ * The thread id arrives in the X-Thread-Id response header, before the first
55
+ * event, and is also latched through `onThreadId`.
56
+ */
57
+ startRun(req: AguiRunRequest, opts: RunStreamOptions): Promise<RunStreamResult>;
58
+ /**
59
+ * Continue the thread's active run: user/system messages steer it, tool-role
60
+ * messages resume it (tool results are awaited by the agent). The server
61
+ * answers with a single SSE frame — INPUT_ACCEPTED, or INPUT_REJECTED
62
+ * (thrown as IntegrationApiError with the machine-readable `code`). Run
63
+ * events keep flowing on the run stream, never on this response.
64
+ *
65
+ * Returns the persisted message id for steers (absent for tool-result
66
+ * resumes) — the caller correlates the later QUEUED_MESSAGE_DELIVERED event
67
+ * with it.
68
+ */
69
+ sendRunMessages(threadId: string, messages: AguiInputMessage[], opts?: {
70
+ tools?: AguiToolDef[];
71
+ signal?: AbortSignal;
72
+ }): Promise<string | undefined>;
73
+ getStatus(opts?: {
74
+ signal?: AbortSignal;
75
+ }): Promise<IntegrationStatus>;
76
+ /**
77
+ * Models this integration accepts (default first, `default: true` marks it).
78
+ * A forced-model integration returns exactly one entry — there is nothing to
79
+ * choose. Used by the `/model` picker.
80
+ */
81
+ listModels(): Promise<ModelInfo[]>;
82
+ listSessions(): Promise<SessionSummary[]>;
83
+ getSession(threadId: string, opts?: {
84
+ history?: boolean;
85
+ historyLimit?: number;
86
+ }): Promise<SessionDetail>;
87
+ patchSessionTitle(threadId: string, title: string, opts?: {
88
+ signal?: AbortSignal;
89
+ }): Promise<void>;
90
+ deleteSession(threadId: string): Promise<void>;
91
+ stopSession(threadId: string): Promise<void>;
92
+ compactSession(threadId: string, focus?: string): Promise<void>;
93
+ /** GET /integrations/<id>/workspace?path= — list a workspace directory. */
94
+ workspaceList(path: string, opts?: {
95
+ signal?: AbortSignal;
96
+ }): Promise<WorkspaceEntry[]>;
97
+ /** GET /integrations/<id>/workspace/read?path= — read one file (base64 when binary). */
98
+ workspaceRead(path: string, opts?: {
99
+ signal?: AbortSignal;
100
+ }): Promise<WorkspaceFile>;
101
+ /** PUT /integrations/<id>/workspace/write — create or overwrite one file. */
102
+ workspaceWrite(path: string, content: string, binary: boolean, opts?: {
103
+ signal?: AbortSignal;
104
+ }): Promise<WorkspaceWriteResult>;
105
+ /**
106
+ * Subscribe to a session's AG-UI event stream (SSE). Resolves when the
107
+ * stream ends (server close or network drop) — the caller reconnects with
108
+ * backoff. AbortError propagates so a deliberate unsubscribe is
109
+ * distinguishable from a drop.
110
+ *
111
+ * Events are delivered verbatim: `MESSAGES_SNAPSHOT` first (transcript +
112
+ * status), then live AG-UI events including `TOOL_CALL_RESULT` for
113
+ * client-executed calls (an error result means the server released the call).
114
+ */
115
+ openSessionEvents(threadId: string, opts: {
116
+ signal?: AbortSignal;
117
+ onEvent: (event: Record<string, unknown>) => void;
118
+ }): Promise<void>;
119
+ private json;
120
+ /** Build an IntegrationApiError from a non-OK response, preserving the
121
+ * machine-readable `error.code` (e.g. `tool_call_expired`). */
122
+ private apiError;
123
+ }