grok-bot-cli 0.2.3 → 0.3.1

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 CHANGED
@@ -14,7 +14,7 @@ Manage [Grok Bot](https://cursor.com/help/grok-bot/plans) agents, groups, and me
14
14
  npm install --global grok-bot-cli
15
15
  ```
16
16
 
17
- Requires Node.js 18+ and the Grok Bot desktop app on macOS or Linux. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. On Linux the app keeps its session under `~/.config/Grok Bot` (or `$XDG_CONFIG_HOME`); when it is stored in the system keyring, `gbot` reads the key with `secret-tool` (package `libsecret-tools`).
17
+ Requires Node.js 18+ and the Grok Bot desktop app on macOS, Linux, or Windows. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. On Linux the app keeps its session under `~/.config/Grok Bot` (or `$XDG_CONFIG_HOME`); when it is stored in the system keyring, `gbot` reads the key with `secret-tool` (package `libsecret-tools`). On Windows the session lives under `%APPDATA%\\Grok Bot` and decrypts with the app's DPAPI-wrapped Safe Storage key.
18
18
 
19
19
  ## Use
20
20
 
@@ -37,6 +37,105 @@ gbot bots delete Writer
37
37
 
38
38
  Run `gbot --help` for every command.
39
39
 
40
+ ## Gateway URL policy
41
+
42
+ By default `gbot` only sends credentials to expected hosts:
43
+
44
+ - **Gateway** URLs (box + API): `https` on `*.cursor.sh` / `*.cursor.com` / `*.cursorvm.com` (the box family EnsureSandBox returns).
45
+ - **Backend** URLs (`EnsureSandBox` / `CURSOR_API_BASE_URL`): `https` on `*.cursor.sh` / `*.cursor.com` only — never `*.cursorvm.com`, so a Cursor access token cannot be pointed at a box host.
46
+
47
+ - `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` — permit `http(s)://127.0.0.1`, `localhost`, and `::1` for **gateways** only (local/dev). Prints a one-shot stderr warning.
48
+ - `GROK_BOT_ALLOW_ANY_GATEWAY=1` — disable host checks (unsafe; for break-glass only). Prints a one-shot stderr warning.
49
+
50
+ All gateway / `EnsureSandBox` fetches use `redirect: "error"` so credentials are not followed across redirects.
51
+
52
+ ## Messaging Codex threads from Grok Bot
53
+
54
+ `gbot codex` attaches to a local [Codex app-server](https://learn.chatgpt.com/docs/app-server) daemon and injects messages into its threads with the documented JSON-RPC methods (`initialize`, `thread/list`, `thread/resume`, `turn/start`).
55
+
56
+ ```sh
57
+ codex app-server daemon start # once per machine session
58
+ gbot codex status # socket, daemon version, reachability
59
+ gbot codex list-threads --limit 10 # id, status, cwd, preview
60
+ gbot codex send <threadId> "Grok here: the build is green, please continue."
61
+ ```
62
+
63
+ `send` resumes the thread, starts a turn with your text, prints the turn id, and returns; Codex keeps working after `gbot` disconnects. Every command accepts `--json`.
64
+
65
+ **Which Codex you reach.** `gbot` connects to `$CODEX_HOME/app-server-control/app-server-control.sock` (default `~/.codex/...`) with a built-in WebSocket client. The daemon must be started by `codex app-server daemon start`. `list-threads` shows the threads recorded under `CODEX_HOME` (CLI, TUI, VS Code); `send` works on any of them that no other client currently holds open. Method and parameter names are pinned to the Codex release recorded in `src/codex-bridge.js` (`codex app-server generate-json-schema`); `status` prints the daemon and CLI versions so a stale daemon is visible, and `codex app-server daemon restart` picks up the installed CLI. Native Windows is not supported yet (AF_UNIX control socket); use WSL, Linux, or macOS.
66
+
67
+ **ChatGPT Desktop limitation.** Desktop runs its own private stdio app-server and does not publish the shared control socket, so external clients cannot reach live Desktop tasks. When the socket is absent, `gbot codex status` exits 1 and says so, naming the upstream issues: [openai/codex#41014](https://github.com/openai/codex/issues/41014) and [openai/codex#41112](https://github.com/openai/codex/issues/41112). `gbot` never reads Desktop's temporary `CODEX_APP_TOOLS_PIPE_PATH` sockets under `/tmp/codex-browser-use/`; that channel is private to Desktop.
68
+
69
+ **Failure modes.**
70
+
71
+ - Socket absent: no daemon, or Desktop-private mode. Start the daemon or wait for the upstream fixes.
72
+ - Unknown thread: `send` fails with "Unknown Codex thread"; use `list-threads`.
73
+ - Thread open elsewhere: a thread with an active writer (VS Code, TUI) fails with "open in another client"; close it there first.
74
+ - Approvals: `gbot` never approves commands or file changes on your behalf. If Codex asks while `gbot` is still connected, `send` refuses the request, exits 1, and tells you the turn id. `send` disconnects as soon as the turn starts, so later approval requests stay with the daemon for a Codex client to answer; for unattended sends set `approval_policy = "never"` in the daemon's `config.toml`.
75
+
76
+ ## Talking to Grok Bot from Codex
77
+
78
+ `plugin/` is an [Agent Bundle](https://scriptedalchemy.github.io/agent-bundle/) plugin
79
+ that gives Codex, Claude Code, and Cursor two MCP tools on a `grok-bot` server,
80
+ `gbot_send` and `gbot_thread`, plus a `talk-to-grok-bot` skill that tells the agent
81
+ when to ping a bot and how to word the message. The tools bundle this repository's
82
+ gateway client, so the installed plugin does not need `gbot` on `PATH`.
83
+
84
+ The plugin is not part of the npm package. From a clone of this repository, build
85
+ the artifact once, then install it into each host you use:
86
+
87
+ ```sh
88
+ git clone https://github.com/ScriptedAlchemy/grok-bot-cli.git
89
+ cd grok-bot-cli/plugin
90
+ npm install
91
+ npm run build
92
+ npx agent-bundle install codex --from artifact
93
+ npx agent-bundle install claude --from artifact
94
+ npx agent-bundle install cursor --from artifact
95
+ npx agent-bundle doctor --from artifact
96
+ ```
97
+
98
+ Add `--replace` to an install command to overwrite an earlier copy. `npm run check`
99
+ runs the plugin gates: source validation, build, artifact validation, typecheck, and
100
+ the route-unit tests, which drive both tools against a loopback fake gateway.
101
+
102
+ Auth resolves exactly as for `gbot`: `GROK_BOT_GATEWAY_URL` + `GROK_BOT_GATEWAY_TOKEN`,
103
+ then the Grok Bot app session, then `CURSOR_ACCESS_TOKEN`. The MCP server therefore
104
+ needs outbound HTTPS to the gateway host and read access to the app-session file
105
+ (`~/.config/Grok Bot` on Linux, `~/Library/Application Support/Grok Bot` on macOS).
106
+ A sandbox that blocks network egress or hides the home directory makes `gbot_send`
107
+ fail with the gateway error; run `gbot doctor` inside the same sandbox to see which
108
+ credential source is visible.
109
+
110
+ To route a repository's agents to a bot by default, add a note to its `AGENTS.md`:
111
+
112
+ ```md
113
+ ## Grok Bot
114
+
115
+ Use the `grok-bot` MCP tools to coordinate with Grok Bot. Send questions and
116
+ handoffs to the `General` bot with `gbot_send` (first line: who you are and what
117
+ you need), then read the reply with `gbot_thread`. Do not block a turn waiting
118
+ for it.
119
+ ```
120
+
121
+ ## Local history
122
+
123
+ Recording is **opt-in**. Set `GROK_BOT_HISTORY=on` (or `true`/`1`) to append successful
124
+ `send`/`thread`/`chat` observations as plaintext JSONL at
125
+ `~/.grok-bot-cli/history.jsonl`. Without that env, nothing is written.
126
+
127
+ ```bash
128
+ export GROK_BOT_HISTORY=on
129
+ gbot send Researcher "Investigate the startup timeout"
130
+ gbot thread Researcher
131
+ gbot history Researcher --search timeout
132
+ gbot history --path
133
+ ```
134
+
135
+ `history` works offline. Use `--history-dir` / `GROK_BOT_HISTORY_DIR` to relocate,
136
+ `--no-history` to skip one command. New dirs are `0700`, files `0600`. Conversation
137
+ text is recorded as you typed it; gateway credentials and raw response metadata are not.
138
+
40
139
  ## License
41
140
 
42
141
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-bot-cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "CLI for Grok Bot agents and groups: create, update, message, inspect threads, and automate cleanup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "changeset": "changeset",
15
15
  "gbot": "node src/cli.js",
16
16
  "release": "changeset publish",
17
- "test": "node --test"
17
+ "test": "node scripts/run-unit-tests.mjs"
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
@@ -43,6 +43,6 @@
43
43
  "LICENSE"
44
44
  ],
45
45
  "devDependencies": {
46
- "@changesets/cli": "3.0.1"
46
+ "@changesets/cli": "3.0.3"
47
47
  }
48
48
  }
@@ -6,10 +6,12 @@ import { join } from "node:path";
6
6
 
7
7
  // Chromium OSCrypt: "v10" = fixed password (macOS: the Keychain password; Linux: "peanuts"),
8
8
  // "v11" = Linux Secret Service password. macOS stretches with 1003 PBKDF2 rounds, Linux with 1.
9
+ // Windows v10 uses AES-256-GCM with a DPAPI-wrapped key from Local State (not PBKDF2).
9
10
  const SAFE_STORAGE_PREFIX_V10 = "v10";
10
11
  const SAFE_STORAGE_PREFIX_V11 = "v11";
12
+ const SAFE_STORAGE_PREFIX_V10_BUF = Buffer.from(SAFE_STORAGE_PREFIX_V10);
11
13
  const LINUX_BASIC_TEXT_PASSWORD = "peanuts";
12
- const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
14
+ const SUPPORTED_PLATFORMS = new Set(["darwin", "linux", "win32"]);
13
15
 
14
16
  export class GrokBotGatewaySessionError extends Error {
15
17
  constructor(code, message) {
@@ -80,23 +82,57 @@ export function decryptSafeStorageString(encryptedBase64, password, platform = "
80
82
  ]).toString("utf8");
81
83
  }
82
84
 
83
- export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
85
+ // Windows Chromium Safe Storage: "v10" + 12-byte nonce + AES-256-GCM ciphertext + 16-byte tag.
86
+ export function decryptWindowsSafeStorageString(encryptedBase64, key) {
87
+ const encrypted = Buffer.from(encryptedBase64, "base64");
88
+ if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX_V10_BUF)) {
89
+ throw new Error("Unsupported Grok Bot Safe Storage format.");
90
+ }
91
+
92
+ const decipher = crypto.createDecipheriv(
93
+ "aes-256-gcm",
94
+ key,
95
+ encrypted.subarray(3, 15),
96
+ );
97
+ decipher.setAuthTag(encrypted.subarray(-16));
98
+ return Buffer.concat([
99
+ decipher.update(encrypted.subarray(15, -16)),
100
+ decipher.final(),
101
+ ]).toString("utf8");
102
+ }
103
+
104
+ function grokBotAppDataPath(home, platform, env = {}) {
105
+ if (platform === "win32") {
106
+ const appData = env.APPDATA || join(home, "AppData/Roaming");
107
+ return join(appData, "Grok Bot");
108
+ }
84
109
  if (platform === "linux") {
85
110
  const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
86
- return join(configHome, "Grok Bot/gateway-descriptor.json");
111
+ return join(configHome, "Grok Bot");
87
112
  }
88
- return join(
89
- home,
90
- "Library/Application Support/Grok Bot/gateway-descriptor.json",
91
- );
113
+ return join(home, "Library/Application Support/Grok Bot");
114
+ }
115
+
116
+ export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
117
+ return join(grokBotAppDataPath(home, platform, env), "gateway-descriptor.json");
118
+ }
119
+
120
+ function sessionEnv({ env = process.env, appData } = {}) {
121
+ // Windows tests pass appData directly; empty string clears APPDATA to exercise the home fallback.
122
+ if (appData !== undefined) return { ...env, APPDATA: appData };
123
+ return env;
92
124
  }
93
125
 
94
126
  export function hasGrokBotGatewaySession({
95
127
  platform = process.platform,
96
128
  home = homedir(),
97
129
  env = process.env,
130
+ appData,
98
131
  } = {}) {
99
- return SUPPORTED_PLATFORMS.has(platform) && existsSync(grokBotGatewayDescriptorPath(home, platform, env));
132
+ return (
133
+ SUPPORTED_PLATFORMS.has(platform) &&
134
+ existsSync(grokBotGatewayDescriptorPath(home, platform, sessionEnv({ env, appData })))
135
+ );
100
136
  }
101
137
 
102
138
  function readKeychainPassword(platform = process.platform) {
@@ -114,27 +150,72 @@ function readKeychainPassword(platform = process.platform) {
114
150
  ).trimEnd();
115
151
  }
116
152
 
153
+ function unprotectWithDpapi(blob) {
154
+ const script =
155
+ "Add-Type -AssemblyName System.Security; " +
156
+ "$blob = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); " +
157
+ "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect($blob, $null, 'CurrentUser'))";
158
+ const out = execFileSync(
159
+ join(
160
+ process.env.SystemRoot ?? "C:\\Windows",
161
+ "System32/WindowsPowerShell/v1.0/powershell.exe",
162
+ ),
163
+ ["-NoProfile", "-NonInteractive", "-Command", script],
164
+ { input: blob.toString("base64"), encoding: "utf8" },
165
+ );
166
+ return Buffer.from(out.trim(), "base64");
167
+ }
168
+
169
+ function readWindowsSafeStorageKey(home, env, unprotectData) {
170
+ const path = join(grokBotAppDataPath(home, "win32", env), "Local State");
171
+ const encryptedKey = existsSync(path)
172
+ ? JSON.parse(readFileSync(path, "utf8")).os_crypt?.encrypted_key
173
+ : null;
174
+ const blob = Buffer.from(
175
+ typeof encryptedKey === "string" ? encryptedKey : "",
176
+ "base64",
177
+ );
178
+ if (blob.subarray(0, 5).toString("latin1") !== "DPAPI") {
179
+ throw new GrokBotGatewaySessionError(
180
+ "MISSING_SAFE_STORAGE_KEY",
181
+ "Grok Bot Local State has no Safe Storage key.",
182
+ );
183
+ }
184
+ return unprotectData(blob.subarray(5));
185
+ }
186
+
117
187
  export function loadGrokBotGatewaySession({
118
188
  platform = process.platform,
119
189
  home = homedir(),
120
190
  env = process.env,
191
+ appData,
121
192
  getKeychainPassword = readKeychainPassword,
193
+ unprotectData = unprotectWithDpapi,
122
194
  } = {}) {
123
195
  if (!SUPPORTED_PLATFORMS.has(platform)) return null;
124
196
 
125
- const path = grokBotGatewayDescriptorPath(home, platform, env);
197
+ const effectiveEnv = sessionEnv({ env, appData });
198
+ const path = grokBotGatewayDescriptorPath(home, platform, effectiveEnv);
126
199
  if (!existsSync(path)) return null;
127
200
 
128
201
  const wrapped = JSON.parse(readFileSync(path, "utf8"));
129
202
  const encrypted = encryptedPayload(wrapped);
130
- const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1");
131
- // Linux v10 is the keyring-less basic_text backend; no secret store to ask.
132
- const needsKeychain = !(platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10);
133
- const clear = decryptSafeStorageString(
134
- encrypted,
135
- needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
136
- platform,
137
- );
203
+ let clear;
204
+ if (platform === "win32") {
205
+ clear = decryptWindowsSafeStorageString(
206
+ encrypted,
207
+ readWindowsSafeStorageKey(home, effectiveEnv, unprotectData),
208
+ );
209
+ } else {
210
+ const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1");
211
+ // Linux v10 is the keyring-less basic_text backend; no secret store to ask.
212
+ const needsKeychain = !(platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10);
213
+ clear = decryptSafeStorageString(
214
+ encrypted,
215
+ needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
216
+ platform,
217
+ );
218
+ }
138
219
  const descriptor = JSON.parse(clear);
139
220
  if (!descriptor.baseUrl || !descriptor.token) {
140
221
  throw new GrokBotGatewaySessionError(
package/src/cli.js CHANGED
@@ -3,6 +3,10 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan
3
3
  import { hasGatewayAuth } from "./gateway.js";
4
4
  import { openBackend } from "./commands.js";
5
5
  import { inspectGrokBotGatewaySession } from "./app-session.js";
6
+ import { entryText, transcriptEntries } from "./transcript.js";
7
+ import { historyPath, readHistory, saveHistory } from "./history.js";
8
+ import { redactSecrets } from "./url-policy.js";
9
+ import { codexStatus, listCodexThreads, sendToCodexThread } from "./codex-bridge.js";
6
10
 
7
11
  function print(value) {
8
12
  if (typeof value === "string") process.stdout.write(value + "\n");
@@ -11,8 +15,17 @@ function print(value) {
11
15
 
12
16
  function fail(err) {
13
17
  let message = err instanceof Error ? err.message : String(err);
14
- message = message.replace(/Bearer\s+[A-Za-z0-9._\-]+/g, "Bearer <redacted>");
15
- process.stderr.write(message + "\n");
18
+ message = redactSecrets(message);
19
+ if (jsonErrors && err instanceof Error) {
20
+ const out = { error: message };
21
+ if (err.delivery !== undefined) out.delivery = err.delivery;
22
+ if (err.threadId !== undefined) out.threadId = err.threadId;
23
+ if (err.turnId !== undefined) out.turnId = err.turnId;
24
+ if (err.targetId !== undefined) out.targetId = err.targetId;
25
+ process.stderr.write(JSON.stringify(out) + "\n");
26
+ } else {
27
+ process.stderr.write(message + "\n");
28
+ }
16
29
  process.exit(1);
17
30
  }
18
31
 
@@ -45,8 +58,13 @@ function usage() {
45
58
  " groups set <group> --member ID [--member ...]",
46
59
  " groups delete <id-or-name>",
47
60
  " send <bot-or-group> <message...>",
48
- " thread <bot-or-group> [--limit N] [--root MESSAGE_ID]",
61
+ " thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]",
49
62
  " chat <bot-or-group> alias for thread",
63
+ " history [bot-or-group] [--search TEXT] [--limit N] (offline)",
64
+ " history --path print the local JSONL file path",
65
+ " codex status",
66
+ " codex list-threads [--limit N]",
67
+ " codex send <threadId> <message...>",
50
68
  "",
51
69
  "Max group members: " + MAX_GROUP_MEMBERS,
52
70
  "--description / --instructions is the UI Instructions field (same key).",
@@ -55,6 +73,10 @@ function usage() {
55
73
  "Flags: --gateway --files --dir DIR --json",
56
74
  "Auth: GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN, or the Grok Bot app session, or CURSOR_ACCESS_TOKEN",
57
75
  "File fallback: GROK_BOT_AGENTS_DIR",
76
+ "Codex: talks to the local app-server daemon socket under CODEX_HOME (default ~/.codex)",
77
+ "History: opt-in plaintext JSONL at ~/.grok-bot-cli/history.jsonl",
78
+ " GROK_BOT_HISTORY=on to record; --history-dir / GROK_BOT_HISTORY_DIR to relocate",
79
+ " --no-history to skip one command",
58
80
  ].join("\n");
59
81
  }
60
82
 
@@ -105,6 +127,81 @@ function hasFlag(args, name) {
105
127
  return true;
106
128
  }
107
129
 
130
+ /** Peel a trailing flag that `--` does not protect; free-text commands keep mid-text tokens. */
131
+ function takeTrailingFlag(args, name) {
132
+ const stop = args.indexOf("--");
133
+ const end = stop === -1 ? args.length : stop;
134
+ if (end > 0 && args[end - 1] === name) {
135
+ args.splice(end - 1, 1);
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /** Peel global CLI options only from the leading argv (before the command). */
142
+ let jsonErrors = false;
143
+ function takeLeadingGlobals(args) {
144
+ let json = false;
145
+ let gateway = false;
146
+ let files = false;
147
+ let noHistory = false;
148
+ let historyDir;
149
+ let dir;
150
+ while (args.length) {
151
+ const a = args[0];
152
+ if (a === "--") {
153
+ args.shift();
154
+ break;
155
+ }
156
+ if (a === "--json") {
157
+ json = true;
158
+ jsonErrors = true;
159
+ args.shift();
160
+ continue;
161
+ }
162
+ if (a === "--gateway") {
163
+ gateway = true;
164
+ args.shift();
165
+ continue;
166
+ }
167
+ if (a === "--files") {
168
+ files = true;
169
+ args.shift();
170
+ continue;
171
+ }
172
+ if (a === "--no-history") {
173
+ noHistory = true;
174
+ args.shift();
175
+ continue;
176
+ }
177
+ if (a === "--history-dir") {
178
+ args.shift();
179
+ const value = args.shift();
180
+ if (value == null || value.startsWith("-")) throw new StoreError("--history-dir needs a value");
181
+ historyDir = value;
182
+ continue;
183
+ }
184
+ if (a === "--dir") {
185
+ args.shift();
186
+ const value = args.shift();
187
+ if (value == null || value.startsWith("-")) throw new StoreError("--dir needs a value");
188
+ dir = value;
189
+ continue;
190
+ }
191
+ break;
192
+ }
193
+ return { json, gateway, files, dir, noHistory, historyDir };
194
+ }
195
+
196
+ /** Strip CSI/OSC and other C0/C1 controls so thread fields cannot drive the terminal. */
197
+ function stripTerminalControls(text) {
198
+ return String(text)
199
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
200
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
201
+ .replace(/\u001b./g, "")
202
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
203
+ }
204
+
108
205
  function parseOnOff(value, flag) {
109
206
  const v = String(value).trim().toLowerCase();
110
207
  if (v === "on" || v === "true" || v === "1" || v === "yes") return true;
@@ -184,27 +281,10 @@ function formatRecord(rec, all) {
184
281
  return kind + " " + rec.name + title + "\n " + rec.id + desc + avatar + settingsLine + extra;
185
282
  }
186
283
 
187
- function entryText(e) {
188
- if (!e || typeof e !== "object") return "";
189
- const direct = e.text || e.prompt || e.message || e.preview;
190
- if (typeof direct === "string" && direct) return direct;
191
- const content = e.content;
192
- if (typeof content === "string") return content;
193
- if (Array.isArray(content)) {
194
- return content.map((part) => {
195
- if (typeof part === "string") return part;
196
- if (part && typeof part === "object") return part.text || part.content || "";
197
- return "";
198
- }).filter(Boolean).join("\n");
199
- }
200
- if (content && typeof content === "object") return content.text || JSON.stringify(content);
201
- return "";
202
- }
203
-
204
- function formatTranscript(out) {
284
+ function formatTranscript(out, { full = false } = {}) {
205
285
  const rec = out.target;
206
286
  const payload = out.transcript || out.thread || {};
207
- const entries = payload.entries || payload.messages || payload.items || (Array.isArray(payload) ? payload : []);
287
+ const entries = transcriptEntries(payload);
208
288
  const header = (rec.isGroup ? "group" : "bot") + " " + rec.name + "\n " + rec.id;
209
289
  if (!Array.isArray(entries) || entries.length === 0) {
210
290
  return header + "\n (no messages)";
@@ -214,11 +294,70 @@ function formatTranscript(out) {
214
294
  const role = e.role || e.kind || e.sender || e.type || "msg";
215
295
  const text = entryText(e);
216
296
  const id = e.id || e.messageId || "";
217
- lines.push("[" + role + (id ? " " + id : "") + "] " + String(text).slice(0, 400));
297
+ lines.push("[" + role + (id ? " " + id : "") + "] " + (full ? text : truncateCliText(text)));
218
298
  }
219
299
  return lines.join("\n");
220
300
  }
221
301
 
302
+ /** Bounded preview; --json/--full still retrieve the complete text. */
303
+ function truncateCliText(text, max = 400) {
304
+ if (text.length <= max) return text;
305
+ return text.slice(0, max) + "… [+" + (text.length - max) + " chars; --full or --json for complete text]";
306
+ }
307
+
308
+ function formatCodexStatus(s) {
309
+ const lines = ["socket: " + s.socketPath];
310
+ if (!s.reachable) return lines.concat("reachable: no", s.message).join("\n");
311
+ lines.push("reachable: yes (daemon)");
312
+ lines.push("daemon version: " + (s.daemonVersion ?? "unknown") + " cli version: " + (s.cliVersion ?? "unknown") + " pinned schema: " + s.pinnedVersion);
313
+ if (s.versionMismatch) lines.push("warning: daemon and CLI versions differ; `codex app-server daemon restart` picks up the installed CLI");
314
+ return lines.join("\n");
315
+ }
316
+
317
+ function formatCodexThread(t) {
318
+ const title = t.name ? " - " + stripTerminalControls(t.name) : "";
319
+ const preview = t.preview
320
+ ? "\n " + stripTerminalControls(String(t.preview).replace(/\s+/g, " ")).slice(0, 200)
321
+ : "";
322
+ return stripTerminalControls(t.id) + " " + stripTerminalControls(t.status) + title + "\n " + stripTerminalControls(t.cwd ?? "") + preview;
323
+ }
324
+
325
+ async function runCodex(sub, rest, json) {
326
+ // Structured subcommands take no free text, so --json peels anywhere. Send
327
+ // peels a trailing --json only; mid-message tokens stay message content.
328
+ if (sub === "status" || sub === "list-threads") {
329
+ if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
330
+ }
331
+ if (sub === "status") {
332
+ const status = await codexStatus();
333
+ print(json ? status : formatCodexStatus(status));
334
+ if (!status.reachable) process.exitCode = 1;
335
+ return;
336
+ }
337
+ if (sub === "list-threads") {
338
+ const limitRaw = takeFlag(rest, "--limit");
339
+ const limit = limitRaw ? Number(limitRaw) : 20;
340
+ if (!Number.isInteger(limit) || limit < 1) throw new StoreError("--limit must be a positive integer");
341
+ const out = await listCodexThreads({ limit });
342
+ if (json) print(out);
343
+ else if (out.threads.length === 0) print("No Codex threads.");
344
+ else print(out.threads.map(formatCodexThread).join("\n\n"));
345
+ return;
346
+ }
347
+ if (sub === "send") {
348
+ const threadId = rest.shift();
349
+ if (takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; }
350
+ if (rest[0] === "--") rest.shift();
351
+ const message = rest.join(" ").trim();
352
+ if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send <threadId> <message...>");
353
+ const out = await sendToCodexThread(threadId, message);
354
+ if (json) print(out);
355
+ else print("Started turn " + out.turnId + " (" + out.turnStatus + ") on Codex thread " + out.threadId);
356
+ return;
357
+ }
358
+ throw new StoreError("gbot codex status | list-threads [--limit N] | send <threadId> <message...>");
359
+ }
360
+
222
361
  async function main(argv) {
223
362
  const args = argv.slice(2);
224
363
  if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
@@ -226,10 +365,8 @@ async function main(argv) {
226
365
  return;
227
366
  }
228
367
 
229
- const json = hasFlag(args, "--json");
230
- const gateway = hasFlag(args, "--gateway");
231
- const filesMode = hasFlag(args, "--files");
232
- const rootFlag = takeFlag(args, "--dir");
368
+ // `json` may also be peeled later from command-local argv (trailing `--json`).
369
+ let { json, gateway, files: filesMode, dir: rootFlag, noHistory, historyDir } = takeLeadingGlobals(args);
233
370
  const cmd = args[0];
234
371
  const sub = args[1];
235
372
  const rest = args.slice(2);
@@ -267,6 +404,41 @@ async function main(argv) {
267
404
  return;
268
405
  }
269
406
 
407
+ if (cmd === "history") {
408
+ const options = args.slice(1);
409
+ // Command-local flags (globals only peel from argv before the command).
410
+ if (hasFlag(options, "--json")) { json = true; jsonErrors = true; }
411
+ const showPath = hasFlag(options, "--path");
412
+ const search = takeFlag(options, "--search");
413
+ const limitRaw = takeFlag(options, "--limit");
414
+ const limit = limitRaw === undefined ? 40 : Number(limitRaw);
415
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new StoreError("--limit must be a positive integer");
416
+ if (options.length > 1 || options[0]?.startsWith("-") || (showPath && (options.length || search !== undefined || limitRaw !== undefined))) {
417
+ throw new StoreError("gbot history [bot-or-group] [--search TEXT] [--limit N], or history --path");
418
+ }
419
+ const path = historyPath(historyDir);
420
+ if (showPath) print(json ? { path } : path);
421
+ else {
422
+ const rows = await readHistory(path, { ref: options[0], search, limit });
423
+ if (json) print(rows);
424
+ else print(rows.length ? rows.map((row) =>
425
+ "[" + row.recordedAt + "] " + row.target.name + " (" + row.target.id + ") [" + row.role + "] " + row.text
426
+ ).join("\n") : "No local history.");
427
+ }
428
+ return;
429
+ }
430
+
431
+ if (cmd === "codex") {
432
+ await runCodex(sub, rest, json);
433
+ return;
434
+ }
435
+
436
+ // Peel command-local --json before touching the backend so auth/gateway
437
+ // failures honor it. Send peels a trailing --json only (mid-text tokens stay
438
+ // message content); `--` protects everything after it.
439
+ if (cmd === "send" && takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; }
440
+ if ((cmd === "thread" || cmd === "chat") && hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
441
+
270
442
  const backend = await openBackend({ root: rootFlag, gateway, files: filesMode });
271
443
 
272
444
  if (cmd === "bots" && sub === "list") {
@@ -376,23 +548,29 @@ async function main(argv) {
376
548
 
377
549
  if (cmd === "send") {
378
550
  const ref = sub;
551
+ if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
379
552
  const message = rest.join(" ").trim();
380
553
  if (!ref || !message) throw new StoreError("gbot send <bot-or-group> <message...>");
381
554
  const out = await backend.send(ref, message);
382
- if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result });
383
- else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")");
555
+ saveHistory(out, { dir: historyDir, disabled: noHistory, event: "send", prompt: message });
556
+ const receipt = out.messageId ? " message " + out.messageId : "";
557
+ if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result, delivery: out.delivery || "accepted", ...(out.messageId ? { messageId: out.messageId } : {}) });
558
+ else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")" + receipt);
384
559
  return;
385
560
  }
386
561
 
387
562
  if (cmd === "thread" || cmd === "chat") {
388
563
  const ref = sub;
389
- if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID]");
564
+ if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]");
565
+ const full = hasFlag(rest, "--full");
390
566
  const limitRaw = takeFlag(rest, "--limit");
391
567
  const rootId = takeFlag(rest, "--root");
392
568
  const limit = limitRaw ? Number(limitRaw) : 40;
569
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new StoreError("--limit must be an integer 1-200");
393
570
  const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit);
571
+ saveHistory(out, { dir: historyDir, disabled: noHistory, event: cmd, rootId });
394
572
  if (json) print(out);
395
- else print(formatTranscript(out));
573
+ else print(formatTranscript(out, { full }));
396
574
  return;
397
575
  }
398
576