cli-setu 0.1.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 +40 -0
- package/bin/setu.js +104 -0
- package/package.json +40 -0
- package/src/cli-adapters/README.md +74 -0
- package/src/cli-adapters/claude.js +304 -0
- package/src/cli-adapters/codex.js +261 -0
- package/src/cli-adapters/generic.js +32 -0
- package/src/cli-adapters/index.js +27 -0
- package/src/cli-adapters/shell.js +52 -0
- package/src/config.js +54 -0
- package/src/hooks.js +80 -0
- package/src/onPermissionRequest.js +89 -0
- package/src/pair.js +83 -0
- package/src/status.js +65 -0
- package/src/stop.js +51 -0
- package/src/supervisor.js +1264 -0
- package/src/update.js +73 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Codex adapter. Verified interactively against codex-cli 0.154.0 by
|
|
2
|
+
// actually spawning it in a real node-pty PTY and driving it through a
|
|
3
|
+
// file-write and a shell-command request (not guessed from --help text or
|
|
4
|
+
// docs) - see the project chat history for the exact probe scripts and
|
|
5
|
+
// their raw captured output.
|
|
6
|
+
//
|
|
7
|
+
// Codex's real per-action approval prompt (confirmed live, after asking
|
|
8
|
+
// it to write a file):
|
|
9
|
+
//
|
|
10
|
+
// Would you like to make the following edits?
|
|
11
|
+
//
|
|
12
|
+
// Description: Apply proposed file edits
|
|
13
|
+
// Destination:
|
|
14
|
+
// <full path>
|
|
15
|
+
//
|
|
16
|
+
// › 1. Yes, proceed (y)
|
|
17
|
+
// 2. Yes, and don't ask again for these files (a)
|
|
18
|
+
// 3. No, and tell Codex what to do differently (esc)
|
|
19
|
+
//
|
|
20
|
+
// Press enter to confirm or esc to cancel
|
|
21
|
+
//
|
|
22
|
+
// This is genuinely different from Claude Code's approval mechanism in
|
|
23
|
+
// two ways that matter for this adapter's shape:
|
|
24
|
+
// 1. No hook system - Claude Code's PermissionRequest hook (see
|
|
25
|
+
// claude.js) reports an approval as a structured JSON event BEFORE
|
|
26
|
+
// it's shown, via a file Claude Code itself writes. Codex has no
|
|
27
|
+
// equivalent for its interactive PTY mode - the ONLY signal is the
|
|
28
|
+
// rendered prompt text itself, appearing in the same PTY stream as
|
|
29
|
+
// everything else Codex prints. This adapter's approvals therefore
|
|
30
|
+
// work by scrolling-buffer text matching (sourceMode: 'pty-scrape'),
|
|
31
|
+
// not a queue file - supervisor-qr.js branches on this flag.
|
|
32
|
+
// 2. The reject option is "esc" (a bare Escape keystroke), not a
|
|
33
|
+
// numbered "3" the way Claude Code's Bash prompts work - "3" here is
|
|
34
|
+
// actually a WORDED alternative phrasing of the same escape action,
|
|
35
|
+
// not a separate hotkey. getDialogOptions maps this correctly (see
|
|
36
|
+
// below) rather than assuming Claude Code's "always numbered 1/2/3"
|
|
37
|
+
// pattern generalizes.
|
|
38
|
+
//
|
|
39
|
+
// Two more numbered-menu screens appear on a brand new working directory
|
|
40
|
+
// (also confirmed live) that are NOT per-action approvals and must NOT be
|
|
41
|
+
// relayed to the phone as if they were - they're one-time local setup
|
|
42
|
+
// gates:
|
|
43
|
+
// "Do you trust the contents of this directory?" (1. Yes, continue /
|
|
44
|
+
// 2. No, quit) and "Set up the Codex agent sandbox..." (1. Set up
|
|
45
|
+
// default sandbox [needs Administrator] / 2. Use non-admin sandbox /
|
|
46
|
+
// 3. Quit). autoAnswerOnboarding below detects and answers both
|
|
47
|
+
// automatically (continue + non-admin sandbox) so pairing a fresh
|
|
48
|
+
// laptop/folder doesn't stall waiting on a phone tap for a purely local
|
|
49
|
+
// one-time step - see supervisor-qr.js's onData handler for where this
|
|
50
|
+
// gets called before real-approval detection even runs.
|
|
51
|
+
const ONBOARDING_TRUST_RE = /do you trust the contents of this directory\?/i;
|
|
52
|
+
const ONBOARDING_SANDBOX_RE = /set up the codex agent sandbox/i;
|
|
53
|
+
|
|
54
|
+
// autoAnswerOnboarding(scrollbackWindow) -> keystrokes to write, or null
|
|
55
|
+
// if no onboarding gate is present in this window. Checked BEFORE real
|
|
56
|
+
// approval detection on every chunk (see supervisor-qr.js) - deliberately
|
|
57
|
+
// simple string matches, not full menu parsing, since these two screens'
|
|
58
|
+
// wording is fixed and their answers are hardcoded, not user-configurable.
|
|
59
|
+
function autoAnswerOnboarding(scrollbackWindow) {
|
|
60
|
+
if (ONBOARDING_TRUST_RE.test(scrollbackWindow)) {
|
|
61
|
+
return { keystrokes: '\r', reason: 'trust-directory' }; // "1. Yes, continue" is the default selection
|
|
62
|
+
}
|
|
63
|
+
if (ONBOARDING_SANDBOX_RE.test(scrollbackWindow)) {
|
|
64
|
+
return { keystrokes: '2\r', reason: 'non-admin-sandbox' }; // avoid requiring Administrator
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The real per-action approval prompt (see header comment). Loose enough
|
|
70
|
+
// to tolerate ANSI noise between words (confirmed necessary - Codex's TUI
|
|
71
|
+
// interleaves cursor-sync escape codes mid-render far more heavily than
|
|
72
|
+
// Claude Code's), but anchored on "Would you like to make the following
|
|
73
|
+
// edits?" / "Press enter to confirm or esc to cancel" as the real, fixed
|
|
74
|
+
// bracketing text - both confirmed present verbatim in a live capture.
|
|
75
|
+
//
|
|
76
|
+
// Real gap found by testing: Codex has AT LEAST TWO distinct approval
|
|
77
|
+
// prompt templates, not one - a second, differently-worded shape appeared
|
|
78
|
+
// for a shell command (as opposed to a file edit):
|
|
79
|
+
//
|
|
80
|
+
// Would you like to run the following command?
|
|
81
|
+
//
|
|
82
|
+
// Environment: local
|
|
83
|
+
// Reason: Do you want to allow me to create hello.txt in the current workspace?
|
|
84
|
+
//
|
|
85
|
+
// $ Set-Content -LiteralPath hello.txt -Value 'hello world' -NoNewline
|
|
86
|
+
//
|
|
87
|
+
// 1. Yes, proceed (y)
|
|
88
|
+
// 2. Yes, and don't ask again for commands that start with `Set-Content ...` (p)
|
|
89
|
+
// 3. No, and tell Codex what to do differently (esc)
|
|
90
|
+
//
|
|
91
|
+
// A regex built against only the file-edit shape ("make the following
|
|
92
|
+
// edits?" / "Description:" / "Destination:") silently matched nothing at
|
|
93
|
+
// all for this one - detectApproval below tries BOTH start patterns and
|
|
94
|
+
// extracts shape-appropriate fields, rather than assuming Codex has one
|
|
95
|
+
// canonical prompt template the way the first version of this file did.
|
|
96
|
+
const APPROVAL_START_RE = /would you like to (?:make the following edits|run the following command)\?/i;
|
|
97
|
+
const APPROVAL_END_RE = /press enter to confirm or esc to cancel/i;
|
|
98
|
+
const DESCRIPTION_RE = /description:\s*([^\n]+)/i;
|
|
99
|
+
const DESTINATION_RE = /destination:\s*\n?\s*([^\n]+)/i;
|
|
100
|
+
const REASON_RE = /reason:\s*([^\n]+)/i;
|
|
101
|
+
const COMMAND_RE = /\$\s*([^\n]+)/;
|
|
102
|
+
|
|
103
|
+
// Real bug found by testing: description/destination text came through
|
|
104
|
+
// with raw ANSI codes embedded IN the value itself (e.g. "\x1b[3mApply
|
|
105
|
+
// proposed file edits\x1b[23m\x1b[K" instead of a clean "Apply proposed
|
|
106
|
+
// file edits") - Codex's TUI wraps individual words/spans in their own
|
|
107
|
+
// italic/clear-to-EOL codes, not just around the whole line the way
|
|
108
|
+
// Claude Code's simpler /cost render does, so [^\n]+ alone wasn't enough
|
|
109
|
+
// to keep them out. Stripping all ANSI/CSI sequences before running the
|
|
110
|
+
// field regexes (rather than trying to make the regexes themselves
|
|
111
|
+
// tolerate embedded codes) is the fix that generalizes to whatever else
|
|
112
|
+
// Codex might wrap the same way.
|
|
113
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g;
|
|
114
|
+
|
|
115
|
+
function stripAnsi(text) {
|
|
116
|
+
return text.replace(ANSI_RE, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Returns a NEW approval event if the window contains a complete,
|
|
120
|
+
// not-yet-seen prompt, else null. `seenAt` lets supervisor-qr.js avoid
|
|
121
|
+
// re-detecting the SAME still-on-screen prompt on every subsequent chunk
|
|
122
|
+
// (the PTY keeps redrawing it while nothing's changed) - it hashes the
|
|
123
|
+
// description/destination text, not the whole noisy raw window.
|
|
124
|
+
function detectApproval(scrollbackWindow) {
|
|
125
|
+
const startMatch = scrollbackWindow.match(APPROVAL_START_RE);
|
|
126
|
+
const endMatch = scrollbackWindow.match(APPROVAL_END_RE);
|
|
127
|
+
if (!startMatch || !endMatch || endMatch.index < startMatch.index) return null;
|
|
128
|
+
|
|
129
|
+
const section = stripAnsi(scrollbackWindow.slice(startMatch.index, endMatch.index + endMatch[0].length));
|
|
130
|
+
const isCommandPrompt = /run the following command/i.test(startMatch[0]);
|
|
131
|
+
|
|
132
|
+
// File-edit shape: Description: / Destination:
|
|
133
|
+
// Shell-command shape: Reason: / a "$ <command>" line - no
|
|
134
|
+
// Description/Destination fields at all, confirmed absent in the live
|
|
135
|
+
// capture, so falling back to DESCRIPTION_RE/DESTINATION_RE for this
|
|
136
|
+
// shape would just silently return null forever.
|
|
137
|
+
const description = isCommandPrompt
|
|
138
|
+
? section.match(REASON_RE)?.[1]?.trim() || null
|
|
139
|
+
: section.match(DESCRIPTION_RE)?.[1]?.trim() || null;
|
|
140
|
+
const destination = isCommandPrompt
|
|
141
|
+
? section.match(COMMAND_RE)?.[1]?.trim() || null
|
|
142
|
+
: section.match(DESTINATION_RE)?.[1]?.trim() || null;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
// A stable-ish key for THIS prompt's identity, so supervisor-qr.js
|
|
146
|
+
// can tell "still the same on-screen prompt" apart from "a new one
|
|
147
|
+
// just appeared" without needing Codex to hand out its own id (it
|
|
148
|
+
// doesn't, for this UI) - description+destination is what actually
|
|
149
|
+
// varies between two different edit/command requests.
|
|
150
|
+
fingerprint: `${description || ''}|${destination || ''}`,
|
|
151
|
+
isCommandPrompt,
|
|
152
|
+
description,
|
|
153
|
+
destination,
|
|
154
|
+
raw: section,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Codex's own wording IS the option labels - "Yes, proceed (y)" etc. -
|
|
159
|
+
// unlike Claude Code, where getDialogOptions has to invent labels for a
|
|
160
|
+
// bare tool_name/tool_input. keystroke '\x1b' for the reject path (option
|
|
161
|
+
// 3's parenthetical says "(esc)", not a hotkey digit - confirmed in the
|
|
162
|
+
// live capture) is the one place this genuinely differs from Claude
|
|
163
|
+
// Code's all-numeric-hotkey menus.
|
|
164
|
+
//
|
|
165
|
+
// Option 2's wording actually differs by prompt shape (confirmed live):
|
|
166
|
+
// file-edit says "...for these files", the shell-command shape says
|
|
167
|
+
// "...for commands that start with `<the actual command>`" - kept
|
|
168
|
+
// generic here ("don't ask again") rather than hardcoding either exact
|
|
169
|
+
// phrasing, since neither is universally correct and the phone doesn't
|
|
170
|
+
// need Codex's exact words to convey what the option does.
|
|
171
|
+
function getDialogOptions(event) {
|
|
172
|
+
return [
|
|
173
|
+
{ label: 'Yes, proceed', keystroke: '1', style: 'approve' },
|
|
174
|
+
{ label: "Yes, and don't ask again", keystroke: '2', style: 'approve-alt' },
|
|
175
|
+
{ label: 'No, tell Codex what to do differently', keystroke: '\x1b', style: 'reject' },
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function describePrompt(event) {
|
|
180
|
+
if (event.isCommandPrompt) {
|
|
181
|
+
if (event.description && event.destination) return `${event.description}\n$ ${event.destination}`;
|
|
182
|
+
return event.destination ? `Run: ${event.destination}` : 'Run this command?';
|
|
183
|
+
}
|
|
184
|
+
if (event.description && event.destination) return `${event.description}?\n${event.destination}`;
|
|
185
|
+
return event.description || 'Approve this action?';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
id: 'codex',
|
|
190
|
+
displayName: 'Codex',
|
|
191
|
+
// See claude.js's icon comment for why this is adapter-declared, not
|
|
192
|
+
// hardcoded in the UI - and why this is a generic icon, not OpenAI's
|
|
193
|
+
// actual logo (no official asset bundled/fabricated here).
|
|
194
|
+
icon: { name: 'terminal', color: '#10A37F' }, // OpenAI's own public brand accent is a teal/green - approximated here, not an official value
|
|
195
|
+
spawn: {
|
|
196
|
+
// Real, verified path (see `where codex` / `Get-Command codex` output
|
|
197
|
+
// in the project chat history) - node-pty on Windows needs a real
|
|
198
|
+
// resolved executable, the same requirement claude.js's spawn.command
|
|
199
|
+
// already works around for Claude Code.
|
|
200
|
+
command:
|
|
201
|
+
process.platform === 'win32'
|
|
202
|
+
? 'C:\\Users\\hp\\AppData\\Roaming\\npm\\codex.cmd'
|
|
203
|
+
: 'codex',
|
|
204
|
+
args: () => [],
|
|
205
|
+
},
|
|
206
|
+
approvals: {
|
|
207
|
+
sourceMode: 'pty-scrape', // supervisor-qr.js branches on this vs. claude.js's 'hook-queue'
|
|
208
|
+
autoAnswerOnboarding,
|
|
209
|
+
detectApproval,
|
|
210
|
+
describePrompt,
|
|
211
|
+
getDialogOptions,
|
|
212
|
+
},
|
|
213
|
+
// Real per-turn token usage (`turn.completed`'s `usage` object) is only
|
|
214
|
+
// reachable via `codex exec --json`'s spawn-per-turn model - a
|
|
215
|
+
// fundamentally different execution model from the persistent PTY this
|
|
216
|
+
// adapter drives for approvals (see README.md), not built here.
|
|
217
|
+
//
|
|
218
|
+
// What IS available for free: Codex's interactive TUI shows a live "NN%
|
|
219
|
+
// context left" indicator in its status bar continuously (confirmed
|
|
220
|
+
// live - no slash command or injection needed, unlike Claude Code's
|
|
221
|
+
// /cost). triggerKeystrokes: null signals supervisor-qr.js to skip its
|
|
222
|
+
// periodic-injection timer entirely for this adapter and instead just
|
|
223
|
+
// watch every chunk - cheap, since parseFromOutput here is one small
|
|
224
|
+
// regex, not the multi-regex /cost parse Claude Code's adapter needs.
|
|
225
|
+
// Remote slash-command palette (Section 15 roadmap item #6). Verified
|
|
226
|
+
// LIVE against codex-cli 0.155.1 by actually typing '/' at a real
|
|
227
|
+
// prompt (not guessed from --help) - the real menu also lists /ide,
|
|
228
|
+
// /keymap, /vim, and /experimental, deliberately excluded here since
|
|
229
|
+
// they only make sense for whoever is physically typing at THIS
|
|
230
|
+
// terminal (a keybinding remap, a composer mode toggle) - nothing a
|
|
231
|
+
// phone tap communicates any intent for. /model, /permissions,
|
|
232
|
+
// /approve, /memories are the ones a REMOTE user plausibly wants to
|
|
233
|
+
// trigger. No /compact or /cost exist for Codex at all (confirmed
|
|
234
|
+
// absent from the real menu) - matches this adapter's usage section
|
|
235
|
+
// above, which already gets Codex's numbers from the passive "% context
|
|
236
|
+
// left" status bar instead of a slash command.
|
|
237
|
+
commands: [
|
|
238
|
+
{ id: 'model', label: 'Switch model', keystrokes: '/model\r' },
|
|
239
|
+
{ id: 'permissions', label: 'Change permissions', keystrokes: '/permissions\r' },
|
|
240
|
+
{ id: 'approve', label: 'Approve recent retry', keystrokes: '/approve\r' },
|
|
241
|
+
{ id: 'memories', label: 'Memory settings', keystrokes: '/memories\r' },
|
|
242
|
+
],
|
|
243
|
+
usage: {
|
|
244
|
+
triggerKeystrokes: null,
|
|
245
|
+
parseFromOutput(chunk) {
|
|
246
|
+
const match = chunk.match(/(\d{1,3})%\s*context left/i);
|
|
247
|
+
if (!match) return null;
|
|
248
|
+
return {
|
|
249
|
+
totalTokens: null,
|
|
250
|
+
inputTokens: null,
|
|
251
|
+
outputTokens: null,
|
|
252
|
+
costUsd: null,
|
|
253
|
+
// Codex reports "% LEFT", Claude Code's adapter reports "% USED" -
|
|
254
|
+
// inverted here so both adapters feed the same
|
|
255
|
+
// sessionContextUsedPct field with the same meaning.
|
|
256
|
+
sessionContextUsedPct: 100 - Number(match[1]),
|
|
257
|
+
raw: chunk,
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The fallback adapter - what an unrecognized/never-integrated CLI gets.
|
|
2
|
+
// This is what makes "supports any CLI" literally true rather than "the
|
|
3
|
+
// two CLIs someone bothered to write an adapter for": spawn it in a PTY,
|
|
4
|
+
// mirror its raw output to the phone, done. No structured Confirmations
|
|
5
|
+
// card (approvals: null - the human sees the prompt live in the mirrored
|
|
6
|
+
// terminal and has to answer it from the laptop itself, same as running
|
|
7
|
+
// the CLI with no bridge at all), no token/cost tracking (usage: null).
|
|
8
|
+
//
|
|
9
|
+
// command comes from whatever --cli <name> the user actually typed -
|
|
10
|
+
// this adapter doesn't know or care what that binary does, only that
|
|
11
|
+
// it's a real command on PATH the user asked to run.
|
|
12
|
+
module.exports = function genericAdapter(cliCommand) {
|
|
13
|
+
return {
|
|
14
|
+
id: cliCommand,
|
|
15
|
+
displayName: cliCommand,
|
|
16
|
+
// Generic fallback icon - same neutral glyph for ANY CLI without its
|
|
17
|
+
// own adapter (see claude.js/codex.js for what a real adapter
|
|
18
|
+
// declares instead). Muted grey rather than an accent color, since
|
|
19
|
+
// this represents "unknown CLI," not a specific recognized brand.
|
|
20
|
+
icon: { name: 'terminal-outline', color: '#7C8B99' },
|
|
21
|
+
spawn: {
|
|
22
|
+
command: cliCommand,
|
|
23
|
+
args: () => [],
|
|
24
|
+
},
|
|
25
|
+
approvals: null,
|
|
26
|
+
usage: null,
|
|
27
|
+
// No known slash-commands for an unrecognized CLI - the phone shows
|
|
28
|
+
// no palette at all rather than a guessed/empty one (see
|
|
29
|
+
// DevicesScreen.js/TerminalScreen.js's null-check).
|
|
30
|
+
commands: null,
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One place supervisor.js asks "which adapter for this CLI name" -
|
|
2
|
+
// unrecognized names fall through to the generic PTY-only adapter
|
|
3
|
+
// (see generic.js) rather than erroring out, since "not yet integrated"
|
|
4
|
+
// should never mean "can't run this CLI through the bridge at all."
|
|
5
|
+
const claude = require('./claude');
|
|
6
|
+
const codex = require('./codex');
|
|
7
|
+
const shell = require('./shell');
|
|
8
|
+
const makeGeneric = require('./generic');
|
|
9
|
+
|
|
10
|
+
const KNOWN_ADAPTERS = {
|
|
11
|
+
claude,
|
|
12
|
+
codex,
|
|
13
|
+
shell,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Real product decision (confirmed directly by the PM): bare `setu` with
|
|
17
|
+
// no --cli flag bridges the user's own plain terminal, not Claude Code
|
|
18
|
+
// specifically - the user decides what to run once it's open. Defaulting
|
|
19
|
+
// to 'claude' here silently assumed every user wanted an AI CLI running
|
|
20
|
+
// the instant they typed `setu`, which isn't actually the product's real
|
|
21
|
+
// intent (a genuine remote terminal, not an AI-agent-only bridge).
|
|
22
|
+
function getAdapter(cliName) {
|
|
23
|
+
const normalized = (cliName || 'shell').toLowerCase();
|
|
24
|
+
return KNOWN_ADAPTERS[normalized] || makeGeneric(cliName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { getAdapter, KNOWN_ADAPTERS };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The default adapter when a user runs plain `setu` with no --cli flag at
|
|
2
|
+
// all - a real gap in the earlier design: `setu` (bare) always launched
|
|
3
|
+
// Claude Code specifically, silently assuming that's what every user
|
|
4
|
+
// wants. The actual product intent (confirmed directly by the PM) is
|
|
5
|
+
// simpler and more honest: `setu` bridges the user's OWN real terminal to
|
|
6
|
+
// their phone - a genuine remote shell, nothing AI-specific - and the
|
|
7
|
+
// user decides what to run inside it once it's open (`claude`, `codex`,
|
|
8
|
+
// `npm install`, `git status`, anything). Running an AI CLI inside this
|
|
9
|
+
// bridged shell still gets real approval detection IF that CLI has its
|
|
10
|
+
// own adapter (this adapter itself has none - see approvals: null below)
|
|
11
|
+
// - detection is about what's actually running inside the PTY moment to
|
|
12
|
+
// moment, not about which adapter spawned the shell around it.
|
|
13
|
+
const os = require('os');
|
|
14
|
+
|
|
15
|
+
function resolveShellCommand() {
|
|
16
|
+
if (process.platform === 'win32') {
|
|
17
|
+
// Real users' machines vary here - PowerShell is the modern Windows
|
|
18
|
+
// default (present on every Windows 10/11 install), but COMSPEC
|
|
19
|
+
// (usually cmd.exe) is what Windows itself considers "the" shell for
|
|
20
|
+
// a process that doesn't ask for anything more specific. Preferring
|
|
21
|
+
// PowerShell when it's genuinely on PATH, falling back to COMSPEC
|
|
22
|
+
// (or a bare cmd.exe) otherwise, covers both cases without assuming
|
|
23
|
+
// one specific install layout the way a hardcoded path would.
|
|
24
|
+
return 'powershell.exe';
|
|
25
|
+
}
|
|
26
|
+
// SHELL is the real, standard POSIX convention for "the user's own
|
|
27
|
+
// configured shell" (bash, zsh, fish, ...) - respecting it here means
|
|
28
|
+
// this bridges whatever shell the user already actually uses day to
|
|
29
|
+
// day, not a hardcoded assumption that might not even be installed.
|
|
30
|
+
return process.env.SHELL || '/bin/bash';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
id: 'shell',
|
|
35
|
+
displayName: os.platform() === 'win32' ? 'PowerShell' : 'Shell',
|
|
36
|
+
icon: { name: 'terminal-outline', color: '#7C8B99' },
|
|
37
|
+
spawn: {
|
|
38
|
+
command: resolveShellCommand(),
|
|
39
|
+
args: () => [],
|
|
40
|
+
},
|
|
41
|
+
// No structured Confirmations card - a plain shell has no permission-
|
|
42
|
+
// prompt concept of its own to detect. If the user launches an AI CLI
|
|
43
|
+
// INSIDE this shell, that CLI's own real adapter (claude.js/codex.js)
|
|
44
|
+
// still isn't active here, since supervisor.js picks exactly one
|
|
45
|
+
// adapter per process/session at startup - a real user wanting
|
|
46
|
+
// structured approvals for a specific CLI should run `setu --cli
|
|
47
|
+
// claude` (or codex) directly, same as always. This adapter is
|
|
48
|
+
// specifically for "just give me my terminal, I'll decide what to run."
|
|
49
|
+
approvals: null,
|
|
50
|
+
usage: null,
|
|
51
|
+
commands: null,
|
|
52
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Real gap in the proof-of-concept this package replaces: supervisor-qr.js
|
|
2
|
+
// required a hand-typed device name and session id on every single run
|
|
3
|
+
// (`node supervisor-qr.js Laptop-1 session-A`) - fine for testing several
|
|
4
|
+
// fake "laptops" side by side on one dev machine, wrong for a real
|
|
5
|
+
// install. A real user's computer already has a name; this reads it
|
|
6
|
+
// automatically instead of asking, and remembers it across runs.
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), '.cli-setu');
|
|
12
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
function defaultDeviceName() {
|
|
15
|
+
return os.hostname() || 'My Computer';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Saved once, on the first `setu pair`, so a later OS-level hostname
|
|
19
|
+
// change (renaming the machine, moving to a new user profile) doesn't
|
|
20
|
+
// silently rename an already-paired device out from under the user - the
|
|
21
|
+
// Devices tab's identity for this machine stays whatever it was when it
|
|
22
|
+
// was actually paired, not whatever the OS reports today.
|
|
23
|
+
function readConfig() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeConfig(config) {
|
|
32
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
33
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Called by `setu pair` - creates the config the FIRST time, using the
|
|
37
|
+
// auto-detected hostname as the default device name. A config that
|
|
38
|
+
// already exists (this machine has paired before) is left untouched;
|
|
39
|
+
// re-pairing intentionally reuses the same device identity rather than
|
|
40
|
+
// minting a second, confusing "device" for the same physical laptop.
|
|
41
|
+
function loadOrCreateConfig() {
|
|
42
|
+
const existing = readConfig();
|
|
43
|
+
if (existing) return existing;
|
|
44
|
+
return { deviceName: defaultDeviceName(), deviceToken: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
CONFIG_DIR,
|
|
49
|
+
CONFIG_FILE,
|
|
50
|
+
defaultDeviceName,
|
|
51
|
+
readConfig,
|
|
52
|
+
writeConfig,
|
|
53
|
+
loadOrCreateConfig,
|
|
54
|
+
};
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Real gap in the proof-of-concept this replaces: .claude/settings.json's
|
|
2
|
+
// hook command was a hardcoded absolute path into phase1/, hand-edited
|
|
3
|
+
// once for this one dev machine. A real npm-installed package can't ship
|
|
4
|
+
// that - every user's global install lives at a different path (varies by
|
|
5
|
+
// OS, by nvm/volta version manager, by whether it's a global or local
|
|
6
|
+
// install), and expecting someone to hand-edit JSON before their first
|
|
7
|
+
// `setu` run defeats the entire point of "npm install -g cli-setu" being
|
|
8
|
+
// a real, ready-to-use install. This ensures the CURRENT project's
|
|
9
|
+
// .claude/settings.json has the right hook registered, pointing at
|
|
10
|
+
// wherever THIS running copy of cli-setu actually lives on disk right now
|
|
11
|
+
// - computed fresh each time, never assumed.
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const HOOK_SCRIPT = path.join(__dirname, 'onPermissionRequest.js');
|
|
16
|
+
|
|
17
|
+
function hookEntry() {
|
|
18
|
+
return {
|
|
19
|
+
matcher: '*',
|
|
20
|
+
hooks: [{ type: 'command', command: `node "${HOOK_SCRIPT}"` }],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// True only if every hook entry for this event already points at OUR
|
|
25
|
+
// hook script - a project that has its own unrelated PermissionRequest/
|
|
26
|
+
// PreToolUse hooks configured is left alone rather than silently
|
|
27
|
+
// appending a second, possibly conflicting one under the same event.
|
|
28
|
+
function alreadyRegistered(existingHooksForEvent) {
|
|
29
|
+
if (!Array.isArray(existingHooksForEvent) || existingHooksForEvent.length === 0) return false;
|
|
30
|
+
return existingHooksForEvent.every((entry) =>
|
|
31
|
+
(entry.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('onPermissionRequest.js'))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ensures the CURRENT working directory's .claude/settings.json has both
|
|
36
|
+
// hooks pointed at this package's real install location. Called once at
|
|
37
|
+
// the start of every `setu` (bare) run - cheap (a few small file reads/
|
|
38
|
+
// writes), and self-healing if the package is ever moved/reinstalled at a
|
|
39
|
+
// different path (a stale command string gets overwritten with today's
|
|
40
|
+
// real path automatically, no manual fix needed).
|
|
41
|
+
function ensureHooksRegistered(projectDir) {
|
|
42
|
+
const claudeDir = path.join(projectDir, '.claude');
|
|
43
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
44
|
+
|
|
45
|
+
let settings = {};
|
|
46
|
+
try {
|
|
47
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
48
|
+
} catch {
|
|
49
|
+
// No settings.json yet, or it's not valid JSON - start fresh rather
|
|
50
|
+
// than crash; an unreadable existing file is left untouched below by
|
|
51
|
+
// simply not being parsed further, but we still want `setu` itself to
|
|
52
|
+
// keep working for this session's approval detection.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
settings.hooks = settings.hooks || {};
|
|
56
|
+
|
|
57
|
+
let changed = false;
|
|
58
|
+
// PostToolUse (new) - fires once a tool call actually runs, which can
|
|
59
|
+
// only happen after its PermissionRequest was answered somehow (locally
|
|
60
|
+
// at the keyboard or remotely from the phone). This is what lets
|
|
61
|
+
// supervisor.js detect "answered locally" and stop a phone's card from
|
|
62
|
+
// being stuck pending forever - see onPermissionRequest.js's own
|
|
63
|
+
// comment on why this event specifically.
|
|
64
|
+
for (const event of ['PermissionRequest', 'PreToolUse', 'PostToolUse']) {
|
|
65
|
+
if (!alreadyRegistered(settings.hooks[event])) {
|
|
66
|
+
// Merge, don't replace - a project's own unrelated hooks for this
|
|
67
|
+
// event (if any) are kept; ours is appended alongside them.
|
|
68
|
+
settings.hooks[event] = [...(settings.hooks[event] || []), hookEntry()];
|
|
69
|
+
changed = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (changed) {
|
|
74
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
76
|
+
console.log('[setu] Registered the approval-detection hook in .claude/settings.json');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { ensureHooksRegistered };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The PermissionRequest hook handler. Claude Code invokes this script and
|
|
3
|
+
// pipes the event payload to its stdin every time a tool call needs a
|
|
4
|
+
// permission decision - BEFORE the prompt is shown to the user. This
|
|
5
|
+
// script only OBSERVES (logs) - it deliberately never returns a
|
|
6
|
+
// "decision" object, so the real prompt still shows normally.
|
|
7
|
+
// Auto-approving here would defeat the whole point of a remote approval
|
|
8
|
+
// system.
|
|
9
|
+
//
|
|
10
|
+
// Registered into each project's own .claude/settings.json by
|
|
11
|
+
// src/hooks.js, pointing at THIS script's real location inside the
|
|
12
|
+
// globally-installed cli-setu package (not copied per-project).
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { CONFIG_DIR } = require('./config');
|
|
16
|
+
const { queueFileFor } = require('./cli-adapters/claude').approvals;
|
|
17
|
+
|
|
18
|
+
const LOG_FILE = path.join(CONFIG_DIR, 'hook-events.log');
|
|
19
|
+
|
|
20
|
+
function readStdinSync() {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(0, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function main() {
|
|
29
|
+
const raw = readStdinSync();
|
|
30
|
+
const receivedAt = new Date().toISOString();
|
|
31
|
+
|
|
32
|
+
let event;
|
|
33
|
+
try {
|
|
34
|
+
event = JSON.parse(raw);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
fs.appendFileSync(LOG_FILE, `[${receivedAt}] PARSE_ERROR: ${err.message} | raw="${raw}"\n`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const summary = {
|
|
41
|
+
receivedAt,
|
|
42
|
+
hook_event_name: event.hook_event_name,
|
|
43
|
+
tool_name: event.tool_name,
|
|
44
|
+
tool_input: event.tool_input,
|
|
45
|
+
session_id: event.session_id,
|
|
46
|
+
cwd: event.cwd,
|
|
47
|
+
permission_mode: event.permission_mode,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(summary) + '\n');
|
|
51
|
+
|
|
52
|
+
// Signal the supervisor for PermissionRequest (a new prompt to relay)
|
|
53
|
+
// AND PostToolUse (real gap found by testing: a prompt answered
|
|
54
|
+
// directly at the laptop's own keyboard - never via the phone at all -
|
|
55
|
+
// left the phone's card stuck "pending" forever, since nothing ever
|
|
56
|
+
// told the Backend the tool actually ran. PostToolUse only fires once
|
|
57
|
+
// the tool call actually executes, which can only happen after ITS
|
|
58
|
+
// permission prompt was answered somehow, locally or remotely - so
|
|
59
|
+
// supervisor.js treats a PostToolUse for a tool_name/session_id it's
|
|
60
|
+
// still holding pending as proof that prompt is resolved, see
|
|
61
|
+
// resolveIfPending() there). PreToolUse is still observed (logged
|
|
62
|
+
// above) but not written to the queue - it fires on EVERY tool call,
|
|
63
|
+
// not just ones that will show a prompt, and would make the supervisor
|
|
64
|
+
// try to resolve prompts that were never actually pending.
|
|
65
|
+
// queueFileFor(CONFIG_DIR, event.cwd) must resolve to the exact same
|
|
66
|
+
// path supervisor.js's own QUEUE_FILE does - both call the SAME
|
|
67
|
+
// function from cli-adapters/claude.js rather than each computing
|
|
68
|
+
// their own copy of this path, so they can never drift apart.
|
|
69
|
+
if (event.hook_event_name === 'PermissionRequest' || event.hook_event_name === 'PostToolUse') {
|
|
70
|
+
// Real bug found by testing: a broken queueFileFor import here threw
|
|
71
|
+
// silently - Claude Code invokes this script as a subprocess with no
|
|
72
|
+
// visible stderr, so the exception vanished with zero indication
|
|
73
|
+
// anywhere that the queue write never happened. Catching and logging
|
|
74
|
+
// it explicitly means a future regression here shows up in
|
|
75
|
+
// hook-events.log instead of silently breaking the whole relay chain.
|
|
76
|
+
try {
|
|
77
|
+
fs.appendFileSync(queueFileFor(CONFIG_DIR, event.cwd), JSON.stringify(summary) + '\n');
|
|
78
|
+
} catch (err) {
|
|
79
|
+
fs.appendFileSync(LOG_FILE, `[${receivedAt}] QUEUE_WRITE_ERROR: ${err.message}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Deliberately exit 0 with NO stdout decision object.
|
|
84
|
+
// Per current docs, exit code 2 is ignored for PermissionRequest anyway -
|
|
85
|
+
// only a JSON `decision` on stdout can gate it, and we never emit one.
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|