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/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "open-claude-p",
3
+ "version": "1.0.0",
4
+ "description": "A PTY-backed compatibility shim for `claude -p` (Claude Code headless/print mode). Drives the interactive `claude` CLI via node-pty and exposes the same option surface and stream-json contract. Ships as both a library and an `ocp` CLI binary.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./chat": "./src/chat/index.js",
10
+ "./options": "./src/options/index.js",
11
+ "./parsers": "./src/parsers/index.js",
12
+ "./output": "./src/output/index.js",
13
+ "./pty": "./src/pty/index.js"
14
+ },
15
+ "bin": {
16
+ "ocp": "bin/cli.js"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin",
21
+ "scripts/postinstall.js",
22
+ "README.md",
23
+ "README.ko.md",
24
+ "README.ja.md",
25
+ "README.zh.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test test/**/*.test.js",
30
+ "postinstall": "node scripts/postinstall.js",
31
+ "prepublishOnly": "npm test",
32
+ "pack:check": "npm pack --dry-run"
33
+ },
34
+ "keywords": [
35
+ "claude",
36
+ "claude-code",
37
+ "claude-p",
38
+ "pty",
39
+ "node-pty",
40
+ "headless",
41
+ "cli",
42
+ "compatibility-shim"
43
+ ],
44
+ "author": "Lee Yongbeom <top6616@gmail.com>",
45
+ "license": "MIT",
46
+ "homepage": "https://github.com/empty-user77/open-claude-p#readme",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/empty-user77/open-claude-p.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/empty-user77/open-claude-p/issues"
53
+ },
54
+ "funding": {
55
+ "type": "github",
56
+ "url": "https://github.com/sponsors/empty-user77"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "dependencies": {
62
+ "node-pty": "^1.1.0",
63
+ "proper-lockfile": "^4.1.2"
64
+ },
65
+ "engines": {
66
+ "node": ">=18.0.0"
67
+ }
68
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Postinstall fixup for node-pty prebuilds.
4
+ //
5
+ // npm sometimes drops the execute bit on prebuilt helper binaries during tar
6
+ // extraction. node-pty ships a `spawn-helper` per (platform, arch) that MUST
7
+ // be executable; without the +x bit every `posix_spawnp` call fails on macOS
8
+ // with the unhelpful message "posix_spawnp failed.".
9
+ //
10
+ // This script restores the execute bit for whichever prebuild matches the
11
+ // current platform/arch. It is a no-op everywhere else.
12
+ //
13
+ // Safe to run repeatedly; safe to fail (e.g. when node-pty is not installed
14
+ // yet in some lifecycle ordering).
15
+
16
+ import { chmod, lstat } from 'node:fs/promises';
17
+ import { createRequire } from 'node:module';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+
21
+ // Resolve via `require.resolve` against THIS script's location, not
22
+ // process.cwd(). The npm-install case happens to use cwd = package
23
+ // root and the relative path worked accidentally, but manual
24
+ // invocation from another cwd would have chmod'd the wrong tree (or
25
+ // nothing). With require.resolve the path is always the node-pty
26
+ // actually loaded by our package.
27
+ let nodePtyRoot;
28
+ try {
29
+ const req = createRequire(import.meta.url);
30
+ nodePtyRoot = path.dirname(req.resolve('node-pty/package.json'));
31
+ } catch {
32
+ // node-pty not installed yet (lifecycle ordering) — exit silently.
33
+ process.exit(0);
34
+ }
35
+
36
+ const targets = [
37
+ path.join(nodePtyRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
38
+ ];
39
+
40
+ let fixedAny = false;
41
+ for (const full of targets) {
42
+ try {
43
+ const st = await lstat(full);
44
+ // Refuse to follow symlinks: a malicious package preinstall hook could
45
+ // replace the prebuild path with a symlink to a sensitive binary and
46
+ // gain mode 0755 on it. lstat + bail keeps us on a regular file.
47
+ if (st.isSymbolicLink() || !st.isFile()) continue;
48
+ await chmod(full, 0o755);
49
+ process.stdout.write(`postinstall: chmod 0755 ${path.relative(process.cwd(), full) || full}\n`);
50
+ fixedAny = true;
51
+ } catch {
52
+ // file not present for this (platform, arch) — fine, skip silently
53
+ }
54
+ }
55
+
56
+ if (!fixedAny) {
57
+ // Quiet success: in some environments (Windows, or pruned installs) none of
58
+ // the targets exist; that is not an error.
59
+ process.exit(0);
60
+ }
@@ -0,0 +1,116 @@
1
+ // Shared event-level cleanup helpers used by the CLI text adapter, the
2
+ // chat SDK, and the bundled sample server. Centralising them here keeps
3
+ // the "what counts as TUI noise vs. real content" decision in one
4
+ // place — when claude's TUI changes shape, a single edit propagates
5
+ // everywhere.
6
+ //
7
+ // Imported by:
8
+ // - src/output/text.js (CLI live spinner)
9
+ // - sample/server.js (SSE chat stream)
10
+ // - chat.send() (when `filterNoise: true`)
11
+
12
+ // Forward-declared so cleanSpinnerLabel can use it before the function
13
+ // definition below — keeps the export ordering readable.
14
+ const SPINNER_ICON_PREFIX = /^[✻✶✺✹✸✷✵●✢✳✽·✾✿❀❁❂❃❄❅❆❇❈❉❊❋]\s*/;
15
+
16
+ /**
17
+ * Convert an upstream `spinner` event label into a user-facing line of
18
+ * progress text. Returns `null` if the label is pure noise (token
19
+ * counters, lone digits, empty after trim, etc.).
20
+ *
21
+ * Examples:
22
+ * "✻ Brewing… (2s · 47 tokens)" → "Brewing…"
23
+ * "✻ Deliberating… 10s 23 still" → "Deliberating…"
24
+ * "✻ thinking" → null
25
+ * "✻" → null
26
+ *
27
+ * @param {string} label
28
+ * @returns {string|null}
29
+ */
30
+ export function cleanSpinnerLabel(label) {
31
+ let s = String(label).replace(SPINNER_ICON_PREFIX, '').trim();
32
+ s = s
33
+ .replace(/\s{2,}\d.*$/, '') // " 10s ..." time/token stats
34
+ .replace(/\s{3,}.*$/, '') // TUI suffix after 3+ spaces
35
+ .replace(/\s+[·↓↑].*$/, '') // " · N tokens …" separator
36
+ .replace(/\s*\(.*$/, '') // "(stats)" suffix
37
+ .replace(/[\s\d·↓↑]+$/, '') // trailing junk
38
+ .trim();
39
+ if (!s) return null;
40
+ if (/^(thinking|still|tokens?)$/i.test(s)) return null;
41
+ if (/tokens[\)·]/.test(s) || /^\d/.test(s)) return null;
42
+ if (!/\S{3,}/.test(s)) return null;
43
+ // Final defensive strip — claude's spinner text reaches the user's
44
+ // terminal as live progress; an exotic upstream that smuggled an
45
+ // ANSI sequence into the label would otherwise execute it.
46
+ return stripTerminalControl(s, 128);
47
+ }
48
+
49
+ /**
50
+ * `true` if an `assistant-text` event payload is TUI chrome rather than
51
+ * model output — TUI prompt characters, box borders, redraw artifacts,
52
+ * tree-view glyphs, "Found N" search status lines, status indicators,
53
+ * and tool-call announcements that other systems should not surface as
54
+ * assistant content.
55
+ *
56
+ * @param {string} text
57
+ * @returns {boolean}
58
+ */
59
+ export function isAssistantTextNoise(text) {
60
+ const t = String(text ?? '');
61
+ if (!t) return true;
62
+ if (/^\s*[❯›❮‹✳✽·✾✿❀❁❂❃❄]\s*$/.test(t)) return true; // lone TUI glyph
63
+ if (/^[\s\d\r\n]+$/.test(t)) return true; // whitespace / digits only
64
+ if (/⎿/.test(t)) return true; // tree-view marker
65
+ if (/MCP servers? failed/i.test(t)) return true; // MCP status line
66
+ if (/^\s*(↑|↓|·|still running)\s*$/i.test(t)) return true; // arrow / indicator
67
+ if (/^(Found \d+|Did \d+)\b/.test(t.trim())) return true; // search status
68
+ if (/^\s*(?:Web ?Search|WebFetch|Bash|Read|Write|Glob|Grep|LS|Edit|MultiEdit|TodoWrite|TodoRead|mcp__\w+)\(/.test(t)) return true; // tool-call announcement
69
+ // "Inferring… 8 word" — spinner + digit + word, pure animation frame
70
+ if (/\w(?:…|\.{2,3})\s{3,}\S.*$/.test(t)) return true;
71
+ // Heavy leading indent + ≤2 words — usually a single-line spinner element
72
+ { const tr = t.trim(); if ((t.length - tr.length) > 8 && tr.split(/\s+/).length <= 2) return true; }
73
+ // "Nucleating... 4 3 Nucleating... 5" — strip CapWord… tokens; if only digits/spaces remain it's pure spinner noise
74
+ if (/[A-Z][a-zA-Z]+[\.…]{2,3}/.test(t)) {
75
+ const rest = t.replace(/[A-Z][a-zA-Z\-]+[\.…]{2,3}/g, '').replace(/\s/g, '');
76
+ if (/^\d*$/.test(rest)) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Strip control / escape characters that would execute as terminal
83
+ * sequences when echoed to a TTY. Use on any string sourced from
84
+ * untrusted-by-the-terminal channels (upstream JSONL `tool_use.name`,
85
+ * spinner labels, user prompt previews) before writing to stderr.
86
+ * Caps the length to keep meta-line / status-line wraps sane.
87
+ *
88
+ * @param {string} s
89
+ * @param {number} [max=64]
90
+ * @returns {string}
91
+ */
92
+ export function stripTerminalControl(s, max = 64) {
93
+ // Strip C0 controls + DEL + the full C1 8-bit control range. The
94
+ // narrower `\x9b` (CSI) class missed `\x90` (DCS), `\x9d` (OSC),
95
+ // `\x9e` (PM), `\x9f` (APC) — all of which execute on VT100-compatible
96
+ // terminals when 8-bit forms are accepted. Also strips ESC (`\x1b`)
97
+ // which is the 7-bit prefix for the same sequences.
98
+ return String(s ?? '')
99
+ .replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '')
100
+ .slice(0, max);
101
+ }
102
+
103
+ /** Tool-call announcement pattern → first capture is the tool name. */
104
+ const TOOL_CALL_RE = /^\s*(Web Search|WebSearch|WebFetch|Bash|Read|Write|Glob|Grep|LS|Edit|MultiEdit|TodoWrite|TodoRead|mcp__[\w]+)\(/;
105
+
106
+ /**
107
+ * Extract the tool name from an `assistant-text` line that announces a
108
+ * tool call. Returns `null` if the line is not a tool announcement.
109
+ *
110
+ * @param {string} text
111
+ * @returns {string|null}
112
+ */
113
+ export function extractToolName(text) {
114
+ const m = String(text ?? '').match(TOOL_CALL_RE);
115
+ return m ? m[1] : null;
116
+ }