@ghostystudio/cli 0.13.0 → 0.13.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.
Files changed (3) hide show
  1. package/README.md +10 -1
  2. package/dist/index.js +82 -21
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -36,7 +36,7 @@ ghosty turns ls <id> [--errors | --status running|done|stopp
36
36
  ghosty turns show <id> [turn-id|last] # where a turn's time went: each tool with its seconds, output tokens
37
37
  ghosty channels whatsapp ls|enable|disable <id> [number] [--dry-run]
38
38
  ghosty channels whatsapp link|unlink <id> --integration ID --answer on|off [--forward] [--dry-run] # bring a WABA number from Formmy
39
- ghosty channels wa import <id> --from-file F --own-number yes|no [--dry-run] # personal WhatsApp session, no QR
39
+ ghosty channels wa import <id> --from-file F | --from-dir DIR --own-number yes|no [--dry-run] # personal WhatsApp session, no QR
40
40
  ghosty channels wa pair|status|pause <id> [--phone N]
41
41
  ghosty channels wa groups ls|set|create <id> [group] [--role equipo|cliente] [--on|--off] [--dry-run] # personal WhatsApp groups
42
42
  ghosty board archive <id> --integration X | --column X | --before 30d [--dry-run]
@@ -79,6 +79,15 @@ Exit codes: `0` ok · `1` API error · `2` usage error · `3` not signed in.
79
79
  - `try --replay FILE --out DIR`: replays real conversations from your machine. The report is written after every step, survives Ghosty deploys (it honors `Retry-After`), and `--resume` continues a partial run.
80
80
  - `agents doctor` speaks English like the rest of the CLI; `channels whatsapp ls` returns a full URL.
81
81
 
82
+ ### Changes in 0.13.2
83
+
84
+ - `channels whatsapp ls` shows the real number, "records only" numbers, and whether Formmy forwards to gs. `whatsapp link` stores the number's name and phone.
85
+ - `agents set --channels wa=on,teams=off` (wa = personal WhatsApp).
86
+
87
+ ### Changes in 0.13.1
88
+
89
+ - `channels wa import --from-dir DIR`: import a Baileys multi-file auth folder (`creds.json` + one file per key, e.g. nanoclaw's `store/auth`); it undoes Baileys' file-name escaping per key type.
90
+
82
91
  ### Changes in 0.13.0
83
92
 
84
93
  - Migrate WhatsApp numbers without signing up again: `channels whatsapp link|unlink` (a WhatsApp Business number that lives in Formmy; `--answer on|off` is required, `--dry-run` shows what changes in gs and in Formmy) and `channels wa import` (a personal WhatsApp session exported from EasyBits, no QR). Also `channels wa pair --phone` (pairing code), `wa status` and `wa pause`.
package/dist/index.js CHANGED
@@ -282,7 +282,7 @@ async function postLong(path, body, timeoutMs = 9e5) {
282
282
  const req = httpsRequest(`${base}${path}`, {
283
283
  method: "POST",
284
284
  timeout: timeoutMs,
285
- headers: { authorization: `Bearer ${token}`, "user-agent": `ghosty-cli/${"0.13.0"}`, "content-type": "application/json", "content-length": Buffer.byteLength(data) }
285
+ headers: { authorization: `Bearer ${token}`, "user-agent": `ghosty-cli/${"0.13.2"}`, "content-type": "application/json", "content-length": Buffer.byteLength(data) }
286
286
  }, (res) => {
287
287
  let buf = "";
288
288
  res.on("data", (c) => buf += c);
@@ -324,7 +324,7 @@ async function request(path, opts = {}, retried = false) {
324
324
  method: opts.method ?? (opts.body !== void 0 || opts.raw !== void 0 ? "POST" : "GET"),
325
325
  headers: {
326
326
  authorization: `Bearer ${token}`,
327
- "user-agent": `ghosty-cli/${"0.13.0"}`,
327
+ "user-agent": `ghosty-cli/${"0.13.2"}`,
328
328
  ...opts.body !== void 0 ? { "content-type": "application/json" } : {},
329
329
  ...opts.headers
330
330
  },
@@ -548,6 +548,15 @@ async function agentsSet(id, v) {
548
548
  if (v.model) body.model = v.model;
549
549
  if (v.prompt) body.prompt = v.prompt;
550
550
  if (v["prompt-file"]) body.prompt = await readFile3(v["prompt-file"], "utf8");
551
+ if (v.channels) {
552
+ const ch = {};
553
+ for (const pair of v.channels.split(",").map((x) => x.trim()).filter(Boolean)) {
554
+ const [k, val] = pair.split("=");
555
+ if (!["teams", "web", "whatsapp", "messenger", "wa"].includes(k) || val !== "on" && val !== "off") throw usage(`Bad --channels entry: ${pair}.`, "Use teams|web|whatsapp|messenger|wa=on|off, comma-separated (wa = personal WhatsApp)");
556
+ ch[k] = val === "on";
557
+ }
558
+ body.channels = ch;
559
+ }
551
560
  const env = parseEnv(v.env);
552
561
  if (env) body.env = env;
553
562
  const connectors = {};
@@ -570,7 +579,7 @@ async function agentsSet(id, v) {
570
579
  if (v["auto-stage"] !== "on" && v["auto-stage"] !== "off") throw usage("--auto-stage takes on or off.");
571
580
  body.autoStage = v["auto-stage"] === "on";
572
581
  }
573
- if (!Object.keys(body).length) throw usage("Nothing to change.", "Use --name, --engine, --model, --prompt, --prompt-file, --env, --connect, --disconnect, --voice, --voice-replies or --auto-stage.");
582
+ if (!Object.keys(body).length) throw usage("Nothing to change.", "Use --name, --engine, --model, --prompt, --prompt-file, --env, --channels, --connect, --disconnect, --voice, --voice-replies or --auto-stage.");
574
583
  const r = await api(agentPath(id), { method: "PATCH", body });
575
584
  out(r, () => `${lavender("\u2713")} Updated ${Object.keys(body).join(", ")}.`);
576
585
  }
@@ -1136,7 +1145,7 @@ async function call(path, v, init = {}) {
1136
1145
  "x-ghosty-ts": ts,
1137
1146
  "x-ghosty-sig": sig,
1138
1147
  "content-type": "application/json",
1139
- "user-agent": `ghosty-cli/${"0.13.0"}`
1148
+ "user-agent": `ghosty-cli/${"0.13.2"}`
1140
1149
  },
1141
1150
  body: method === "GET" ? void 0 : raw
1142
1151
  });
@@ -1574,7 +1583,42 @@ async function credentialsCmd2(sub, provider, v) {
1574
1583
  }
1575
1584
 
1576
1585
  // src/commands/channels.ts
1577
- import { readFile as readFile6 } from "node:fs/promises";
1586
+ import { readFile as readFile7 } from "node:fs/promises";
1587
+
1588
+ // src/wa-auth.ts
1589
+ import { readdir as readdir2, readFile as readFile6 } from "node:fs/promises";
1590
+ import { join as join4 } from "node:path";
1591
+ var TYPES = ["app-state-sync-version", "app-state-sync-key", "sender-key-memory", "sender-key", "lid-mapping", "device-list", "identity-key", "pre-key", "session", "tctoken"];
1592
+ function keyOfFileName(name) {
1593
+ for (const type of TYPES) {
1594
+ if (!name.startsWith(`${type}-`)) continue;
1595
+ let id = name.slice(type.length + 1).replace(/__/g, "/");
1596
+ if (type === "sender-key" || type === "sender-key-memory") id = id.replace(/--/g, "::");
1597
+ return id ? { type, id } : null;
1598
+ }
1599
+ return null;
1600
+ }
1601
+ async function readMultiFileAuth(dir) {
1602
+ const files = (await readdir2(dir)).filter((f) => f.endsWith(".json"));
1603
+ if (!files.includes("creds.json")) throw new Error(`${dir} has no creds.json: not a Baileys multi-file auth folder`);
1604
+ const authCreds = JSON.parse(await readFile6(join4(dir, "creds.json"), "utf8"));
1605
+ const authKeys = {};
1606
+ const counts = {};
1607
+ const skipped = [];
1608
+ for (const f of files) {
1609
+ if (f === "creds.json") continue;
1610
+ const k = keyOfFileName(f.slice(0, -".json".length));
1611
+ if (!k) {
1612
+ skipped.push(f);
1613
+ continue;
1614
+ }
1615
+ (authKeys[k.type] ??= {})[k.id] = JSON.parse(await readFile6(join4(dir, f), "utf8"));
1616
+ counts[k.type] = (counts[k.type] ?? 0) + 1;
1617
+ }
1618
+ return { authCreds, authKeys, counts, skipped };
1619
+ }
1620
+
1621
+ // src/commands/channels.ts
1578
1622
  var enc7 = encodeURIComponent;
1579
1623
  var digits = (s) => s.replace(/\D/g, "");
1580
1624
  function find(nums, ref) {
@@ -1590,10 +1634,17 @@ async function channelsCmd(kind, sub, id, ref, v) {
1590
1634
  if (sub === "import" || sub === "status" || sub === "pause" || sub === "pair") return waCmd(sub, id, v);
1591
1635
  if (sub === "enable" && !v["dry-run"]) requireYesIfHeadless(v.yes, `ghosty channels whatsapp enable ${id} ${ref ?? "<number>"}`);
1592
1636
  const path = `/api/v2/agents/${enc7(id)}/whatsapp`;
1593
- const cur = await api(path);
1594
- if (!sub || sub === "ls" || sub === "list") {
1595
- const rows = cur.numbers.map((n2) => ({ number: n2.label, answers: n2.mine ? lavender("this agent") : n2.takenByOther ? "another agent" : dim("nobody (silent)"), id: n2.id }));
1596
- return out(cur, () => rows.length ? table(rows, ["number", "answers", "id"]) : dim(`No WhatsApp Business numbers yet. Pair one at ${cur.salesUrl}`));
1637
+ const listing = !sub || sub === "ls" || sub === "list";
1638
+ const cur = await api(listing ? `${path}?formmy=1` : path);
1639
+ if (listing) {
1640
+ const fwd = (n2) => !n2.formmy ? dim("\u2014") : "error" in n2.formmy ? dim("?") : n2.formmy.forwards ? "yes" : lavender("NO");
1641
+ const rows = cur.numbers.map((n2) => ({
1642
+ number: n2.phoneNumber ? `+${n2.phoneNumber}${n2.label !== n2.phoneNumber && n2.label !== n2.id ? ` ${n2.label}` : ""}` : n2.label,
1643
+ answers: n2.mine ? lavender("this agent") : n2.recordsOnly ? "this agent (records only)" : n2.takenByOther ? "another agent" : dim("nobody (silent)"),
1644
+ "formmy forwards": fwd(n2),
1645
+ id: n2.id
1646
+ }));
1647
+ return out(cur, () => rows.length ? table(rows, ["number", "answers", "formmy forwards", "id"]) : dim(`No WhatsApp Business numbers yet. Pair one at ${cur.salesUrl}`));
1597
1648
  }
1598
1649
  if (sub !== "enable" && sub !== "disable") throw usage(`Unknown subcommand: ${sub}.`, "Try: ghosty channels whatsapp ls | enable | disable | link | unlink \xB7 ghosty channels wa import | status | pause | groups");
1599
1650
  if (!ref) throw usage("Missing number.", `Example: ghosty channels whatsapp ${sub} ${id} +52 55 1234 5678`);
@@ -1678,17 +1729,22 @@ ${dim("On the phone: WhatsApp \u2192 Linked devices \u2192 Link with phone numbe
1678
1729
  await api(base, { method: "DELETE" });
1679
1730
  return out({ paused: true }, () => `${lavender("\u2713")} Paused. The session is kept: ${dim(`ghosty channels wa import \u2026 or the Channels page reconnects it`)}`);
1680
1731
  }
1681
- const file = v["from-file"];
1682
- if (!file) throw usage("Missing --from-file.", `Example: ghosty channels wa import ${id} --from-file creds.json --own-number no --dry-run`);
1732
+ const file = v["from-file"] ?? v["from-dir"];
1733
+ if (!file || v["from-file"] && v["from-dir"]) throw usage("Use --from-file F (EasyBits JSON) or --from-dir DIR (Baileys multi-file folder).", `Example: ghosty channels wa import ${id} --from-file creds.json --own-number no --dry-run`);
1683
1734
  const own = v["own-number"];
1684
1735
  if (own !== "yes" && own !== "no") throw usage("Missing --own-number yes|no.", "yes = the number belongs to the agent \xB7 no = it is a person's number (only their chat with themselves is the owner's)");
1685
- const data = JSON.parse(await readFile6(file, "utf8"));
1736
+ let data;
1737
+ if (v["from-dir"]) {
1738
+ const m = await readMultiFileAuth(v["from-dir"]);
1739
+ info(dim(`${v["from-dir"]}: ${Object.entries(m.counts).map(([t, n]) => `${t} ${n}`).join(" \xB7 ") || "no keys"}${m.skipped.length ? ` \xB7 skipped ${m.skipped.length} unknown file(s)` : ""}`));
1740
+ data = { authCreds: m.authCreds, authKeys: m.authKeys };
1741
+ } else data = JSON.parse(await readFile7(file, "utf8"));
1686
1742
  const body = { authCreds: data.authCreds, authKeys: data.authKeys, phone: data.phone, groups: data.groups, ownNumber: own === "yes", replace: !!v.replace };
1687
1743
  const plan = await api(`${base}/import`, { method: "POST", body: { ...body, dryRun: true } });
1688
1744
  if (v["dry-run"]) return out(plan, () => `${plan.plan.join("\n")}
1689
1745
  ${dim("Dry run: nothing changed.")}`);
1690
1746
  await confirm(`${bold(plan.plan.join("\n"))}
1691
- This number starts living in gs. Turn off its EasyBits socket first, WITHOUT logout (two sockets knock each other off).`, { yes: v.yes, expect: plan.phone.slice(-4), retry: `ghosty channels wa import ${id} --from-file ${file} --own-number ${own}${v.replace ? " --replace" : ""}` });
1747
+ This number starts living in gs. Turn off its EasyBits socket first, WITHOUT logout (two sockets knock each other off).`, { yes: v.yes, expect: plan.phone.slice(-4), retry: `ghosty channels wa import ${id} ${v["from-dir"] ? "--from-dir" : "--from-file"} ${file} --own-number ${own}${v.replace ? " --replace" : ""}` });
1692
1748
  const r = await api(`${base}/import`, { method: "POST", body: { ...body, connect: !v["no-connect"] } });
1693
1749
  return out(r, () => `${lavender("\u2713")} +${r.phone} imported \xB7 status: ${r.status === "connected" ? lavender("connected") : r.status}${r.reason ? dim(` (${r.reason})`) : ""}
1694
1750
  ${dim(`Next: ghosty channels wa groups ls ${id} \xB7 delete ${file}: it holds the account's keys`)}`);
@@ -1751,6 +1807,7 @@ var HELPS = {
1751
1807
  ],
1752
1808
  flags: [
1753
1809
  ["--fields a,b", "get: return only these fields"],
1810
+ ["--channels k=on|off,\u2026", "set: turn channels on/off: teams, web, whatsapp, messenger, wa (personal WhatsApp)"],
1754
1811
  ["--voice E:ID", "set: voice for voice notes: elevenlabs:<voiceId> or kokoro:em_santa|em_alex|ef_dora"],
1755
1812
  ["--voice-replies M", "set: auto = answer with a voice note when the customer sends or asks for audio; off = always text"],
1756
1813
  ["--auto-stage on|off", "set: move board cards by itself after each customer turn (one extra model call per turn)"],
@@ -1941,7 +1998,7 @@ HELPS.channels = {
1941
1998
  "ghosty channels whatsapp disable <agent-id> <number> [--dry-run]",
1942
1999
  "ghosty channels whatsapp link <agent-id> --integration <formmy-id> --answer on|off [--forward] [--dry-run] [--yes]",
1943
2000
  "ghosty channels whatsapp unlink <agent-id> --integration <formmy-id> [--dry-run] [--yes]",
1944
- "ghosty channels wa import <agent-id> --from-file creds.json --own-number yes|no [--replace] [--no-connect] [--dry-run] [--yes]",
2001
+ "ghosty channels wa import <agent-id> --from-file creds.json | --from-dir auth/ --own-number yes|no [--replace] [--no-connect] [--dry-run] [--yes]",
1945
2002
  "ghosty channels wa pair <agent-id> --phone 5215512345678",
1946
2003
  "ghosty channels wa status|pause <agent-id>",
1947
2004
  "ghosty channels wa groups ls <agent-id> [--cached]",
@@ -1958,6 +2015,7 @@ HELPS.channels = {
1958
2015
  ["--answer on|off", "link (required, no default): on = this agent answers real customers; off = it only records"],
1959
2016
  ["--forward", "link --answer off: still make Formmy forward, so gs sees the conversations"],
1960
2017
  ["--from-file F", "import: {authCreds, authKeys, phone, groups} exported from EasyBits"],
2018
+ ["--from-dir DIR", "import: a Baileys multi-file auth folder (creds.json + one file per key; e.g. nanoclaw's store/auth)"],
1961
2019
  ["--own-number yes|no", "import (required): the number is the agent's, or a person's (only their self-chat is the owner's)"],
1962
2020
  ["--replace", "import: replace a session already saved in gs"],
1963
2021
  ["--no-connect", "import: save it but don't connect yet"]
@@ -2015,10 +2073,10 @@ function helpText(cmd) {
2015
2073
  }
2016
2074
 
2017
2075
  // src/update.ts
2018
- import { mkdir as mkdir4, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
2076
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
2019
2077
  import { homedir as homedir3 } from "node:os";
2020
- import { dirname as dirname3, join as join4 } from "node:path";
2021
- var FILE = join4(process.env.XDG_CONFIG_HOME || join4(homedir3(), ".config"), "ghosty", "update.json");
2078
+ import { dirname as dirname3, join as join5 } from "node:path";
2079
+ var FILE = join5(process.env.XDG_CONFIG_HOME || join5(homedir3(), ".config"), "ghosty", "update.json");
2022
2080
  var DAY = 24 * 60 * 60 * 1e3;
2023
2081
  var UPGRADE = "npm i -g @ghostystudio/cli@latest";
2024
2082
  function newer(a, b) {
@@ -2031,7 +2089,7 @@ async function latestVersion() {
2031
2089
  if (process.env.GHOSTY_NO_UPDATE_CHECK || process.env.CI) return null;
2032
2090
  let cache = {};
2033
2091
  try {
2034
- cache = JSON.parse(await readFile7(FILE, "utf8"));
2092
+ cache = JSON.parse(await readFile8(FILE, "utf8"));
2035
2093
  } catch {
2036
2094
  }
2037
2095
  if (!cache.checkedAt || Date.now() - cache.checkedAt > DAY) {
@@ -2067,6 +2125,7 @@ ${bold("Agents")}
2067
2125
  agents get <id> Show an agent's config [--fields name,model,prompt]
2068
2126
  agents set <id> Change identity or engine [--name] [--engine] [--model] [--prompt | --prompt-file FILE] [--env K=V,\u2026]
2069
2127
  [--connect google-drive,\u2026] [--disconnect \u2026] give/remove access to your connectors
2128
+ [--channels wa=on,teams=off] turn channels on/off (wa = personal WhatsApp)
2070
2129
  [--voice elevenlabs:ID | kokoro:em_santa] [--voice-replies auto|off] [--auto-stage on|off]
2071
2130
  agents doctor <id> Check the config: overlapping skills, tools the prompt names, broken grants, failing turns
2072
2131
  agents docs ls|grant|revoke <id> [doc] Documents it reads and may send [--write] [--no-deliver]
@@ -2084,7 +2143,7 @@ ${bold("Configure an agent")}
2084
2143
  mcp get|set <id> MCP servers [--file servers.json]
2085
2144
  channels whatsapp ls|enable|disable <id> [number] Which WhatsApp Business numbers it answers [--dry-run]
2086
2145
  channels whatsapp link|unlink <id> --integration ID --answer on|off Bring a WhatsApp Business number from Formmy/EasyBits to this agent, or send it back [--forward] [--dry-run]
2087
- channels wa import <id> --from-file F --own-number yes|no Move a personal WhatsApp session here, no QR [--dry-run] [--replace] [--no-connect]
2146
+ channels wa import <id> --from-file F | --from-dir DIR --own-number yes|no Move a personal WhatsApp session here, no QR [--dry-run] [--replace] [--no-connect]
2088
2147
  channels wa pair <id> --phone N Pair a personal WhatsApp by code (no QR)
2089
2148
  channels wa status|pause <id> Personal WhatsApp: how it is / pause it (keeps the session)
2090
2149
  channels wa groups ls|set|create <id> [group] Personal WhatsApp groups it answers, and as whom [--role equipo|cliente] [--on|--off] [--dry-run]
@@ -2217,9 +2276,11 @@ async function main() {
2217
2276
  recycle: { type: "boolean" },
2218
2277
  role: { type: "string" },
2219
2278
  "from-file": { type: "string" },
2279
+ "from-dir": { type: "string" },
2220
2280
  "own-number": { type: "string" },
2221
2281
  answer: { type: "string" },
2222
2282
  phone: { type: "string" },
2283
+ channels: { type: "string" },
2223
2284
  forward: { type: "boolean" },
2224
2285
  replace: { type: "boolean" },
2225
2286
  "no-connect": { type: "boolean" },
@@ -2232,7 +2293,7 @@ async function main() {
2232
2293
  const tokens = splitTokens(pos, v);
2233
2294
  globals.token = tokens.session;
2234
2295
  globals.profile = v.profile;
2235
- if (v.version) return out({ version: "0.13.0" }, () => "0.13.0");
2296
+ if (v.version) return out({ version: "0.13.2" }, () => "0.13.2");
2236
2297
  const [cmd, sub, a1, a2] = pos;
2237
2298
  if (cmd === "partner" && (v.help || !sub || sub === "help")) return out({ help: PARTNER_HELP }, () => PARTNER_HELP);
2238
2299
  const topic = cmd === "help" ? sub : v.help ? cmd : void 0;
@@ -2317,7 +2378,7 @@ async function main() {
2317
2378
  throw unknown(cmd, cmd, COMMANDS, "ghosty --help");
2318
2379
  }
2319
2380
  }
2320
- var notice = updateNotice("0.13.0").catch(() => null);
2381
+ var notice = updateNotice("0.13.2").catch(() => null);
2321
2382
  main().then(
2322
2383
  async () => {
2323
2384
  const n = await notice;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghostystudio/cli",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Official Ghosty Studio CLI — configure, test and chat with your agents from the terminal. Built for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/index.js --banner:js='#!/usr/bin/env node' --define:__VERSION__=\\\"$npm_package_version\\\"",
18
18
  "prepack": "npm run build",
19
- "test": "for t in flags prompt api rows; do esbuild src/$t.test.ts --bundle --platform=node --format=esm --outfile=/tmp/ghosty-cli-$t.test.mjs --log-level=error || exit 1; done && node --test /tmp/ghosty-cli-*.test.mjs"
19
+ "test": "for t in flags prompt api rows wa-auth; do esbuild src/$t.test.ts --bundle --platform=node --format=esm --outfile=/tmp/ghosty-cli-$t.test.mjs --log-level=error || exit 1; done && node --test /tmp/ghosty-cli-*.test.mjs"
20
20
  },
21
21
  "keywords": [
22
22
  "ghosty",