open-claude-p 1.0.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/LICENSE +21 -0
- package/README.ja.md +708 -0
- package/README.ko.md +713 -0
- package/README.md +850 -0
- package/README.zh.md +708 -0
- package/bin/cli.js +782 -0
- package/package.json +68 -0
- package/scripts/postinstall.js +60 -0
- package/src/chat/event-filters.js +116 -0
- package/src/chat/index.js +1225 -0
- package/src/completion/detector.js +163 -0
- package/src/daemon/client.js +172 -0
- package/src/daemon/server.js +267 -0
- package/src/daemon/socket.js +78 -0
- package/src/index.js +908 -0
- package/src/options/index.js +4 -0
- package/src/options/parse-argv.js +214 -0
- package/src/options/spec.js +519 -0
- package/src/options/validate.js +104 -0
- package/src/output/index.js +8 -0
- package/src/output/json.js +83 -0
- package/src/output/registry.js +35 -0
- package/src/output/stream-json.js +111 -0
- package/src/output/text.js +94 -0
- package/src/parsers/ansi-strip.js +94 -0
- package/src/parsers/index.js +8 -0
- package/src/parsers/pipeline.js +50 -0
- package/src/parsers/registry.js +43 -0
- package/src/parsers/sentinel.js +41 -0
- package/src/parsers/tui-frame.js +256 -0
- package/src/print-mode.js +214 -0
- package/src/pty/index.js +3 -0
- package/src/pty/pool.js +127 -0
- package/src/pty/session.js +88 -0
- package/src/session-log.js +124 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// `ocp` — CLI entry point.
|
|
4
|
+
//
|
|
5
|
+
// Accepts the same argv shape as `claude -p` (with `-p` itself implicit).
|
|
6
|
+
// Pipeline:
|
|
7
|
+
// argv -> parseArgv -> validate -> createDriver().runOneShot()
|
|
8
|
+
// -> output adapter (text / json / stream-json) -> stdout
|
|
9
|
+
// Unknown flags are forwarded verbatim to the upstream `claude` process via
|
|
10
|
+
// the driver's `passThroughArgv` so this binary stays argv-transparent.
|
|
11
|
+
|
|
12
|
+
// Don't crash on `ocp "…" | head -1` style usage where the downstream
|
|
13
|
+
// reader closes before we finish writing. Both stdout (assistant text)
|
|
14
|
+
// and stderr (live spinner, meta line, debug log) are vulnerable.
|
|
15
|
+
process.stdout.on('error', (e) => { if (e?.code !== 'EPIPE') throw e; });
|
|
16
|
+
process.stderr.on('error', (e) => { if (e?.code !== 'EPIPE') throw e; });
|
|
17
|
+
|
|
18
|
+
// Surface stray rejections / uncaught exceptions with a friendly
|
|
19
|
+
// prefix instead of Node's default `[UnhandledPromiseRejection]` dump.
|
|
20
|
+
process.on('unhandledRejection', (r) => {
|
|
21
|
+
process.stderr.write(`ocp: unhandled rejection: ${r?.stack || r?.message || r}\n`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
process.on('uncaughtException', (e) => {
|
|
25
|
+
process.stderr.write(`ocp: uncaught exception: ${e?.stack || e?.message || e}\n`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import { parseArgv } from '../src/options/parse-argv.js';
|
|
30
|
+
import { validate } from '../src/options/validate.js';
|
|
31
|
+
import { OPTION_SPEC } from '../src/options/spec.js';
|
|
32
|
+
import { createDriver } from '../src/index.js';
|
|
33
|
+
import { sendToDaemon } from '../src/daemon/client.js';
|
|
34
|
+
import { daemonKey, socketPath, resolveCwd } from '../src/daemon/socket.js';
|
|
35
|
+
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import {
|
|
39
|
+
registerOutputAdapter, getOutputAdapter,
|
|
40
|
+
} from '../src/output/registry.js';
|
|
41
|
+
import { textOutputAdapter } from '../src/output/text.js';
|
|
42
|
+
import { jsonOutputAdapter } from '../src/output/json.js';
|
|
43
|
+
import { streamJsonOutputAdapter } from '../src/output/stream-json.js';
|
|
44
|
+
import { stripTerminalControl } from '../src/chat/event-filters.js';
|
|
45
|
+
import { DEFAULT_APPEND_SYSTEM_PROMPT } from '../src/chat/index.js';
|
|
46
|
+
import { isatty } from 'node:tty';
|
|
47
|
+
import readline from 'node:readline';
|
|
48
|
+
|
|
49
|
+
// Register the bundled adapters once at startup. Plugins can extend this
|
|
50
|
+
// registry via the public `open-claude-p/output` entry point.
|
|
51
|
+
registerOutputAdapter(textOutputAdapter);
|
|
52
|
+
registerOutputAdapter(jsonOutputAdapter);
|
|
53
|
+
registerOutputAdapter(streamJsonOutputAdapter);
|
|
54
|
+
|
|
55
|
+
const EXIT = {
|
|
56
|
+
OK: 0,
|
|
57
|
+
GENERIC_ERROR: 1,
|
|
58
|
+
PARSE_ERROR: 2,
|
|
59
|
+
VALIDATION_ERROR: 3,
|
|
60
|
+
TIMEOUT: 4,
|
|
61
|
+
CANCELLED: 5,
|
|
62
|
+
INTERACTIVE_REQUIRED: 6,
|
|
63
|
+
NOT_IMPLEMENTED: 8,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
async function main() {
|
|
67
|
+
const { options, positional, unknown, errors } = parseArgv(process.argv.slice(2));
|
|
68
|
+
|
|
69
|
+
if (errors.length > 0) {
|
|
70
|
+
process.stderr.write(errors.map((e) => `error: ${e}`).join('\n') + '\n');
|
|
71
|
+
return EXIT.PARSE_ERROR;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.help === true) {
|
|
75
|
+
process.stdout.write(buildHelp() + '\n');
|
|
76
|
+
return EXIT.OK;
|
|
77
|
+
}
|
|
78
|
+
if (options.version === true) {
|
|
79
|
+
const v = await readPackageVersion();
|
|
80
|
+
process.stdout.write(`ocp ${v}\n`);
|
|
81
|
+
return EXIT.OK;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// CLI-only default: when the user has not explicitly set
|
|
85
|
+
// `--dangerously-skip-permissions`, honour the `OCP_DEFAULT_SKIP_PERMS=1`
|
|
86
|
+
// env. Rationale — `ocp` is a non-interactive automation surface; a
|
|
87
|
+
// permission prompt that wants a human answer makes most prompts
|
|
88
|
+
// (WebSearch, Bash, Read, Write) silently no-op since PTY automation
|
|
89
|
+
// cannot answer. Library callers via `createDriver()` retain the safer
|
|
90
|
+
// default (`false`) — this opt-in only changes the CLI default.
|
|
91
|
+
if (options['dangerously-skip-permissions'] !== true
|
|
92
|
+
&& process.env.OCP_DEFAULT_SKIP_PERMS === '1') {
|
|
93
|
+
options['dangerously-skip-permissions'] = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// CLI-only default: pre-approve the read-only network tools so that a
|
|
97
|
+
// plain `ocp "오늘 날씨"` actually uses WebSearch/WebFetch instead of
|
|
98
|
+
// silently no-op'ing with "I can't access real-time info". These are
|
|
99
|
+
// safe-by-construction (no filesystem mutation, no shell exec); a
|
|
100
|
+
// headless tool that refuses to fetch the web for an automation
|
|
101
|
+
// surface is the wrong default. Caller-supplied `--allowed-tools`
|
|
102
|
+
// takes precedence (additive — we merge rather than replace).
|
|
103
|
+
// Set OCP_NO_DEFAULT_TOOLS=1 to opt out entirely.
|
|
104
|
+
if (process.env.OCP_NO_DEFAULT_TOOLS !== '1') {
|
|
105
|
+
const DEFAULT_SAFE_TOOLS = ['WebSearch', 'WebFetch'];
|
|
106
|
+
const existing = Array.isArray(options['allowed-tools']) ? options['allowed-tools'] : [];
|
|
107
|
+
const merged = [...existing];
|
|
108
|
+
for (const t of DEFAULT_SAFE_TOOLS) {
|
|
109
|
+
if (!merged.includes(t)) merged.push(t);
|
|
110
|
+
}
|
|
111
|
+
options['allowed-tools'] = merged;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No CLI-side `--permission-mode` default. `acceptEdits` looked
|
|
115
|
+
// attractive ("auto-accept edits, prompt for rest"), but in PTY
|
|
116
|
+
// interactive mode claude's permission prompt is an interactive y/n
|
|
117
|
+
// box the headless driver can't answer — so any tool NOT in
|
|
118
|
+
// `--allowed-tools` still hangs the turn. `bypassPermissions` (=
|
|
119
|
+
// `--dangerously-skip-permissions`) is the only mode that's truly
|
|
120
|
+
// hang-free, and we don't default to it because it's a real security
|
|
121
|
+
// escalation (Bash, Edit, Write all run unprompted). Users who want
|
|
122
|
+
// full auto set `OCP_DEFAULT_SKIP_PERMS=1` once in their shell, or
|
|
123
|
+
// pass `--dangerously-skip-permissions` per call. The `--allowed-tools`
|
|
124
|
+
// default below pre-approves the safe network tools so the most
|
|
125
|
+
// common case (current-info queries) works without bypass.
|
|
126
|
+
|
|
127
|
+
// Per-invocation reminder when both opt-in envs are active — that
|
|
128
|
+
// combination means "any new cwd auto-trusted + every tool runs
|
|
129
|
+
// without a prompt". A forgotten `export` in ~/.zshrc is otherwise
|
|
130
|
+
// invisible. Set OCP_NO_WARN=1 to silence.
|
|
131
|
+
if (process.env.OCP_DEFAULT_SKIP_PERMS === '1'
|
|
132
|
+
&& process.env.OCP_AUTO_ACCEPT_TRUST === '1'
|
|
133
|
+
&& process.stderr.isTTY
|
|
134
|
+
&& process.env.OCP_NO_WARN !== '1') {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
'\x1b[33m[ocp] OCP_DEFAULT_SKIP_PERMS + OCP_AUTO_ACCEPT_TRUST active — tools run unprompted in any cwd.\n' +
|
|
137
|
+
' Unset one of those envs in ~/.zshrc to restore prompts, or set OCP_NO_WARN=1 to silence this notice.\x1b[0m\n',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// CLI-only default: encourage tool use for current/time-sensitive
|
|
142
|
+
// queries via the same SDK-wide DEFAULT_APPEND_SYSTEM_PROMPT the
|
|
143
|
+
// chat client already uses. Without this, a plain `ocp "오늘 날씨"`
|
|
144
|
+
// refuses with "I can't access real-time data" instead of calling
|
|
145
|
+
// WebSearch — because Claude's print-mode default behaviour leans
|
|
146
|
+
// toward declining over tool-use when it can answer with disclaimers.
|
|
147
|
+
// This is a generic, ONE-LINE rule ("use tools when you need to look
|
|
148
|
+
// something up"), not tool-by-tool guidance, so it stays within the
|
|
149
|
+
// "common rules" policy. Caller-supplied `--append-system-prompt`
|
|
150
|
+
// takes precedence (additive — we prepend the default before it).
|
|
151
|
+
// Set OCP_NO_DEFAULT_PROMPT=1 to opt out.
|
|
152
|
+
if (process.env.OCP_NO_DEFAULT_PROMPT !== '1') {
|
|
153
|
+
const userAppend = options['append-system-prompt'];
|
|
154
|
+
options['append-system-prompt'] = (typeof userAppend === 'string' && userAppend.length > 0)
|
|
155
|
+
? `${DEFAULT_APPEND_SYSTEM_PROMPT}\n\n${userAppend}`
|
|
156
|
+
: DEFAULT_APPEND_SYSTEM_PROMPT;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const validationErrors = validate(options);
|
|
160
|
+
if (validationErrors.length > 0) {
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
validationErrors.map((e) => `error: ${e}`).join('\n') + '\n',
|
|
163
|
+
);
|
|
164
|
+
return EXIT.VALIDATION_ERROR;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (options.debug && unknown.length > 0) {
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`[ocp] forwarding unknown flags to claude: ${unknown.join(' ')}\n`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const inputFormat = options['input-format'] ?? 'text';
|
|
174
|
+
|
|
175
|
+
// For `--input-format=text` the prompt resolution is positional argv
|
|
176
|
+
// first, then stdin if non-TTY. For `--input-format=stream-json` we
|
|
177
|
+
// drive a loop over NDJSON user messages on stdin instead — see
|
|
178
|
+
// runStreamJsonInputLoop() below.
|
|
179
|
+
let prompt = '';
|
|
180
|
+
if (inputFormat === 'text') {
|
|
181
|
+
prompt = positional.length > 0 ? positional.join(' ') : '';
|
|
182
|
+
if (!prompt && !isatty(0)) {
|
|
183
|
+
prompt = await readStdin();
|
|
184
|
+
}
|
|
185
|
+
if (!prompt) {
|
|
186
|
+
process.stderr.write(
|
|
187
|
+
'error: no prompt provided. Pass it positionally (`ocp "hello"`) or pipe it via stdin.\n\n',
|
|
188
|
+
);
|
|
189
|
+
process.stderr.write(buildHelp() + '\n');
|
|
190
|
+
return EXIT.PARSE_ERROR;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const outputFormat = options['output-format'] ?? 'text';
|
|
195
|
+
const adapterDef = getOutputAdapter(outputFormat);
|
|
196
|
+
if (!adapterDef) {
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`error: --output-format=${outputFormat} is not registered.\n`,
|
|
199
|
+
);
|
|
200
|
+
return EXIT.NOT_IMPLEMENTED;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Daemon path ──────────────────────────────────────────────────────────
|
|
204
|
+
// The daemon keeps a PTY alive between invocations so conversation context
|
|
205
|
+
// is preserved and subsequent commands skip the 2.5 s warmup delay.
|
|
206
|
+
//
|
|
207
|
+
// Bypassed when:
|
|
208
|
+
// OCP_NO_DAEMON=1 — explicit opt-out
|
|
209
|
+
// --input-format=stream-json — daemon doesn't help multi-turn loops
|
|
210
|
+
// --resume / --continue / --fork-session — caller wants a specific session
|
|
211
|
+
// Print-mode (--print-mode / OCP_PRINT_MODE) bypasses PTY+TUI entirely.
|
|
212
|
+
// Daemon is irrelevant for that path — the child process is short-lived.
|
|
213
|
+
const printMode =
|
|
214
|
+
options['print-mode'] === true ||
|
|
215
|
+
process.env.OCP_PRINT_MODE === '1' ||
|
|
216
|
+
process.env.OCP_PRINT_MODE === 'true';
|
|
217
|
+
|
|
218
|
+
const useDaemon = !process.env.OCP_NO_DAEMON
|
|
219
|
+
&& !printMode
|
|
220
|
+
&& inputFormat !== 'stream-json'
|
|
221
|
+
&& !options.resume
|
|
222
|
+
&& !options.continue
|
|
223
|
+
&& !options['fork-session'];
|
|
224
|
+
|
|
225
|
+
if (useDaemon) {
|
|
226
|
+
// Include claudeBin in the key — two ocp runs in the same cwd but
|
|
227
|
+
// with different OCP_CLAUDE_BIN values must address different
|
|
228
|
+
// daemons (otherwise the second silently rides on the first's
|
|
229
|
+
// binary).
|
|
230
|
+
const key = daemonKey({ cwd: options.cwd, claudeBin: process.env.OCP_CLAUDE_BIN });
|
|
231
|
+
const sockPath = socketPath(key);
|
|
232
|
+
|
|
233
|
+
const req = {
|
|
234
|
+
prompt,
|
|
235
|
+
// Echo the realpath-normalised cwd we keyed the daemon on. Both
|
|
236
|
+
// sides must use the SAME normalisation (realpath) — otherwise
|
|
237
|
+
// two shells reaching the same directory via different symlinks
|
|
238
|
+
// route to the same socket (key uses realpath) but the daemon
|
|
239
|
+
// would reject the second shell's raw req.cwd as "cwd mismatch".
|
|
240
|
+
cwd: resolveCwd(options.cwd),
|
|
241
|
+
// Echo the claudeBin we expected this daemon to be bound to. A
|
|
242
|
+
// same-uid peer can otherwise connect directly to our socket and
|
|
243
|
+
// drive requests through whatever binary the daemon is running,
|
|
244
|
+
// regardless of what the requester intended. Daemon rejects on
|
|
245
|
+
// mismatch.
|
|
246
|
+
claudeBin: process.env.OCP_CLAUDE_BIN || 'claude',
|
|
247
|
+
model: options.model,
|
|
248
|
+
systemPrompt: options['system-prompt'],
|
|
249
|
+
appendSystemPrompt: options['append-system-prompt'],
|
|
250
|
+
allowedTools: options['allowed-tools'],
|
|
251
|
+
disallowedTools: options['disallowed-tools'],
|
|
252
|
+
dangerouslySkipPermissions: options['dangerously-skip-permissions'],
|
|
253
|
+
permissionMode: options['permission-mode'],
|
|
254
|
+
debug: options.debug,
|
|
255
|
+
verbose: options.verbose,
|
|
256
|
+
maxTurns: options['max-turns'],
|
|
257
|
+
maxBudgetUsd: options['max-budget-usd'],
|
|
258
|
+
taskBudget: options['task-budget'],
|
|
259
|
+
noSessionPersistence: options['no-session-persistence'],
|
|
260
|
+
passThroughArgv: unknown,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const result = await sendToDaemon(sockPath, req, { cwd: options.cwd, debug: options.debug }, key);
|
|
265
|
+
|
|
266
|
+
const adapter = adapterDef.create(
|
|
267
|
+
{ outputFormat, jsonSchema: options['json-schema'] },
|
|
268
|
+
process.stdout,
|
|
269
|
+
);
|
|
270
|
+
for (const ev of (result.events ?? [])) adapter.onEvent(ev);
|
|
271
|
+
adapter.end({
|
|
272
|
+
text: result.text,
|
|
273
|
+
isError: result.isError,
|
|
274
|
+
sessionId: result.sessionId,
|
|
275
|
+
cost: result.cost,
|
|
276
|
+
durationMs: result.durationMs,
|
|
277
|
+
completionReason: result.completionReason,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (result.sessionId && options.debug) process.stderr.write(`[ocp] sessionId=${result.sessionId}\n`);
|
|
281
|
+
if (options.debug) {
|
|
282
|
+
process.stderr.write(`[ocp] daemon: completion=${result.completionReason} duration=${result.durationMs}ms\n`);
|
|
283
|
+
}
|
|
284
|
+
return mapCompletionToExit(result);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
// Distinguish "daemon unreachable / crashed" (safe to retry direct)
|
|
287
|
+
// from "daemon explicitly refused this request" (must NOT retry —
|
|
288
|
+
// the refusal is a security decision such as cwd mismatch).
|
|
289
|
+
if (e.fromDaemon) {
|
|
290
|
+
process.stderr.write(`ocp: daemon refused request: ${e.message}\n`);
|
|
291
|
+
return EXIT.GENERIC_ERROR;
|
|
292
|
+
}
|
|
293
|
+
// Setup errors that won't be fixed by retrying direct (e.g. wrong-
|
|
294
|
+
// owner ~/.ocp/) — surface immediately rather than silently
|
|
295
|
+
// falling back. ensureOcpDir's wrong-uid error is the canonical
|
|
296
|
+
// case; identify it by message prefix so a chown hint reaches
|
|
297
|
+
// the user without --debug.
|
|
298
|
+
if (typeof e.message === 'string' && e.message.startsWith('~/.ocp is owned by')) {
|
|
299
|
+
process.stderr.write(`ocp: ${e.message}\n`);
|
|
300
|
+
return EXIT.GENERIC_ERROR;
|
|
301
|
+
}
|
|
302
|
+
if (options.debug) {
|
|
303
|
+
process.stderr.write(`[ocp] daemon unreachable (${e.message}), falling back to direct mode\n`);
|
|
304
|
+
}
|
|
305
|
+
// Fall through to direct mode below.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Direct path (no daemon) ──────────────────────────────────────────────
|
|
310
|
+
const driver = createDriver({
|
|
311
|
+
debug: options.debug,
|
|
312
|
+
cwd: options.cwd,
|
|
313
|
+
printMode,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const ac = new AbortController();
|
|
317
|
+
setupSignalHandlers(ac);
|
|
318
|
+
|
|
319
|
+
// Print-mode bypasses the adapter: claude's stdout (already in the
|
|
320
|
+
// requested --output-format) is streamed verbatim to process.stdout.
|
|
321
|
+
if (printMode) {
|
|
322
|
+
try {
|
|
323
|
+
const result = await driver.runOneShot({
|
|
324
|
+
prompt,
|
|
325
|
+
cwd: options.cwd,
|
|
326
|
+
outputFormat,
|
|
327
|
+
model: options.model,
|
|
328
|
+
systemPrompt: options['system-prompt'],
|
|
329
|
+
appendSystemPrompt: options['append-system-prompt'],
|
|
330
|
+
allowedTools: options['allowed-tools'],
|
|
331
|
+
disallowedTools: options['disallowed-tools'],
|
|
332
|
+
dangerouslySkipPermissions: options['dangerously-skip-permissions'],
|
|
333
|
+
permissionMode: options['permission-mode'],
|
|
334
|
+
verbose: options.verbose,
|
|
335
|
+
continue: options.continue,
|
|
336
|
+
resume: options.resume,
|
|
337
|
+
forkSession: options['fork-session'],
|
|
338
|
+
noSessionPersistence: options['no-session-persistence'],
|
|
339
|
+
sessionId: options['session-id'],
|
|
340
|
+
passThroughArgv: unknown,
|
|
341
|
+
abortSignal: ac.signal,
|
|
342
|
+
maxTurns: options['max-turns'],
|
|
343
|
+
maxBudgetUsd: options['max-budget-usd'],
|
|
344
|
+
printSink: process.stdout,
|
|
345
|
+
});
|
|
346
|
+
if (result.sessionId && options.debug) process.stderr.write(`[ocp] sessionId=${result.sessionId}\n`);
|
|
347
|
+
if (options.debug) {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
`[ocp] print-mode completion=${result.completionReason} duration=${result.durationMs}ms\n`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return mapCompletionToExit(result);
|
|
353
|
+
} finally {
|
|
354
|
+
await driver.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const adapter = adapterDef.create(
|
|
359
|
+
{ outputFormat, jsonSchema: options['json-schema'] },
|
|
360
|
+
process.stdout,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
if (inputFormat === 'stream-json') {
|
|
365
|
+
return await runStreamJsonInputLoop({
|
|
366
|
+
driver, adapter, options, unknown, ac,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const result = await runOneTurn({
|
|
371
|
+
driver, adapter, options, unknown, ac, prompt,
|
|
372
|
+
resumeOverride: options.resume,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (result.sessionId && options.debug) {
|
|
376
|
+
process.stderr.write(`[ocp] sessionId=${result.sessionId}\n`);
|
|
377
|
+
}
|
|
378
|
+
if (options.debug) {
|
|
379
|
+
process.stderr.write(
|
|
380
|
+
`[ocp] completion=${result.completionReason} duration=${result.durationMs}ms ` +
|
|
381
|
+
`rawBytes=${result.diagnostics.rawBytes} strippedBytes=${result.diagnostics.strippedBytes}\n`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return mapCompletionToExit(result);
|
|
385
|
+
} finally {
|
|
386
|
+
await driver.close();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Run a single user prompt through the driver, forwarding events into the
|
|
392
|
+
* shared adapter and calling `adapter.end()` once on completion. Used by
|
|
393
|
+
* both the default single-turn path and the stream-json input loop.
|
|
394
|
+
*/
|
|
395
|
+
async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resumeOverride }) {
|
|
396
|
+
const startTime = Date.now();
|
|
397
|
+
const result = await driver.runOneShot({
|
|
398
|
+
prompt,
|
|
399
|
+
cwd: options.cwd,
|
|
400
|
+
model: options.model,
|
|
401
|
+
systemPrompt: options['system-prompt'],
|
|
402
|
+
appendSystemPrompt: options['append-system-prompt'],
|
|
403
|
+
allowedTools: options['allowed-tools'],
|
|
404
|
+
disallowedTools: options['disallowed-tools'],
|
|
405
|
+
dangerouslySkipPermissions: options['dangerously-skip-permissions'],
|
|
406
|
+
permissionMode: options['permission-mode'],
|
|
407
|
+
debug: options.debug,
|
|
408
|
+
verbose: options.verbose,
|
|
409
|
+
continue: options.continue,
|
|
410
|
+
resume: resumeOverride ?? options.resume,
|
|
411
|
+
forkSession: options['fork-session'],
|
|
412
|
+
noSessionPersistence: options['no-session-persistence'],
|
|
413
|
+
resumeSessionAt: options['resume-session-at'],
|
|
414
|
+
rewindFiles: options['rewind-files'],
|
|
415
|
+
sessionId: options['session-id'],
|
|
416
|
+
name: options.name,
|
|
417
|
+
passThroughArgv: unknown,
|
|
418
|
+
abortSignal: ac.signal,
|
|
419
|
+
maxTurns: options['max-turns'],
|
|
420
|
+
maxBudgetUsd: options['max-budget-usd'],
|
|
421
|
+
taskBudget: options['task-budget'],
|
|
422
|
+
onEvent: (ev) => adapter.onEvent(ev),
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Prefer the upstream JSONL session file's clean markdown over the
|
|
426
|
+
// PTY-extracted text when available. PTY-stripped buffers commonly
|
|
427
|
+
// interleave statusline / HUD plugin output that region extraction
|
|
428
|
+
// alone cannot scrub; the JSONL file is what claude itself stores
|
|
429
|
+
// and contains only the assistant's message verbatim. We call
|
|
430
|
+
// readSessionText even when sessionId is null — it falls back to the
|
|
431
|
+
// most-recently-modified JSONL written during this request window.
|
|
432
|
+
let finalText = result.text;
|
|
433
|
+
let usage = null;
|
|
434
|
+
let toolsFromSession = [];
|
|
435
|
+
if (!result.isError) {
|
|
436
|
+
try {
|
|
437
|
+
const { readSessionText } = await import('../src/chat/index.js');
|
|
438
|
+
const sessionRead = await readSessionText(result.sessionId, startTime, options.cwd);
|
|
439
|
+
if (sessionRead?.text) finalText = sessionRead.text;
|
|
440
|
+
if (sessionRead?.usage) usage = sessionRead.usage;
|
|
441
|
+
if (sessionRead?.tools) toolsFromSession = sessionRead.tools;
|
|
442
|
+
} catch { /* keep PTY-extracted fallback */ }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
adapter.end({
|
|
446
|
+
text: finalText,
|
|
447
|
+
isError: result.isError,
|
|
448
|
+
sessionId: result.sessionId,
|
|
449
|
+
cost: result.cost,
|
|
450
|
+
durationMs: result.durationMs,
|
|
451
|
+
completionReason: result.completionReason,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Persist a per-turn record under <cwd>/.ocp/<sessionId>/ for the
|
|
455
|
+
// direct (no-daemon) CLI path. Daemon-routed turns already record
|
|
456
|
+
// server-side. Best-effort — failures are captured inside
|
|
457
|
+
// recordSession and never block the response.
|
|
458
|
+
try {
|
|
459
|
+
const { recordSession } = await import('../src/session-log.js');
|
|
460
|
+
recordSession({
|
|
461
|
+
cwd: options.cwd ?? process.cwd(),
|
|
462
|
+
sessionId: result.sessionId,
|
|
463
|
+
prompt,
|
|
464
|
+
response: finalText,
|
|
465
|
+
meta: {
|
|
466
|
+
isError: result.isError,
|
|
467
|
+
completionReason: result.completionReason,
|
|
468
|
+
durationMs: result.durationMs,
|
|
469
|
+
cost: result.cost,
|
|
470
|
+
tools: toolsFromSession,
|
|
471
|
+
},
|
|
472
|
+
events: result.events,
|
|
473
|
+
}).catch(() => {});
|
|
474
|
+
} catch { /* ignore */ }
|
|
475
|
+
|
|
476
|
+
await printMetaLine({ options, result, usage, toolsFromSession });
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function printMetaLine({ options, result, usage, toolsFromSession }) {
|
|
481
|
+
// The meta line is supplementary UX — only show it when stderr is an
|
|
482
|
+
// interactive terminal AND the user has not opted out. Suppressed in
|
|
483
|
+
// pipe / script contexts so script consumers see only the response.
|
|
484
|
+
if (!process.stderr.isTTY) return;
|
|
485
|
+
if (options['no-meta'] === true) return;
|
|
486
|
+
if (process.env.OCP_NO_META === '1') return;
|
|
487
|
+
if (result.isError) return;
|
|
488
|
+
|
|
489
|
+
let totalInputTokens, computeCost, formatTokens, stripTerminalControl;
|
|
490
|
+
try {
|
|
491
|
+
const m = await import('../src/chat/index.js');
|
|
492
|
+
totalInputTokens = m.totalInputTokens;
|
|
493
|
+
computeCost = m.computeCost;
|
|
494
|
+
formatTokens = m.formatTokens;
|
|
495
|
+
stripTerminalControl = m.stripTerminalControl;
|
|
496
|
+
} catch { return; }
|
|
497
|
+
|
|
498
|
+
// Defense in depth: tool names are already sanitised at JSONL
|
|
499
|
+
// extraction time, but we also scrub here in case a future code path
|
|
500
|
+
// routes around that sink.
|
|
501
|
+
const tools = new Set();
|
|
502
|
+
for (const t of toolsFromSession ?? []) {
|
|
503
|
+
const safe = stripTerminalControl(t);
|
|
504
|
+
if (safe) tools.add(safe);
|
|
505
|
+
}
|
|
506
|
+
if (usage?.server_tool_use?.web_search_requests > 0) tools.add('web_search');
|
|
507
|
+
if (usage?.server_tool_use?.web_fetch_requests > 0) tools.add('web_fetch');
|
|
508
|
+
|
|
509
|
+
const secs = ((result.durationMs ?? 0) / 1000).toFixed(1);
|
|
510
|
+
const inTok = formatTokens(totalInputTokens(usage));
|
|
511
|
+
const outTok = usage?.output_tokens != null ? formatTokens(usage.output_tokens) : '?';
|
|
512
|
+
const cost = computeCost(usage);
|
|
513
|
+
const costStr = cost != null ? `$${cost.toFixed(4)}` : '$?';
|
|
514
|
+
const toolsStr = tools.size > 0 ? [...tools].join(', ') : 'none';
|
|
515
|
+
|
|
516
|
+
// Dim grey so the meta line doesn't compete with the response above.
|
|
517
|
+
process.stderr.write(
|
|
518
|
+
`\x1b[90m⏱ ${secs}s · ↑${inTok} ↓${outTok} tok · ${costStr} · 🔧 ${toolsStr}\x1b[0m\n`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* --input-format=stream-json loop.
|
|
524
|
+
*
|
|
525
|
+
* Reads NDJSON user messages from stdin and runs each through a fresh
|
|
526
|
+
* driver request. The session id is threaded through so the conversation
|
|
527
|
+
* persists across messages. The shared adapter accumulates per-turn output
|
|
528
|
+
* (stream-json adapters emit init once on first session id, then assistant
|
|
529
|
+
* + result per turn).
|
|
530
|
+
*
|
|
531
|
+
* Supported message shapes:
|
|
532
|
+
* { "type": "user", "content": "..." }
|
|
533
|
+
* { "type": "user", "message": { "content": "..." } }
|
|
534
|
+
* Unknown shapes / unparseable lines are reported as `status` warnings on
|
|
535
|
+
* stderr.
|
|
536
|
+
*/
|
|
537
|
+
async function runStreamJsonInputLoop({ driver, adapter, options, unknown, ac }) {
|
|
538
|
+
if ((options['output-format'] ?? 'text') !== 'stream-json') {
|
|
539
|
+
// Cross-rule R1 enforces this earlier, but double-check.
|
|
540
|
+
process.stderr.write('error: --input-format=stream-json requires --output-format=stream-json.\n');
|
|
541
|
+
return EXIT.VALIDATION_ERROR;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
545
|
+
let currentSessionId = options.resume ?? null;
|
|
546
|
+
let lastExit = EXIT.OK;
|
|
547
|
+
let any = false;
|
|
548
|
+
ac.signal.addEventListener('abort', () => rl.close(), { once: true });
|
|
549
|
+
|
|
550
|
+
for await (const line of rl) {
|
|
551
|
+
if (!line.trim()) continue;
|
|
552
|
+
let msg;
|
|
553
|
+
try { msg = JSON.parse(line); } catch (e) {
|
|
554
|
+
process.stderr.write(`[ocp] stream-json input: invalid JSON (${e.message})\n`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (msg?.type !== 'user') {
|
|
558
|
+
process.stderr.write(`[ocp] stream-json input: skipping non-user message type=${JSON.stringify(msg?.type)}\n`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const content =
|
|
562
|
+
typeof msg.content === 'string' ? msg.content
|
|
563
|
+
: typeof msg.message?.content === 'string' ? msg.message.content
|
|
564
|
+
: null;
|
|
565
|
+
if (!content) {
|
|
566
|
+
process.stderr.write('[ocp] stream-json input: user message missing string content\n');
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
any = true;
|
|
570
|
+
const result = await runOneTurn({
|
|
571
|
+
driver, adapter, options, unknown, ac,
|
|
572
|
+
prompt: content,
|
|
573
|
+
resumeOverride: currentSessionId,
|
|
574
|
+
});
|
|
575
|
+
currentSessionId = result.sessionId ?? currentSessionId;
|
|
576
|
+
if (options.debug) {
|
|
577
|
+
process.stderr.write(
|
|
578
|
+
`[ocp] turn done: sid=${currentSessionId} completion=${result.completionReason}\n`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const turnExit = mapCompletionToExit(result);
|
|
582
|
+
if (turnExit !== EXIT.OK) lastExit = turnExit;
|
|
583
|
+
if (ac.signal.aborted) break;
|
|
584
|
+
}
|
|
585
|
+
if (!any) {
|
|
586
|
+
process.stderr.write('error: stream-json input ended with no usable user messages.\n');
|
|
587
|
+
return EXIT.PARSE_ERROR;
|
|
588
|
+
}
|
|
589
|
+
return lastExit;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function mapCompletionToExit(result) {
|
|
593
|
+
if (result.completionReason === 'timeout') {
|
|
594
|
+
printStalledOutput(result);
|
|
595
|
+
process.stderr.write(
|
|
596
|
+
'ocp: hard timeout waiting for response. Increase OCP_MAX_RESPONSE_MS\n' +
|
|
597
|
+
' if the upstream genuinely needs more time, or inspect the screen\n' +
|
|
598
|
+
' above for an interactive prompt we did not recognise.\n',
|
|
599
|
+
);
|
|
600
|
+
return EXIT.TIMEOUT;
|
|
601
|
+
}
|
|
602
|
+
if (result.completionReason === 'cancelled') return EXIT.CANCELLED;
|
|
603
|
+
if (result.completionReason === 'trust-required') {
|
|
604
|
+
process.stderr.write(
|
|
605
|
+
'ocp: upstream is waiting on the "Do you trust this folder?" dialog.\n' +
|
|
606
|
+
' Run `OCP_AUTO_ACCEPT_TRUST=1 ocp …` to auto-accept it, or run\n' +
|
|
607
|
+
' `claude` directly in this directory once and choose "Yes".\n',
|
|
608
|
+
);
|
|
609
|
+
printStalledOutput(result);
|
|
610
|
+
return EXIT.INTERACTIVE_REQUIRED;
|
|
611
|
+
}
|
|
612
|
+
if (result.completionReason === 'interactive-required') {
|
|
613
|
+
process.stderr.write(
|
|
614
|
+
'ocp: no response after waiting — claude TUI appears to be blocked\n' +
|
|
615
|
+
' on an interactive prompt we cannot answer (tool-permission, MCP\n' +
|
|
616
|
+
' auth, login expiry, theme picker, or a dialog new to this claude\n' +
|
|
617
|
+
' version). The current PTY screen is shown below so you can handle\n' +
|
|
618
|
+
' it manually by running `claude` directly in this directory.\n',
|
|
619
|
+
);
|
|
620
|
+
printStalledOutput(result);
|
|
621
|
+
return EXIT.INTERACTIVE_REQUIRED;
|
|
622
|
+
}
|
|
623
|
+
if (result.completionReason === 'max-turns') return EXIT.GENERIC_ERROR;
|
|
624
|
+
return result.isError ? EXIT.GENERIC_ERROR : EXIT.OK;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function printStalledOutput(result) {
|
|
628
|
+
const tail = result.diagnostics?.stalledOutputTail;
|
|
629
|
+
if (!tail) return;
|
|
630
|
+
// ansiStripParser removes only ESC-introduced sequences. Bare C1 /
|
|
631
|
+
// BEL / BS / CR injected by upstream into the TUI buffer would still
|
|
632
|
+
// reach stderr and corrupt the user's terminal. Strip them here.
|
|
633
|
+
// stripTerminalControl's default max=64 truncates to uselessness for
|
|
634
|
+
// a full-screen TUI capture — explicitly pass the buffer cap from
|
|
635
|
+
// upstream so the operator sees the actual stalled screen.
|
|
636
|
+
const safe = stripTerminalControl(tail, Number.MAX_SAFE_INTEGER);
|
|
637
|
+
process.stderr.write('\n─── current claude TUI screen (tail) ───\n');
|
|
638
|
+
process.stderr.write(safe + '\n');
|
|
639
|
+
process.stderr.write('─── end ───\n\n');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
function setupSignalHandlers(ac) {
|
|
645
|
+
const onSig = () => {
|
|
646
|
+
ac.abort();
|
|
647
|
+
};
|
|
648
|
+
process.once('SIGINT', onSig);
|
|
649
|
+
process.once('SIGTERM', onSig);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function readPackageVersion() {
|
|
653
|
+
try {
|
|
654
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
655
|
+
const pkgPath = path.resolve(here, '..', 'package.json');
|
|
656
|
+
const json = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
657
|
+
return json.version ?? '0.0.0';
|
|
658
|
+
} catch {
|
|
659
|
+
return '0.0.0';
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function readStdin() {
|
|
664
|
+
// Number(env) || fb accepts -1 (truthy) and absurd values silently —
|
|
665
|
+
// route through a positive-int validator so garbage falls back.
|
|
666
|
+
const rawMax = Number(process.env.OCP_MAX_STDIN_BYTES);
|
|
667
|
+
const MAX = (Number.isFinite(rawMax) && rawMax > 0) ? Math.floor(rawMax) : 262_144;
|
|
668
|
+
return new Promise((resolve, reject) => {
|
|
669
|
+
let data = '';
|
|
670
|
+
let bytes = 0;
|
|
671
|
+
process.stdin.setEncoding('utf8');
|
|
672
|
+
process.stdin.on('data', (c) => {
|
|
673
|
+
bytes += Buffer.byteLength(c);
|
|
674
|
+
if (bytes > MAX) {
|
|
675
|
+
process.stdin.pause();
|
|
676
|
+
reject(new Error(`stdin prompt exceeds ${MAX} bytes; pipe a file path or raise OCP_MAX_STDIN_BYTES`));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
data += c;
|
|
680
|
+
});
|
|
681
|
+
process.stdin.on('end', () => resolve(data.replace(/\s+$/, '')));
|
|
682
|
+
process.stdin.on('error', (e) => {
|
|
683
|
+
if (e?.code === 'EISDIR') {
|
|
684
|
+
reject(new Error('stdin is a directory — pass a file via `ocp < file.txt` or a positional prompt'));
|
|
685
|
+
} else if (e?.code === 'ENOENT') {
|
|
686
|
+
reject(new Error('stdin source not found'));
|
|
687
|
+
} else {
|
|
688
|
+
reject(e);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Generate help text by walking OPTION_SPEC. */
|
|
695
|
+
function buildHelp() {
|
|
696
|
+
const lines = [];
|
|
697
|
+
lines.push('Usage:');
|
|
698
|
+
lines.push(' ocp [options] [prompt]');
|
|
699
|
+
lines.push(' echo "<prompt>" | ocp [options]');
|
|
700
|
+
lines.push('');
|
|
701
|
+
lines.push('A PTY-backed compatibility shim for `claude -p` (headless / print mode).');
|
|
702
|
+
lines.push('`-p` / `--print` is implicit; `ocp "hi"` is equivalent to `claude -p "hi"`.');
|
|
703
|
+
lines.push('');
|
|
704
|
+
lines.push('Options:');
|
|
705
|
+
for (const spec of OPTION_SPEC) {
|
|
706
|
+
const shortPart = spec.short ? `-${spec.short}, ` : ' ';
|
|
707
|
+
const longPart = `--${spec.name}`;
|
|
708
|
+
const aliasPart = (spec.aliases ?? []).length > 0
|
|
709
|
+
? ` (alias: ${spec.aliases.map((a) => `--${a}`).join(', ')})`
|
|
710
|
+
: '';
|
|
711
|
+
const valuePart =
|
|
712
|
+
spec.kind === 'boolean' ? ''
|
|
713
|
+
: spec.kind === 'enum' ? ` <${spec.choices.join('|')}>`
|
|
714
|
+
: spec.kind === 'array' ? ' <value>…'
|
|
715
|
+
: ' <value>';
|
|
716
|
+
const head = ` ${shortPart}${longPart}${valuePart}${aliasPart}`;
|
|
717
|
+
lines.push(head);
|
|
718
|
+
if (spec.description) {
|
|
719
|
+
// Indent description under the head line.
|
|
720
|
+
for (const w of wrap(spec.description, 76)) {
|
|
721
|
+
lines.push(' ' + w);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (Object.prototype.hasOwnProperty.call(spec, 'default') && spec.default !== false) {
|
|
725
|
+
lines.push(` (default: ${JSON.stringify(spec.default)})`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
lines.push('');
|
|
729
|
+
lines.push('Environment:');
|
|
730
|
+
lines.push(' OCP_CLAUDE_BIN Path to upstream `claude` binary (default: `claude`).');
|
|
731
|
+
lines.push(' OCP_WARMUP_MS Delay before sending the prompt (default: 2500).');
|
|
732
|
+
lines.push(' OCP_IDLE_MS Idle silence threshold for completion (default: 1500).');
|
|
733
|
+
lines.push(' OCP_MAX_RESPONSE_MS Hard timeout in ms (default: 86400000 = 24 h).');
|
|
734
|
+
lines.push(' OCP_AUTO_ACCEPT_TRUST=1');
|
|
735
|
+
lines.push(' Auto-accept the upstream "Do you trust this folder?"');
|
|
736
|
+
lines.push(' dialog. Off by default — without this, ocp aborts fast');
|
|
737
|
+
lines.push(' on first use in an unknown cwd with exit code 6.');
|
|
738
|
+
lines.push(' OCP_DEFAULT_SKIP_PERMS=1');
|
|
739
|
+
lines.push(' Default `--dangerously-skip-permissions` to on for the');
|
|
740
|
+
lines.push(' CLI so tool calls (WebSearch, Bash, Read, Write, …) run');
|
|
741
|
+
lines.push(' without permission prompts. Off by default; explicit');
|
|
742
|
+
lines.push(' `--dangerously-skip-permissions` always wins.');
|
|
743
|
+
lines.push(' OCP_NO_LIVE=1 Disable the live spinner / phase indicator on stderr');
|
|
744
|
+
lines.push(' even when the terminal is a TTY (useful with --debug).');
|
|
745
|
+
lines.push('');
|
|
746
|
+
lines.push('Daemon (background PTY, keeps conversation alive):');
|
|
747
|
+
lines.push(' OCP_NO_DAEMON=1 Disable daemon; use a fresh PTY for every call.');
|
|
748
|
+
lines.push(' OCP_DAEMON_IDLE_MS Idle timeout before daemon exits (default: 600000 = 10 min).');
|
|
749
|
+
lines.push(' OCP_MAX_DAEMONS Max concurrent active+idle terminals (default: 30).');
|
|
750
|
+
lines.push(' ~/.ocp/ State files and sockets (one daemon per working directory).');
|
|
751
|
+
return lines.join('\n');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function wrap(s, width) {
|
|
755
|
+
const out = [];
|
|
756
|
+
let line = '';
|
|
757
|
+
for (const w of s.split(/\s+/)) {
|
|
758
|
+
if ((line + ' ' + w).trim().length > width) {
|
|
759
|
+
out.push(line.trim());
|
|
760
|
+
line = w;
|
|
761
|
+
} else {
|
|
762
|
+
line += ' ' + w;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (line.trim()) out.push(line.trim());
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ── entry ──────────────────────────────────────────────────────────────
|
|
770
|
+
|
|
771
|
+
main()
|
|
772
|
+
.then((code) => process.exit(code))
|
|
773
|
+
.catch((e) => {
|
|
774
|
+
// Default: surface only the message — stack traces include absolute
|
|
775
|
+
// homedir paths, line numbers, and internal class names that leak
|
|
776
|
+
// architecture when users paste failures into bug reports.
|
|
777
|
+
// Opt in to the full stack via --debug or OCP_DEBUG=1.
|
|
778
|
+
const wantStack = process.argv.includes('--debug') || process.env.OCP_DEBUG === '1';
|
|
779
|
+
const text = wantStack ? (e?.stack || e?.message || String(e)) : (e?.message || String(e));
|
|
780
|
+
process.stderr.write(`ocp: ${text}\n`);
|
|
781
|
+
process.exit(EXIT.GENERIC_ERROR);
|
|
782
|
+
});
|