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.
@@ -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
+ }
@@ -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
+ }