@voltade/volty 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @voltade/volty
2
+
3
+ The Volty command-line tool for [Claude Code](https://claude.com/claude-code). You set it up once; after that, you don't use it directly — **Claude does**. It lets Claude test the Volty agent for real (safely, in a sandbox — nothing ever reaches a real contact) and look inside what the agent saw and decided.
4
+
5
+ ## One-time setup
6
+
7
+ Open a terminal and run these four steps.
8
+
9
+ **1. Install Bun** (skip if you already have it):
10
+
11
+ ```sh
12
+ curl -fsSL https://bun.sh/install | bash
13
+ ```
14
+
15
+ Then close and reopen your terminal.
16
+
17
+ **2. Install the Volty tool:**
18
+
19
+ ```sh
20
+ bun add -g @voltade/volty
21
+ ```
22
+
23
+ **3. Teach Claude Code how to use it:**
24
+
25
+ ```sh
26
+ volty skill install
27
+ ```
28
+
29
+ **4. Log in** — this opens your browser; approve it with your normal Volty account:
30
+
31
+ ```sh
32
+ volty auth login --env prod
33
+ ```
34
+
35
+ To check it worked: `volty auth whoami --env prod` should print your email, org, and role.
36
+
37
+ That's it. You never need to memorize any other commands.
38
+
39
+ ## Using it — just ask Claude
40
+
41
+ Open Claude Code anywhere and ask in plain language. The skill you installed in step 3 kicks in automatically. For example:
42
+
43
+ - *"Test the Volty agent — ask it whether we're GST registered."*
44
+ - *"Send the agent a follow-up in that same test conversation."*
45
+ - *"What did the agent actually reply with, and why? Show me what the LLM decided."*
46
+ - *"Why didn't the agent learn anything from that conversation?"*
47
+ - *"Clean up the test conversations on prod."*
48
+
49
+ Claude knows the safety rules: tests run against a sandboxed draft version of the agent with a synthetic contact, so nothing can ever be sent to a real contact, and Claude cleans up after itself when it's done.
50
+
51
+ ## Upgrading
52
+
53
+ When a newer version exists, the tool prints a notice — Claude will see it and upgrade for you. To do it yourself:
54
+
55
+ ```sh
56
+ bun add -g @voltade/volty@latest && volty skill install
57
+ ```
package/dist/volty.js ADDED
@@ -0,0 +1,868 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/volty.ts
4
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
5
+ import { homedir as homedir3 } from "node:os";
6
+ import { join as join4 } from "node:path";
7
+
8
+ // ../shared/ops/retired-prefix.ts
9
+ function retiredPrefix(retired, argv) {
10
+ for (let n = Math.min(argv.length, 4);n >= 1; n--) {
11
+ const name = argv.slice(0, n).join(" ");
12
+ const replacement = retired[name];
13
+ if (replacement !== undefined)
14
+ return { name, replacement };
15
+ }
16
+ return null;
17
+ }
18
+
19
+ // src/api.ts
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ var CATALOG_TTL_MS = 5 * 60 * 1000;
24
+ var targetOrg;
25
+ function setOrgOverride(org) {
26
+ targetOrg = org;
27
+ }
28
+ function authHeaders(ctx) {
29
+ const headers = {};
30
+ if (ctx.apiKey)
31
+ headers.authorization = `Bearer ${ctx.apiKey}`;
32
+ if (targetOrg)
33
+ headers["x-volty-org"] = targetOrg;
34
+ return headers;
35
+ }
36
+ async function apiFetch(ctx, path, init) {
37
+ if (!ctx.baseUrl)
38
+ throw new Error("no baseUrl for this context — run `volty env use <env> --base-url <url>`");
39
+ const res = await fetch(`${ctx.baseUrl}${path}`, {
40
+ method: init?.method ?? "GET",
41
+ headers: {
42
+ "content-type": "application/json",
43
+ ...authHeaders(ctx),
44
+ ...init?.headers
45
+ },
46
+ body: init?.body !== undefined ? JSON.stringify(init.body) : undefined
47
+ });
48
+ const text = await res.text();
49
+ let body = text;
50
+ try {
51
+ body = text ? JSON.parse(text) : null;
52
+ } catch {}
53
+ return { ok: res.ok, status: res.status, body };
54
+ }
55
+ var cachePath = (env) => join(tmpdir(), `volty-catalog-${env}-${targetOrg ?? "default"}.json`);
56
+ async function fetchCatalog(ctx, env, useCache = true) {
57
+ const path = cachePath(env);
58
+ if (useCache && existsSync(path)) {
59
+ try {
60
+ const cached = JSON.parse(readFileSync(path, "utf8"));
61
+ if (Date.now() - cached.at < CATALOG_TTL_MS && cached.retired)
62
+ return { ops: cached.ops, retired: cached.retired };
63
+ } catch {}
64
+ }
65
+ const res = await apiFetch(ctx, "/api/v/_catalog");
66
+ if (!res.ok || !res.body.catalog) {
67
+ throw new Error(`could not fetch the operation catalog (HTTP ${res.status})${res.status === 401 ? " — run `volty auth login`" : ""}`);
68
+ }
69
+ const ops = res.body.catalog;
70
+ const retired = res.body.retired ?? {};
71
+ try {
72
+ mkdirSync(tmpdir(), { recursive: true });
73
+ writeFileSync(path, JSON.stringify({ at: Date.now(), ops, retired }));
74
+ } catch {}
75
+ return { ops, retired };
76
+ }
77
+ async function fetchOrgs(ctx, env, useCache = true) {
78
+ const path = join(tmpdir(), `volty-orgs-${env}.json`);
79
+ if (useCache && existsSync(path)) {
80
+ try {
81
+ const cached = JSON.parse(readFileSync(path, "utf8"));
82
+ if (Date.now() - cached.at < CATALOG_TTL_MS)
83
+ return cached.orgs;
84
+ } catch {}
85
+ }
86
+ const res = await apiFetch(ctx, "/api/cli/orgs");
87
+ if (!res.ok || !res.body.orgs) {
88
+ throw new Error(`could not fetch your org memberships (HTTP ${res.status})${res.status === 401 ? " - run `volty auth login`" : ""}`);
89
+ }
90
+ const orgs = res.body.orgs;
91
+ try {
92
+ writeFileSync(path, JSON.stringify({ at: Date.now(), orgs }));
93
+ } catch {}
94
+ return orgs;
95
+ }
96
+ function pathForOp(name) {
97
+ return `/api/v/${name.trim().split(/\s+/).join("/")}`;
98
+ }
99
+
100
+ // src/auth.ts
101
+ import { spawn } from "node:child_process";
102
+
103
+ // src/config.ts
104
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
105
+ import { homedir } from "node:os";
106
+ import { dirname, join as join2 } from "node:path";
107
+ var DEFAULT_BASE_URLS = {
108
+ dev: "http://localhost:3000",
109
+ prod: "https://volty.voltade.com"
110
+ };
111
+ var globalConfigPath = () => join2(homedir(), ".config", "volty", "config.json");
112
+ function localConfigPath() {
113
+ let dir = process.cwd();
114
+ const home = homedir();
115
+ for (;; ) {
116
+ const candidate = join2(dir, ".volty", "config.json");
117
+ if (existsSync2(candidate))
118
+ return candidate;
119
+ if (existsSync2(join2(dir, ".git")) || dir === home)
120
+ return null;
121
+ const parent = dirname(dir);
122
+ if (parent === dir)
123
+ return null;
124
+ dir = parent;
125
+ }
126
+ }
127
+ function readJson(path) {
128
+ try {
129
+ return JSON.parse(readFileSync2(path, "utf8"));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ function emptyConfig() {
135
+ return { contexts: {} };
136
+ }
137
+ function loadConfig() {
138
+ const global = readJson(globalConfigPath()) ?? emptyConfig();
139
+ const localPath = localConfigPath();
140
+ const local = localPath ? readJson(localPath) : null;
141
+ if (!local)
142
+ return global;
143
+ const merged = {
144
+ current: local.current ?? global.current,
145
+ contexts: { ...global.contexts }
146
+ };
147
+ for (const [name, ctx] of Object.entries(local.contexts)) {
148
+ merged.contexts[name] = { ...merged.contexts[name], ...ctx };
149
+ }
150
+ return merged;
151
+ }
152
+ function saveConfig(config) {
153
+ const path = globalConfigPath();
154
+ mkdirSync2(dirname(path), { recursive: true });
155
+ writeFileSync2(path, `${JSON.stringify(config, null, 2)}
156
+ `, { mode: 384 });
157
+ }
158
+ function activeEnv(config, envFlag) {
159
+ if (envFlag)
160
+ return envFlag;
161
+ if (config.current)
162
+ return config.current;
163
+ const names = Object.keys(config.contexts);
164
+ return names.length === 1 ? names[0] : "dev";
165
+ }
166
+ function ensureContext(config, env) {
167
+ const existing = config.contexts[env];
168
+ if (existing)
169
+ return existing;
170
+ const ctx = { baseUrl: DEFAULT_BASE_URLS[env] ?? "" };
171
+ config.contexts[env] = ctx;
172
+ return ctx;
173
+ }
174
+
175
+ // src/auth.ts
176
+ async function whoami(ctx) {
177
+ if (!ctx.apiKey)
178
+ return null;
179
+ const res = await apiFetch(ctx, "/api/cli/whoami");
180
+ if (!res.ok || !res.body.organizationId)
181
+ return null;
182
+ ctx.organizationId = res.body.organizationId;
183
+ ctx.principal = res.body.email ?? res.body.name ?? res.body.userId;
184
+ return res.body;
185
+ }
186
+ function openBrowser(url) {
187
+ const cmd = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", url] : ["xdg-open", url];
188
+ try {
189
+ const child = spawn(cmd[0], cmd.slice(1), { detached: true, stdio: "ignore" });
190
+ child.on("error", () => {});
191
+ child.unref();
192
+ } catch {}
193
+ }
194
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
195
+ async function deviceGrant(ctx) {
196
+ const start = await apiFetch(ctx, "/api/cli/grant", { method: "POST", body: {} });
197
+ if (!start.ok || !start.body.code || !start.body.verifyUrl) {
198
+ throw new Error(`could not start the device grant (HTTP ${start.status})`);
199
+ }
200
+ const { code, verifyUrl } = start.body;
201
+ process.stdout.write(`
202
+ Authorize this CLI in your browser:
203
+ ${verifyUrl}
204
+
205
+ Waiting for approval…
206
+ `);
207
+ openBrowser(verifyUrl);
208
+ const deadline = Date.now() + 5 * 60 * 1000;
209
+ while (Date.now() < deadline) {
210
+ await sleep(2000);
211
+ const poll = await apiFetch(ctx, `/api/cli/grant/poll?code=${encodeURIComponent(code)}`);
212
+ if (poll.status === 404)
213
+ throw new Error("the authorization request expired — run `volty auth login` again");
214
+ if (poll.body.status === "approved" && poll.body.apiKey) {
215
+ if (poll.body.organizationId)
216
+ ctx.organizationId = poll.body.organizationId;
217
+ return poll.body.apiKey;
218
+ }
219
+ }
220
+ throw new Error("timed out waiting for browser approval");
221
+ }
222
+ async function devGrant(ctx, persona, org) {
223
+ const res = await apiFetch(ctx, "/api/cli/dev-grant", {
224
+ method: "POST",
225
+ body: { persona, ...org ? { org } : {} }
226
+ });
227
+ if (res.status === 404)
228
+ return null;
229
+ if (!res.ok || !res.body.apiKey) {
230
+ throw new Error(res.body?.error?.message ?? `the dev grant failed (HTTP ${res.status})`);
231
+ }
232
+ if (res.body.organizationId)
233
+ ctx.organizationId = res.body.organizationId;
234
+ return res.body.apiKey;
235
+ }
236
+ async function login(config, env, opts) {
237
+ const ctx = ensureContext(config, env);
238
+ if (opts.baseUrl)
239
+ ctx.baseUrl = opts.baseUrl;
240
+ if (!ctx.baseUrl)
241
+ throw new Error(`no base URL for '${env}' — pass --base-url <url>`);
242
+ if (!opts.rotate && await whoami(ctx)) {
243
+ return `Already authenticated to '${env}' as ${ctx.principal ?? "(unknown)"} — reusing the stored key.`;
244
+ }
245
+ if (opts.token) {
246
+ ctx.apiKey = opts.token;
247
+ const who = await whoami(ctx);
248
+ if (!who)
249
+ throw new Error("the provided --token did not validate");
250
+ return `Authenticated to '${env}' as ${ctx.principal ?? "(unknown)"} (org ${ctx.organizationId}).`;
251
+ }
252
+ if (opts.devPersona) {
253
+ const key = await devGrant(ctx, opts.devPersona, opts.org);
254
+ if (key) {
255
+ ctx.apiKey = key;
256
+ const who = await whoami(ctx);
257
+ return `Authenticated to '${env}' as ${who?.email ?? ctx.organizationId ?? "(unknown)"} (dev persona ${opts.devPersona}).`;
258
+ }
259
+ process.stdout.write(`The '${env}' server is not in development mode, so --as has no dev grant to use. Falling back to the browser grant.
260
+ `);
261
+ }
262
+ ctx.apiKey = await deviceGrant(ctx);
263
+ const who = await whoami(ctx);
264
+ return `Authenticated to '${env}' as ${who?.email ?? ctx.organizationId ?? "(unknown)"}.`;
265
+ }
266
+ async function logout(config, env) {
267
+ const ctx = config.contexts[env];
268
+ if (!ctx?.apiKey)
269
+ return `No stored key for '${env}'.`;
270
+ await apiFetch(ctx, "/api/cli/logout", { method: "POST", body: {} });
271
+ ctx.apiKey = undefined;
272
+ ctx.principal = undefined;
273
+ return `Logged out of '${env}' (key revoked).`;
274
+ }
275
+
276
+ // ../shared/ops/flag-parse.ts
277
+ function acceptsNull(prop) {
278
+ if (prop.type === "null")
279
+ return true;
280
+ return Array.isArray(prop.anyOf) && prop.anyOf.some((branch) => branch?.type === "null");
281
+ }
282
+ function valueBranch(prop) {
283
+ if (!Array.isArray(prop.anyOf) || !acceptsNull(prop))
284
+ return prop;
285
+ return prop.anyOf.find((branch) => branch?.type !== "null") ?? prop;
286
+ }
287
+ function coerce(flag, raw, prop0, UsageError) {
288
+ if (acceptsNull(prop0) && raw === "null")
289
+ return null;
290
+ const prop = valueBranch(prop0);
291
+ if (prop.type === "boolean") {
292
+ if (raw === undefined || raw === "true")
293
+ return true;
294
+ if (raw === "false")
295
+ return false;
296
+ throw new UsageError(`--${flag} expects true|false`);
297
+ }
298
+ if (raw === undefined)
299
+ throw new UsageError(`--${flag} requires a value`);
300
+ if (prop.type === "number" || prop.type === "integer") {
301
+ const n = Number(raw);
302
+ if (Number.isNaN(n))
303
+ throw new UsageError(`--${flag} expects a number`);
304
+ return n;
305
+ }
306
+ if (prop.type === "object") {
307
+ try {
308
+ return JSON.parse(raw);
309
+ } catch {
310
+ throw new UsageError(`--${flag} expects JSON`);
311
+ }
312
+ }
313
+ return raw;
314
+ }
315
+ function jsonArrayValue(raw) {
316
+ if (raw === undefined || !raw.trimStart().startsWith("["))
317
+ return null;
318
+ try {
319
+ const parsed = JSON.parse(raw);
320
+ return Array.isArray(parsed) ? parsed : null;
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+ function canonicalFlag(props, flag) {
326
+ if (props[flag])
327
+ return flag;
328
+ const camel = flag.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
329
+ return props[camel] ? camel : undefined;
330
+ }
331
+ function parseFlagTokens(props, rest, UsageError) {
332
+ const input = {};
333
+ let json = false;
334
+ for (let i = 0;i < rest.length; i++) {
335
+ const token = rest[i];
336
+ if (token === "--json") {
337
+ json = true;
338
+ continue;
339
+ }
340
+ const match = /^--([^=\s]+)(?:=([\s\S]*))?$/.exec(token);
341
+ if (!match)
342
+ throw new UsageError(`unexpected argument '${token}' (flags only, see --help)`);
343
+ const raw0 = match[1];
344
+ const flag = canonicalFlag(props, raw0);
345
+ const prop = flag ? props[flag] : undefined;
346
+ if (!flag || !prop)
347
+ throw new UsageError(`unknown flag --${raw0}`);
348
+ let raw = match[2];
349
+ const next = rest[i + 1];
350
+ if (raw === undefined && valueBranch(prop).type !== "boolean" && next !== undefined && !next.startsWith("--")) {
351
+ raw = rest[++i];
352
+ }
353
+ if (prop.type === "array") {
354
+ const list = input[flag] ?? [];
355
+ const spread = jsonArrayValue(raw);
356
+ if (spread)
357
+ list.push(...spread);
358
+ else
359
+ list.push(coerce(flag, raw, prop.items ?? {}, UsageError));
360
+ input[flag] = list;
361
+ } else {
362
+ input[flag] = coerce(flag, raw, prop, UsageError);
363
+ }
364
+ }
365
+ return { input, json };
366
+ }
367
+
368
+ // src/dispatch.ts
369
+ class CliUsageError extends Error {
370
+ }
371
+ function resolveOp(catalog, argv) {
372
+ const byName = new Map(catalog.map((c) => [c.name, c]));
373
+ for (let n = Math.min(argv.length, 4);n >= 1; n--) {
374
+ const op = byName.get(argv.slice(0, n).join(" "));
375
+ if (op)
376
+ return { op, rest: argv.slice(n) };
377
+ }
378
+ return null;
379
+ }
380
+ function parseFlags(op, rest) {
381
+ return parseFlagTokens(op.input.properties ?? {}, rest, CliUsageError);
382
+ }
383
+ async function runOp(ctx, op, input) {
384
+ const res = await apiFetch(ctx, pathForOp(op.name), { method: "POST", body: input });
385
+ return { status: res.status, ok: res.ok, body: res.body };
386
+ }
387
+
388
+ // src/format.ts
389
+ function flagType(prop) {
390
+ if (Array.isArray(prop.enum))
391
+ return prop.enum.join("|");
392
+ if (prop.type === "number" || prop.type === "integer")
393
+ return "n";
394
+ if (prop.type === "boolean")
395
+ return "true|false";
396
+ if (prop.type === "array")
397
+ return "value, repeatable, or a JSON array";
398
+ if (prop.type === "object")
399
+ return "json";
400
+ return "text";
401
+ }
402
+ function kebab(name) {
403
+ return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
404
+ }
405
+ function flagsOf(op) {
406
+ const props = op.input.properties ?? {};
407
+ const required = new Set(op.input.required ?? []);
408
+ return Object.entries(props).map(([name, prop]) => ({
409
+ name: kebab(name),
410
+ required: required.has(name),
411
+ type: flagType(prop),
412
+ description: prop.description
413
+ }));
414
+ }
415
+ function opUsage(op) {
416
+ const parts = [`volty ${op.name}`];
417
+ for (const f of flagsOf(op)) {
418
+ const token = `--${f.name}=<${f.type}>`;
419
+ parts.push(f.required ? token : `[${token}]`);
420
+ }
421
+ return parts.join(" ");
422
+ }
423
+ function renderCatalog(catalog) {
424
+ const lines = catalog.toSorted((a, b) => a.name.localeCompare(b.name)).map((op) => ` ${op.name} — ${op.description}`);
425
+ return [
426
+ "Run a Volty platform operation: volty <operation> [--flags] [--json]",
427
+ "",
428
+ ...lines,
429
+ "",
430
+ "`volty <operation> --help` shows usage; `--json` returns the machine-readable result."
431
+ ].join(`
432
+ `);
433
+ }
434
+ function renderOpHelp(op) {
435
+ const lines = [opUsage(op), "", op.description];
436
+ const flags = flagsOf(op);
437
+ if (flags.length > 0) {
438
+ lines.push("", "Flags:");
439
+ for (const f of flags) {
440
+ const optional = f.required ? "" : " (optional)";
441
+ lines.push(` --${f.name}=<${f.type}>${optional}${f.description ? ` — ${f.description}` : ""}`);
442
+ }
443
+ }
444
+ if (op.prompt)
445
+ lines.push("", op.prompt);
446
+ lines.push("", "Add --json for the full machine-readable result.");
447
+ return lines.join(`
448
+ `);
449
+ }
450
+ function renderError(body, status) {
451
+ const error = { code: body.error?.code ?? "http_error", message: body.error?.message ?? `HTTP ${status}`, status };
452
+ return JSON.stringify({ error }, null, 2);
453
+ }
454
+ function renderResult(body, json) {
455
+ if (json)
456
+ return JSON.stringify({ content: body.content, structuredContent: body.structuredContent }, null, 2);
457
+ const text = (body.content ?? []).map((b) => b.type === "text" ? b.text ?? "" : `[${b.type}]`).join(`
458
+ `);
459
+ return text || "(no output)";
460
+ }
461
+
462
+ // src/org-guard.ts
463
+ var ORG_NATIVE = new Set(["use", "current", "list"]);
464
+ function isOrgFreeCommand(command) {
465
+ const [cmd, sub] = command;
466
+ if (!cmd || cmd === "help" || cmd === "--help")
467
+ return true;
468
+ if (command.includes("--help"))
469
+ return true;
470
+ if (cmd === "auth" || cmd === "env" || cmd === "docs" || cmd === "skill")
471
+ return true;
472
+ return cmd === "org" && ORG_NATIVE.has(sub ?? "");
473
+ }
474
+ function isProdShaped(env, baseUrl) {
475
+ if (env === "prod")
476
+ return true;
477
+ if (!baseUrl)
478
+ return false;
479
+ let host;
480
+ try {
481
+ host = new URL(baseUrl).hostname;
482
+ } catch {
483
+ return false;
484
+ }
485
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1" && host !== "[::1]";
486
+ }
487
+ var PROD_ORG_REFUSAL = "prod needs an explicit org: pass --org <id> or VOLTY_ORG=<id>; run 'volty org list --env prod' to see your orgs";
488
+ var STICKY_ORG_REFUSAL = "prod does not honour a sticky org: pass --org <id> or VOLTY_ORG=<id> on each command instead; run 'volty org list --env prod' to see your orgs";
489
+ function resolveTargetOrg({ env, baseUrl, override, sticky, command }) {
490
+ if (!isProdShaped(env, baseUrl))
491
+ return { org: override ?? sticky };
492
+ if (override)
493
+ return { org: override };
494
+ if (isOrgFreeCommand(command))
495
+ return { org: undefined };
496
+ return { refusal: PROD_ORG_REFUSAL };
497
+ }
498
+ function describeOrgTarget(orgs, org, env) {
499
+ const match = orgs.find((o) => o.organizationId === org);
500
+ if (!match)
501
+ return { refusal: `org ${org} is not one of your memberships on ${env}` };
502
+ return { line: `→ ${env} · ${match.name} (${match.organizationId}) · ${match.role}` };
503
+ }
504
+
505
+ // src/proposals.ts
506
+ function decideProposal(ctx, proposalId, decision, reason) {
507
+ return apiFetch(ctx, `/api/changes/proposals/${encodeURIComponent(proposalId)}/decide`, {
508
+ method: "POST",
509
+ body: { decision, ...reason ? { reason } : {} }
510
+ });
511
+ }
512
+
513
+ // src/update.ts
514
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
515
+ import { homedir as homedir2 } from "node:os";
516
+ import { dirname as dirname2, join as join3 } from "node:path";
517
+ var CACHE_PATH = join3(homedir2(), ".config", "volty", "update-check.json");
518
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
519
+ var REGISTRY_LATEST = "https://registry.npmjs.org/@voltade/volty/latest";
520
+ function currentVersion() {
521
+ try {
522
+ const pkg = JSON.parse(readFileSync3(join3(import.meta.dirname, "..", "package.json"), "utf8"));
523
+ return pkg.version ?? null;
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ function cmpVersion(a, b) {
529
+ const pa = a.split(".").map(Number);
530
+ const pb = b.split(".").map(Number);
531
+ for (let i = 0;i < Math.max(pa.length, pb.length); i++) {
532
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
533
+ if (d !== 0)
534
+ return d < 0 ? -1 : 1;
535
+ }
536
+ return 0;
537
+ }
538
+ function readCache() {
539
+ try {
540
+ return JSON.parse(readFileSync3(CACHE_PATH, "utf8"));
541
+ } catch {
542
+ return null;
543
+ }
544
+ }
545
+ function writeCache(cache) {
546
+ try {
547
+ mkdirSync3(dirname2(CACHE_PATH), { recursive: true });
548
+ writeFileSync3(CACHE_PATH, `${JSON.stringify(cache)}
549
+ `);
550
+ } catch {}
551
+ }
552
+ async function maybePrintUpdateHint() {
553
+ if (process.env.VOLTY_NO_UPDATE_CHECK)
554
+ return;
555
+ if (import.meta.url.endsWith(".ts"))
556
+ return;
557
+ const current = currentVersion();
558
+ if (!current)
559
+ return;
560
+ let cache = readCache();
561
+ if (!cache || Date.now() - cache.checkedAt > CHECK_INTERVAL_MS) {
562
+ cache = { checkedAt: Date.now(), latest: cache?.latest };
563
+ try {
564
+ const res = await fetch(REGISTRY_LATEST, { signal: AbortSignal.timeout(1500) });
565
+ if (res.ok) {
566
+ const body = await res.json();
567
+ if (body.version)
568
+ cache.latest = body.version;
569
+ }
570
+ } catch {}
571
+ writeCache(cache);
572
+ }
573
+ if (cache.latest && cmpVersion(current, cache.latest) < 0) {
574
+ process.stderr.write(`volty ${current} → ${cache.latest} available — run \`bun add -g @voltade/volty@latest\`, ` + `then \`volty skill install\` to refresh the skill.
575
+ `);
576
+ }
577
+ }
578
+
579
+ // bin/volty.ts
580
+ function fail(message) {
581
+ process.stderr.write(`volty: ${message}
582
+ `);
583
+ process.exit(1);
584
+ }
585
+ function failResult(label, body, status, json) {
586
+ if (json) {
587
+ process.stdout.write(`${renderError(body, status)}
588
+ `);
589
+ process.exit(1);
590
+ }
591
+ fail(`${label}: ${body.error?.message ?? `HTTP ${status}`}`);
592
+ }
593
+ function takeValue(argv, name) {
594
+ const rest = [];
595
+ let value;
596
+ for (let i = 0;i < argv.length; i++) {
597
+ const t = argv[i];
598
+ if (t === name) {
599
+ value = argv[++i];
600
+ } else if (t.startsWith(`${name}=`)) {
601
+ value = t.slice(name.length + 1);
602
+ } else {
603
+ rest.push(t);
604
+ }
605
+ }
606
+ return { value, rest };
607
+ }
608
+ function takeBool(argv, name) {
609
+ const rest = argv.filter((t) => t !== name);
610
+ return { present: rest.length !== argv.length, rest };
611
+ }
612
+ async function handleAuth(rest, config, env, orgOverride) {
613
+ const sub = rest[0];
614
+ if (sub === "login") {
615
+ const token = takeValue(rest.slice(1), "--token");
616
+ const baseUrl = takeValue(token.rest, "--base-url");
617
+ const rotate = takeBool(baseUrl.rest, "--rotate");
618
+ const persona = takeValue(rotate.rest, "--as");
619
+ const msg = await login(config, env, {
620
+ token: token.value,
621
+ baseUrl: baseUrl.value,
622
+ rotate: rotate.present,
623
+ devPersona: persona.value ?? (process.env.VOLTY_DEV_PERSONA || undefined),
624
+ org: orgOverride
625
+ });
626
+ saveConfig(config);
627
+ process.stdout.write(`${msg}
628
+ `);
629
+ return;
630
+ }
631
+ if (sub === "whoami") {
632
+ const ctx = config.contexts[env];
633
+ if (!ctx?.apiKey)
634
+ fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
635
+ const who = await whoami(ctx);
636
+ if (!who)
637
+ fail("the stored key did not validate — run `volty auth login --rotate`");
638
+ saveConfig(config);
639
+ process.stdout.write(`${who.email ?? who.userId} · org ${who.organizationId} · role ${who.role}
640
+ `);
641
+ return;
642
+ }
643
+ if (sub === "logout") {
644
+ process.stdout.write(`${await logout(config, env)}
645
+ `);
646
+ saveConfig(config);
647
+ return;
648
+ }
649
+ fail("usage: volty auth <login|whoami|logout> [--env <e>] [--as <persona>] [--org <slug-or-id>] (--as and --org are dev servers only)");
650
+ }
651
+ function handleEnv(rest, config, env) {
652
+ const sub = rest[0];
653
+ if (sub === "use") {
654
+ const name = rest[1];
655
+ if (!name)
656
+ fail("usage: volty env use <name> [--base-url <url>]");
657
+ const baseUrl = takeValue(rest.slice(2), "--base-url");
658
+ config.current = name;
659
+ const ctx = ensureContext(config, name);
660
+ if (baseUrl.value)
661
+ ctx.baseUrl = baseUrl.value;
662
+ saveConfig(config);
663
+ process.stdout.write(`Active env: ${name}${ctx.baseUrl ? ` (${ctx.baseUrl})` : ""}
664
+ `);
665
+ return;
666
+ }
667
+ if (sub === "list") {
668
+ const names = Object.keys(config.contexts);
669
+ if (names.length === 0)
670
+ process.stdout.write("(no contexts — run `volty auth login --env dev`)\n");
671
+ for (const name of names) {
672
+ const ctx = config.contexts[name];
673
+ const marker = name === config.current ? "*" : " ";
674
+ process.stdout.write(`${marker} ${name} ${ctx?.baseUrl ?? ""} ${ctx?.principal ?? ""}
675
+ `);
676
+ }
677
+ return;
678
+ }
679
+ if (sub === "current") {
680
+ const ctx = config.contexts[env];
681
+ process.stdout.write(`${env} ${ctx?.baseUrl ?? "(no base URL)"} ${ctx?.principal ?? "(not logged in)"}
682
+ `);
683
+ return;
684
+ }
685
+ fail("usage: volty env <use|list|current>");
686
+ }
687
+ async function handleOrg(rest, config, env, target) {
688
+ const sub = rest[0];
689
+ const ctx = ensureContext(config, env);
690
+ if (sub === "use") {
691
+ const id = rest[1];
692
+ if (!id)
693
+ fail("usage: volty org use <organization-id> | volty org use --clear");
694
+ if (id === "--clear" || id === "default") {
695
+ ctx.activeOrg = undefined;
696
+ saveConfig(config);
697
+ process.stdout.write(`Org override cleared for '${env}' — using the key's default org.
698
+ `);
699
+ return;
700
+ }
701
+ if (target.prod)
702
+ fail(STICKY_ORG_REFUSAL);
703
+ ctx.activeOrg = id;
704
+ saveConfig(config);
705
+ process.stdout.write(`Active org for '${env}': ${id}
706
+ `);
707
+ return;
708
+ }
709
+ if (sub === "current") {
710
+ if (target.prod) {
711
+ process.stdout.write(`${target.org ?? "(none: prod needs --org <id> or VOLTY_ORG=<id>)"}
712
+ `);
713
+ return;
714
+ }
715
+ process.stdout.write(`${ctx.activeOrg ?? "(key default)"}
716
+ `);
717
+ return;
718
+ }
719
+ if (sub === "list") {
720
+ const json = takeBool(rest.slice(1), "--json");
721
+ if (!ctx.apiKey)
722
+ fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
723
+ const res = await apiFetch(ctx, "/api/cli/orgs");
724
+ if (!res.ok || !res.body.orgs)
725
+ failResult("org list", res.body, res.status, json.present);
726
+ if (json.present) {
727
+ process.stdout.write(`${JSON.stringify(res.body, null, 2)}
728
+ `);
729
+ return;
730
+ }
731
+ const active = target.org ?? res.body.current;
732
+ if (res.body.orgs.length === 0)
733
+ process.stdout.write(`(no org memberships)
734
+ `);
735
+ for (const o of res.body.orgs) {
736
+ const marker = o.organizationId === active ? "*" : " ";
737
+ process.stdout.write(`${marker} ${o.organizationId} ${o.role} ${o.name}
738
+ `);
739
+ }
740
+ return;
741
+ }
742
+ fail("usage: volty org <use|current|list>");
743
+ }
744
+ async function handleChanges(rest, ctx) {
745
+ if (rest[0] !== "decide")
746
+ fail("usage: volty changes decide --proposal-id <id> --decision approve|reject [--reason <text>]");
747
+ const pid = takeValue(rest.slice(1), "--proposal-id");
748
+ const dec = takeValue(pid.rest, "--decision");
749
+ const reason = takeValue(dec.rest, "--reason");
750
+ const json = takeBool(reason.rest, "--json");
751
+ if (!pid.value)
752
+ fail("--proposal-id <id> is required");
753
+ if (dec.value !== "approve" && dec.value !== "reject")
754
+ fail("--decision must be approve or reject");
755
+ const res = await decideProposal(ctx, pid.value, dec.value, reason.value);
756
+ if (!res.ok)
757
+ failResult("changes decide", res.body, res.status, json.present);
758
+ if (json.present) {
759
+ process.stdout.write(`${JSON.stringify(res.body, null, 2)}
760
+ `);
761
+ return;
762
+ }
763
+ process.stdout.write(`${dec.value === "approve" ? "Approved" : "Rejected"} proposal ${res.body.proposal?.id ?? pid.value} → ${res.body.proposal?.status ?? "decided"}
764
+ `);
765
+ }
766
+ async function handleDocs(config, env) {
767
+ const ctx = ensureContext(config, env);
768
+ if (!ctx.baseUrl)
769
+ fail(`no base URL for '${env}' — run \`volty env use ${env} --base-url <url>\``);
770
+ const res = await fetch(new URL("/llms.txt", ctx.baseUrl));
771
+ if (!res.ok)
772
+ fail(`GET ${ctx.baseUrl}/llms.txt → HTTP ${res.status}`);
773
+ process.stdout.write(await res.text());
774
+ }
775
+ function handleSkill(rest) {
776
+ if (rest[0] !== "install")
777
+ fail("usage: volty skill install");
778
+ const candidates = [
779
+ join4(import.meta.dirname, "..", "skill", "volty-cli-ops", "SKILL.md"),
780
+ join4(import.meta.dirname, "..", "..", ".claude", "skills", "volty-cli-ops", "SKILL.md")
781
+ ];
782
+ const source = candidates.find((p) => existsSync3(p));
783
+ if (!source)
784
+ fail("bundled SKILL.md not found — reinstall the @voltade/volty package");
785
+ const destDir = join4(homedir3(), ".claude", "skills", "volty-cli-ops");
786
+ mkdirSync4(destDir, { recursive: true });
787
+ writeFileSync4(join4(destDir, "SKILL.md"), readFileSync4(source, "utf8"));
788
+ process.stdout.write(`Installed the volty-cli-ops skill → ${join4(destDir, "SKILL.md")}
789
+ `);
790
+ }
791
+ async function main() {
792
+ await maybePrintUpdateHint();
793
+ const envExtract = takeValue(process.argv.slice(2), "--env");
794
+ let argv = envExtract.rest;
795
+ const orgExtract = takeValue(argv, "--org");
796
+ argv = orgExtract.rest;
797
+ const cacheFlag = takeBool(argv, "--no-cache");
798
+ argv = cacheFlag.rest;
799
+ const config = loadConfig();
800
+ const env = activeEnv(config, envExtract.value ?? (process.env.VOLTY_ENV || undefined));
801
+ const peek = config.contexts[env];
802
+ const prod = isProdShaped(env, peek?.baseUrl);
803
+ const target = resolveTargetOrg({
804
+ env,
805
+ baseUrl: peek?.baseUrl,
806
+ override: orgExtract.value ?? (process.env.VOLTY_ORG || undefined),
807
+ sticky: peek?.activeOrg,
808
+ command: argv
809
+ });
810
+ if ("refusal" in target)
811
+ fail(target.refusal);
812
+ setOrgOverride(target.org);
813
+ const [cmd, ...rest] = argv;
814
+ if (cmd === "auth")
815
+ return handleAuth(rest, config, env, orgExtract.value ?? (process.env.VOLTY_ORG || undefined));
816
+ if (cmd === "env")
817
+ return handleEnv(rest, config, env);
818
+ if (cmd === "org" && ORG_NATIVE.has(rest[0] ?? ""))
819
+ return handleOrg(rest, config, env, { ...target, prod });
820
+ if (cmd === "docs")
821
+ return handleDocs(config, env);
822
+ if (cmd === "skill")
823
+ return handleSkill(rest);
824
+ const ctx = config.contexts[env];
825
+ if (!ctx?.apiKey)
826
+ fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
827
+ if (prod && !isOrgFreeCommand(argv) && target.org) {
828
+ const described = describeOrgTarget(await fetchOrgs(ctx, env, !cacheFlag.present), target.org, env);
829
+ if ("refusal" in described)
830
+ fail(described.refusal);
831
+ process.stderr.write(`${described.line}
832
+ `);
833
+ }
834
+ if (cmd === "changes" && rest[0] === "decide")
835
+ return handleChanges(rest, ctx);
836
+ const catalog = await fetchCatalog(ctx, env, !cacheFlag.present);
837
+ if (!cmd || cmd === "help" || cmd === "--help") {
838
+ process.stdout.write(`${renderCatalog(catalog.ops)}
839
+ `);
840
+ return;
841
+ }
842
+ const resolved = resolveOp(catalog.ops, argv);
843
+ if (!resolved) {
844
+ const retired = retiredPrefix(catalog.retired, argv);
845
+ if (retired)
846
+ fail(`\`${retired.name}\` is retired: ${retired.replacement}`);
847
+ fail(`unknown operation '${argv.join(" ")}' — run \`volty help\``);
848
+ }
849
+ if (resolved.rest.includes("--help")) {
850
+ process.stdout.write(`${renderOpHelp(resolved.op)}
851
+ `);
852
+ return;
853
+ }
854
+ let parsed;
855
+ try {
856
+ parsed = parseFlags(resolved.op, resolved.rest);
857
+ } catch (err) {
858
+ if (err instanceof CliUsageError)
859
+ fail(err.message);
860
+ throw err;
861
+ }
862
+ const result = await runOp(ctx, resolved.op, parsed.input);
863
+ if (!result.ok)
864
+ failResult(resolved.op.name, result.body, result.status, parsed.json);
865
+ process.stdout.write(`${renderResult(result.body, parsed.json)}
866
+ `);
867
+ }
868
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@voltade/volty",
3
+ "version": "0.1.9",
4
+ "description": "Remote CLI for the Volty platform — run operations, trigger real agent wakes, and inspect LLM I/O over an API key.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "volty": "dist/volty.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "skill"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20.11"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "bun build bin/volty.ts --target=node --banner='#!/usr/bin/env node' --outfile dist/volty.js",
22
+ "prepublishOnly": "bun run build && rm -rf skill && bun scripts/public-skill.ts"
23
+ }
24
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: volty-cli-ops
3
+ description: "Drive Volty's agent with the `volty` CLI: run a real agent turn in one command on either lane (a contact message or a staff session) and read what the model saw and did. Use whenever you need to exercise the live agent end to end: does an inbound trigger the right reply, learning or proposal, can Volty read or operate a connected system (CRM, MCP server, drive, channel), why did a tool call come back wrong. Reproducing a reported behaviour costs one command, so reach for it before theorising. Triggers: 'test the agent', 'trigger a wake', 'wake-sync', 'session-sync', 'ask Volty on prod', 'what did the agent do with X', 'run a real LLM turn', 'reproduce this inbound', 'can Volty see X?'."
4
+ ---
5
+
6
+ # volty CLI
7
+
8
+ The `volty` command runs Volty operations over an API key. Its main use is running a real agent turn (real model, real prompt, real operations) and then reading exactly what the model saw and did.
9
+
10
+ This skill says which lane to use and what to keep to on prod. The commands and flags come from the server, so they always match it: `volty docs` prints the live guide (the same page as `/llms.txt`), `volty help` lists every op your key can run, and `volty <op> --help` shows one op's flags. Read `volty docs` once per session before the first prod command.
11
+
12
+ ## Install
13
+
14
+ `bun add -g @voltade/volty`, then `volty skill install` to install or refresh this skill, then `volty auth login --env prod`. When the CLI prints an upgrade line, run `bun add -g @voltade/volty@latest` and `volty skill install` before continuing, because an old CLI or skill can describe commands the server has since changed.
15
+
16
+ ## Pick the lane
17
+
18
+ - `debug wake-sync` sends a synthetic contact message into the org's sandbox: the contact lane.
19
+ - `debug session-sync` runs a turn on the internal Volty chat as you: the staff lane.
20
+
21
+ Use the lane the behaviour was reported on. On the contact lane Volty declines to list internal records to a contact, as it should, so "can Volty read our CRM or MCP server" belongs on the staff lane; asked on the contact lane, that refusal looks like a broken feature.
22
+
23
+ ## On prod
24
+
25
+ - Name the org on every command (`--env prod --org <id>`, or `VOLTY_ENV` and `VOLTY_ORG` in the process), and read the stderr line the CLI prints before each command to check it names the org you meant. Act only in the org the user named.
26
+ - Test through the sandbox, where every reply stays a draft. A real conversation is never a test target.
27
+ - Run `volty debug sandbox-cleanup --all --env prod --org <id>` when you finish.
28
+ - A `{pending:true}` result means the turn outran the wait, not that it failed: poll for it (`volty docs` names the command) instead of sending the message again.