nexrall-code 0.5.107 → 0.5.108
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/dist/audit.js +102 -0
- package/dist/commands/auth.js +134 -0
- package/dist/commands/chat.js +2715 -0
- package/dist/commands/plugin.js +393 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/update.js +174 -0
- package/dist/index.js +1677 -36
- package/dist/permissions/handler.js +332 -0
- package/dist/terminal/terminalSetup.js +395 -0
- package/dist/trust.js +137 -0
- package/dist/ui/diff.js +51 -0
- package/dist/ui/inkReadlineAdapter.js +116 -0
- package/dist/ui/inkTerminal.js +1097 -0
- package/dist/ui/resizeRepaint.js +36 -0
- package/dist/ui/screen.js +482 -0
- package/dist/ui/theme.js +385 -0
- package/dist/ui/trustPrompt.js +126 -0
- package/package.json +1 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// ─── /terminal-setup: make Shift+Enter insert a newline ─────────────────────
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
//
|
|
5
|
+
// Pressing Shift+Enter in the input box submits the line on most terminals
|
|
6
|
+
// instead of inserting a newline, and no amount of work inside this CLI can
|
|
7
|
+
// change that — the terminal never tells us Shift was held. Measured directly:
|
|
8
|
+
// VS Code sends a bare `\r` for Shift+Enter, byte-identical to a plain Enter,
|
|
9
|
+
// so the two are indistinguishable before the terminal is reconfigured.
|
|
10
|
+
//
|
|
11
|
+
// The fix is therefore split across two layers, and BOTH are required:
|
|
12
|
+
//
|
|
13
|
+
// • This module reconfigures the terminal to send a DISTINCT sequence for
|
|
14
|
+
// Shift+Enter — `ESC CR` (`\x1b\r`).
|
|
15
|
+
// • ui/inkTerminal.tsx's decideEnterKey recognises that sequence (it arrives
|
|
16
|
+
// as return+meta, not return+shift) and inserts a newline.
|
|
17
|
+
//
|
|
18
|
+
// Getting only one of the two is useless, which is exactly the state this CLI
|
|
19
|
+
// shipped in: the handler had a `key.shift` branch that no terminal could ever
|
|
20
|
+
// trigger, and there was no command to install a keybinding in the first place.
|
|
21
|
+
//
|
|
22
|
+
// Terminals fall into three groups (per Claude Code's own terminal-config
|
|
23
|
+
// documentation, cross-checked against the `claude` binary's behaviour):
|
|
24
|
+
//
|
|
25
|
+
// • Ghostty, Kitty, iTerm2, WezTerm, Warp, Windows Terminal — already send a
|
|
26
|
+
// distinct sequence. Nothing to install.
|
|
27
|
+
// • VS Code, Cursor, Windsurf, VSCodium — need a `sendSequence` keybinding.
|
|
28
|
+
// • Apple Terminal — has no Shift+Enter binding to install before macOS 27;
|
|
29
|
+
// the supported route is Option+Enter, which needs "Use Option as Meta
|
|
30
|
+
// Key" enabled. macOS 27 gained Shift+Return natively.
|
|
31
|
+
//
|
|
32
|
+
// Everywhere, Ctrl+J already inserts a newline with no setup at all — that is
|
|
33
|
+
// what decideEnterKey's lone-`\n` rule buys, and it is why this command is a
|
|
34
|
+
// convenience rather than the only way to get a multi-line prompt.
|
|
35
|
+
import * as os from 'os';
|
|
36
|
+
import * as path from 'path';
|
|
37
|
+
/**
|
|
38
|
+
* The sequence a terminal must send for Shift+Enter.
|
|
39
|
+
*
|
|
40
|
+
* `ESC CR`. Ink parses this as `return` with `meta: true` (NOT `shift`), which
|
|
41
|
+
* is why decideEnterKey keys off meta — see its own table. Deliberately
|
|
42
|
+
* identical to what `claude`'s /terminal-setup installs, so a machine set up
|
|
43
|
+
* for either tool works with both, and so a user who already ran claude's
|
|
44
|
+
* version needs no second keybinding.
|
|
45
|
+
*/
|
|
46
|
+
export const SHIFT_ENTER_SEQUENCE = '\x1b\r';
|
|
47
|
+
/**
|
|
48
|
+
* Terminals that send a distinct Shift+Enter sequence out of the box, keyed by
|
|
49
|
+
* the `TERM_PROGRAM` value each one sets. Values are display names.
|
|
50
|
+
*
|
|
51
|
+
* Sourced from Claude Code's terminal-config table. Kept as data rather than a
|
|
52
|
+
* chain of `if`s so the list is inspectable by the test that pins it.
|
|
53
|
+
*/
|
|
54
|
+
const NATIVE_SHIFT_ENTER = {
|
|
55
|
+
ghostty: 'Ghostty',
|
|
56
|
+
'iTerm.app': 'iTerm2',
|
|
57
|
+
WezTerm: 'WezTerm',
|
|
58
|
+
WarpTerminal: 'Warp',
|
|
59
|
+
// Kitty sets TERM=xterm-kitty rather than TERM_PROGRAM; handled below.
|
|
60
|
+
};
|
|
61
|
+
/** Editors that need a keybinding, keyed by their `TERM_PROGRAM` value. */
|
|
62
|
+
const EDITOR_TERM_PROGRAMS = {
|
|
63
|
+
vscode: 'VS Code',
|
|
64
|
+
cursor: 'Cursor',
|
|
65
|
+
windsurf: 'Windsurf',
|
|
66
|
+
vscodium: 'VSCodium',
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The first macOS major version whose Terminal.app sends a distinct
|
|
70
|
+
* Shift+Return on its own.
|
|
71
|
+
*
|
|
72
|
+
* Taken from the `claude` binary, which gates its Terminal.app setup on
|
|
73
|
+
* `macOSMajor >= 27` and below that tells the user "Option+Enter will now
|
|
74
|
+
* enter a newline" instead. Below 27 there is no Shift+Enter binding to
|
|
75
|
+
* install — Option+Enter is the supported route.
|
|
76
|
+
*/
|
|
77
|
+
export const MACOS_NATIVE_SHIFT_RETURN_MAJOR = 27;
|
|
78
|
+
/**
|
|
79
|
+
* Decides what /terminal-setup should do, from environment alone. Pure so the
|
|
80
|
+
* terminal matrix is testable — the whole point of this command is that its
|
|
81
|
+
* behaviour differs per terminal, and none of that is observable otherwise.
|
|
82
|
+
*
|
|
83
|
+
* `env` is `process.env`-shaped; `platform` is `process.platform`;
|
|
84
|
+
* `macOSMajor` is the major version from `os.release()` (Darwin kernel major
|
|
85
|
+
* minus 9 — see `detectMacOSMajor`), or null off macOS.
|
|
86
|
+
*/
|
|
87
|
+
export function decideTerminalSetup(input) {
|
|
88
|
+
const termProgram = input.env.TERM_PROGRAM ?? null;
|
|
89
|
+
const term = input.env.TERM ?? null;
|
|
90
|
+
// Kitty advertises itself through TERM, not TERM_PROGRAM.
|
|
91
|
+
if (term && term.includes('kitty'))
|
|
92
|
+
return { kind: 'already-native', terminal: 'Kitty' };
|
|
93
|
+
// Windows Terminal sets WT_SESSION and no TERM_PROGRAM.
|
|
94
|
+
if (input.env.WT_SESSION)
|
|
95
|
+
return { kind: 'already-native', terminal: 'Windows Terminal' };
|
|
96
|
+
if (termProgram) {
|
|
97
|
+
const native = NATIVE_SHIFT_ENTER[termProgram];
|
|
98
|
+
if (native)
|
|
99
|
+
return { kind: 'already-native', terminal: native };
|
|
100
|
+
const editor = EDITOR_TERM_PROGRAMS[termProgram];
|
|
101
|
+
if (editor)
|
|
102
|
+
return { kind: 'editor', editor };
|
|
103
|
+
if (termProgram === 'Apple_Terminal') {
|
|
104
|
+
if (input.macOSMajor !== null && input.macOSMajor >= MACOS_NATIVE_SHIFT_RETURN_MAJOR) {
|
|
105
|
+
return { kind: 'already-native', terminal: 'Apple Terminal' };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'apple-terminal', macOSMajor: input.macOSMajor };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// An editor's integrated terminal can lose TERM_PROGRAM (it is unset when
|
|
111
|
+
// the CLI is launched from an extension host rather than a shell, and TERM
|
|
112
|
+
// falls back to "dumb"). VSCODE_PID / CURSOR_* are still present, so fall
|
|
113
|
+
// back to them rather than reporting "unsupported" inside VS Code — which is
|
|
114
|
+
// the single most likely place this command is run.
|
|
115
|
+
if (input.env.VSCODE_PID || input.env.VSCODE_IPC_HOOK || input.env.VSCODE_CWD) {
|
|
116
|
+
return { kind: 'editor', editor: editorFromBundleId(input.env.__CFBundleIdentifier) };
|
|
117
|
+
}
|
|
118
|
+
return { kind: 'unsupported', terminal: termProgram };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Distinguishes VS Code from its forks when only VSCODE_* vars are present.
|
|
122
|
+
* The forks keep VS Code's own env var names, so the macOS bundle id is the
|
|
123
|
+
* only thing that still differs.
|
|
124
|
+
*/
|
|
125
|
+
function editorFromBundleId(bundleId) {
|
|
126
|
+
switch (bundleId) {
|
|
127
|
+
case 'com.todesktop.230313mzl4w4u92': // Cursor's (stable) bundle id
|
|
128
|
+
case 'com.cursor.Cursor':
|
|
129
|
+
return 'Cursor';
|
|
130
|
+
case 'com.exafunction.windsurf':
|
|
131
|
+
return 'Windsurf';
|
|
132
|
+
case 'com.vscodium':
|
|
133
|
+
case 'com.visualstudio.code.oss':
|
|
134
|
+
return 'VSCodium';
|
|
135
|
+
default:
|
|
136
|
+
return 'VS Code';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Major macOS version (e.g. 26), derived from the Darwin kernel release.
|
|
141
|
+
*
|
|
142
|
+
* Since macOS 11, the Darwin major has run exactly ONE behind the macOS major
|
|
143
|
+
* (verified on this machine: os.release() "25.2.0" with sw_vers reporting
|
|
144
|
+
* macOS 26.2, so Darwin 25 = macOS 26). Returns null off macOS or when the
|
|
145
|
+
* release is unparseable, and callers treat null as "not known to be native",
|
|
146
|
+
* which is the safe direction:
|
|
147
|
+
* it offers the Option+Enter setup rather than wrongly claiming Shift+Enter
|
|
148
|
+
* already works.
|
|
149
|
+
*/
|
|
150
|
+
export function detectMacOSMajor(platform = process.platform, release = os.release()) {
|
|
151
|
+
if (platform !== 'darwin')
|
|
152
|
+
return null;
|
|
153
|
+
const darwinMajor = Number.parseInt(release.split('.')[0] ?? '', 10);
|
|
154
|
+
if (!Number.isFinite(darwinMajor))
|
|
155
|
+
return null;
|
|
156
|
+
return darwinMajor + 1;
|
|
157
|
+
}
|
|
158
|
+
/** Directory name each editor uses under the user's config root. */
|
|
159
|
+
const EDITOR_CONFIG_DIR = {
|
|
160
|
+
'VS Code': 'Code',
|
|
161
|
+
Cursor: 'Cursor',
|
|
162
|
+
Windsurf: 'Windsurf',
|
|
163
|
+
VSCodium: 'VSCodium',
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Absolute path to an editor's `keybindings.json`.
|
|
167
|
+
*
|
|
168
|
+
* Mirrors the layout `claude` uses: `AppData/Roaming/<dir>/User` on Windows,
|
|
169
|
+
* `Library/Application Support/<dir>/User` on macOS, `.config/<dir>/User`
|
|
170
|
+
* elsewhere.
|
|
171
|
+
*/
|
|
172
|
+
export function keybindingsPath(editor, platform = process.platform, home = os.homedir()) {
|
|
173
|
+
const dir = EDITOR_CONFIG_DIR[editor];
|
|
174
|
+
const userDir = platform === 'win32'
|
|
175
|
+
? path.join(home, 'AppData', 'Roaming', dir, 'User')
|
|
176
|
+
: platform === 'darwin'
|
|
177
|
+
? path.join(home, 'Library', 'Application Support', dir, 'User')
|
|
178
|
+
: path.join(home, '.config', dir, 'User');
|
|
179
|
+
return path.join(userDir, 'keybindings.json');
|
|
180
|
+
}
|
|
181
|
+
export const SHIFT_ENTER_KEYBINDING = {
|
|
182
|
+
key: 'shift+enter',
|
|
183
|
+
command: 'workbench.action.terminal.sendSequence',
|
|
184
|
+
args: { text: SHIFT_ENTER_SEQUENCE },
|
|
185
|
+
when: 'terminalFocus',
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Decides how to merge the Shift+Enter binding into a parsed keybindings.json.
|
|
189
|
+
*
|
|
190
|
+
* Pure and separate from the file I/O so every branch — conflict, idempotent
|
|
191
|
+
* re-run, malformed file — is testable without touching a real config
|
|
192
|
+
* directory. `parsed` is whatever JSON.parse produced (`undefined` when the
|
|
193
|
+
* file does not exist yet).
|
|
194
|
+
*/
|
|
195
|
+
export function mergeKeybinding(parsed, binding = SHIFT_ENTER_KEYBINDING) {
|
|
196
|
+
if (parsed === undefined || parsed === null)
|
|
197
|
+
return { kind: 'insert', next: [binding] };
|
|
198
|
+
if (!Array.isArray(parsed))
|
|
199
|
+
return { kind: 'not-an-array' };
|
|
200
|
+
const existing = parsed.find((entry) => typeof entry === 'object' &&
|
|
201
|
+
entry !== null &&
|
|
202
|
+
entry.key === binding.key &&
|
|
203
|
+
entry.command === binding.command &&
|
|
204
|
+
entry.when === binding.when);
|
|
205
|
+
if (existing) {
|
|
206
|
+
if (existing.args?.text === binding.args?.text)
|
|
207
|
+
return { kind: 'already-configured' };
|
|
208
|
+
return { kind: 'conflict', existingText: existing.args?.text };
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'insert', next: [...parsed, binding] };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Parses a keybindings.json body.
|
|
214
|
+
*
|
|
215
|
+
* VS Code writes this file with `//` comments in its default template, and
|
|
216
|
+
* `JSON.parse` rejects those — so a first-time user's file (which is nothing
|
|
217
|
+
* but comments and an empty array) would look corrupt and abort the install.
|
|
218
|
+
* Comments are stripped before parsing for that reason. Returns `undefined`
|
|
219
|
+
* for an empty/whitespace body, and throws only when the content is genuinely
|
|
220
|
+
* not JSON.
|
|
221
|
+
*/
|
|
222
|
+
export function parseKeybindingsFile(body) {
|
|
223
|
+
const withoutComments = stripJsonComments(body).trim();
|
|
224
|
+
if (withoutComments === '')
|
|
225
|
+
return undefined;
|
|
226
|
+
return JSON.parse(withoutComments);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Removes `//` and block comments, preserving anything inside string literals
|
|
230
|
+
* (a `//` in a "when" clause or a Windows path must survive) and escape
|
|
231
|
+
* sequences within them.
|
|
232
|
+
*/
|
|
233
|
+
function stripJsonComments(input) {
|
|
234
|
+
let out = '';
|
|
235
|
+
let inString = false;
|
|
236
|
+
let inLineComment = false;
|
|
237
|
+
let inBlockComment = false;
|
|
238
|
+
for (let i = 0; i < input.length; i++) {
|
|
239
|
+
const ch = input[i];
|
|
240
|
+
const next = input[i + 1];
|
|
241
|
+
if (inLineComment) {
|
|
242
|
+
if (ch === '\n') {
|
|
243
|
+
inLineComment = false;
|
|
244
|
+
out += ch;
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (inBlockComment) {
|
|
249
|
+
if (ch === '*' && next === '/') {
|
|
250
|
+
inBlockComment = false;
|
|
251
|
+
i++;
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (inString) {
|
|
256
|
+
out += ch;
|
|
257
|
+
// A backslash escapes the next character, so a `\"` does not end the
|
|
258
|
+
// string — copy both and skip ahead.
|
|
259
|
+
if (ch === '\\' && next !== undefined) {
|
|
260
|
+
out += next;
|
|
261
|
+
i++;
|
|
262
|
+
}
|
|
263
|
+
else if (ch === '"') {
|
|
264
|
+
inString = false;
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (ch === '"') {
|
|
269
|
+
inString = true;
|
|
270
|
+
out += ch;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (ch === '/' && next === '/') {
|
|
274
|
+
inLineComment = true;
|
|
275
|
+
i++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (ch === '/' && next === '*') {
|
|
279
|
+
inBlockComment = true;
|
|
280
|
+
i++;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
out += ch;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Installs the Shift+Enter keybinding into an editor's keybindings.json.
|
|
289
|
+
*
|
|
290
|
+
* The existing file is backed up before any write, because this edits a file
|
|
291
|
+
* the user owns and may have spent real time on — losing a hand-built
|
|
292
|
+
* keybindings.json to a bug in this command would be far worse than the
|
|
293
|
+
* problem it fixes. A backup that cannot be written aborts the install rather
|
|
294
|
+
* than proceeding unprotected.
|
|
295
|
+
*/
|
|
296
|
+
export async function installEditorKeybinding(editor, deps) {
|
|
297
|
+
const filePath = keybindingsPath(editor, deps.platform ?? process.platform, deps.home ?? os.homedir());
|
|
298
|
+
let body;
|
|
299
|
+
try {
|
|
300
|
+
body = await deps.readFile(filePath);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
// ENOENT is the normal first-run case: no keybindings.json yet. Anything
|
|
304
|
+
// else (EACCES on a locked-down config dir, EISDIR…) is a real failure and
|
|
305
|
+
// must not be reported as a successful install.
|
|
306
|
+
if (err.code !== 'ENOENT') {
|
|
307
|
+
return { kind: 'failed', filePath, reason: describeErr(err) };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = body === undefined ? undefined : parseKeybindingsFile(body);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
return {
|
|
316
|
+
kind: 'failed',
|
|
317
|
+
filePath,
|
|
318
|
+
reason: `it isn't valid JSON (${describeErr(err)}) — fix or move it, then retry`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const merge = mergeKeybinding(parsed);
|
|
322
|
+
if (merge.kind === 'already-configured')
|
|
323
|
+
return { kind: 'already-configured', filePath };
|
|
324
|
+
if (merge.kind === 'conflict')
|
|
325
|
+
return { kind: 'conflict', filePath, existingText: merge.existingText };
|
|
326
|
+
if (merge.kind === 'not-an-array') {
|
|
327
|
+
return { kind: 'failed', filePath, reason: "it isn't a JSON array — fix or move it, then retry" };
|
|
328
|
+
}
|
|
329
|
+
// Back up before writing, but only when there is something to lose.
|
|
330
|
+
if (body !== undefined) {
|
|
331
|
+
try {
|
|
332
|
+
await deps.copyFile(filePath, `${filePath}.${deps.randomSuffix()}.bak`);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
return { kind: 'failed', filePath, reason: `couldn't back it up first (${describeErr(err)})` };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
await deps.mkdir(path.dirname(filePath));
|
|
340
|
+
await deps.writeFile(filePath, `${JSON.stringify(merge.next, null, 2)}\n`);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
return { kind: 'failed', filePath, reason: describeErr(err) };
|
|
344
|
+
}
|
|
345
|
+
return { kind: 'installed', filePath };
|
|
346
|
+
}
|
|
347
|
+
function describeErr(err) {
|
|
348
|
+
return err instanceof Error ? err.message : String(err);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Enables "Use Option as Meta Key" for Apple Terminal's active profiles, so
|
|
352
|
+
* Option+Enter sends `ESC CR` and thus inserts a newline.
|
|
353
|
+
*
|
|
354
|
+
* This is the Apple Terminal story below macOS 27: there is no Shift+Enter
|
|
355
|
+
* binding to install (Terminal.app has no keybinding file we can extend the
|
|
356
|
+
* way an editor does), so Option+Enter is the supported route — which is
|
|
357
|
+
* exactly what `claude` falls back to on the same versions.
|
|
358
|
+
*
|
|
359
|
+
* Both the Default and Startup profiles are updated when they differ, because
|
|
360
|
+
* changing only one leaves new windows behaving differently from the current
|
|
361
|
+
* one — a confusing half-fix. `Add` is tried before `Set` since the key is
|
|
362
|
+
* absent on a profile that has never had it (verified on this machine: it did
|
|
363
|
+
* not exist on the "Basic" profile).
|
|
364
|
+
*/
|
|
365
|
+
export async function enableAppleTerminalOptionAsMeta(deps) {
|
|
366
|
+
const profiles = [];
|
|
367
|
+
for (const which of ['Default Window Settings', 'Startup Window Settings']) {
|
|
368
|
+
const res = await deps.run('defaults', ['read', 'com.apple.Terminal', which]);
|
|
369
|
+
const name = res.stdout.trim();
|
|
370
|
+
if (res.code === 0 && name && !profiles.includes(name))
|
|
371
|
+
profiles.push(name);
|
|
372
|
+
}
|
|
373
|
+
if (profiles.length === 0) {
|
|
374
|
+
return { ok: false, profiles: [], reason: "couldn't read Terminal.app's active profile" };
|
|
375
|
+
}
|
|
376
|
+
let changed = false;
|
|
377
|
+
for (const profile of profiles) {
|
|
378
|
+
const key = `:'Window Settings':'${profile}':useOptionAsMetaKey`;
|
|
379
|
+
const add = await deps.run('/usr/libexec/PlistBuddy', ['-c', `Add ${key} bool true`, deps.plistPath]);
|
|
380
|
+
if (add.code === 0) {
|
|
381
|
+
changed = true;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
// Already present (Add fails on an existing key) — set it instead.
|
|
385
|
+
const set = await deps.run('/usr/libexec/PlistBuddy', ['-c', `Set ${key} true`, deps.plistPath]);
|
|
386
|
+
if (set.code === 0)
|
|
387
|
+
changed = true;
|
|
388
|
+
}
|
|
389
|
+
if (!changed)
|
|
390
|
+
return { ok: false, profiles, reason: 'PlistBuddy rejected every profile' };
|
|
391
|
+
// Terminal.app reads its prefs through cfprefsd, which caches them; without
|
|
392
|
+
// this the change is invisible until the daemon happens to reload.
|
|
393
|
+
await deps.run('killall', ['cfprefsd']);
|
|
394
|
+
return { ok: true, profiles };
|
|
395
|
+
}
|
package/dist/trust.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
// ─── Workspace Trust ───────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Opening a folder in `nex` is not a passive act. Before the user types
|
|
6
|
+
// anything, the session already reads and acts on files found in that folder:
|
|
7
|
+
//
|
|
8
|
+
// • `.nexrall/mcp.json` declares MCP servers, and connecting to a stdio
|
|
9
|
+
// server runs `spawn(server.command, server.args)` — arbitrary command
|
|
10
|
+
// execution, straight from a file in the repo.
|
|
11
|
+
// • `nexrall.md` is injected into the system prompt as trusted project
|
|
12
|
+
// instructions, so its contents steer the agent for the whole session.
|
|
13
|
+
// • `.nexrall/{skills,commands}` define playbooks the agent will follow, and
|
|
14
|
+
// `.nexrall/permissions.json` can pre-approve tool calls that would
|
|
15
|
+
// otherwise prompt.
|
|
16
|
+
// • `.agents/skills/` is the same kind of playbook, discovered from the
|
|
17
|
+
// agentskills.io cross-client convention (core's agent/skills.ts) rather
|
|
18
|
+
// than a Nexrall-specific path — worth its own line since it may have
|
|
19
|
+
// been authored for a different client entirely.
|
|
20
|
+
//
|
|
21
|
+
// So `git clone` of a hostile repo followed by `nex` is enough to execute
|
|
22
|
+
// attacker-chosen commands. The confirmation prompt is what turns that into a
|
|
23
|
+
// deliberate user decision.
|
|
24
|
+
//
|
|
25
|
+
// ── Why trust is NOT remembered between sessions ──────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// An earlier version of this file persisted trusted folders to
|
|
28
|
+
// ~/.nexrall/trusted-folders.json and skipped the prompt on later runs. That
|
|
29
|
+
// is weaker than it looks, because trust was granted to a PATH while the risk
|
|
30
|
+
// lives in the folder's CONTENTS, and contents change:
|
|
31
|
+
//
|
|
32
|
+
// 1. You trust ~/work/api today. It has no .nexrall/mcp.json.
|
|
33
|
+
// 2. Tomorrow `git pull` (or a dependency update, or a teammate's commit)
|
|
34
|
+
// adds .nexrall/mcp.json.
|
|
35
|
+
// 3. You run `nex` — and because the path was trusted once, the prompt never
|
|
36
|
+
// appears and the new server is spawned silently.
|
|
37
|
+
//
|
|
38
|
+
// Persisting trust therefore hands future, unreviewed content the approval the
|
|
39
|
+
// user gave to the content they saw once. Asking every session keeps the
|
|
40
|
+
// decision attached to what is in the folder RIGHT NOW, which is the thing
|
|
41
|
+
// that can actually hurt you. The cost is one keystroke per session; the
|
|
42
|
+
// benefit is that a newly-introduced mcp.json can never run un-announced.
|
|
43
|
+
// (Claude Code behaves the same way — it re-asks on every start rather than
|
|
44
|
+
// remembering.)
|
|
45
|
+
//
|
|
46
|
+
// This module is consequently stateless: there is no trust store, nothing
|
|
47
|
+
// written to disk, and nothing a repository could plant to mark itself
|
|
48
|
+
// trusted.
|
|
49
|
+
/**
|
|
50
|
+
* Escape hatch for non-interactive runs, which have no way to show a prompt.
|
|
51
|
+
*
|
|
52
|
+
* This is deliberately an ENVIRONMENT VARIABLE and not a file: it must be set
|
|
53
|
+
* by whoever invokes the process (a CI job, a wrapper script), and cannot be
|
|
54
|
+
* committed into a repository the way a config file could. A hostile repo
|
|
55
|
+
* therefore cannot grant itself trust.
|
|
56
|
+
*/
|
|
57
|
+
export const TRUST_ENV_VAR = 'NEXRALL_TRUST_WORKSPACE';
|
|
58
|
+
/** True when the invoker explicitly pre-authorised this run via the environment. */
|
|
59
|
+
export function trustGrantedByEnv() {
|
|
60
|
+
const v = process.env[TRUST_ENV_VAR];
|
|
61
|
+
return v === '1' || v === 'true';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Which trust-sensitive files actually exist in `dir`, so the prompt can tell
|
|
65
|
+
* the user what THIS folder will do rather than describing risks in the
|
|
66
|
+
* abstract. Ordered most- to least-dangerous.
|
|
67
|
+
*/
|
|
68
|
+
/** Does the project settings file bind plugin aliases to remote repos? */
|
|
69
|
+
function declaresPluginSources(dir) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(path.join(dir, '.nexrall', 'settings.json'), 'utf-8');
|
|
72
|
+
const obj = JSON.parse(raw);
|
|
73
|
+
const s = obj.pluginSources;
|
|
74
|
+
return Boolean(s && typeof s === 'object' && !Array.isArray(s) && Object.keys(s).length > 0);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function detectTrustSignals(dir) {
|
|
81
|
+
const signals = [];
|
|
82
|
+
const exists = (...p) => {
|
|
83
|
+
try {
|
|
84
|
+
return fs.existsSync(path.join(dir, ...p));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// Code-executing first — these can run local commands before the user types
|
|
91
|
+
// anything, so they are what the decision really hinges on.
|
|
92
|
+
if (exists('.nexrall', 'mcp.json')) {
|
|
93
|
+
signals.push('.nexrall/mcp.json — can start MCP servers (runs local commands)');
|
|
94
|
+
}
|
|
95
|
+
if (exists('.nexrall', 'plugins')) {
|
|
96
|
+
// Plugins are scanned from the workspace and may contribute their own MCP
|
|
97
|
+
// servers (see core/src/plugins/index.ts → pluginMcpServers), so this is in
|
|
98
|
+
// the same risk class as mcp.json rather than a cosmetic add-on.
|
|
99
|
+
signals.push('.nexrall/plugins/ — project plugins (may add MCP servers)');
|
|
100
|
+
}
|
|
101
|
+
// Then things that widen what the agent may do without asking.
|
|
102
|
+
if (exists('.nexrall', 'permissions.json')) {
|
|
103
|
+
signals.push('.nexrall/permissions.json — pre-approved tool permissions');
|
|
104
|
+
}
|
|
105
|
+
if (exists('.nexrall', 'settings.json')) {
|
|
106
|
+
// Called out separately when the file declares plugin sources: those bind a
|
|
107
|
+
// short alias to a remote repo, so `nex plugin install <alias>` would fetch
|
|
108
|
+
// whatever the REPOSITORY chose. Installing still needs a human and its own
|
|
109
|
+
// confirmation — nothing is fetched from a declaration alone — but the user
|
|
110
|
+
// should know the repo is trying to name what they install.
|
|
111
|
+
signals.push(declaresPluginSources(dir)
|
|
112
|
+
? '.nexrall/settings.json — project settings, and declares plugin sources (aliases for remote plugin repos)'
|
|
113
|
+
: '.nexrall/settings.json — project settings and permission rules');
|
|
114
|
+
}
|
|
115
|
+
// Then things that steer the agent's behaviour.
|
|
116
|
+
if (exists('nexrall.md')) {
|
|
117
|
+
signals.push('nexrall.md — project instructions added to the agent’s prompt');
|
|
118
|
+
}
|
|
119
|
+
if (exists('.nexrall', 'skills')) {
|
|
120
|
+
signals.push('.nexrall/skills/ — custom agent playbooks');
|
|
121
|
+
}
|
|
122
|
+
if (exists('.nexrall', 'commands')) {
|
|
123
|
+
signals.push('.nexrall/commands/ — custom slash commands');
|
|
124
|
+
}
|
|
125
|
+
if (exists('.nexrall', 'agents')) {
|
|
126
|
+
signals.push('.nexrall/agents/ — custom sub-agent definitions');
|
|
127
|
+
}
|
|
128
|
+
if (exists('.agents', 'skills')) {
|
|
129
|
+
// Same risk class as .nexrall/skills/ (instructions the agent follows,
|
|
130
|
+
// not code it executes) — called out separately because this path is the
|
|
131
|
+
// agentskills.io cross-client convention, so a skill here may have been
|
|
132
|
+
// authored for/by a DIFFERENT tool than the one asking to trust this
|
|
133
|
+
// folder right now.
|
|
134
|
+
signals.push('.agents/skills/ — cross-client agent playbooks (agentskills.io convention)');
|
|
135
|
+
}
|
|
136
|
+
return signals;
|
|
137
|
+
}
|
package/dist/ui/diff.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { structuredPatch } from 'diff';
|
|
3
|
+
// ─── Show Diff ────────────────────────────────────────────────────────────────
|
|
4
|
+
/**
|
|
5
|
+
* Display a unified diff between oldContent and newContent for the given path.
|
|
6
|
+
* If oldContent is null the file is treated as newly created.
|
|
7
|
+
*/
|
|
8
|
+
export function showDiff(filePath, oldContent, newContent) {
|
|
9
|
+
const isNew = oldContent === null;
|
|
10
|
+
const from = isNew ? '/dev/null' : `a/${filePath}`;
|
|
11
|
+
const to = `b/${filePath}`;
|
|
12
|
+
console.log();
|
|
13
|
+
console.log(chalk.bold(`--- ${from}`));
|
|
14
|
+
console.log(chalk.bold(`+++ ${to}`));
|
|
15
|
+
if (isNew) {
|
|
16
|
+
// Show the entire new file as additions
|
|
17
|
+
const lines = newContent.split('\n');
|
|
18
|
+
// Remove trailing empty line artifact from split
|
|
19
|
+
if (lines[lines.length - 1] === '')
|
|
20
|
+
lines.pop();
|
|
21
|
+
console.log(chalk.cyan(`@@ -0,0 +1,${lines.length} @@`));
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
console.log(chalk.green(`+${line}`));
|
|
24
|
+
}
|
|
25
|
+
console.log();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Compute unified diff using the diff package
|
|
29
|
+
const patch = structuredPatch(from, to, oldContent, newContent, '', '', { context: 3 });
|
|
30
|
+
if (patch.hunks.length === 0) {
|
|
31
|
+
console.log(chalk.dim(' (no changes)'));
|
|
32
|
+
console.log();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const hunk of patch.hunks) {
|
|
36
|
+
const hunkHeader = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
37
|
+
console.log(chalk.cyan(hunkHeader));
|
|
38
|
+
for (const line of hunk.lines) {
|
|
39
|
+
if (line.startsWith('+')) {
|
|
40
|
+
console.log(chalk.green(line));
|
|
41
|
+
}
|
|
42
|
+
else if (line.startsWith('-')) {
|
|
43
|
+
console.log(chalk.red(line));
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
console.log(chalk.gray(line));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
console.log();
|
|
51
|
+
}
|