@crewhaus/slash-commands 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/package.json +42 -0
- package/src/index.test.ts +250 -0
- package/src/index.ts +191 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/slash-commands",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Markdown-templated slash commands with $ARGUMENTS substitution",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0",
|
|
16
|
+
"yaml": "^2.6.0"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": {
|
|
20
|
+
"name": "Max Meier",
|
|
21
|
+
"email": "max@studiomax.io",
|
|
22
|
+
"url": "https://studiomax.io"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
27
|
+
"directory": "packages/slash-commands"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/slash-commands#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "restricted"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"NOTICE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type SlashCommand, expand, loadCommands, parseCommandFile } from "./index";
|
|
6
|
+
|
|
7
|
+
function withTempCwd(
|
|
8
|
+
setup: (cwd: string) => void,
|
|
9
|
+
body: (cwd: string) => Promise<void> | void,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const cwd = mkdtempSync(join(tmpdir(), "slash-cwd-"));
|
|
12
|
+
try {
|
|
13
|
+
setup(cwd);
|
|
14
|
+
return Promise.resolve(body(cwd)).finally(() => {
|
|
15
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
} catch (err) {
|
|
18
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeCommand(cwd: string, name: string, content: string): string {
|
|
24
|
+
const dir = join(cwd, ".crewhaus", "commands");
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
const file = join(dir, `${name}.md`);
|
|
27
|
+
writeFileSync(file, content);
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("loadCommands", () => {
|
|
32
|
+
test("returns empty map when no directory", async () => {
|
|
33
|
+
await withTempCwd(
|
|
34
|
+
() => {},
|
|
35
|
+
async (cwd) => {
|
|
36
|
+
const cmds = await loadCommands({ cwd });
|
|
37
|
+
expect(cmds.size).toBe(0);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("loads body-only files (no frontmatter)", async () => {
|
|
43
|
+
await withTempCwd(
|
|
44
|
+
(cwd) => {
|
|
45
|
+
writeCommand(cwd, "explain", "Explain $ARGUMENTS in two sentences.");
|
|
46
|
+
},
|
|
47
|
+
async (cwd) => {
|
|
48
|
+
const cmds = await loadCommands({ cwd });
|
|
49
|
+
expect(cmds.size).toBe(1);
|
|
50
|
+
const cmd = cmds.get("explain");
|
|
51
|
+
expect(cmd?.body).toBe("Explain $ARGUMENTS in two sentences.");
|
|
52
|
+
expect(cmd?.description).toBeUndefined();
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("parses optional frontmatter (description, argument-hint)", async () => {
|
|
58
|
+
await withTempCwd(
|
|
59
|
+
(cwd) => {
|
|
60
|
+
writeCommand(
|
|
61
|
+
cwd,
|
|
62
|
+
"review",
|
|
63
|
+
`---\ndescription: code review on a PR\nargument-hint: "<pr-number>"\n---\nReview PR $ARGUMENTS.`,
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
async (cwd) => {
|
|
67
|
+
const cmds = await loadCommands({ cwd });
|
|
68
|
+
const cmd = cmds.get("review");
|
|
69
|
+
expect(cmd?.description).toBe("code review on a PR");
|
|
70
|
+
expect(cmd?.argumentHint).toBe("<pr-number>");
|
|
71
|
+
expect(cmd?.body).toBe("Review PR $ARGUMENTS.");
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("supports camelCase alias `argumentHint`", async () => {
|
|
77
|
+
await withTempCwd(
|
|
78
|
+
(cwd) => {
|
|
79
|
+
writeCommand(cwd, "alias", `---\nargumentHint: "<x>"\n---\nbody`);
|
|
80
|
+
},
|
|
81
|
+
async (cwd) => {
|
|
82
|
+
const cmds = await loadCommands({ cwd });
|
|
83
|
+
expect(cmds.get("alias")?.argumentHint).toBe("<x>");
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("ignores non-md files and subdirectories", async () => {
|
|
89
|
+
await withTempCwd(
|
|
90
|
+
(cwd) => {
|
|
91
|
+
mkdirSync(join(cwd, ".crewhaus", "commands", "subdir"), { recursive: true });
|
|
92
|
+
writeFileSync(join(cwd, ".crewhaus", "commands", "notmd.txt"), "ignored");
|
|
93
|
+
writeCommand(cwd, "real", "body");
|
|
94
|
+
},
|
|
95
|
+
async (cwd) => {
|
|
96
|
+
const cmds = await loadCommands({ cwd });
|
|
97
|
+
expect([...cmds.keys()]).toEqual(["real"]);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("treats unterminated frontmatter as plain body (defensive)", async () => {
|
|
103
|
+
await withTempCwd(
|
|
104
|
+
(cwd) => {
|
|
105
|
+
writeCommand(cwd, "rule", "---\nthis is not really frontmatter\nstill the body");
|
|
106
|
+
},
|
|
107
|
+
async (cwd) => {
|
|
108
|
+
const cmds = await loadCommands({ cwd });
|
|
109
|
+
const cmd = cmds.get("rule");
|
|
110
|
+
expect(cmd?.body.startsWith("---\n")).toBe(true);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("expand — non-slash and unknown", () => {
|
|
117
|
+
test("non-slash input passes through unchanged", () => {
|
|
118
|
+
const cmds = new Map<string, SlashCommand>([["x", { name: "x", body: "B", filePath: "/x" }]]);
|
|
119
|
+
const r = expand("hello world", cmds);
|
|
120
|
+
expect(r.handled).toBe(false);
|
|
121
|
+
expect(r.expanded).toBe("hello world");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("unknown command name passes through", () => {
|
|
125
|
+
const cmds = new Map<string, SlashCommand>([["x", { name: "x", body: "B", filePath: "/x" }]]);
|
|
126
|
+
const r = expand("/missing some args", cmds);
|
|
127
|
+
expect(r.handled).toBe(false);
|
|
128
|
+
expect(r.expanded).toBe("/missing some args");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("empty commands map → not handled", () => {
|
|
132
|
+
const r = expand("/anything", new Map());
|
|
133
|
+
expect(r.handled).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("expand — known command", () => {
|
|
138
|
+
const cmds = new Map<string, SlashCommand>([
|
|
139
|
+
[
|
|
140
|
+
"explain",
|
|
141
|
+
{
|
|
142
|
+
name: "explain",
|
|
143
|
+
body: "Explain $ARGUMENTS in two sentences.",
|
|
144
|
+
filePath: "/x",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
test("substitutes simple args", () => {
|
|
150
|
+
const r = expand("/explain quicksort", cmds);
|
|
151
|
+
expect(r.handled).toBe(true);
|
|
152
|
+
expect(r.expanded).toBe("Explain quicksort in two sentences.");
|
|
153
|
+
expect(r.arguments).toBe("quicksort");
|
|
154
|
+
expect(r.command?.name).toBe("explain");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("empty args become empty string", () => {
|
|
158
|
+
const r = expand("/explain", cmds);
|
|
159
|
+
expect(r.handled).toBe(true);
|
|
160
|
+
expect(r.expanded).toBe("Explain in two sentences.");
|
|
161
|
+
expect(r.arguments).toBe("");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("multi-line args preserved", () => {
|
|
165
|
+
const r = expand("/explain a\nmulti\nline", cmds);
|
|
166
|
+
expect(r.expanded).toBe("Explain a\nmulti\nline in two sentences.");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("leading whitespace before slash is allowed", () => {
|
|
170
|
+
const r = expand(" /explain trimmed", cmds);
|
|
171
|
+
expect(r.handled).toBe(true);
|
|
172
|
+
expect(r.expanded).toContain("trimmed");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("expand — $ARGUMENTS property test (T9)", () => {
|
|
177
|
+
// Generate random body fragments and args, assert the algebraic property:
|
|
178
|
+
// expanded === body.split("$ARGUMENTS").join(args)
|
|
179
|
+
// Charset deliberately includes the placeholder, regex specials, $, \, \n.
|
|
180
|
+
const CHARS = "abc 123 \n\t \\ $ARGUMENTS .*+?^${}()|[]{} 你好 emoji $$$ \" '";
|
|
181
|
+
|
|
182
|
+
function randomString(maxLen: number): string {
|
|
183
|
+
const len = Math.floor(Math.random() * maxLen);
|
|
184
|
+
let out = "";
|
|
185
|
+
for (let i = 0; i < len; i++) {
|
|
186
|
+
out += CHARS.charAt(Math.floor(Math.random() * CHARS.length));
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function randomBody(): string {
|
|
192
|
+
// Drop in 0–4 placeholders interleaved with random text.
|
|
193
|
+
const placeholders = Math.floor(Math.random() * 5);
|
|
194
|
+
let out = randomString(20);
|
|
195
|
+
for (let i = 0; i < placeholders; i++) {
|
|
196
|
+
out += "$ARGUMENTS";
|
|
197
|
+
out += randomString(10);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test("substitution matches body.split($ARGUMENTS).join(extracted-args) for 200 random pairs", () => {
|
|
203
|
+
for (let i = 0; i < 200; i++) {
|
|
204
|
+
const body = randomBody();
|
|
205
|
+
const args = randomString(30);
|
|
206
|
+
const cmds = new Map<string, SlashCommand>([["t", { name: "t", body, filePath: "/x" }]]);
|
|
207
|
+
const result = expand(`/t ${args}`, cmds);
|
|
208
|
+
// Property is keyed on `result.arguments` (the canonical extraction)
|
|
209
|
+
// so we don't pre-suppose anything about how the separator between
|
|
210
|
+
// command name and args is parsed. The invariant under test is the
|
|
211
|
+
// substitution algebra: every literal `$ARGUMENTS` in the body is
|
|
212
|
+
// replaced with whatever extracted args was, and nothing else.
|
|
213
|
+
const expected = body.split("$ARGUMENTS").join(result.arguments ?? "");
|
|
214
|
+
expect(result.handled).toBe(true);
|
|
215
|
+
expect(result.expanded).toBe(expected);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("args containing $ARGUMENTS are NOT recursively expanded", () => {
|
|
220
|
+
const cmds = new Map<string, SlashCommand>([
|
|
221
|
+
["t", { name: "t", body: "before $ARGUMENTS after", filePath: "/x" }],
|
|
222
|
+
]);
|
|
223
|
+
const result = expand("/t $ARGUMENTS-literal", cmds);
|
|
224
|
+
// Single non-recursive substitution: args land verbatim, $ARGUMENTS in
|
|
225
|
+
// the args is NOT re-substituted.
|
|
226
|
+
expect(result.expanded).toBe("before $ARGUMENTS-literal after");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("regex specials in args are not interpreted", () => {
|
|
230
|
+
const cmds = new Map<string, SlashCommand>([
|
|
231
|
+
["t", { name: "t", body: "[$ARGUMENTS]", filePath: "/x" }],
|
|
232
|
+
]);
|
|
233
|
+
const result = expand("/t .*+?^${}()|[]\\", cmds);
|
|
234
|
+
expect(result.expanded).toBe("[.*+?^${}()|[]\\]");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("parseCommandFile direct", () => {
|
|
239
|
+
test("body-only", () => {
|
|
240
|
+
const r = parseCommandFile("plain body");
|
|
241
|
+
expect(r.body).toBe("plain body");
|
|
242
|
+
expect(r.description).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("frontmatter then body", () => {
|
|
246
|
+
const r = parseCommandFile("---\ndescription: d\n---\nbody here");
|
|
247
|
+
expect(r.description).toBe("d");
|
|
248
|
+
expect(r.body).toBe("body here");
|
|
249
|
+
});
|
|
250
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R9 `slash-commands` — markdown-templated user-input shortcuts
|
|
3
|
+
* loaded from `<cwd>/.crewhaus/commands/<name>.md`.
|
|
4
|
+
*
|
|
5
|
+
* Each file is a markdown body with optional YAML frontmatter:
|
|
6
|
+
*
|
|
7
|
+
* ---
|
|
8
|
+
* description: explain X in two sentences
|
|
9
|
+
* argument-hint: "<topic>"
|
|
10
|
+
* ---
|
|
11
|
+
* Explain $ARGUMENTS in two sentences.
|
|
12
|
+
*
|
|
13
|
+
* The basename (without `.md`) is the command name. When the user types
|
|
14
|
+
* `/<name> <args...>`, `expand()` replaces every literal `$ARGUMENTS` token
|
|
15
|
+
* with whatever followed the command name. Substitution is non-recursive
|
|
16
|
+
* and uses `String.prototype.replaceAll` (string form), so args containing
|
|
17
|
+
* `$ARGUMENTS`, regex specials, or newlines pass through untouched.
|
|
18
|
+
*
|
|
19
|
+
* `expand` returns `{ handled: false, expanded: input }` when:
|
|
20
|
+
* - input does not start with `/`
|
|
21
|
+
* - the command name is not in the loaded map
|
|
22
|
+
* Otherwise: `{ handled: true, expanded, command, arguments }`.
|
|
23
|
+
*
|
|
24
|
+
* Hook integration (`pre-slash`) fires in `runtime-core`, not here. This
|
|
25
|
+
* package is pure: filesystem read + string templating, no I/O elsewhere.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
28
|
+
import { basename, join } from "node:path";
|
|
29
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
30
|
+
import { parse as parseYaml } from "yaml";
|
|
31
|
+
|
|
32
|
+
const COMMANDS_RELATIVE = ".crewhaus/commands";
|
|
33
|
+
const COMMAND_PLACEHOLDER = "$ARGUMENTS";
|
|
34
|
+
|
|
35
|
+
export type SlashCommand = {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly description?: string;
|
|
38
|
+
readonly argumentHint?: string;
|
|
39
|
+
readonly body: string;
|
|
40
|
+
readonly filePath: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type LoadCommandsOptions = {
|
|
44
|
+
readonly cwd?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ExpandResult = {
|
|
48
|
+
readonly handled: boolean;
|
|
49
|
+
readonly expanded: string;
|
|
50
|
+
readonly command?: SlashCommand;
|
|
51
|
+
readonly arguments?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export class SlashCommandError extends CrewhausError {
|
|
55
|
+
override readonly name = "SlashCommandError";
|
|
56
|
+
constructor(message: string, cause?: unknown) {
|
|
57
|
+
super("config", message, cause);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read every `.md` file directly under `<cwd>/.crewhaus/commands/`. Each
|
|
63
|
+
* file's basename (sans extension) becomes its key in the returned map.
|
|
64
|
+
* Subdirectories are not currently walked (v1 keeps the namespace flat).
|
|
65
|
+
*/
|
|
66
|
+
export async function loadCommands(
|
|
67
|
+
opts: LoadCommandsOptions = {},
|
|
68
|
+
): Promise<Map<string, SlashCommand>> {
|
|
69
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
70
|
+
const root = join(cwd, COMMANDS_RELATIVE);
|
|
71
|
+
if (!existsSync(root)) return new Map();
|
|
72
|
+
const out = new Map<string, SlashCommand>();
|
|
73
|
+
let entries: string[];
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(root);
|
|
76
|
+
} catch {
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (!entry.endsWith(".md")) continue;
|
|
81
|
+
const file = join(root, entry);
|
|
82
|
+
let st: ReturnType<typeof statSync>;
|
|
83
|
+
try {
|
|
84
|
+
st = statSync(file);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!st.isFile()) continue;
|
|
89
|
+
const name = basename(entry, ".md");
|
|
90
|
+
if (name.length === 0) continue;
|
|
91
|
+
let raw: string;
|
|
92
|
+
try {
|
|
93
|
+
raw = readFileSync(file, "utf8");
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new SlashCommandError(`failed to read ${file}: ${(err as Error).message}`, err);
|
|
96
|
+
}
|
|
97
|
+
let parsed: ParsedCommand;
|
|
98
|
+
try {
|
|
99
|
+
parsed = parseCommandFile(raw);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new SlashCommandError(`failed to parse ${file}: ${(err as Error).message}`, err);
|
|
102
|
+
}
|
|
103
|
+
out.set(name, {
|
|
104
|
+
name,
|
|
105
|
+
body: parsed.body,
|
|
106
|
+
filePath: file,
|
|
107
|
+
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
108
|
+
...(parsed.argumentHint !== undefined ? { argumentHint: parsed.argumentHint } : {}),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type ParsedCommand = {
|
|
115
|
+
description?: string;
|
|
116
|
+
argumentHint?: string;
|
|
117
|
+
body: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Split a slash-command markdown file into its optional frontmatter and
|
|
122
|
+
* its body. Frontmatter is optional — files without a leading `---` are
|
|
123
|
+
* treated as all-body, and the description / argumentHint stay undefined.
|
|
124
|
+
*
|
|
125
|
+
* Exported for tests. Mirrors `parseSkillFile` from `skills-registry` but
|
|
126
|
+
* scoped to slash-command frontmatter shape.
|
|
127
|
+
*/
|
|
128
|
+
export function parseCommandFile(content: string): ParsedCommand {
|
|
129
|
+
const trimmed = content.replace(/^/, "");
|
|
130
|
+
if (!trimmed.startsWith("---")) {
|
|
131
|
+
return { body: trimmed };
|
|
132
|
+
}
|
|
133
|
+
const endIdx = findFrontmatterEnd(trimmed);
|
|
134
|
+
if (endIdx === -1) {
|
|
135
|
+
// No closing delimiter — treat as a plain body (don't throw; users may
|
|
136
|
+
// legitimately put a `---` horizontal rule at the top of a free-form
|
|
137
|
+
// command body without intending it as frontmatter).
|
|
138
|
+
return { body: trimmed };
|
|
139
|
+
}
|
|
140
|
+
const fmRaw = trimmed.slice(trimmed.indexOf("\n") + 1, endIdx).trimEnd();
|
|
141
|
+
const bodyStart = trimmed.indexOf("\n", endIdx + 3);
|
|
142
|
+
const body = bodyStart === -1 ? "" : trimmed.slice(bodyStart + 1);
|
|
143
|
+
let parsed: unknown;
|
|
144
|
+
try {
|
|
145
|
+
parsed = parseYaml(fmRaw);
|
|
146
|
+
} catch {
|
|
147
|
+
return { body };
|
|
148
|
+
}
|
|
149
|
+
if (typeof parsed !== "object" || parsed === null) return { body };
|
|
150
|
+
const v = parsed as Record<string, unknown>;
|
|
151
|
+
const description = typeof v["description"] === "string" ? v["description"] : undefined;
|
|
152
|
+
const hintRaw = v["argument-hint"] ?? v["argumentHint"];
|
|
153
|
+
const argumentHint = typeof hintRaw === "string" ? hintRaw : undefined;
|
|
154
|
+
return {
|
|
155
|
+
body,
|
|
156
|
+
...(description !== undefined ? { description } : {}),
|
|
157
|
+
...(argumentHint !== undefined ? { argumentHint } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findFrontmatterEnd(content: string): number {
|
|
162
|
+
let from = 3;
|
|
163
|
+
while (from < content.length) {
|
|
164
|
+
const idx = content.indexOf("\n---", from);
|
|
165
|
+
if (idx === -1) return -1;
|
|
166
|
+
const after = content.charAt(idx + 4);
|
|
167
|
+
if (after === "\n" || after === "" || after === "\r") return idx + 1;
|
|
168
|
+
from = idx + 4;
|
|
169
|
+
}
|
|
170
|
+
return -1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Try to interpret `input` as a slash-command invocation. Returns
|
|
175
|
+
* `{ handled: false, expanded: input }` when there's no leading slash or
|
|
176
|
+
* the command name doesn't resolve. Substitution is `replaceAll` with the
|
|
177
|
+
* literal placeholder — no recursive interpretation of args.
|
|
178
|
+
*/
|
|
179
|
+
export function expand(input: string, commands: ReadonlyMap<string, SlashCommand>): ExpandResult {
|
|
180
|
+
if (commands.size === 0) return { handled: false, expanded: input };
|
|
181
|
+
const trimmed = input.trimStart();
|
|
182
|
+
if (!trimmed.startsWith("/")) return { handled: false, expanded: input };
|
|
183
|
+
const m = trimmed.match(/^\/(\S+)\s*([\s\S]*)$/);
|
|
184
|
+
if (m === null) return { handled: false, expanded: input };
|
|
185
|
+
const name = m[1] ?? "";
|
|
186
|
+
const args = m[2] ?? "";
|
|
187
|
+
const command = commands.get(name);
|
|
188
|
+
if (!command) return { handled: false, expanded: input };
|
|
189
|
+
const expanded = command.body.split(COMMAND_PLACEHOLDER).join(args);
|
|
190
|
+
return { handled: true, expanded, command, arguments: args };
|
|
191
|
+
}
|