@vatio-ai/cli 0.37.2

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.
@@ -0,0 +1,307 @@
1
+ // `vatio mcp` — the same CLI, spoken as Model Context Protocol over stdio, so a
2
+ // coding agent drives the commands a developer would instead of writing its own
3
+ // client for the API.
4
+ //
5
+ // It wraps the CLI rather than the API on purpose: what the agent reads is
6
+ // exactly what the developer would have seen, including the advice at the end
7
+ // of a failure. Every command gets a decision recorded in one of two tables --
8
+ // COMMANDS (offered) or WITHHELD (deliberately not) -- and the default for a
9
+ // command nobody thought about is "not offered".
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
13
+ import { createInterface } from "node:readline";
14
+ import { dirname, join } from "node:path";
15
+
16
+ import { VERSION } from "../version.mjs";
17
+
18
+ const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
19
+ const MAX_OUTPUT = 20_000;
20
+ const TIMEOUT_MS = 180_000;
21
+
22
+ const COMMANDS = {
23
+ docs: {
24
+ usage: "[--save [PATH]]",
25
+ summary:
26
+ "The Vatio developer contract as markdown, live from the platform: vatio.yml, tools, auth, " +
27
+ "visitor identity, knowledge, channels, the tool result shape, the whole CLI and API. Read " +
28
+ "this before writing or changing anything in a workspace.",
29
+ auth: false,
30
+ annotations: { readOnlyHint: true, openWorldHint: true }
31
+ },
32
+ doctor: {
33
+ usage: "",
34
+ summary:
35
+ "Node, config, workspace and token status. Run this when a command fails for a reason that " +
36
+ "does not look like the workspace itself.",
37
+ auth: false,
38
+ annotations: { readOnlyHint: true }
39
+ },
40
+ tools: {
41
+ usage: "check",
42
+ summary:
43
+ "`tools check` sends the workspace to Vatio and validates it against the contracts there — " +
44
+ "manifest shape, tool specs, handler signatures — without deploying anything. The fastest " +
45
+ "way to find out whether an edit is deployable; push runs the same check anyway.",
46
+ annotations: { readOnlyHint: true }
47
+ },
48
+ status: {
49
+ usage: "",
50
+ summary: "Preview and live deployment state for this workspace.",
51
+ annotations: { readOnlyHint: true }
52
+ },
53
+ diff: {
54
+ usage: "[--env NAME] [--stat|--name-only|--format json|--full]",
55
+ summary:
56
+ "What this directory would change against a deployment — preview by default. `--env live` " +
57
+ "answers \"what would publishing change?\" without publishing.",
58
+ annotations: { readOnlyHint: true }
59
+ },
60
+ push: {
61
+ usage: "[--env NAME]",
62
+ summary:
63
+ "Validate the workspace and update a preview deployment. Never touches live. `--env NAME` " +
64
+ "lands it on its own named preview (one per pull request) instead of the default one.",
65
+ annotations: { readOnlyHint: false }
66
+ },
67
+ publish: {
68
+ usage: "[--env NAME]",
69
+ summary:
70
+ "Promote a preview to live, which is what customers see. Ask the developer first: this is " +
71
+ "the one step that changes what real visitors get.",
72
+ annotations: { readOnlyHint: false, destructiveHint: true }
73
+ },
74
+ rollback: {
75
+ usage: "",
76
+ summary: "Restore the previous live deployment. Same weight as publish — ask first.",
77
+ annotations: { readOnlyHint: false, destructiveHint: true }
78
+ },
79
+ chat: {
80
+ usage: '"message" | transcript | debug | reset [--env NAME]',
81
+ summary:
82
+ "Talk to the deployed agent as the developer holding the token, and read the transcript. " +
83
+ "`debug` shows the developer view, including tool calls. `--env live` is a real conversation " +
84
+ "that appears in the inbox.",
85
+ annotations: { readOnlyHint: false }
86
+ },
87
+ secrets: {
88
+ usage: "list | set KEY VALUE | rm KEY",
89
+ summary:
90
+ "Credentials the workspace's JS tools read as ctx.env. Values are write-only — listing shows " +
91
+ "keys, never values.",
92
+ annotations: { readOnlyHint: false }
93
+ },
94
+ kb: {
95
+ usage: "[list] | show NAME | create NAME | add-source BASE NAME URL | upload BASE FILE... | reindex BASE",
96
+ summary:
97
+ "Knowledge bases, which belong to the workspace rather than to a deployment: `knowledge:` in " +
98
+ "vatio.yml is a list of names, and a push neither fills nor empties one.",
99
+ annotations: { readOnlyHint: false, openWorldHint: true }
100
+ },
101
+ tokens: {
102
+ usage: "list | create [--env NAME] [--label NAME] | revoke PREFIX",
103
+ summary:
104
+ "Publishable tokens for the widget and anything built on the SDK. A created token is shown in " +
105
+ "full exactly once.",
106
+ annotations: { readOnlyHint: false }
107
+ },
108
+ widget: {
109
+ usage: "[--env NAME]",
110
+ summary:
111
+ "What the platform will actually enforce for the widget, the origin allowlist, and the tokens " +
112
+ "that exist. Read-only: vatio.yml owns every field, so push is what changes it.",
113
+ annotations: { readOnlyHint: true }
114
+ },
115
+ whatsapp: {
116
+ usage: "[status] | check | activate | deactivate | numbers [list | add PHONE | verify PHONE CODE]",
117
+ summary:
118
+ "The workspace's own WhatsApp number, which serves live: whether it is connected, whether Meta " +
119
+ "delivers to it, and whether it is answering — three things that fail separately. `numbers` is " +
120
+ "the other thing: the shared WhatsApp preview, test phones that reach preview and need no Meta " +
121
+ "account. A code goes to the handset, so a human has to read it back to you.",
122
+ annotations: { readOnlyHint: false, openWorldHint: true }
123
+ },
124
+ instagram: {
125
+ usage: "[status] | check | accounts [list | add @HANDLE | verify CODE]",
126
+ summary:
127
+ "The workspace's own Instagram account, which serves live, and the shared Instagram preview " +
128
+ "under `accounts`. Registering a test account goes handle first, then the developer DMs the " +
129
+ "preview account from it and Vatio replies with a code. `instagram connect` is not here — it " +
130
+ "needs Meta's consent screen in a browser.",
131
+ annotations: { readOnlyHint: false, openWorldHint: true }
132
+ }
133
+ };
134
+
135
+ // Named rather than merely absent, so the reason travels with the refusal.
136
+ const WITHHELD = {
137
+ login: "needs a browser and a person approving a code",
138
+ logout: "would take the developer's credential away from them",
139
+ init: "creates a remote workspace; the developer decides that one",
140
+ issue: "writes to a human's inbox in the developer's name",
141
+ config: "edits the developer's own credentials file",
142
+ version: "nothing an agent needs; `doctor` says more",
143
+ help: "the tool list is this",
144
+ mcp: "this"
145
+ };
146
+
147
+ const BLOCKED_SUBCOMMANDS = {
148
+ chat: { destroy: "destroys a conversation; ask the developer" },
149
+ kb: { rm: "deletes an indexed base; ask the developer" },
150
+ whatsapp: { connect: "takes Meta credentials the developer holds", disconnect: "ask the developer" },
151
+ instagram: { connect: "needs Meta's consent screen in a browser", disconnect: "ask the developer" }
152
+ };
153
+
154
+ const toolName = (command) => `vatio_${command}`;
155
+
156
+ export async function mcp(config) {
157
+ const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "vatio.mjs");
158
+ const reader = createInterface({ input: process.stdin, crlfDelay: Infinity });
159
+
160
+ for await (const line of reader) {
161
+ if (line.trim() === "") continue;
162
+
163
+ let message;
164
+ try {
165
+ message = JSON.parse(line);
166
+ } catch {
167
+ continue;
168
+ }
169
+
170
+ const response = await handle(message, { config, cliPath });
171
+ // A notification has no id and gets no reply, which is the whole of the
172
+ // difference between the two in JSON-RPC.
173
+ if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
174
+ }
175
+ }
176
+
177
+ async function handle(message, context) {
178
+ const id = message.id;
179
+ if (id === undefined || id === null) return null;
180
+
181
+ const reply = (result) => ({ jsonrpc: "2.0", id, result });
182
+
183
+ switch (message.method) {
184
+ case "initialize":
185
+ return reply(initializeResult(message.params));
186
+ case "ping":
187
+ return reply({});
188
+ case "tools/list":
189
+ return reply({ tools: toolDefinitions() });
190
+ case "tools/call":
191
+ return reply(await callTool(message.params ?? {}, context));
192
+ default:
193
+ return { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${message.method}` } };
194
+ }
195
+ }
196
+
197
+ function initializeResult(params) {
198
+ const requested = params?.protocolVersion;
199
+ const version = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0];
200
+
201
+ return {
202
+ protocolVersion: version,
203
+ capabilities: { tools: { listChanged: false } },
204
+ serverInfo: { name: "vatio", version: VERSION },
205
+ instructions: [
206
+ "These tools run the Vatio CLI. A workspace is any directory holding a vatio.yml, and that",
207
+ "directory decides which remote it deploys to — there is no workspace flag. Pass workspace_dir",
208
+ "when the workspace is not the directory this server started in.",
209
+ "",
210
+ "Start with vatio_docs: it is the contract for vatio.yml, tools, auth, visitor identity and",
211
+ "knowledge, fetched live from the platform, so it is what the platform actually accepts.",
212
+ "",
213
+ "The loop is: edit files, vatio_push (preview only), vatio_chat to read the agent's answer,",
214
+ "vatio_publish when the developer says so. Nothing a customer sees changes until publish.",
215
+ "",
216
+ "Anything that needs a browser or a person — login, issue, instagram connect — is not here;",
217
+ "ask the developer to run it in their terminal."
218
+ ].join("\n")
219
+ };
220
+ }
221
+
222
+ function toolDefinitions() {
223
+ return Object.entries(COMMANDS).map(([command, spec]) => ({
224
+ name: toolName(command),
225
+ description: `${spec.summary}\n\nUsage: ${usageLine(command, spec)}`,
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ args: { type: "array", items: { type: "string" }, description: `Arguments after \`vatio ${command}\`` },
230
+ workspace_dir: { type: "string", description: "Directory holding vatio.yml; defaults to the cwd" }
231
+ },
232
+ required: []
233
+ },
234
+ annotations: spec.annotations ?? {}
235
+ }));
236
+ }
237
+
238
+ function usageLine(command, spec) {
239
+ return spec.usage ? `vatio ${command} ${spec.usage}` : `vatio ${command}`;
240
+ }
241
+
242
+ async function callTool(params, { config, cliPath }) {
243
+ const name = params.name;
244
+ const command = Object.keys(COMMANDS).find((key) => toolName(key) === name);
245
+ if (!command) {
246
+ const withheld = Object.keys(WITHHELD).find((key) => toolName(key) === name);
247
+ if (withheld) return errorResult(`\`vatio ${withheld}\` is not offered here: ${WITHHELD[withheld]}.`);
248
+ return errorResult(`Unknown tool ${name}. Offered: ${Object.keys(COMMANDS).map(toolName).join(", ")}`);
249
+ }
250
+
251
+ const args = Array.isArray(params.arguments?.args) ? params.arguments.args.map(String) : [];
252
+ const blocked = BLOCKED_SUBCOMMANDS[command]?.[args[0]];
253
+ if (blocked) return errorResult(`\`vatio ${command} ${args[0]}\` is not offered here: ${blocked}.`);
254
+
255
+ if (COMMANDS[command].auth !== false && !config.resolveToken()) {
256
+ return errorResult(
257
+ "Not logged in: no token in VATIO_TOKEN or ~/.vatio/config.json. Ask the developer to run " +
258
+ "`vatio login` in their terminal — the device authorization needs a browser — and this " +
259
+ "server picks the token up from there."
260
+ );
261
+ }
262
+
263
+ const dir = params.arguments?.workspace_dir ?? process.cwd();
264
+ const { stdout, stderr, code, timedOut } = await run(cliPath, [command, ...args], dir);
265
+
266
+ if (timedOut) return errorResult(`\`vatio ${command}\` did not finish within ${TIMEOUT_MS / 1000}s.`);
267
+
268
+ const body = [stdout, stderr].map((part) => part.trim()).filter(Boolean).join("\n");
269
+ if (code !== 0) return errorResult(`${body}\n\n\`vatio ${command}\` exited ${code}.`.trim());
270
+ return { content: [{ type: "text", text: truncate(body) }], isError: false };
271
+ }
272
+
273
+ function run(cliPath, args, cwd) {
274
+ return new Promise((resolvePromise) => {
275
+ const child = spawn(process.execPath, [cliPath, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
276
+ let stdout = "";
277
+ let stderr = "";
278
+ let timedOut = false;
279
+
280
+ const timer = setTimeout(() => {
281
+ timedOut = true;
282
+ child.kill("SIGKILL");
283
+ }, TIMEOUT_MS);
284
+
285
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
286
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
287
+ child.on("error", (error) => {
288
+ clearTimeout(timer);
289
+ resolvePromise({ stdout, stderr: String(error.message), code: 1, timedOut });
290
+ });
291
+ child.on("close", (code) => {
292
+ clearTimeout(timer);
293
+ resolvePromise({ stdout, stderr, code: code ?? 1, timedOut });
294
+ });
295
+ });
296
+ }
297
+
298
+ // A failure is a tool result, not a protocol error: exiting or raising would
299
+ // take the whole session down over one bad push.
300
+ function errorResult(message) {
301
+ return { content: [{ type: "text", text: truncate(message) }], isError: true };
302
+ }
303
+
304
+ function truncate(text) {
305
+ const value = String(text ?? "");
306
+ return value.length <= MAX_OUTPUT ? value : `${value.slice(0, MAX_OUTPUT - 3)}...`;
307
+ }
@@ -0,0 +1,148 @@
1
+ // version / doctor / config / init.
2
+
3
+ import { existsSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { stringify as stringifyYaml } from "yaml";
6
+
7
+ import { KEYS, SLUG_FORMAT } from "../config.mjs";
8
+ import { MANIFEST_FILE, manifestPath } from "../workspace.mjs";
9
+ import { VERSION } from "../version.mjs";
10
+ import { fail, takeValue } from "../support.mjs";
11
+ import { deviceLogin } from "./auth.mjs";
12
+ import { workspacesClient } from "./deploy.mjs";
13
+
14
+ export const DOCS_URL = "https://vatio.ai/docs";
15
+
16
+ export function version() {
17
+ console.log(`Vatio CLI ${VERSION} (node)`);
18
+ console.log(`Node ${process.version} (${process.platform}-${process.arch})`);
19
+ }
20
+
21
+ // What to read when a command fails for a reason that does not look like the
22
+ // workspace itself.
23
+ export function doctor(config) {
24
+ version();
25
+ console.log(`CLI executable: ${process.argv[1]}`);
26
+ console.log(`Node executable: ${process.execPath}`);
27
+ console.log(`Base URL: ${config.resolveBaseUrl()}`);
28
+ console.log(
29
+ `Config: ${config.configPath}${existsSync(config.configPath) ? "" : " (missing — run `vatio login`)"}`
30
+ );
31
+ console.log(`Workspace root: ${config.workspaceRoot ?? "(none — no vatio.yml at or above the cwd)"}`);
32
+ const manifest = config.manifestPath();
33
+ if (manifest) console.log(`Manifest: ${manifest}`);
34
+ let slug = null;
35
+ try {
36
+ slug = config.resolveWorkspace();
37
+ } catch (error) {
38
+ console.log(`Workspace: unreadable — ${error.message}`);
39
+ }
40
+ if (slug) console.log(`Workspace: ${slug}`);
41
+ console.log(`Token: ${config.resolveToken() ? "configured" : "missing — run `vatio login`"}`);
42
+ console.log(`Docs: ${DOCS_URL}`);
43
+ }
44
+
45
+ export function configCommand(config, args) {
46
+ const sub = args.shift();
47
+
48
+ if (sub === "show" || sub === undefined) {
49
+ const shown = config.displayHash();
50
+ if (Object.keys(shown).length === 0) {
51
+ console.log(`No config in ${config.configPath}`);
52
+ return;
53
+ }
54
+ for (const [key, value] of Object.entries(shown)) console.log(`${key}=${value}`);
55
+ return;
56
+ }
57
+ if (sub === "get") {
58
+ const value = config.get(requireArg(args, "config get KEY"));
59
+ if (value) console.log(value);
60
+ return;
61
+ }
62
+ if (sub === "set") {
63
+ const key = requireArg(args, "config set KEY VALUE");
64
+ const value = requireArg(args, "config set KEY VALUE");
65
+ config.set(key, value);
66
+ console.log(`Set ${key}`);
67
+ return;
68
+ }
69
+ if (sub === "unset") {
70
+ const key = requireArg(args, "config unset KEY");
71
+ config.unset(key);
72
+ console.log(`Unset ${key}`);
73
+ return;
74
+ }
75
+
76
+ fail(`Unknown config command: ${sub}\n\nKeys: ${KEYS.join(", ")}`);
77
+ }
78
+
79
+ // `vatio init` writes the one file that makes a directory a workspace, and
80
+ // names the remote it deploys to. Nothing constrains where that directory is --
81
+ // the point of the file is that an agent can live inside the repository of the
82
+ // backend it calls, not in a separate folder of agents.
83
+ export async function init(config, args) {
84
+ const baseUrl = takeValue(args, "--base-url");
85
+ const name = takeValue(args, "--name");
86
+
87
+ const target = process.cwd();
88
+ const existing = manifestPath(target);
89
+ if (existing) fail(`Already a workspace: ${existing}`);
90
+
91
+ // A workspace inside a workspace would shadow the outer one for every command
92
+ // run below it, and no deploy would ever include it.
93
+ if (config.workspaceRoot) {
94
+ fail(`${target} is already inside the workspace at ${config.workspaceRoot}.\nRun \`vatio init\` somewhere outside it.`);
95
+ }
96
+
97
+ let slug = String(args.shift() ?? "").trim().toLowerCase();
98
+ if (slug === "") slug = config.suggestedSlug();
99
+ if (!SLUG_FORMAT.test(slug)) {
100
+ fail(`Invalid slug "${slug}" (use lowercase letters, numbers, hyphens)`);
101
+ }
102
+
103
+ // Logging in first means the remote is created against a base_url the
104
+ // developer has actually authorized, and a fresh machine gets through
105
+ // `vatio init` in one command instead of two.
106
+ if (!config.resolveToken()) {
107
+ const auth = await deviceLogin({ baseUrl, message: "Starting device authorization for Vatio CLI…" });
108
+ config.writeAuth({ baseUrl: auth.baseUrl, token: auth.token });
109
+ console.log(`Token saved to ${config.configPath}`);
110
+ console.log("");
111
+ }
112
+
113
+ const clients = workspacesClient(config);
114
+ const created = !(await clients.workspaceExists(slug));
115
+ if (created) await clients.createWorkspace({ slug, name });
116
+
117
+ const manifest = join(target, MANIFEST_FILE);
118
+ writeFileSync(manifest, starterManifest(slug, name));
119
+
120
+ console.log(`${created ? "Created" : "Linked"} remote workspace ${slug}`);
121
+ console.log(`Wrote ${manifest}`);
122
+ console.log("");
123
+ console.log("Next:");
124
+ console.log(` edit ${MANIFEST_FILE}, then \`vatio push\` and \`vatio publish\``);
125
+ console.log(` docs: ${DOCS_URL}`);
126
+ }
127
+
128
+ // The smallest file that deploys: which workspace, and what the agent does.
129
+ // The slug is already validated as a slug, so it needs no quoting; a display
130
+ // name is whatever the developer typed, so it goes through the YAML emitter.
131
+ function starterManifest(slug, name) {
132
+ const lines = ["# Which Vatio workspace this directory deploys to.", `workspace: ${slug}`, ""];
133
+ if (String(name ?? "").trim() !== "") {
134
+ lines.push(stringifyYaml({ business: { name: String(name).trim() } }).trimEnd());
135
+ lines.push("");
136
+ }
137
+ lines.push("agent:");
138
+ lines.push(" instructions: |");
139
+ lines.push(" Describe what this agent does and how it should answer.");
140
+ lines.push("");
141
+ return lines.join("\n");
142
+ }
143
+
144
+ function requireArg(args, usage) {
145
+ const value = args.shift();
146
+ if (value === undefined) fail(`Usage: vatio ${usage}`);
147
+ return value;
148
+ }
@@ -0,0 +1,147 @@
1
+ // docs / issue — the two commands that are about Vatio rather than about a
2
+ // deployment.
3
+
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+
7
+ import { HttpClient } from "../http.mjs";
8
+ import { VERSION } from "../version.mjs";
9
+ import { fail, takeFlag, takeValue } from "../support.mjs";
10
+ import { DOCS_URL } from "./misc.mjs";
11
+
12
+ // The whole developer contract as markdown, live from the platform. Fetched
13
+ // rather than bundled: the docs describe the deploy you are talking to, and a
14
+ // copy shipped inside this package would start lying the day either moves.
15
+ export async function docs(config, args) {
16
+ const save = takeFlag(args, "--save");
17
+ const savePath = takeValue(args, "--save-to") ?? (save ? args.shift() ?? null : null);
18
+
19
+ const baseUrl = config.resolveBaseUrl();
20
+ let markdown;
21
+ try {
22
+ const response = await fetch(`${baseUrl}/docs.md`, {
23
+ headers: { Accept: "text/markdown, text/plain" },
24
+ signal: AbortSignal.timeout(30_000)
25
+ });
26
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
27
+ markdown = await response.text();
28
+ } catch (error) {
29
+ fail(`Could not fetch the docs: ${error.message}\nRead them at ${DOCS_URL} instead.`);
30
+ }
31
+
32
+ if (!save) {
33
+ process.stdout.write(markdown);
34
+ return;
35
+ }
36
+
37
+ const target = resolve(savePath ?? "vatio-docs.md");
38
+ mkdirSync(dirname(target), { recursive: true });
39
+ writeFileSync(target, markdown);
40
+ console.log(`Wrote ${target} (${markdown.split("\n").length} lines, from ${baseUrl})`);
41
+ console.log("Point your coding agent at it, and re-run `vatio docs --save` to refresh.");
42
+ }
43
+
44
+ function issuesClient(config) {
45
+ const token = config.resolveToken();
46
+ if (!token) fail(`Run \`vatio login\` first (no token in ${config.configPath})`);
47
+ return new HttpClient({ baseUrl: config.resolveBaseUrl(), token });
48
+ }
49
+
50
+ // `vatio issue` is a change worth making, written against a template the
51
+ // platform also validates against. It is not an issue tracker: there are no
52
+ // states, labels, assignees or backlog, and that is on purpose.
53
+ export async function issue(config, args) {
54
+ const sub = args[0];
55
+ if (sub === "list") return await issueList(config);
56
+ if (sub === "show") return await issueShow(config, args[1]);
57
+ if (sub === "comment") return await issueComment(config, args[1], args.slice(2));
58
+
59
+ if (takeFlag(args, "--template")) return await issueTemplate(config);
60
+
61
+ const file = takeValue(args, "--file");
62
+ const workspace = takeValue(args, "--workspace") ?? safeWorkspace(config);
63
+
64
+ // Before anything is composed: nothing is more annoying than writing one of
65
+ // these and only then being told to log in.
66
+ const client = issuesClient(config);
67
+
68
+ const body = { cli_version: VERSION };
69
+ if (workspace) body.workspace = workspace;
70
+ if (file) {
71
+ body.markdown = readFileSync(file, "utf8");
72
+ } else {
73
+ const message = args.join(" ").trim();
74
+ if (message === "") {
75
+ fail(
76
+ "Usage: vatio issue \"what should change\"\n" +
77
+ " vatio issue --file ISSUE.md\n" +
78
+ " vatio issue --template # the shape it is read against"
79
+ );
80
+ }
81
+ body.message = message;
82
+ }
83
+
84
+ const payload = await client.post("/cli/issues", body);
85
+ console.log(`Sent issue ${payload.id}.`);
86
+ if (payload.url) console.log(` ${payload.url}`);
87
+ console.log("A person reads it and answers by email; `vatio issue list` shows the thread.");
88
+ }
89
+
90
+ async function issueTemplate(config) {
91
+ const response = await fetch(`${config.resolveBaseUrl()}/cli/issue_template`, {
92
+ headers: { Accept: "text/markdown, text/plain" },
93
+ signal: AbortSignal.timeout(30_000)
94
+ });
95
+ if (!response.ok) fail(`Could not fetch the template (HTTP ${response.status})`);
96
+ process.stdout.write(await response.text());
97
+ }
98
+
99
+ async function issueList(config) {
100
+ const payload = await issuesClient(config).get("/cli/issues");
101
+ const issues = Array.isArray(payload.issues) ? payload.issues : [];
102
+ if (issues.length === 0) {
103
+ console.log("You have not sent any issues yet.");
104
+ console.log(' Send one: vatio issue "what should change"');
105
+ return;
106
+ }
107
+ console.log(`Your issues (${issues.length}):`);
108
+ for (const row of issues) {
109
+ console.log(` [${row.id}] ${row.status ?? ""} ${row.title ?? row.summary ?? ""}`.trimEnd());
110
+ }
111
+ }
112
+
113
+ // The markdown view, which is the same document Vatio's own triage agent
114
+ // reads -- the whole conversation included, so a coding agent can pick the
115
+ // thread up where a person left it.
116
+ async function issueShow(config, id) {
117
+ if (!id) fail("Usage: vatio issue show ID");
118
+
119
+ const response = await fetch(`${config.resolveBaseUrl()}/cli/issues/${encodeURIComponent(id)}.md`, {
120
+ headers: {
121
+ Accept: "text/markdown, text/plain",
122
+ Authorization: `Bearer ${config.resolveToken() ?? ""}`
123
+ },
124
+ signal: AbortSignal.timeout(30_000)
125
+ });
126
+ if (!response.ok) fail(`Could not read issue ${id} (HTTP ${response.status})`);
127
+ process.stdout.write(await response.text());
128
+ }
129
+
130
+ async function issueComment(config, id, rest) {
131
+ if (!id) fail("Usage: vatio issue comment ID \"your reply\"");
132
+ const body = rest.join(" ").trim();
133
+ if (body === "") fail("Usage: vatio issue comment ID \"your reply\"");
134
+
135
+ await issuesClient(config).post(`/cli/issues/${encodeURIComponent(id)}/comments`, { body });
136
+ console.log(`Replied on issue ${id}.`);
137
+ }
138
+
139
+ // A workspace is useful context on an issue but never required: `vatio issue`
140
+ // has to work from anywhere, including a directory that is not a workspace.
141
+ function safeWorkspace(config) {
142
+ try {
143
+ return config.resolveWorkspace();
144
+ } catch {
145
+ return null;
146
+ }
147
+ }