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