grok-bot-cli 0.2.3 → 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 +100 -1
- package/package.json +3 -3
- package/src/app-session.js +98 -17
- package/src/cli.js +176 -30
- package/src/codex-bridge.js +480 -0
- package/src/gateway.js +79 -15
- package/src/history.js +83 -0
- package/src/transcript.js +50 -0
- package/src/url-policy.js +161 -0
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
|
|
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.
|
|
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
|
|
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.
|
|
46
|
+
"@changesets/cli": "3.0.3"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/app-session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
111
|
+
return join(configHome, "Grok Bot");
|
|
87
112
|
}
|
|
88
|
-
return join(
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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,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
|
|
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
|
|
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 =
|
|
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 : "") + "] " +
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
|