@ghostystudio/cli 0.1.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 (3) hide show
  1. package/README.md +38 -0
  2. package/dist/index.js +736 -0
  3. package/package.json +36 -0
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @ghostystudio/cli
2
+
3
+ The official [Ghosty Studio](https://www.ghosty.studio) CLI: configure, test and chat with your agents from the terminal. Built for humans **and** coding agents (Claude Code, Codex, Cursor).
4
+
5
+ ```bash
6
+ npx -y @ghostystudio/cli login
7
+ # or
8
+ npm i -g @ghostystudio/cli && ghosty login
9
+ ```
10
+
11
+ `ghosty login` opens your browser (OAuth2 + PKCE). If you already have a session on ghosty.studio, opening the link is all it takes. A coding agent can drive it for you: `ghosty login --json` prints `{"event":"login_url","url":…}` first so the agent can hand you the link.
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ ghosty whoami
17
+ ghosty agents ls
18
+ ghosty agents get <id> [--fields name,model,prompt]
19
+ ghosty agents set <id> [--name] [--model] [--prompt | --prompt-file FILE]
20
+ ghosty agents restart <id>
21
+
22
+ ghosty files ls|get|put|rm <id> [path] [--file LOCAL] [--out LOCAL]
23
+ ghosty skills ls|add|rm <id> [slug] [--file SKILL.md]
24
+ ghosty mcp get|set <id> [--file servers.json]
25
+
26
+ ghosty try <id> "message" [--session] [--reset] [--whatsapp]
27
+ ghosty chat <id> ["message"] [--conversation ID]
28
+ ghosty conversations ls <id>
29
+
30
+ ghosty me files ls|upload|rm [--kind image|video|audio|document|other]
31
+ ghosty docs
32
+ ```
33
+
34
+ Global: `--json` (stdout is JSON only), `--token <token>` / `GHOSTY_TOKEN` (a `gat_…` agent token reaches only that agent), `--help`, `--version`.
35
+
36
+ Exit codes: `0` ok · `1` API error · `2` usage error · `3` not signed in.
37
+
38
+ Requires Node 22+. Docs: https://www.ghosty.studio/docs/cli · Skill for coding agents: `npx skills add https://www.ghosty.studio`
package/dist/index.js ADDED
@@ -0,0 +1,736 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // src/banner.ts
7
+ var BANNER = " \x1B[38;2;154;153;234m\u28C0\u2834\u2812\u281B\u2809\u2809\u2819\u2832\u2884\u2840\x1B[0m\n \x1B[38;2;212;212;215m\u28F4\u285A\u2809\x1B[38;2;154;153;234m\u28FD\x1B[38;2;212;212;215m\u28F7\u28C4\u28E4\u28C4\u28F4\x1B[38;2;154;153;234m\u28EF\x1B[38;2;212;212;215m\u2849\u2819\x1B[38;2;154;153;234m\u28F6\x1B[38;2;212;212;215m\u2844 \x1B[38;2;244;245;251m\u28A0\u2844\x1B[0m\n\x1B[38;2;212;212;215m\u28BA\x1B[38;2;154;153;234m\u285F \u28B8\u28FF\u28FF\u28FF \x1B[38;2;212;212;215m\u28B8\x1B[38;2;154;153;234m\u28FF\u28FF\u2847 \u2818\x1B[38;2;212;212;215m\u28FF\u2802 \x1B[38;2;244;245;251m\u2880\u28F4\u283F\u283F\u28B7\u28E6 \u28FF\u2807 \x1B[38;2;154;153;234m\u28C0\u2840 \x1B[38;2;244;245;251m\u2880\u28C0\u2840 \u28A0\u28FF\x1B[0m\n\x1B[38;2;154;153;234m\u2808\x1B[38;2;212;212;215m\u2867\u28C0 \x1B[38;2;154;153;234m\u289B\u28FB\x1B[38;2;212;212;215m\u281F \u2808\u28AE\x1B[38;2;154;153;234m\u28CB \x1B[38;2;212;212;215m\u2880\u2860\u284F \x1B[38;2;244;245;251m\u2880\u28FF\u280F\u2880\u28C0\u28C0\u28C0\u2840\u28B8\u287F\u28E0\u28F6\u28BF\u28F7 \x1B[38;2;154;153;234m\u28E0\u28FE\u28FF\u28F7\u28C4 \x1B[38;2;244;245;251m\u2830\u28FF\u2849\u281B\u2802\u28E4\u28E4\u28FF\u28E7\u28E4\u2804 \u28FC\u2847 \u28A0\u28FF\u2807\x1B[0m\n \x1B[38;2;154;153;234m\u2847\x1B[38;2;212;212;215m\u2808\u2809\u2809 \u2808\u2809\u2809 \x1B[38;2;154;153;234m\u2847 \x1B[38;2;244;245;251m\u28B8\u28FF \u2808\u289B\u28FF\u280F\u2801\u28FF\u28FF\u281F\u2801\u28FC\u284F\x1B[38;2;154;153;234m\u28B4\u28FF\x1B[38;2;212;212;215m\u28B9\x1B[38;2;154;153;234m\u28DF\x1B[38;2;212;212;215m\u2889\x1B[38;2;154;153;234m\u28FF\x1B[38;2;244;245;251m\u2880\u2840\u2819\u283B\u28E6\u2840 \u28FF\u284F \u2880\u28C0\u28B0\u28FF \u28E0\u28FF\u285F\x1B[0m\n \x1B[38;2;154;153;234m\u2847 \u2847 \x1B[38;2;244;245;251m\u2818\u28BF\u28E6\u28F4\u287E\u280B \u28B8\u287F\u2803 \u2830\u28FF\u28F6\x1B[38;2;154;153;234m\u2818\u28BF\u28FF\u28FF\u287F\u280B\x1B[38;2;244;245;251m\u2808\u28BF\u28E6\u28E4\u28FF\u2807 \u28BF\u28F7\u28F6\u283F\u2803\u2838\u28F7\u287E\u28BF\u287F\u2801\x1B[0m\n \x1B[38;2;154;153;234m\u2847 \u2847 \x1B[38;2;244;245;251m\u2808\u2809 \u28E0\u2844 \u28A0\u28FF\u2803\x1B[0m\n \x1B[38;2;154;153;234m\u28F7 \u28E0\u28C4 \u2880\u28C0\u2840\u28B8\u2847 \x1B[38;2;244;245;251m\u2838\u28FF\u28F6\u283F\u2801\x1B[0m\n \x1B[38;2;154;153;234m\u28BB\u28C0\u2864\u280A \u2831\u2824\u2816\u2809 \u2819\u280B\x1B[0m";
8
+
9
+ // src/auth.ts
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { createServer } from "node:http";
12
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+
16
+ // src/util.ts
17
+ var EXIT = { OK: 0, API: 1, USAGE: 2, AUTH: 3 };
18
+ var CliError = class extends Error {
19
+ constructor(message, code = EXIT.API, hint) {
20
+ super(message);
21
+ this.code = code;
22
+ this.hint = hint;
23
+ }
24
+ code;
25
+ hint;
26
+ };
27
+ var usage = (message, hint) => new CliError(message, EXIT.USAGE, hint);
28
+ var globals = { json: false };
29
+ var color = process.stderr.isTTY && !process.env.NO_COLOR;
30
+ var paint = (code) => (s) => color ? `\x1B[${code}m${s}\x1B[0m` : s;
31
+ var dim = paint("2");
32
+ var bold = paint("1");
33
+ var lavender = paint("38;2;154;153;234");
34
+ var red = paint("31");
35
+ var info = (s) => process.stderr.write(s + "\n");
36
+ function out(data, human) {
37
+ if (globals.json || !human) process.stdout.write(JSON.stringify(data, null, globals.json ? 0 : 2) + "\n");
38
+ else process.stdout.write(human() + "\n");
39
+ }
40
+ function table(rows, cols) {
41
+ if (!rows.length) return dim("(empty)");
42
+ const cell = (v) => v === null || v === void 0 ? "\u2014" : String(v);
43
+ const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)));
44
+ const line = (vals) => vals.map((v, i) => v.padEnd(w[i])).join(" ").trimEnd();
45
+ return [bold(line(cols.map((c) => c.toUpperCase()))), ...rows.map((r) => line(cols.map((c) => cell(r[c]))))].join("\n");
46
+ }
47
+ function fail(e) {
48
+ const err = e instanceof CliError ? e : new CliError(e instanceof Error ? e.message : String(e));
49
+ if (globals.json) {
50
+ process.stdout.write(JSON.stringify({ error: err.message, code: err.code, ...err.hint ? { hint: err.hint } : {} }) + "\n");
51
+ } else {
52
+ info(red("Error: ") + err.message);
53
+ if (err.hint) info(dim(err.hint));
54
+ }
55
+ process.exit(err.code);
56
+ }
57
+ async function openUrl(url) {
58
+ const { spawn } = await import("node:child_process");
59
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
60
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
61
+ try {
62
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {
63
+ }).unref();
64
+ } catch {
65
+ }
66
+ }
67
+
68
+ // src/auth.ts
69
+ var CLIENT_ID = "ghosty-cli";
70
+ var SCOPES = "profile agents:read agents:write files";
71
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
72
+ var CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "ghosty", "config.json");
73
+ async function readConfig() {
74
+ try {
75
+ return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
76
+ } catch {
77
+ return {};
78
+ }
79
+ }
80
+ async function writeConfig(c) {
81
+ await mkdir(dirname(CONFIG_PATH), { recursive: true });
82
+ await writeFile(CONFIG_PATH, JSON.stringify(c, null, 2), { mode: 384 });
83
+ await chmod(CONFIG_PATH, 384);
84
+ }
85
+ var clearConfig = () => rm(CONFIG_PATH, { force: true });
86
+ async function baseUrl() {
87
+ const url = process.env.GHOSTY_URL || (await readConfig()).baseUrl || "https://www.ghosty.studio";
88
+ return url.replace(/\/+$/, "");
89
+ }
90
+ var explicitToken = () => process.env.GHOSTY_TOKEN || globals.token;
91
+ var b64url = (buf) => buf.toString("base64url");
92
+ async function tokenRequest(base, form) {
93
+ const res = await fetch(`${base}/oauth2/token`, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/x-www-form-urlencoded" },
96
+ body: new URLSearchParams({ client_id: CLIENT_ID, ...form })
97
+ });
98
+ const body = await res.json().catch(() => ({}));
99
+ if (!res.ok || !body.access_token) {
100
+ throw new CliError(`Login failed (${body.error ?? res.status}).`, EXIT.AUTH, "Run: ghosty login");
101
+ }
102
+ return body;
103
+ }
104
+ var CLOSE_PAGE = (ok, base) => `<!doctype html><html lang="es"><meta charset="utf-8">
105
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>Ghosty CLI</title>
106
+ <body style="font-family:system-ui,sans-serif;background:#F4F5FB;color:#191A20;display:grid;place-items:center;min-height:100vh;margin:0;padding:16px;box-sizing:border-box">
107
+ <div style="text-align:center;max-width:420px">
108
+ <img src="${base}/mascot.png" alt="Ghosty" width="112" height="133" style="display:block;margin:0 auto 20px">
109
+ <h1 style="font-weight:700;font-size:28px;margin:0 0 8px">${ok ? "\xA1Listo, ya entraste!" : "No se pudo entrar"}</h1>
110
+ <p style="margin:0;color:#4b4d57;line-height:1.5">${ok ? "Ya puedes cerrar esta pesta\xF1a y volver a la terminal." : "Vuelve a la terminal y corre <code>ghosty login</code> otra vez."}</p>
111
+ </div>`;
112
+ async function login() {
113
+ const base = await baseUrl();
114
+ const verifier = b64url(randomBytes(32));
115
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
116
+ const state = b64url(randomBytes(16));
117
+ const { code, redirectUri } = await new Promise((resolve, reject) => {
118
+ const server = createServer((req, res) => {
119
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
120
+ if (u.pathname !== "/cb") {
121
+ res.writeHead(404).end();
122
+ return;
123
+ }
124
+ const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
125
+ res.writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" }).end(CLOSE_PAGE(ok, base));
126
+ clearTimeout(timer);
127
+ server.close();
128
+ if (ok) resolve({ code: u.searchParams.get("code"), redirectUri: redirectUri2 });
129
+ else reject(new CliError(`Login was not completed (${u.searchParams.get("error") ?? "state mismatch"}).`, EXIT.AUTH, "Run: ghosty login"));
130
+ });
131
+ let redirectUri2 = "";
132
+ const timer = setTimeout(() => {
133
+ server.close();
134
+ reject(new CliError("Login timed out after 5 minutes.", EXIT.AUTH, "Run: ghosty login"));
135
+ }, LOGIN_TIMEOUT_MS);
136
+ server.listen(0, "127.0.0.1", () => {
137
+ const port = server.address().port;
138
+ redirectUri2 = `http://127.0.0.1:${port}/cb`;
139
+ const url = `${base}/oauth2/authorize?` + new URLSearchParams({
140
+ response_type: "code",
141
+ client_id: CLIENT_ID,
142
+ redirect_uri: redirectUri2,
143
+ scope: SCOPES,
144
+ state,
145
+ code_challenge: challenge,
146
+ code_challenge_method: "S256"
147
+ });
148
+ if (globals.json) process.stdout.write(JSON.stringify({ event: "login_url", url }) + "\n");
149
+ info(`${lavender("\u203A")} Opening your browser to sign in to Ghosty\u2026`);
150
+ info(dim(` If it doesn't open, visit: ${url}`));
151
+ void openUrl(url);
152
+ });
153
+ });
154
+ const tok = await tokenRequest(base, {
155
+ grant_type: "authorization_code",
156
+ code,
157
+ redirect_uri: redirectUri,
158
+ code_verifier: verifier
159
+ });
160
+ const prev = await readConfig();
161
+ const cfg = {
162
+ ...prev.baseUrl ? { baseUrl: prev.baseUrl } : {},
163
+ accessToken: tok.access_token,
164
+ refreshToken: tok.refresh_token,
165
+ expiresAt: Date.now() + (tok.expires_in ?? 3600) * 1e3
166
+ };
167
+ await writeConfig(cfg);
168
+ return cfg;
169
+ }
170
+ function hasLocalBrowser() {
171
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.CI) return false;
172
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
173
+ return true;
174
+ }
175
+ async function loginDevice() {
176
+ const base = await baseUrl();
177
+ const res = await fetch(`${base}/oauth2/device`, {
178
+ method: "POST",
179
+ headers: { "content-type": "application/x-www-form-urlencoded" },
180
+ body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPES })
181
+ });
182
+ const d = await res.json().catch(() => ({}));
183
+ if (!res.ok || !d.device_code) throw new CliError(`Could not start the login (${d.error ?? res.status}).`, EXIT.AUTH);
184
+ if (globals.json) process.stdout.write(JSON.stringify({ event: "login_url", url: d.verification_uri_complete, code: d.user_code, method: "device" }) + "\n");
185
+ info(`${lavender("\u203A")} Open this link on any device and confirm the code ${lavender(d.user_code)}:`);
186
+ info(` ${d.verification_uri_complete}`);
187
+ let interval = (d.interval ?? 5) * 1e3;
188
+ const deadline = Date.now() + (d.expires_in ?? 600) * 1e3;
189
+ while (Date.now() < deadline) {
190
+ await new Promise((r) => setTimeout(r, interval));
191
+ const t = await fetch(`${base}/oauth2/token`, {
192
+ method: "POST",
193
+ headers: { "content-type": "application/x-www-form-urlencoded" },
194
+ body: new URLSearchParams({ client_id: CLIENT_ID, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: d.device_code })
195
+ });
196
+ const body = await t.json().catch(() => ({}));
197
+ if (t.ok && body.access_token) {
198
+ const prev = await readConfig();
199
+ const cfg = {
200
+ ...prev.baseUrl ? { baseUrl: prev.baseUrl } : {},
201
+ accessToken: body.access_token,
202
+ refreshToken: body.refresh_token,
203
+ expiresAt: Date.now() + (body.expires_in ?? 3600) * 1e3
204
+ };
205
+ await writeConfig(cfg);
206
+ return cfg;
207
+ }
208
+ if (body.error === "authorization_pending") continue;
209
+ if (body.error === "slow_down") {
210
+ interval += 5e3;
211
+ continue;
212
+ }
213
+ if (body.error === "access_denied") throw new CliError("The login was rejected.", EXIT.AUTH, "Run: ghosty login");
214
+ throw new CliError(`Login failed (${body.error ?? t.status}).`, EXIT.AUTH, "Run: ghosty login");
215
+ }
216
+ throw new CliError("The code expired before it was confirmed.", EXIT.AUTH, "Run: ghosty login");
217
+ }
218
+ async function refresh(cfg) {
219
+ if (!cfg.refreshToken) return null;
220
+ try {
221
+ const tok = await tokenRequest(await baseUrl(), { grant_type: "refresh_token", refresh_token: cfg.refreshToken });
222
+ const next = {
223
+ ...cfg,
224
+ accessToken: tok.access_token,
225
+ refreshToken: tok.refresh_token ?? cfg.refreshToken,
226
+ expiresAt: Date.now() + (tok.expires_in ?? 3600) * 1e3
227
+ };
228
+ await writeConfig(next);
229
+ return next;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+ async function logout() {
235
+ const cfg = await readConfig();
236
+ if (cfg.refreshToken) {
237
+ await fetch(`${await baseUrl()}/oauth2/revoke`, {
238
+ method: "POST",
239
+ headers: { "content-type": "application/x-www-form-urlencoded" },
240
+ body: new URLSearchParams({ client_id: CLIENT_ID, token: cfg.refreshToken })
241
+ }).catch(() => {
242
+ });
243
+ }
244
+ await clearConfig();
245
+ }
246
+ var interactive = () => Boolean(process.stdin.isTTY && process.stderr.isTTY) && !globals.json;
247
+
248
+ // src/api.ts
249
+ async function currentToken() {
250
+ const explicit = explicitToken();
251
+ if (explicit) return explicit;
252
+ let cfg = await readConfig();
253
+ if (!cfg.accessToken) {
254
+ if (!interactive()) throw new CliError("Not logged in.", EXIT.AUTH, "Run: ghosty login (or set GHOSTY_TOKEN)");
255
+ info(dim("Not logged in yet \u2014 let's fix that."));
256
+ cfg = await (hasLocalBrowser() ? login() : loginDevice());
257
+ }
258
+ if (cfg.expiresAt !== void 0 && cfg.expiresAt - Date.now() < 6e4) cfg = await refresh(cfg) ?? cfg;
259
+ return cfg.accessToken;
260
+ }
261
+ async function api(path, opts = {}) {
262
+ const res = await request(path, opts);
263
+ const text = await res.text();
264
+ if (!text) return void 0;
265
+ try {
266
+ return JSON.parse(text);
267
+ } catch {
268
+ return text;
269
+ }
270
+ }
271
+ async function request(path, opts = {}, retried = false) {
272
+ const base = await baseUrl();
273
+ const token = await currentToken();
274
+ const res = await fetch(`${base}${path}`, {
275
+ method: opts.method ?? (opts.body !== void 0 || opts.raw !== void 0 ? "POST" : "GET"),
276
+ headers: {
277
+ authorization: `Bearer ${token}`,
278
+ "user-agent": `ghosty-cli/${"0.1.0"}`,
279
+ ...opts.body !== void 0 ? { "content-type": "application/json" } : {},
280
+ ...opts.headers
281
+ },
282
+ body: opts.raw ?? (opts.body !== void 0 ? JSON.stringify(opts.body) : void 0),
283
+ signal: opts.signal
284
+ });
285
+ if (res.status === 401 && !retried && !explicitToken()) {
286
+ const cfg = await refresh(await readConfig());
287
+ if (cfg) return request(path, opts, true);
288
+ }
289
+ if (!res.ok) throw await toError(res);
290
+ return res;
291
+ }
292
+ async function toError(res) {
293
+ const text = await res.text().catch(() => "");
294
+ let body = {};
295
+ try {
296
+ body = JSON.parse(text);
297
+ } catch {
298
+ }
299
+ const detail = body.message || body.error_description || body.error || text.slice(0, 300) || res.statusText;
300
+ if (res.status === 401) return new CliError("Your session is not valid anymore.", EXIT.AUTH, "Run: ghosty login");
301
+ if (res.status === 403) return new CliError(`Not allowed: ${detail}`, EXIT.API, "Run: ghosty login (to refresh your permissions)");
302
+ if (res.status === 404) return new CliError("Not found.", EXIT.API, "Check the agent id (ghosty agents ls) or that your token belongs to that agent.");
303
+ if (body.error === "agente_sin_maquina") {
304
+ return new CliError("This agent's engine has no machine of its own: files, skills, MCP and restart are not available for it.", EXIT.API, "Name, model and prompt can still be changed: ghosty agents set <id> \u2026");
305
+ }
306
+ return new CliError(`${res.status} ${detail}`, EXIT.API);
307
+ }
308
+
309
+ // src/commands/agents.ts
310
+ import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
311
+ var enc = encodeURIComponent;
312
+ var agentPath = (id, rest = "") => `/api/v2/agents/${enc(id)}${rest}`;
313
+ var need = (v, what, example) => {
314
+ if (!v) throw usage(`Missing ${what}.`, `Example: ${example}`);
315
+ return v;
316
+ };
317
+ async function agentsLs() {
318
+ const r = await api("/api/v2/me/agents?client=cli");
319
+ const rows = r.agentes.map((a) => ({
320
+ id: a.id,
321
+ name: a.nombre,
322
+ engine: a.motor ?? a.origen,
323
+ source: a.origen,
324
+ lastActivity: a.ultimaActividad ?? null,
325
+ ...a.compartidoPor ? { sharedBy: a.compartidoPor } : {}
326
+ }));
327
+ out(rows, () => table(rows, ["id", "name", "engine", "lastActivity"]));
328
+ }
329
+ async function agentsGet(id, fields) {
330
+ id = need(id, "agent id", "ghosty agents get <agent-id>");
331
+ const q = fields ? `?fields=${enc(fields)}` : "?full=1";
332
+ out(await api(agentPath(id, q)));
333
+ }
334
+ async function agentsSet(id, v) {
335
+ id = need(id, "agent id", "ghosty agents set <agent-id> --prompt-file PROMPT.md");
336
+ const body = {};
337
+ if (v.name) body.name = v.name;
338
+ if (v.model) body.model = v.model;
339
+ if (v.prompt) body.prompt = v.prompt;
340
+ if (v["prompt-file"]) body.prompt = await readFile2(v["prompt-file"], "utf8");
341
+ if (!Object.keys(body).length) throw usage("Nothing to change.", "Use --name, --model, --prompt or --prompt-file.");
342
+ const r = await api(agentPath(id), { method: "PATCH", body });
343
+ out(r, () => `${lavender("\u2713")} Updated ${Object.keys(body).join(", ")}.`);
344
+ }
345
+ async function agentsRestart(id) {
346
+ id = need(id, "agent id", "ghosty agents restart <agent-id>");
347
+ out(await api(agentPath(id, "/restart"), { method: "POST", body: {} }), () => `${lavender("\u2713")} Restarting \u2014 the next turn uses the new config.`);
348
+ }
349
+ async function tryTurn(id, text, v) {
350
+ id = need(id, "agent id", 'ghosty try <agent-id> "hello"');
351
+ if (!text && !v.reset) throw usage("Missing message.", 'Example: ghosty try <agent-id> "hello"');
352
+ if (!process.stdout.isTTY || !text) {
353
+ } else info(dim("Thinking\u2026 (a test turn can take up to 3 minutes)"));
354
+ const r = await api(agentPath(id, "/try"), {
355
+ body: { text, session: v.session, reset: v.reset, ...v.whatsapp ? { as: "whatsapp" } : {} }
356
+ });
357
+ out(r, () => (r.text ?? "") + (r.error ? `
358
+ ${dim(`(warning: ${r.error})`)}` : ""));
359
+ }
360
+ async function filesCmd(sub, id, path, v) {
361
+ const ex = "ghosty files ls <agent-id> | get <agent-id> <path> | put <agent-id> <path> --file <local> | rm <agent-id> <path>";
362
+ id = need(id, "agent id", ex);
363
+ const p = (path ?? "").replace(/^\/+/, "");
364
+ switch (sub) {
365
+ case "ls": {
366
+ const r = await api(agentPath(id, `/files/${p}`));
367
+ const cols = r.files?.length ? Object.keys(r.files[0]).slice(0, 4) : ["path"];
368
+ out(r, () => `${dim(r.dir)}
369
+ ${table(r.files ?? [], cols)}`);
370
+ return;
371
+ }
372
+ case "get": {
373
+ need(path, "path", ex);
374
+ const res = await request(agentPath(id, `/files/${p}`));
375
+ const bytes = Buffer.from(await res.arrayBuffer());
376
+ if (v.out) {
377
+ await writeFile2(v.out, bytes);
378
+ out({ path: p, saved: v.out, bytes: bytes.length }, () => `${lavender("\u2713")} Saved ${bytes.length} bytes to ${v.out}`);
379
+ } else process.stdout.write(bytes);
380
+ return;
381
+ }
382
+ case "put": {
383
+ need(path, "path", ex);
384
+ const data = v.file ? await readFile2(v.file) : await readStdin();
385
+ const r = await api(agentPath(id, `/files/${p}`), { method: "PUT", raw: new Uint8Array(data), headers: { "content-type": "application/octet-stream" } });
386
+ out(r ?? { path: p, bytes: data.length }, () => `${lavender("\u2713")} Wrote ${p} (${data.length} bytes). Run \`ghosty agents restart ${id}\` if the agent must reload it.`);
387
+ return;
388
+ }
389
+ case "rm": {
390
+ need(path, "path", ex);
391
+ out(await api(agentPath(id, `/files/${p}`), { method: "DELETE" }) ?? { deleted: p }, () => `${lavender("\u2713")} Deleted ${p}`);
392
+ return;
393
+ }
394
+ default:
395
+ throw usage(`Unknown files subcommand: ${sub ?? "(none)"}.`, ex);
396
+ }
397
+ }
398
+ async function readStdin() {
399
+ if (process.stdin.isTTY) throw usage("Nothing to upload.", "Use --file <local> or pipe the content through stdin.");
400
+ const parts = [];
401
+ for await (const c of process.stdin) parts.push(c);
402
+ return Buffer.concat(parts);
403
+ }
404
+ async function skillsCmd(sub, id, slug, v) {
405
+ const ex = "ghosty skills ls <agent-id> | add <agent-id> <slug> [--file SKILL.md] | rm <agent-id> <slug>";
406
+ id = need(id, "agent id", ex);
407
+ switch (sub) {
408
+ case "ls":
409
+ out(await api(agentPath(id, "/skills")));
410
+ return;
411
+ case "add": {
412
+ slug = need(slug, "skill slug", ex);
413
+ const body = v.file ? { markdown: await readFile2(v.file, "utf8") } : { fromCatalog: true };
414
+ out(await api(agentPath(id, `/skills/${enc(slug)}`), { method: "PUT", body }), () => `${lavender("\u2713")} Skill ${slug} installed. Run \`ghosty agents restart ${id}\` to load it.`);
415
+ return;
416
+ }
417
+ case "rm":
418
+ slug = need(slug, "skill slug", ex);
419
+ out(await api(agentPath(id, `/skills/${enc(slug)}`), { method: "DELETE" }) ?? { removed: slug }, () => `${lavender("\u2713")} Skill ${slug} removed.`);
420
+ return;
421
+ default:
422
+ throw usage(`Unknown skills subcommand: ${sub ?? "(none)"}.`, ex);
423
+ }
424
+ }
425
+ async function mcpCmd(sub, id, v) {
426
+ const ex = "ghosty mcp get <agent-id> | set <agent-id> --file servers.json";
427
+ id = need(id, "agent id", ex);
428
+ if (sub === "get") return out(await api(agentPath(id, "/mcp")));
429
+ if (sub === "set") {
430
+ const file = need(v.file, "--file", ex);
431
+ const parsed = JSON.parse(await readFile2(file, "utf8"));
432
+ const body = { servers: Array.isArray(parsed) ? parsed : parsed.servers ?? [] };
433
+ return out(await api(agentPath(id, "/mcp"), { method: "PUT", body }), () => `${lavender("\u2713")} MCP servers replaced. Run \`ghosty agents restart ${id}\` to load them.`);
434
+ }
435
+ throw usage(`Unknown mcp subcommand: ${sub ?? "(none)"}.`, ex);
436
+ }
437
+
438
+ // src/commands/chat.ts
439
+ import { randomUUID } from "node:crypto";
440
+ import { createInterface } from "node:readline/promises";
441
+ import { readFile as readFile3 } from "node:fs/promises";
442
+ import { basename } from "node:path";
443
+
444
+ // src/sse.ts
445
+ async function* readSse(body) {
446
+ const decoder = new TextDecoder();
447
+ let buf = "";
448
+ for await (const chunk of body) {
449
+ buf += decoder.decode(chunk, { stream: true });
450
+ let idx;
451
+ while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
452
+ const raw = buf.slice(0, idx);
453
+ buf = buf.slice(idx + (buf[idx] === "\r" ? 4 : 2));
454
+ let event = "message";
455
+ const data = [];
456
+ for (const line of raw.split(/\r?\n/)) {
457
+ if (!line || line.startsWith(":")) continue;
458
+ const colon = line.indexOf(":");
459
+ const field = colon === -1 ? line : line.slice(0, colon);
460
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
461
+ if (field === "event") event = value;
462
+ else if (field === "data") data.push(value);
463
+ }
464
+ if (data.length || event !== "message") yield { event, data: data.join("\n") };
465
+ }
466
+ }
467
+ }
468
+ function parseData(e) {
469
+ try {
470
+ return JSON.parse(e.data || "{}");
471
+ } catch {
472
+ return {};
473
+ }
474
+ }
475
+
476
+ // src/commands/chat.ts
477
+ var enc2 = encodeURIComponent;
478
+ var convPath = (agent, rest = "") => `/api/v2/me/agents/${enc2(agent)}/conversations${rest}`;
479
+ async function conversationsLs(agent) {
480
+ if (!agent) throw usage("Missing agent id.", "Example: ghosty conversations ls <agent-id>");
481
+ const r = await api(convPath(agent));
482
+ const rows = r.conversaciones.map((c) => ({ id: c.id, title: c.titulo, updated: c.actualizada }));
483
+ out(rows, () => table(rows, ["id", "title", "updated"]));
484
+ }
485
+ async function turn(agent, sid, text, first) {
486
+ const turnId = randomUUID();
487
+ const ctrl = new AbortController();
488
+ const res = await request(convPath(agent, `/${enc2(sid)}/events`), { signal: ctrl.signal, headers: { accept: "text/event-stream" } });
489
+ let cancelled = false;
490
+ const onSigint = () => {
491
+ if (cancelled) process.exit(130);
492
+ cancelled = true;
493
+ info(dim("\n(cancelling\u2026 press Ctrl-C again to quit)"));
494
+ void api(convPath(agent, `/${enc2(sid)}/cancel`), { method: "POST", body: {} }).catch(() => {
495
+ });
496
+ };
497
+ process.on("SIGINT", onSigint);
498
+ const sent = api(convPath(agent, `/${enc2(sid)}/messages`), { body: { content: text, turnId, primerTurno: first } });
499
+ let answer = "";
500
+ let usageData;
501
+ let ours = false;
502
+ try {
503
+ await sent;
504
+ for await (const ev of readSse(res.body)) {
505
+ const d = parseData(ev);
506
+ if (d.turnId) ours = d.turnId === turnId;
507
+ if (!ours) continue;
508
+ if (ev.event === "chunk" && d.text) {
509
+ answer += d.text;
510
+ if (globals.json) process.stdout.write(JSON.stringify({ event: "chunk", text: d.text }) + "\n");
511
+ else process.stdout.write(d.text);
512
+ } else if (ev.event === "tool" && d.title) {
513
+ if (globals.json) process.stdout.write(JSON.stringify({ event: "tool", ...d }) + "\n");
514
+ else if (!d.status || d.status === "pending" || d.status === "in_progress") info(dim(` \u2699 ${d.title}`));
515
+ } else if (ev.event === "usage") {
516
+ usageData = d;
517
+ } else if (ev.event === "error") {
518
+ throw new CliError(d.message ?? "The turn failed.", EXIT.API);
519
+ } else if (ev.event === "done") {
520
+ break;
521
+ }
522
+ }
523
+ } finally {
524
+ process.off("SIGINT", onSigint);
525
+ ctrl.abort();
526
+ }
527
+ if (globals.json) process.stdout.write(JSON.stringify({ event: "done", conversation: sid, turnId, text: answer, usage: usageData }) + "\n");
528
+ else if (!answer.endsWith("\n")) process.stdout.write("\n");
529
+ return { text: answer, usage: usageData };
530
+ }
531
+ async function chat(agent, message, v) {
532
+ if (!agent) throw usage("Missing agent id.", 'Example: ghosty chat <agent-id> "hello" (ghosty agents ls to find it)');
533
+ let sid = v.conversation;
534
+ let first = false;
535
+ if (!sid) {
536
+ sid = (await api(convPath(agent), { method: "POST", body: {} })).id;
537
+ first = true;
538
+ if (!globals.json) info(dim(`conversation ${sid} (continue it with --conversation ${sid})`));
539
+ }
540
+ if (message) {
541
+ await turn(agent, sid, message, first);
542
+ return;
543
+ }
544
+ if (!process.stdin.isTTY) {
545
+ const piped = (await readAll()).trim();
546
+ if (!piped) throw usage("Nothing to send.", 'Example: ghosty chat <agent-id> "hello"');
547
+ await turn(agent, sid, piped, first);
548
+ return;
549
+ }
550
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
551
+ info(dim("Type your message. Ctrl-C cancels a turn, Ctrl-D exits."));
552
+ for (; ; ) {
553
+ let line;
554
+ try {
555
+ line = await rl.question(lavender("\u203A "));
556
+ } catch {
557
+ break;
558
+ }
559
+ if (!line.trim()) continue;
560
+ rl.pause();
561
+ await turn(agent, sid, line, first).catch((e) => info(e instanceof Error ? e.message : String(e)));
562
+ first = false;
563
+ rl.resume();
564
+ }
565
+ rl.close();
566
+ }
567
+ async function readAll() {
568
+ let s = "";
569
+ for await (const c of process.stdin) s += c;
570
+ return s;
571
+ }
572
+ async function meFiles(sub, arg, v) {
573
+ const ex = "ghosty me files ls [--kind image|video|audio|document|other] | upload <file> | rm <file-id>";
574
+ switch (sub) {
575
+ case "ls": {
576
+ const r = await api(
577
+ `/api/v2/me/files${v.kind ? `?kind=${enc2(v.kind)}` : ""}`
578
+ );
579
+ out(r, () => {
580
+ const rows = r.files.map((f) => ({ id: f.id, name: f.name, kind: f.kind, size: f.size, created: f.createdAt.slice(0, 10) }));
581
+ return `${table(rows, ["id", "name", "kind", "size", "created"])}
582
+ ${dim(`${(r.storage.usedBytes / 1e9).toFixed(2)} GB of ${r.storage.limitGb} GB used`)}`;
583
+ });
584
+ return;
585
+ }
586
+ case "upload": {
587
+ if (!arg) throw usage("Missing file.", ex);
588
+ const bytes = await readFile3(arg);
589
+ const r = await api(`/api/v2/me/files?nombre=${enc2(basename(arg))}`, {
590
+ method: "POST",
591
+ raw: new Uint8Array(bytes),
592
+ headers: { "content-type": "application/octet-stream" }
593
+ });
594
+ out(r, () => `${lavender("\u2713")} Uploaded ${r.name} (${r.id})`);
595
+ return;
596
+ }
597
+ case "rm": {
598
+ if (!arg) throw usage("Missing file id.", ex);
599
+ out(await api(`/api/v2/me/files/${enc2(arg)}`, { method: "DELETE" }) ?? { deleted: arg }, () => `${lavender("\u2713")} Deleted ${arg}`);
600
+ return;
601
+ }
602
+ default:
603
+ throw usage(`Unknown files subcommand: ${sub ?? "(none)"}.`, ex);
604
+ }
605
+ }
606
+
607
+ // src/index.ts
608
+ var HELP = `${bold("Usage:")} ghosty <command> [options]
609
+
610
+ ${bold("Session")}
611
+ login Sign in with your browser (OAuth2). An agent can run it for you.
612
+ --device Sign in from another device with a link + code (auto over SSH/CI)
613
+ logout Sign out and revoke this device's session
614
+ whoami Show who you are signed in as
615
+
616
+ ${bold("Agents")}
617
+ agents ls List your agents
618
+ agents get <id> Show an agent's config [--fields name,model,prompt]
619
+ agents set <id> Change identity [--name] [--model] [--prompt | --prompt-file FILE]
620
+ agents restart <id> Restart so the next turn loads new files/skills/MCP
621
+
622
+ ${bold("Configure an agent")}
623
+ files ls|get|put|rm <id> [path] Files in the agent's machine [--file LOCAL] [--out LOCAL]
624
+ skills ls|add|rm <id> [slug] Skills (add from catalog, or --file SKILL.md)
625
+ mcp get|set <id> MCP servers [--file servers.json]
626
+
627
+ ${bold("Talk to an agent")}
628
+ try <id> "message" One test turn, prints the full answer [--session] [--reset] [--whatsapp]
629
+ chat <id> ["message"] Stream a conversation (no message = interactive) [--conversation ID]
630
+ conversations ls <id> List conversations
631
+
632
+ ${bold("Your account")}
633
+ me files ls|upload|rm Files in your account [--kind image|video|audio|document|other]
634
+ docs [topic] Open the docs
635
+
636
+ ${bold("Global options")}
637
+ --json Machine-readable output (stdout is JSON only)
638
+ --token <token> Use this token (or env GHOSTY_TOKEN). A gat_\u2026 agent token reaches only that agent.
639
+ -h, --help Show help
640
+ -v, --version Show version
641
+
642
+ ${bold("Exit codes")} 0 ok \xB7 1 API error \xB7 2 usage error \xB7 3 not signed in
643
+ ${dim("Docs: https://www.ghosty.studio/docs/cli")}`;
644
+ function showHelp() {
645
+ if (process.stdout.isTTY && !process.env.NO_COLOR && !globals.json) process.stdout.write(BANNER + "\n\n");
646
+ process.stdout.write(HELP + "\n");
647
+ }
648
+ async function main() {
649
+ const { values: v, positionals: pos } = parseArgs({
650
+ allowPositionals: true,
651
+ strict: true,
652
+ options: {
653
+ json: { type: "boolean" },
654
+ token: { type: "string" },
655
+ help: { type: "boolean", short: "h" },
656
+ version: { type: "boolean", short: "v" },
657
+ device: { type: "boolean" },
658
+ fields: { type: "string" },
659
+ name: { type: "string" },
660
+ model: { type: "string" },
661
+ prompt: { type: "string" },
662
+ "prompt-file": { type: "string" },
663
+ file: { type: "string" },
664
+ out: { type: "string" },
665
+ session: { type: "string" },
666
+ reset: { type: "boolean" },
667
+ whatsapp: { type: "boolean" },
668
+ conversation: { type: "string" },
669
+ kind: { type: "string" }
670
+ }
671
+ });
672
+ globals.json = Boolean(v.json);
673
+ globals.token = v.token;
674
+ if (v.version) return out({ version: "0.1.0" }, () => "0.1.0");
675
+ const [cmd, sub, a1, a2] = pos;
676
+ if (!cmd || v.help || cmd === "help") return showHelp();
677
+ switch (cmd) {
678
+ case "login": {
679
+ if (v.token) {
680
+ throw usage("--token is used per command, not saved.", "Set GHOSTY_TOKEN=<token> in your environment instead.");
681
+ }
682
+ await (v.device || !hasLocalBrowser() ? loginDevice() : login());
683
+ const me = await api("/api/v2/me");
684
+ const cfg = await readConfig();
685
+ await writeConfig({ ...cfg, email: me.email });
686
+ return out({ event: "logged_in", email: me.email }, () => `${lavender("\u2713")} Signed in as ${bold(me.email)}`);
687
+ }
688
+ case "logout":
689
+ await logout();
690
+ return out({ loggedOut: true }, () => `${lavender("\u2713")} Signed out.`);
691
+ case "whoami": {
692
+ if (explicitToken()?.startsWith("gat_")) return out({ tokenType: "agent" }, () => "Using an agent token (gat_\u2026): it reaches only its own agent.");
693
+ const me = await api("/api/v2/me");
694
+ return out(me, () => `${bold(me.email)}${dim(` \xB7 ${new URL(process.env.GHOSTY_URL || "https://www.ghosty.studio").host}`)}`);
695
+ }
696
+ case "agents":
697
+ if (sub === "ls" || sub === "list" || !sub) return agentsLs();
698
+ if (sub === "get") return agentsGet(a1, v.fields);
699
+ if (sub === "set") return agentsSet(a1, v);
700
+ if (sub === "restart") return agentsRestart(a1);
701
+ throw usage(`Unknown agents subcommand: ${sub}.`, "Try: ghosty agents ls | get | set | restart");
702
+ case "files":
703
+ return filesCmd(sub, a1, a2, v);
704
+ case "skills":
705
+ return skillsCmd(sub, a1, a2, v);
706
+ case "mcp":
707
+ return mcpCmd(sub, a1, v);
708
+ case "try":
709
+ return tryTurn(sub, a1, v);
710
+ case "chat":
711
+ return chat(sub, a1, v);
712
+ case "conversations":
713
+ if (sub === "ls" || sub === "list") return conversationsLs(a1);
714
+ throw usage(`Unknown conversations subcommand: ${sub ?? "(none)"}.`, "Try: ghosty conversations ls <agent-id>");
715
+ case "me":
716
+ if (sub === "files") return meFiles(a1, a2, v);
717
+ throw usage(`Unknown me subcommand: ${sub ?? "(none)"}.`, "Try: ghosty me files ls");
718
+ case "docs": {
719
+ const url = `${await baseUrl()}/docs${sub ? `/${encodeURIComponent(sub)}` : "/cli"}`;
720
+ if (globals.json) return out({ url });
721
+ info(`${lavender("\u203A")} ${url}`);
722
+ return openUrl(url);
723
+ }
724
+ default:
725
+ throw usage(`Unknown command: ${cmd}.`, "Run: ghosty --help");
726
+ }
727
+ }
728
+ main().then(
729
+ () => process.exit(EXIT.OK),
730
+ (e) => {
731
+ if (e && typeof e === "object" && "code" in e && String(e.code).startsWith("ERR_PARSE_ARGS")) {
732
+ fail(usage(e.message, "Run: ghosty --help"));
733
+ }
734
+ fail(e);
735
+ }
736
+ );
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ghostystudio/cli",
3
+ "version": "0.1.0",
4
+ "description": "Official Ghosty Studio CLI — configure, test and chat with your agents from the terminal. Built for humans and coding agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ghosty": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "scripts": {
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
+ "prepack": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "ghosty",
22
+ "ghosty-studio",
23
+ "cli",
24
+ "agents",
25
+ "ai-agents",
26
+ "coding-agents",
27
+ "oauth2"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/blissito/ghosty-studio.git",
33
+ "directory": "packages/cli"
34
+ },
35
+ "homepage": "https://www.ghosty.studio/docs/cli"
36
+ }