grok-bot-cli 0.2.2 → 0.3.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.
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 macOS app. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required.
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.2",
3
+ "version": "0.3.0",
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
  }
@@ -4,7 +4,14 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
- const SAFE_STORAGE_PREFIX = Buffer.from("v10");
7
+ // Chromium OSCrypt: "v10" = fixed password (macOS: the Keychain password; Linux: "peanuts"),
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).
10
+ const SAFE_STORAGE_PREFIX_V10 = "v10";
11
+ const SAFE_STORAGE_PREFIX_V11 = "v11";
12
+ const SAFE_STORAGE_PREFIX_V10_BUF = Buffer.from(SAFE_STORAGE_PREFIX_V10);
13
+ const LINUX_BASIC_TEXT_PASSWORD = "peanuts";
14
+ const SUPPORTED_PLATFORMS = new Set(["darwin", "linux", "win32"]);
8
15
 
9
16
  export class GrokBotGatewaySessionError extends Error {
10
17
  constructor(code, message) {
@@ -49,13 +56,21 @@ function encryptedPayload(wrapped) {
49
56
  return encrypted;
50
57
  }
51
58
 
52
- export function decryptSafeStorageString(encryptedBase64, password) {
59
+ function safeStorageKey(password, platform) {
60
+ const iterations = platform === "linux" ? 1 : 1003;
61
+ return crypto.pbkdf2Sync(password, "saltysalt", iterations, 16, "sha1");
62
+ }
63
+
64
+ export function decryptSafeStorageString(encryptedBase64, password, platform = "darwin") {
53
65
  const encrypted = Buffer.from(encryptedBase64, "base64");
54
- if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX)) {
66
+ const prefix = encrypted.subarray(0, 3).toString("latin1");
67
+ const linuxBasicText = platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10;
68
+ const keyring = prefix === SAFE_STORAGE_PREFIX_V10 || (platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V11);
69
+ if (!keyring) {
55
70
  throw new Error("Unsupported Grok Bot Safe Storage format.");
56
71
  }
57
72
 
58
- const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
73
+ const key = safeStorageKey(linuxBasicText ? LINUX_BASIC_TEXT_PASSWORD : password, platform);
59
74
  const decipher = crypto.createDecipheriv(
60
75
  "aes-128-cbc",
61
76
  key,
@@ -67,21 +82,67 @@ export function decryptSafeStorageString(encryptedBase64, password) {
67
82
  ]).toString("utf8");
68
83
  }
69
84
 
70
- export function grokBotGatewayDescriptorPath(home = homedir()) {
71
- return join(
72
- home,
73
- "Library/Application Support/Grok Bot/gateway-descriptor.json",
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),
74
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
+ }
109
+ if (platform === "linux") {
110
+ const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
111
+ return join(configHome, "Grok Bot");
112
+ }
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;
75
124
  }
76
125
 
77
126
  export function hasGrokBotGatewaySession({
78
127
  platform = process.platform,
79
128
  home = homedir(),
129
+ env = process.env,
130
+ appData,
80
131
  } = {}) {
81
- return platform === "darwin" && existsSync(grokBotGatewayDescriptorPath(home));
132
+ return (
133
+ SUPPORTED_PLATFORMS.has(platform) &&
134
+ existsSync(grokBotGatewayDescriptorPath(home, platform, sessionEnv({ env, appData })))
135
+ );
82
136
  }
83
137
 
84
- function readKeychainPassword() {
138
+ function readKeychainPassword(platform = process.platform) {
139
+ if (platform === "linux") {
140
+ return execFileSync(
141
+ "secret-tool",
142
+ ["lookup", "xdg:schema", "chrome_libsecret_os_crypt_password_v2", "application", "Grok Bot"],
143
+ { encoding: "utf8" },
144
+ ).trimEnd();
145
+ }
85
146
  return execFileSync(
86
147
  "/usr/bin/security",
87
148
  ["find-generic-password", "-w", "-s", "Grok Bot Safe Storage"],
@@ -89,22 +150,72 @@ function readKeychainPassword() {
89
150
  ).trimEnd();
90
151
  }
91
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
+
92
187
  export function loadGrokBotGatewaySession({
93
188
  platform = process.platform,
94
189
  home = homedir(),
190
+ env = process.env,
191
+ appData,
95
192
  getKeychainPassword = readKeychainPassword,
193
+ unprotectData = unprotectWithDpapi,
96
194
  } = {}) {
97
- if (platform !== "darwin") return null;
195
+ if (!SUPPORTED_PLATFORMS.has(platform)) return null;
98
196
 
99
- const path = grokBotGatewayDescriptorPath(home);
197
+ const effectiveEnv = sessionEnv({ env, appData });
198
+ const path = grokBotGatewayDescriptorPath(home, platform, effectiveEnv);
100
199
  if (!existsSync(path)) return null;
101
200
 
102
201
  const wrapped = JSON.parse(readFileSync(path, "utf8"));
103
202
  const encrypted = encryptedPayload(wrapped);
104
- const clear = decryptSafeStorageString(
105
- encrypted,
106
- getKeychainPassword(),
107
- );
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
+ }
108
219
  const descriptor = JSON.parse(clear);
109
220
  if (!descriptor.baseUrl || !descriptor.token) {
110
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,7 +15,7 @@ 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>");
18
+ message = redactSecrets(message);
15
19
  process.stderr.write(message + "\n");
16
20
  process.exit(1);
17
21
  }
@@ -45,8 +49,13 @@ function usage() {
45
49
  " groups set <group> --member ID [--member ...]",
46
50
  " groups delete <id-or-name>",
47
51
  " send <bot-or-group> <message...>",
48
- " thread <bot-or-group> [--limit N] [--root MESSAGE_ID]",
52
+ " thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]",
49
53
  " chat <bot-or-group> alias for thread",
54
+ " history [bot-or-group] [--search TEXT] [--limit N] (offline)",
55
+ " history --path print the local JSONL file path",
56
+ " codex status",
57
+ " codex list-threads [--limit N]",
58
+ " codex send <threadId> <message...>",
50
59
  "",
51
60
  "Max group members: " + MAX_GROUP_MEMBERS,
52
61
  "--description / --instructions is the UI Instructions field (same key).",
@@ -55,6 +64,10 @@ function usage() {
55
64
  "Flags: --gateway --files --dir DIR --json",
56
65
  "Auth: GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN, or the Grok Bot app session, or CURSOR_ACCESS_TOKEN",
57
66
  "File fallback: GROK_BOT_AGENTS_DIR",
67
+ "Codex: talks to the local app-server daemon socket under CODEX_HOME (default ~/.codex)",
68
+ "History: opt-in plaintext JSONL at ~/.grok-bot-cli/history.jsonl",
69
+ " GROK_BOT_HISTORY=on to record; --history-dir / GROK_BOT_HISTORY_DIR to relocate",
70
+ " --no-history to skip one command",
58
71
  ].join("\n");
59
72
  }
60
73
 
@@ -105,6 +118,68 @@ function hasFlag(args, name) {
105
118
  return true;
106
119
  }
107
120
 
121
+ /** Peel global CLI options only from the leading argv (before the command). */
122
+ function takeLeadingGlobals(args) {
123
+ let json = false;
124
+ let gateway = false;
125
+ let files = false;
126
+ let noHistory = false;
127
+ let historyDir;
128
+ let dir;
129
+ while (args.length) {
130
+ const a = args[0];
131
+ if (a === "--") {
132
+ args.shift();
133
+ break;
134
+ }
135
+ if (a === "--json") {
136
+ json = true;
137
+ args.shift();
138
+ continue;
139
+ }
140
+ if (a === "--gateway") {
141
+ gateway = true;
142
+ args.shift();
143
+ continue;
144
+ }
145
+ if (a === "--files") {
146
+ files = true;
147
+ args.shift();
148
+ continue;
149
+ }
150
+ if (a === "--no-history") {
151
+ noHistory = true;
152
+ args.shift();
153
+ continue;
154
+ }
155
+ if (a === "--history-dir") {
156
+ args.shift();
157
+ const value = args.shift();
158
+ if (value == null || value.startsWith("-")) throw new StoreError("--history-dir needs a value");
159
+ historyDir = value;
160
+ continue;
161
+ }
162
+ if (a === "--dir") {
163
+ args.shift();
164
+ const value = args.shift();
165
+ if (value == null || value.startsWith("-")) throw new StoreError("--dir needs a value");
166
+ dir = value;
167
+ continue;
168
+ }
169
+ break;
170
+ }
171
+ return { json, gateway, files, dir, noHistory, historyDir };
172
+ }
173
+
174
+ /** Strip CSI/OSC and other C0/C1 controls so thread fields cannot drive the terminal. */
175
+ function stripTerminalControls(text) {
176
+ return String(text)
177
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
178
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
179
+ .replace(/\u001b./g, "")
180
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
181
+ }
182
+
108
183
  function parseOnOff(value, flag) {
109
184
  const v = String(value).trim().toLowerCase();
110
185
  if (v === "on" || v === "true" || v === "1" || v === "yes") return true;
@@ -184,27 +259,10 @@ function formatRecord(rec, all) {
184
259
  return kind + " " + rec.name + title + "\n " + rec.id + desc + avatar + settingsLine + extra;
185
260
  }
186
261
 
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) {
262
+ function formatTranscript(out, { full = false } = {}) {
205
263
  const rec = out.target;
206
264
  const payload = out.transcript || out.thread || {};
207
- const entries = payload.entries || payload.messages || payload.items || (Array.isArray(payload) ? payload : []);
265
+ const entries = transcriptEntries(payload);
208
266
  const header = (rec.isGroup ? "group" : "bot") + " " + rec.name + "\n " + rec.id;
209
267
  if (!Array.isArray(entries) || entries.length === 0) {
210
268
  return header + "\n (no messages)";
@@ -214,11 +272,64 @@ function formatTranscript(out) {
214
272
  const role = e.role || e.kind || e.sender || e.type || "msg";
215
273
  const text = entryText(e);
216
274
  const id = e.id || e.messageId || "";
217
- lines.push("[" + role + (id ? " " + id : "") + "] " + String(text).slice(0, 400));
275
+ lines.push("[" + role + (id ? " " + id : "") + "] " + (full ? text : truncateCliText(text)));
218
276
  }
219
277
  return lines.join("\n");
220
278
  }
221
279
 
280
+ /** Bounded preview; --json/--full still retrieve the complete text. */
281
+ function truncateCliText(text, max = 400) {
282
+ if (text.length <= max) return text;
283
+ return text.slice(0, max) + "… [+" + (text.length - max) + " chars; --full or --json for complete text]";
284
+ }
285
+
286
+ function formatCodexStatus(s) {
287
+ const lines = ["socket: " + s.socketPath];
288
+ if (!s.reachable) return lines.concat("reachable: no", s.message).join("\n");
289
+ lines.push("reachable: yes (daemon)");
290
+ lines.push("daemon version: " + (s.daemonVersion ?? "unknown") + " cli version: " + (s.cliVersion ?? "unknown") + " pinned schema: " + s.pinnedVersion);
291
+ if (s.versionMismatch) lines.push("warning: daemon and CLI versions differ; `codex app-server daemon restart` picks up the installed CLI");
292
+ return lines.join("\n");
293
+ }
294
+
295
+ function formatCodexThread(t) {
296
+ const title = t.name ? " - " + stripTerminalControls(t.name) : "";
297
+ const preview = t.preview
298
+ ? "\n " + stripTerminalControls(String(t.preview).replace(/\s+/g, " ")).slice(0, 200)
299
+ : "";
300
+ return stripTerminalControls(t.id) + " " + stripTerminalControls(t.status) + title + "\n " + stripTerminalControls(t.cwd ?? "") + preview;
301
+ }
302
+
303
+ async function runCodex(sub, rest, json) {
304
+ if (sub === "status") {
305
+ const status = await codexStatus();
306
+ print(json ? status : formatCodexStatus(status));
307
+ if (!status.reachable) process.exitCode = 1;
308
+ return;
309
+ }
310
+ if (sub === "list-threads") {
311
+ const limitRaw = takeFlag(rest, "--limit");
312
+ const limit = limitRaw ? Number(limitRaw) : 20;
313
+ if (!Number.isInteger(limit) || limit < 1) throw new StoreError("--limit must be a positive integer");
314
+ const out = await listCodexThreads({ limit });
315
+ if (json) print(out);
316
+ else if (out.threads.length === 0) print("No Codex threads.");
317
+ else print(out.threads.map(formatCodexThread).join("\n\n"));
318
+ return;
319
+ }
320
+ if (sub === "send") {
321
+ const threadId = rest.shift();
322
+ if (rest[0] === "--") rest.shift();
323
+ const message = rest.join(" ").trim();
324
+ if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send <threadId> <message...>");
325
+ const out = await sendToCodexThread(threadId, message);
326
+ if (json) print(out);
327
+ else print("Started turn " + out.turnId + " (" + out.turnStatus + ") on Codex thread " + out.threadId);
328
+ return;
329
+ }
330
+ throw new StoreError("gbot codex status | list-threads [--limit N] | send <threadId> <message...>");
331
+ }
332
+
222
333
  async function main(argv) {
223
334
  const args = argv.slice(2);
224
335
  if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
@@ -226,10 +337,8 @@ async function main(argv) {
226
337
  return;
227
338
  }
228
339
 
229
- const json = hasFlag(args, "--json");
230
- const gateway = hasFlag(args, "--gateway");
231
- const filesMode = hasFlag(args, "--files");
232
- const rootFlag = takeFlag(args, "--dir");
340
+ // `json` may also be peeled later from command-local argv (trailing `--json`).
341
+ let { json, gateway, files: filesMode, dir: rootFlag, noHistory, historyDir } = takeLeadingGlobals(args);
233
342
  const cmd = args[0];
234
343
  const sub = args[1];
235
344
  const rest = args.slice(2);
@@ -267,6 +376,35 @@ async function main(argv) {
267
376
  return;
268
377
  }
269
378
 
379
+ if (cmd === "history") {
380
+ const options = args.slice(1);
381
+ // Command-local flags (globals only peel from argv before the command).
382
+ if (hasFlag(options, "--json")) json = true;
383
+ const showPath = hasFlag(options, "--path");
384
+ const search = takeFlag(options, "--search");
385
+ const limitRaw = takeFlag(options, "--limit");
386
+ const limit = limitRaw === undefined ? 40 : Number(limitRaw);
387
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new StoreError("--limit must be a positive integer");
388
+ if (options.length > 1 || options[0]?.startsWith("-") || (showPath && (options.length || search !== undefined || limitRaw !== undefined))) {
389
+ throw new StoreError("gbot history [bot-or-group] [--search TEXT] [--limit N], or history --path");
390
+ }
391
+ const path = historyPath(historyDir);
392
+ if (showPath) print(json ? { path } : path);
393
+ else {
394
+ const rows = await readHistory(path, { ref: options[0], search, limit });
395
+ if (json) print(rows);
396
+ else print(rows.length ? rows.map((row) =>
397
+ "[" + row.recordedAt + "] " + row.target.name + " (" + row.target.id + ") [" + row.role + "] " + row.text
398
+ ).join("\n") : "No local history.");
399
+ }
400
+ return;
401
+ }
402
+
403
+ if (cmd === "codex") {
404
+ await runCodex(sub, rest, json);
405
+ return;
406
+ }
407
+
270
408
  const backend = await openBackend({ root: rootFlag, gateway, files: filesMode });
271
409
 
272
410
  if (cmd === "bots" && sub === "list") {
@@ -376,23 +514,31 @@ async function main(argv) {
376
514
 
377
515
  if (cmd === "send") {
378
516
  const ref = sub;
517
+ if (hasFlag(rest, "--json")) json = true;
379
518
  const message = rest.join(" ").trim();
380
519
  if (!ref || !message) throw new StoreError("gbot send <bot-or-group> <message...>");
381
520
  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 + ")");
521
+ saveHistory(out, { dir: historyDir, disabled: noHistory, event: "send", prompt: message });
522
+ const receipt = out.messageId ? " message " + out.messageId : "";
523
+ 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 } : {}) });
524
+ else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")" + receipt);
384
525
  return;
385
526
  }
386
527
 
387
528
  if (cmd === "thread" || cmd === "chat") {
388
529
  const ref = sub;
389
- if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID]");
530
+ if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]");
531
+ if (hasFlag(rest, "--json")) json = true;
532
+ const full = rest.includes("--full");
533
+ if (full) rest.splice(rest.indexOf("--full"), 1);
390
534
  const limitRaw = takeFlag(rest, "--limit");
391
535
  const rootId = takeFlag(rest, "--root");
392
536
  const limit = limitRaw ? Number(limitRaw) : 40;
537
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new StoreError("--limit must be an integer 1-200");
393
538
  const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit);
539
+ saveHistory(out, { dir: historyDir, disabled: noHistory, event: cmd, rootId });
394
540
  if (json) print(out);
395
- else print(formatTranscript(out));
541
+ else print(formatTranscript(out, { full }));
396
542
  return;
397
543
  }
398
544