@superjackfruitlabs/salaam 0.0.4 → 0.0.5
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/CHANGELOG.md +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/src/command-sandbox.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { COMMAND_NETWORK_PORT, inSandboxRelaySource } from './command-network.js';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, sep } from 'node:path';
|
|
5
6
|
import { ServiceError } from './protocol.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -25,9 +26,87 @@ export interface Bwrap {
|
|
|
25
26
|
}
|
|
26
27
|
export interface Seatbelt {
|
|
27
28
|
path: string;
|
|
29
|
+
/** The active Apple developer dir (`xcode-select -p`) when it holds a real git. /usr/bin/git is
|
|
30
|
+
* a shim that needs Xcode's licence preferences, which the profile does not open; commands get
|
|
31
|
+
* the real tools first on PATH instead (step 0, 2026-10-31). */
|
|
32
|
+
developerDir?: string;
|
|
28
33
|
}
|
|
29
34
|
export type SandboxKind = 'bwrap' | 'seatbelt' | 'none';
|
|
30
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Phase 1 (2026-10-31): read-only paths outside the workspace an operator lets local commands use
|
|
38
|
+
* (a toolchain, a package cache, `~/.gitconfig`). Never the root, home itself or an ancestor of
|
|
39
|
+
* it, and never a credential directory or file, or an ancestor of one. `toolchain` expands to the
|
|
40
|
+
* ones that exist here: the prefix of the node running Salaam, and home's package caches and
|
|
41
|
+
* toolchains. Writes stay inside the workspace whatever is listed.
|
|
42
|
+
*/
|
|
43
|
+
export const READ_PATHS_TOOLCHAIN = 'toolchain';
|
|
44
|
+
const CREDENTIALS = [
|
|
45
|
+
'.ssh',
|
|
46
|
+
'.gnupg',
|
|
47
|
+
'.aws',
|
|
48
|
+
'.azure',
|
|
49
|
+
'.config/gcloud',
|
|
50
|
+
'.config/gh',
|
|
51
|
+
'.docker',
|
|
52
|
+
'.kube',
|
|
53
|
+
'.netrc',
|
|
54
|
+
'.npmrc',
|
|
55
|
+
'.pypirc',
|
|
56
|
+
'.git-credentials',
|
|
57
|
+
'.claude',
|
|
58
|
+
'.codex',
|
|
59
|
+
'.config/salaam',
|
|
60
|
+
'Library/Keychains',
|
|
61
|
+
];
|
|
62
|
+
const TOOLCHAIN = ['.npm', '.nvm', '.cargo', '.rustup', 'go', '.gitconfig', '.config/git'];
|
|
63
|
+
function within(path: string, base: string): boolean {
|
|
64
|
+
return path === base || path.startsWith(base.endsWith(sep) ? base : base + sep);
|
|
65
|
+
}
|
|
66
|
+
export function resolveReadPaths(entries: readonly string[], home = homedir()): string[] {
|
|
67
|
+
const refuse = (path: string, why: string): never => {
|
|
68
|
+
throw new ServiceError('invalid_field', `local_sandbox_read_paths: ${path} ${why}`);
|
|
69
|
+
};
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const add = (path: string) => {
|
|
72
|
+
if (!isAbsolute(path)) refuse(path, 'must be an absolute path');
|
|
73
|
+
if (/["\\\x00-\x1f\x7f]/.test(path)) refuse(path, 'has a character Seatbelt cannot quote');
|
|
74
|
+
const p = path.length > 1 ? path.replace(/\/+$/, '') : path;
|
|
75
|
+
if (p === '/') refuse(p, 'is the whole filesystem');
|
|
76
|
+
if (within(home, p)) refuse(p, 'would expose the whole home directory');
|
|
77
|
+
for (const c of CREDENTIALS) {
|
|
78
|
+
const secret = join(home, c);
|
|
79
|
+
if (within(p, secret) || within(secret, p)) refuse(p, `covers credentials (${c})`);
|
|
80
|
+
}
|
|
81
|
+
if (!out.includes(p)) out.push(p);
|
|
82
|
+
};
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (typeof entry !== 'string') refuse(String(entry), 'must be a string');
|
|
85
|
+
if (entry !== READ_PATHS_TOOLCHAIN) {
|
|
86
|
+
add(entry);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// The node running Salaam, so `node`/`npx`/`npm` resolve inside the sandbox.
|
|
90
|
+
const nodePrefix = dirname(dirname(realpathSync(process.execPath)));
|
|
91
|
+
if (!within(home, nodePrefix)) add(nodePrefix);
|
|
92
|
+
for (const t of TOOLCHAIN) if (existsSync(join(home, t))) add(join(home, t));
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The active developer dir when it has a real git, else undefined. */
|
|
98
|
+
function developerDir(): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
const dir = execFileSync('/usr/bin/xcode-select', ['-p'], {
|
|
101
|
+
timeout: 5000,
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
}).trim();
|
|
104
|
+
return isAbsolute(dir) && existsSync(join(dir, 'usr/bin/git')) ? dir : undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
31
110
|
/** The pinned bubblewrap: its absolute path and the version it reports, or null when unusable. */
|
|
32
111
|
export function findBwrap(candidates = ['/usr/bin/bwrap', '/bin/bwrap']): Bwrap | null {
|
|
33
112
|
for (const path of candidates) {
|
|
@@ -61,6 +140,8 @@ export function sandboxArgv(
|
|
|
61
140
|
/** Broker network: the host relay's private directory and the node that runs the in-sandbox
|
|
62
141
|
* relay. The network stays unshared; only the relay socket and node are bound in. */
|
|
63
142
|
broker?: { dir: string; node: string };
|
|
143
|
+
/** Operator-allowed read-only paths outside the workspace (resolveReadPaths). */
|
|
144
|
+
readPaths?: readonly string[];
|
|
64
145
|
},
|
|
65
146
|
): string[] {
|
|
66
147
|
if (level === 'full-access') return argv;
|
|
@@ -100,6 +181,8 @@ export function sandboxArgv(
|
|
|
100
181
|
'/dev',
|
|
101
182
|
'--tmpfs',
|
|
102
183
|
'/tmp',
|
|
184
|
+
// Operator-allowed toolchains, read-only, at their own paths (a symlink into one resolves).
|
|
185
|
+
...(options.readPaths ?? []).flatMap((p) => ['--ro-bind-try', p, p]),
|
|
103
186
|
level === 'read-only' ? '--ro-bind' : '--bind',
|
|
104
187
|
root,
|
|
105
188
|
root,
|
|
@@ -153,7 +236,8 @@ export function findSeatbelt(path = '/usr/bin/sandbox-exec'): Seatbelt | null {
|
|
|
153
236
|
],
|
|
154
237
|
{ timeout: 5000, stdio: 'ignore' },
|
|
155
238
|
);
|
|
156
|
-
|
|
239
|
+
const dev = developerDir();
|
|
240
|
+
return dev ? { path, developerDir: dev } : { path };
|
|
157
241
|
} catch {
|
|
158
242
|
return null;
|
|
159
243
|
}
|
|
@@ -176,11 +260,27 @@ export function seatbeltProfile(
|
|
|
176
260
|
level: 'read-only' | 'workspace-write',
|
|
177
261
|
root: string,
|
|
178
262
|
tmp: string,
|
|
179
|
-
/** brokerPort: the host relay's loopback port, the only place a broker-network command may connect.
|
|
180
|
-
|
|
263
|
+
/** brokerPort: the host relay's loopback port, the only place a broker-network command may connect.
|
|
264
|
+
* readPaths: operator-allowed read-only paths outside the workspace. developerDir: the active
|
|
265
|
+
* developer dir, when it lies outside the system paths below. */
|
|
266
|
+
options: {
|
|
267
|
+
network: boolean;
|
|
268
|
+
brokerPort?: number;
|
|
269
|
+
readPaths?: readonly string[];
|
|
270
|
+
developerDir?: string;
|
|
271
|
+
/** A command the person approved to listen (operator ruling 2026-11-01, option C). Seatbelt
|
|
272
|
+
* cannot limit this to loopback: "localhost" in its filters matches every local address. */
|
|
273
|
+
listen?: boolean;
|
|
274
|
+
},
|
|
181
275
|
): string {
|
|
182
276
|
const w = sbplPath(root, 'workspace path'),
|
|
183
277
|
t = sbplPath(tmp, 'tmp path');
|
|
278
|
+
const extra = [
|
|
279
|
+
...(options.readPaths ?? []),
|
|
280
|
+
...(options.developerDir ? [options.developerDir] : []),
|
|
281
|
+
]
|
|
282
|
+
.map((p) => `(subpath ${sbplPath(p, 'read path')})`)
|
|
283
|
+
.join(' ');
|
|
184
284
|
const system = [
|
|
185
285
|
'/usr',
|
|
186
286
|
'/bin',
|
|
@@ -208,12 +308,18 @@ export function seatbeltProfile(
|
|
|
208
308
|
'(allow signal (target same-sandbox))',
|
|
209
309
|
'(allow sysctl-read)',
|
|
210
310
|
'(allow file-read-metadata)',
|
|
211
|
-
`(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t}))`,
|
|
311
|
+
`(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t})${extra ? ' ' + extra : ''})`,
|
|
212
312
|
level === 'workspace-write'
|
|
213
313
|
? `(allow file-write* (subpath ${w}) (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`
|
|
214
314
|
: `(allow file-write* (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`,
|
|
215
315
|
`(allow file-ioctl ${devices} (regex #"^/dev/fd/"))`,
|
|
216
316
|
...(options.network ? ['(allow network*)'] : []),
|
|
317
|
+
...(options.listen && level === 'workspace-write' && !options.network
|
|
318
|
+
? [
|
|
319
|
+
'(allow network-bind (local ip "localhost:*"))',
|
|
320
|
+
'(allow network-inbound (local ip "localhost:*"))',
|
|
321
|
+
]
|
|
322
|
+
: []),
|
|
217
323
|
...(options.brokerPort !== undefined && !options.network
|
|
218
324
|
? [`(allow network-outbound (remote tcp "localhost:${brokerPort(options.brokerPort)}"))`]
|
|
219
325
|
: []),
|
|
@@ -226,7 +332,12 @@ export function seatbeltArgv(
|
|
|
226
332
|
tmp: string,
|
|
227
333
|
argv: string[],
|
|
228
334
|
seatbelt: Seatbelt | null,
|
|
229
|
-
options: {
|
|
335
|
+
options: {
|
|
336
|
+
network: boolean;
|
|
337
|
+
brokerPort?: number;
|
|
338
|
+
readPaths?: readonly string[];
|
|
339
|
+
listen?: boolean;
|
|
340
|
+
},
|
|
230
341
|
): string[] {
|
|
231
342
|
if (level === 'full-access') return argv;
|
|
232
343
|
if (!seatbelt)
|
|
@@ -234,12 +345,21 @@ export function seatbeltArgv(
|
|
|
234
345
|
'sandbox_unavailable',
|
|
235
346
|
'This host has no usable Seatbelt, so it cannot run a sandboxed command; an operator can choose full access explicitly',
|
|
236
347
|
);
|
|
348
|
+
const dev = seatbelt.developerDir;
|
|
349
|
+
// git reads ~/.gitconfig and fails hard when it may not; without the operator allowing it, the
|
|
350
|
+
// command runs with no global git config rather than an error.
|
|
351
|
+
const gitConfig = join(homedir(), '.gitconfig');
|
|
352
|
+
const gitConfigAllowed = (options.readPaths ?? []).some((p) => within(gitConfig, p));
|
|
237
353
|
return [
|
|
238
354
|
seatbelt.path,
|
|
239
355
|
'-p',
|
|
240
|
-
seatbeltProfile(level, root, tmp, options),
|
|
356
|
+
seatbeltProfile(level, root, tmp, { ...options, ...(dev ? { developerDir: dev } : {}) }),
|
|
241
357
|
'/usr/bin/env',
|
|
242
358
|
`TMPDIR=${tmp}/`,
|
|
359
|
+
...(dev
|
|
360
|
+
? [`PATH=${join(dev, 'usr/bin')}:${process.env.PATH ?? '/usr/bin:/bin:/usr/sbin:/sbin'}`]
|
|
361
|
+
: []),
|
|
362
|
+
...(gitConfigAllowed ? [] : ['GIT_CONFIG_GLOBAL=/dev/null']),
|
|
243
363
|
...argv,
|
|
244
364
|
];
|
|
245
365
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import { SLASH_COMMANDS } from './tui-commands.js';
|
|
5
|
+
|
|
6
|
+
/** A saved prompt the person types as `/name args` (Phase 4b). It only ever produces a prompt:
|
|
7
|
+
* it never runs a command, grants a tool or changes a mode or rule, so a repository's own commands
|
|
8
|
+
* are safe to load without trusting the repository. */
|
|
9
|
+
export interface CustomCommand {
|
|
10
|
+
name: string;
|
|
11
|
+
scope: 'repo' | 'user';
|
|
12
|
+
path: string;
|
|
13
|
+
description: string;
|
|
14
|
+
argumentHint?: string;
|
|
15
|
+
/** Read for display; a command does not switch the session's model. */
|
|
16
|
+
model?: string;
|
|
17
|
+
body: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_FILE = 32 * 1024;
|
|
21
|
+
const MAX_FILES = 200;
|
|
22
|
+
const NAME = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
23
|
+
const builtIn = new Set(SLASH_COMMANDS.flatMap((c) => [c.name, ...c.aliases]));
|
|
24
|
+
|
|
25
|
+
export function defaultUserCommandsDir(env = process.env): string {
|
|
26
|
+
return join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'salaam', 'commands');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Repository commands live in `<repo>/.salaam/commands`, yours in `~/.config/salaam/commands`.
|
|
30
|
+
* A subdirectory becomes a namespace (`frontend/component.md` is `/frontend:component`), and your
|
|
31
|
+
* command wins over a repository's of the same name. Built-in commands always win. */
|
|
32
|
+
export function loadCustomCommands(
|
|
33
|
+
repo: string,
|
|
34
|
+
userDir = defaultUserCommandsDir(),
|
|
35
|
+
): { commands: CustomCommand[]; problems: string[] } {
|
|
36
|
+
const problems: string[] = [];
|
|
37
|
+
const found = new Map<string, CustomCommand>();
|
|
38
|
+
for (const [scope, dir] of [
|
|
39
|
+
['repo', join(repo, '.salaam', 'commands')],
|
|
40
|
+
['user', userDir],
|
|
41
|
+
] as const) {
|
|
42
|
+
let root: string;
|
|
43
|
+
try {
|
|
44
|
+
root = realpathSync(dir);
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// A repository's command folder must itself stay inside the repository.
|
|
49
|
+
if (scope === 'repo' && !inside(realpathSync(repo), root)) {
|
|
50
|
+
problems.push(`${dir}: resolves outside the repository; ignored`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const file of walk(root, root, problems)) {
|
|
54
|
+
const parts = relative(root, file).replace(/\.md$/, '').split(sep);
|
|
55
|
+
if (!parts.every((p) => NAME.test(p))) {
|
|
56
|
+
problems.push(`${file}: a name may use only letters, digits, - and _; ignored`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const name = parts.join(':').toLowerCase();
|
|
60
|
+
if (builtIn.has(name)) {
|
|
61
|
+
problems.push(`${file}: /${name} is a built-in command; ignored`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
let text: string;
|
|
65
|
+
try {
|
|
66
|
+
const real = realpathSync(file);
|
|
67
|
+
if (!inside(root, real)) {
|
|
68
|
+
problems.push(`${file}: resolves outside its commands folder; ignored`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (lstatSync(real).size > MAX_FILE) {
|
|
72
|
+
problems.push(`${file}: larger than 32 KiB; ignored`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
text = readFileSync(real, 'utf8');
|
|
76
|
+
} catch (error) {
|
|
77
|
+
problems.push(`${file}: ${(error as Error).message}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const { fields, body } = frontmatter(text);
|
|
81
|
+
for (const key of ['allowed-tools', 'disallowed-tools', 'hooks', 'permission-mode'])
|
|
82
|
+
if (key in fields)
|
|
83
|
+
problems.push(`${file}: ${key} is ignored; a command only writes a prompt`);
|
|
84
|
+
found.set(name, {
|
|
85
|
+
name,
|
|
86
|
+
scope,
|
|
87
|
+
path: file,
|
|
88
|
+
description: fields.description || `Custom ${scope} command`,
|
|
89
|
+
...(fields['argument-hint'] ? { argumentHint: fields['argument-hint'] } : {}),
|
|
90
|
+
...(fields.model ? { model: fields.model } : {}),
|
|
91
|
+
body: body.trim(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { commands: [...found.values()].sort((a, b) => a.name.localeCompare(b.name)), problems };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inside(root: string, path: string): boolean {
|
|
99
|
+
const r = relative(root, path);
|
|
100
|
+
return !r.startsWith('..') && !r.startsWith(sep);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walk(root: string, dir: string, problems: string[], depth = 0): string[] {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
if (depth > 3) return out;
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
113
|
+
if (out.length >= MAX_FILES) break;
|
|
114
|
+
const path = join(dir, e.name);
|
|
115
|
+
if (e.isSymbolicLink()) {
|
|
116
|
+
let real: string;
|
|
117
|
+
try {
|
|
118
|
+
real = realpathSync(path);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Followed only to a file; whether it stays inside is checked when the file is read.
|
|
123
|
+
if (e.name.endsWith('.md') && lstatSync(real).isFile()) out.push(path);
|
|
124
|
+
} else if (e.isDirectory()) out.push(...walk(root, path, problems, depth + 1));
|
|
125
|
+
else if (e.isFile() && e.name.endsWith('.md')) out.push(path);
|
|
126
|
+
}
|
|
127
|
+
return out.slice(0, MAX_FILES);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function frontmatter(text: string): { fields: Record<string, string>; body: string } {
|
|
131
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
132
|
+
if (!m) return { fields: {}, body: text };
|
|
133
|
+
const fields: Record<string, string> = {};
|
|
134
|
+
for (const line of m[1]!.split(/\r?\n/)) {
|
|
135
|
+
const kv = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
136
|
+
if (kv) fields[kv[1]!.toLowerCase()] = kv[2]!.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
137
|
+
}
|
|
138
|
+
return { fields, body: text.slice(m[0].length) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Shell-like splitting: whitespace separates, double or single quotes group. */
|
|
142
|
+
export function splitArguments(args: string): string[] {
|
|
143
|
+
const out: string[] = [];
|
|
144
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
145
|
+
for (let m = re.exec(args); m; m = re.exec(args)) out.push(m[1] ?? m[2] ?? m[3]!);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Placeholders as in Claude Code: `$ARGUMENTS`, `$ARGUMENTS[N]` and `$N` (0-based); `\$` is a
|
|
150
|
+
* literal dollar. With no placeholder the arguments are appended, so they are never dropped. */
|
|
151
|
+
export function expandCustomCommand(command: CustomCommand, args: string): string {
|
|
152
|
+
const list = splitArguments(args);
|
|
153
|
+
let used = false;
|
|
154
|
+
const body = command.body.replace(
|
|
155
|
+
/\\\$|\$ARGUMENTS\[(\d+)\]|\$ARGUMENTS|\$(\d+)/g,
|
|
156
|
+
(all: string, index?: string, short?: string) => {
|
|
157
|
+
if (all === '\\$') return '$';
|
|
158
|
+
used = true;
|
|
159
|
+
if (all === '$ARGUMENTS') return args.trim();
|
|
160
|
+
return list[Number(index ?? short)] ?? '';
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
return !used && args.trim() ? `${body}\n\nARGUMENTS: ${args.trim()}` : body;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A typed `/name args` line that names a custom command, or null. */
|
|
167
|
+
export function matchCustomCommand(
|
|
168
|
+
text: string,
|
|
169
|
+
commands: readonly CustomCommand[],
|
|
170
|
+
): { command: CustomCommand; args: string } | null {
|
|
171
|
+
const m = /^\/([a-z0-9][a-z0-9_:-]*)(?:[ \t]+([\s\S]*))?$/i.exec(text.trim());
|
|
172
|
+
if (!m) return null;
|
|
173
|
+
const command = commands.find((c) => c.name === m[1]!.toLowerCase());
|
|
174
|
+
return command ? { command, args: (m[2] ?? '').trim() } : null;
|
|
175
|
+
}
|