@aistoragedepot/mcp 0.4.0 → 0.5.2

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.
Files changed (4) hide show
  1. package/README.md +22 -9
  2. package/index.mjs +8 -3
  3. package/package.json +2 -1
  4. package/pull.mjs +222 -64
package/README.md CHANGED
@@ -52,30 +52,43 @@ Your stored content shows up where you actually work:
52
52
  ## Slash-commands (`pull`)
53
53
 
54
54
  Not every client turns MCP prompts into `/` slash-commands (some only use the tools). To get
55
- your library as **native** slash-commands that always work, sync it to command files:
55
+ your library as **native** slash-commands, sync it to command files — for every AI tool you
56
+ use, not just Claude:
56
57
 
57
58
  ```bash
58
- npx -y @aistoragedepot/mcp pull --token=aisd_your_token
59
+ npx -y -p @aistoragedepot/mcp aistoragedepot-mcp pull --token=aisd_your_token --to=all
59
60
  ```
60
61
 
61
- This writes `~/.claude/commands/aistoragedepot/*.md` start a new chat and type
62
- **`/aistoragedepot`** to see them. **Skills** run as-is. **Prompts** take input: their
63
- `[fields]` show as an argument hint, and whatever you type after the command fills them —
62
+ | Target | Writes to | Commands appear as |
63
+ | --- | --- | --- |
64
+ | `claude` *(default)* | `~/.claude/commands/aistoragedepot/` | `/aistoragedepot:<name>` |
65
+ | `cursor` | `~/.cursor/commands/` | `/aisd-<name>` |
66
+ | `vscode` | your VS Code profile's `prompts/` folder (Copilot Chat) | `/aisd-<name>` |
67
+ | `windsurf` | `~/.codeium/windsurf/global_workflows/` (Cascade) | `/aisd-<name>` |
68
+ | `codex` | `~/.codex/prompts/` | `/prompts:aisd-<name>` |
69
+
70
+ Tools that aren't installed are skipped automatically. Each tool gets its own dialect
71
+ (frontmatter, argument hints, `$ARGUMENTS` where supported). **Skills** run as-is.
72
+ **Prompts** take input: their `[fields]` show as an argument hint, and whatever you type
73
+ after the command fills them —
64
74
 
65
75
  ```
66
76
  /aistoragedepot:cold-outreach Jane at Globex, about our payments API
67
77
  ```
68
78
 
69
- — with Claude asking for any value you left out. Re-run any time; it only clears and
70
- rewrites its own namespaced folder.
79
+ — with the AI asking for any value you left out.
71
80
 
72
81
  | Option | Default | |
73
82
  | --- | --- | --- |
83
+ | `--to=…` | `claude` | Which tools to write commands for — comma list, or `all`. |
74
84
  | `--token=…` | `AISD_TOKEN` env | Your API token. |
75
85
  | `--types=…` | `skill,prompt` | Which item types become commands (e.g. `skill` for skills only). |
76
86
  | `--workspaces="A,B"` | your personal library | Which workspaces to pull (names, or `all`). |
77
- | `--project` | off | Write to `./.claude/commands/…` (this repo) instead of your home dir. |
78
- | `--dir=<path>` | `~/.claude/commands/aistoragedepot` | Write somewhere else entirely. |
87
+ | `--project` | off | Write into the current repo instead: `.claude/commands/`, `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/`. |
88
+ | `--dir=<path>` | per-target | Write somewhere else entirely (single `--to` only). |
89
+
90
+ Re-runs are safe: each target only clears its own files (claude: its namespaced folder;
91
+ the others: only files with the `aisd-` prefix — your own commands are never touched).
79
92
 
80
93
  ## Notes
81
94
 
package/index.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
 
24
24
  const BASE_URL = (process.env.AISD_BASE_URL || "https://www.aistoragedepot.com").replace(/\/+$/, "");
25
25
 
26
- // CLI mode: `aistoragedepot-mcp pull` syncs the library to native slash-command files, then
26
+ // CLI mode: `npx -y @aistoragedepot/mcp pull` syncs the library to native slash-command files, then
27
27
  // exits. The brief settle delay lets undici finish closing its sockets (Connection: close)
28
28
  // before process.exit — a hard exit mid-teardown trips a libuv assert on Windows (Node 24)
29
29
  // that prints a scary-but-harmless message after the sync succeeds.
@@ -52,7 +52,12 @@ async function api(path) {
52
52
  headers: { Authorization: `Bearer ${TOKEN}`, Accept: "application/json" },
53
53
  });
54
54
  if (res.status === 401) throw new Error("AIStorageDepot rejected the token (401) — check AISD_TOKEN.");
55
- if (!res.ok) throw new Error(`AIStorageDepot ${path} → HTTP ${res.status}`);
55
+ if (!res.ok) {
56
+ // Surface the server's own message when it sends one (e.g. the free-plan
57
+ // monthly pull limit returns 402 with an upgrade note) instead of a bare code.
58
+ const detail = await res.json().then((d) => d?.error).catch(() => null);
59
+ throw new Error(detail || `AIStorageDepot ${path} → HTTP ${res.status}`);
60
+ }
56
61
  return res.json();
57
62
  }
58
63
 
@@ -141,7 +146,7 @@ function fillBody(body, placeholders, args) {
141
146
  }
142
147
 
143
148
  const server = new Server(
144
- { name: "aistoragedepot", version: "0.4.0" },
149
+ { name: "aistoragedepot", version: "0.5.0" },
145
150
  { capabilities: { resources: {}, prompts: {}, tools: {} } },
146
151
  );
147
152
 
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@aistoragedepot/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.2",
4
4
  "description": "Model Context Protocol server for your AIStorageDepot library — exposes your prompts, rules, docs, and skills to MCP-aware AI clients (Claude, Cursor, Cline, …).",
5
5
  "type": "module",
6
6
  "bin": {
7
+ "mcp": "index.mjs",
7
8
  "aistoragedepot-mcp": "index.mjs"
8
9
  },
9
10
  "files": [
package/pull.mjs CHANGED
@@ -1,13 +1,22 @@
1
- // `aistoragedepot-mcp pull` — sync your library into native slash-command files.
1
+ // `npx @aistoragedepot/mcp pull` — sync your library into native slash-command files for the
2
+ // AI tools you use.
2
3
  //
3
- // MCP prompts-as-slash-commands are supported inconsistently across clients, so this writes
4
- // real command files that any Claude Code client picks up: ~/.claude/commands/aistoragedepot/*.md
5
- // (a /aistoragedepot:<name> command each). Defaults to the **skills and prompts** in your
6
- // personal library. Skills run as-is; prompts become commands that take input — their [fields]
7
- // are declared as an argument-hint and filled from whatever you type after the command
8
- // ($ARGUMENTS), with Claude asking for anything missing. Narrow with --types / widen with
9
- // --workspaces. Re-run any time — it clears and rewrites only its own namespaced folder.
10
- import { mkdir, writeFile, rm } from "node:fs/promises";
4
+ // Every major client now reads commands from a folder of markdown files; only the folder
5
+ // and the frontmatter dialect differ. Targets (--to=claude,cursor,vscode,windsurf,codex | all):
6
+ // claude ~/.claude/commands/aistoragedepot/*.md → /aistoragedepot:<name>
7
+ // cursor ~/.cursor/commands/aisd-*.md → /aisd-<name>
8
+ // vscode <VS Code User>/prompts/aisd-*.prompt.md → /aisd-<name> (Copilot Chat)
9
+ // windsurf ~/.codeium/windsurf/global_workflows/aisd-*.md → /aisd-<name> (Cascade)
10
+ // codex ~/.codex/prompts/aisd-*.md → /prompts:aisd-<name>
11
+ //
12
+ // Defaults to --to=claude with the **skills and prompts** in your personal library. Skills
13
+ // run as-is; prompts become commands that take input — their [fields] are declared as an
14
+ // argument-hint and filled from what you type after the command (real $ARGUMENTS where the
15
+ // tool substitutes it: claude + codex; a fill-from-my-message instruction elsewhere), with
16
+ // the AI asking for anything missing. Narrow with --types / widen with --workspaces.
17
+ // Re-runs clear only their own files (claude: its namespaced folder; others: the aisd- prefix).
18
+ import { mkdir, writeFile, rm, readdir } from "node:fs/promises";
19
+ import { existsSync } from "node:fs";
11
20
  import { homedir } from "node:os";
12
21
  import { join, resolve } from "node:path";
13
22
 
@@ -16,27 +25,150 @@ const slug = (s) =>
16
25
  const typeOf = (it) => (it.type?.slug || it.type?.name || "").toLowerCase();
17
26
  const isPromptItem = (it) => it.type?.format === "prompt" || typeOf(it) === "prompt";
18
27
 
19
- // Prompts become commands that take input: the [fields] in the body are declared as an
20
- // argument-hint (shown in the / picker) and filled from whatever the user types after the
21
- // command ($ARGUMENTS); Claude asks for any value that's missing.
22
28
  const FIELD_RE = /\[([A-Za-z0-9 ]+)\]/g;
23
29
  const yq = (s) => String(s).replace(/"/g, "'").replace(/\s+/g, " ").trim(); // YAML-safe one-liner
30
+ const fieldsOf = (body) => [...new Set([...String(body).matchAll(FIELD_RE)].map((m) => m[1]))];
31
+
32
+ // Split a leading YAML frontmatter block off a body (skills carry their own). Values are
33
+ // only shallow-parsed — we just need `description` — everything else is passed through or
34
+ // dropped depending on the target.
35
+ function splitFrontmatter(body) {
36
+ const m = String(body).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
37
+ if (!m) return { meta: {}, content: String(body) };
38
+ const meta = {};
39
+ for (const line of m[1].split(/\r?\n/)) {
40
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
41
+ if (kv) meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
42
+ }
43
+ return { meta, content: String(body).slice(m[0].length) };
44
+ }
45
+
46
+ function descFor(it, meta) {
47
+ if (meta.description) return meta.description;
48
+ return `${isPromptItem(it) ? "Prompt — " : ""}${it.title} (AIStorageDepot)`;
49
+ }
50
+
51
+ function fmFile(fm, body) {
52
+ return ["---", ...Object.entries(fm).map(([k, v]) => `${k}: "${yq(v)}"`), "---", "", String(body).trim(), ""].join("\n");
53
+ }
54
+
55
+ // Tail for tools that substitute $ARGUMENTS (claude, codex).
56
+ const argsTail = (fields) =>
57
+ fields.length
58
+ ? `\n\nInput: $ARGUMENTS\n\nFill the bracketed ${fields.length === 1 ? "field" : "fields"} in the prompt above from the input — ask me for any value that's missing — then carry out the prompt.`
59
+ : "\n\n$ARGUMENTS";
60
+ // Tail for tools that just pass the typed text along with the inserted prompt.
61
+ const fillTail = (fields) =>
62
+ `\n\nFill the bracketed ${fields.length === 1 ? "field" : "fields"} (${fields.map((f) => `[${f}]`).join(", ")}) from anything I typed along with this command — ask me for any value that's missing — then carry out the prompt.`;
24
63
 
25
- function promptCommand(it) {
26
- const body = String(it.body || "").trim();
27
- const fields = [...new Set([...body.matchAll(FIELD_RE)].map((m) => m[1]))];
28
- const head = ["---", `description: "${yq(`Prompt ${it.title} (AIStorageDepot)`)}"`];
29
- if (fields.length) head.push(`argument-hint: "${yq(fields.map((f) => `[${f}]`).join(" "))}"`);
30
- head.push("---");
31
- const tail = fields.length
32
- ? [
33
- "",
34
- "Input: $ARGUMENTS",
35
- "",
36
- `Fill the bracketed ${fields.length === 1 ? "field" : "fields"} in the prompt above from the input — ask me for any value that's missing — then carry out the prompt.`,
37
- ]
38
- : ["", "$ARGUMENTS"]; // no fields: whatever the user types just flows in after the prompt
39
- return [...head, "", body, ...tail, ""].join("\n");
64
+ // ── per-target renderers ─────────────────────────────────────────────────────
65
+
66
+ // Claude Code: skills keep their own frontmatter verbatim; prompts get description +
67
+ // argument-hint + $ARGUMENTS; anything else gets a description wrapper.
68
+ function renderClaude(it) {
69
+ const body = it.body || "";
70
+ if (isPromptItem(it)) {
71
+ const { content } = splitFrontmatter(body);
72
+ const fields = fieldsOf(content);
73
+ const fm = { description: `Prompt — ${it.title} (AIStorageDepot)` };
74
+ if (fields.length) fm["argument-hint"] = fields.map((f) => `[${f}]`).join(" ");
75
+ return fmFile(fm, content.trim() + argsTail(fields));
76
+ }
77
+ if (/^---\r?\n/.test(body)) return body.endsWith("\n") ? body : `${body}\n`;
78
+ return fmFile({ description: `${it.title} (AIStorageDepot)` }, body);
79
+ }
80
+
81
+ // Codex custom prompts: same dialect as Claude (description, argument-hint, $ARGUMENTS),
82
+ // but Codex doesn't know claude-specific skill frontmatter — rebuild it clean.
83
+ function renderCodex(it) {
84
+ const { meta, content } = splitFrontmatter(it.body || "");
85
+ const fields = isPromptItem(it) ? fieldsOf(content) : [];
86
+ const fm = { description: descFor(it, meta) };
87
+ if (isPromptItem(it) && fields.length) fm["argument-hint"] = fields.map((f) => `[${f}]`).join(" ");
88
+ return fmFile(fm, content.trim() + (isPromptItem(it) ? argsTail(fields) : ""));
89
+ }
90
+
91
+ // VS Code prompt files: description + argument-hint frontmatter; typed extras arrive as
92
+ // part of the chat message (no $ARGUMENTS substitution).
93
+ function renderVscode(it) {
94
+ const { meta, content } = splitFrontmatter(it.body || "");
95
+ const fields = isPromptItem(it) ? fieldsOf(content) : [];
96
+ const fm = { description: descFor(it, meta) };
97
+ if (fields.length) fm["argument-hint"] = fields.map((f) => `[${f}]`).join(" ");
98
+ return fmFile(fm, content.trim() + (fields.length ? fillTail(fields) : ""));
99
+ }
100
+
101
+ // Windsurf workflows: description frontmatter; 12k char cap per file (enforced by caller).
102
+ function renderWindsurf(it) {
103
+ const { meta, content } = splitFrontmatter(it.body || "");
104
+ const fields = isPromptItem(it) ? fieldsOf(content) : [];
105
+ return fmFile({ description: descFor(it, meta) }, content.trim() + (fields.length ? fillTail(fields) : ""));
106
+ }
107
+
108
+ // Cursor commands: plain markdown — no frontmatter dialect; the body IS the prompt.
109
+ function renderCursor(it) {
110
+ const { content } = splitFrontmatter(it.body || "");
111
+ const fields = isPromptItem(it) ? fieldsOf(content) : [];
112
+ return content.trim() + (fields.length ? fillTail(fields) : "") + "\n";
113
+ }
114
+
115
+ // ── targets ──────────────────────────────────────────────────────────────────
116
+
117
+ function vscodeUserDir(home) {
118
+ if (process.platform === "win32")
119
+ return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "Code", "User");
120
+ if (process.platform === "darwin") return join(home, "Library", "Application Support", "Code", "User");
121
+ return join(home, ".config", "Code", "User");
122
+ }
123
+
124
+ // prefix'd targets share folders with the user's own files — we only ever delete our prefix.
125
+ function targetDefs(project) {
126
+ const home = homedir();
127
+ return {
128
+ claude: {
129
+ dir: project ? resolve(".claude", "commands", "aistoragedepot") : join(home, ".claude", "commands", "aistoragedepot"),
130
+ parent: null, // Claude Code owns ~/.claude; safe to create
131
+ ownsFolder: true,
132
+ prefix: "",
133
+ ext: ".md",
134
+ render: renderClaude,
135
+ tip: "type /aistoragedepot in a new chat (first sync: fully restart Claude Code — end claude.exe on Windows)",
136
+ },
137
+ cursor: {
138
+ dir: project ? resolve(".cursor", "commands") : join(home, ".cursor", "commands"),
139
+ parent: project ? null : join(home, ".cursor"),
140
+ prefix: "aisd-",
141
+ ext: ".md",
142
+ render: renderCursor,
143
+ tip: "type /aisd- in Cursor's Agent input",
144
+ },
145
+ vscode: {
146
+ dir: project ? resolve(".github", "prompts") : join(vscodeUserDir(home), "prompts"),
147
+ parent: project ? null : vscodeUserDir(home),
148
+ prefix: "aisd-",
149
+ ext: ".prompt.md",
150
+ render: renderVscode,
151
+ tip: "type /aisd- in Copilot Chat",
152
+ },
153
+ windsurf: {
154
+ dir: project ? resolve(".windsurf", "workflows") : join(home, ".codeium", "windsurf", "global_workflows"),
155
+ parent: project ? null : join(home, ".codeium", "windsurf"),
156
+ prefix: "aisd-",
157
+ ext: ".md",
158
+ render: renderWindsurf,
159
+ maxBytes: 12000, // Windsurf rejects workflow files past ~12k chars
160
+ tip: "type /aisd- in Cascade",
161
+ },
162
+ codex: {
163
+ dir: join(process.env.CODEX_HOME || join(home, ".codex"), "prompts"), // global only
164
+ parent: process.env.CODEX_HOME || join(home, ".codex"),
165
+ prefix: "aisd-",
166
+ ext: ".md",
167
+ render: renderCodex,
168
+ noProject: true,
169
+ tip: "type /prompts:aisd- (or /aisd-) in Codex — restart codex to load",
170
+ },
171
+ };
40
172
  }
41
173
 
42
174
  function parseArgs(argv) {
@@ -64,10 +196,28 @@ export async function pull({ BASE_URL, argv }) {
64
196
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json", Connection: "close" },
65
197
  });
66
198
  if (r.status === 401) throw new Error("Token rejected (401) — check it in Settings → API tokens.");
67
- if (!r.ok) throw new Error(`${p} → HTTP ${r.status}`);
199
+ if (!r.ok) {
200
+ // Prefer the server's message (e.g. the free-plan pull limit's 402 upgrade note).
201
+ const detail = await r.json().then((d) => d?.error).catch(() => null);
202
+ throw new Error(detail || `${p} → HTTP ${r.status}`);
203
+ }
68
204
  return r.json();
69
205
  };
70
206
 
207
+ // Which tools to write for. Default: claude. --to=cursor,vscode | all.
208
+ const DEFS = targetDefs(!!o.project);
209
+ const toRaw = String(o.to || "claude").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
210
+ const targets = toRaw.includes("all") ? Object.keys(DEFS) : toRaw;
211
+ const unknown = targets.filter((t) => !DEFS[t]);
212
+ if (unknown.length) {
213
+ console.error(`Unknown --to target(s): ${unknown.join(", ")}. Valid: ${Object.keys(DEFS).join(", ")}, all.`);
214
+ process.exit(1);
215
+ }
216
+ if (o.dir && targets.length > 1) {
217
+ console.error("--dir only makes sense with a single --to target.");
218
+ process.exit(1);
219
+ }
220
+
71
221
  // Which item types to pull. Default: skills + prompts. --types=skill | prompt | all.
72
222
  const TYPES = String(o.types || "skill,prompt").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
73
223
  const wantType = (it) => {
@@ -79,52 +229,60 @@ export async function pull({ BASE_URL, argv }) {
79
229
  };
80
230
 
81
231
  const workspaces = await api("/api/workspaces");
82
- // Scope: default every workspace; --workspaces="A,B" | "personal" | "all".
232
+ // Scope: default your personal library; --workspaces="A,B" | "all".
83
233
  const names = String(o.workspaces || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
84
234
  let scope;
85
235
  if (names.includes("all") || names.includes("*")) scope = workspaces;
86
236
  else if (names.length) scope = workspaces.filter((w) => names.includes((w.name || "").toLowerCase()));
87
- else scope = workspaces.filter((w) => w.type === "PERSONAL"); // default: just your personal library
237
+ else scope = workspaces.filter((w) => w.type === "PERSONAL");
88
238
 
89
239
  const libs = await Promise.all(
90
240
  scope.map((w) => api(`/api/library?workspace=${encodeURIComponent(w.id)}`).catch(() => ({ items: [] }))),
91
241
  );
92
242
  const items = libs.flatMap((l) => (l.items || []).filter(wantType));
93
243
 
94
- const dir = o.dir
95
- ? resolve(String(o.dir))
96
- : o.project
97
- ? resolve(".claude", "commands", "aistoragedepot")
98
- : join(homedir(), ".claude", "commands", "aistoragedepot");
99
- await rm(dir, { recursive: true, force: true }); // clean re-sync of our namespaced folder only
100
- await mkdir(dir, { recursive: true });
101
-
102
- const used = new Set();
103
- let n = 0;
104
- for (const it of items) {
105
- // Clean names by default; on a title collision, try the type as a tiebreaker
106
- // (code-review vs code-review-prompt) before falling back to an id suffix.
107
- let name = slug(it.title);
108
- if (used.has(name)) {
109
- const alt = slug(`${it.title} ${typeOf(it) || "item"}`);
110
- name = used.has(alt) ? `${slug(it.title)}-${it.id.slice(-4)}` : alt;
244
+ for (const tname of targets) {
245
+ const t = DEFS[tname];
246
+ if (t.noProject && o.project) console.log(` (${tname} has no per-project location — writing globally)`);
247
+ // Don't invent config folders for tools that aren't installed (claude is exempt: it's
248
+ // our home target and Claude Code tolerates a pre-made commands dir).
249
+ if (t.parent && !existsSync(t.parent)) {
250
+ console.log(`↷ ${tname} skipped ${t.parent} not found (is it installed? use --to without it, or install first)`);
251
+ continue;
252
+ }
253
+ const dir = o.dir && targets.length === 1 ? resolve(String(o.dir)) : t.dir;
254
+ if (t.ownsFolder) await rm(dir, { recursive: true, force: true }); // clean re-sync of OUR folder only
255
+ await mkdir(dir, { recursive: true });
256
+ if (!t.ownsFolder) {
257
+ // shared folder: remove only files we wrote before (our prefix)
258
+ const existing = await readdir(dir).catch(() => []);
259
+ await Promise.all(
260
+ existing.filter((n) => n.startsWith(t.prefix) && n.endsWith(t.ext)).map((n) => rm(join(dir, n), { force: true })),
261
+ );
111
262
  }
112
- while (used.has(name)) name = `${name}-${it.id.slice(-4)}`;
113
- used.add(name);
114
- const body = it.body || "";
115
- // Prompts get the argument treatment. Skills already carry their own YAML frontmatter
116
- // (name/description) — keep them as-is; anything else without frontmatter gets a
117
- // description so it shows nicely in the / menu.
118
- const content = isPromptItem(it)
119
- ? promptCommand(it)
120
- : /^---\r?\n/.test(body)
121
- ? body.endsWith("\n") ? body : `${body}\n`
122
- : `---\ndescription: "${yq(`${it.title} (AIStorageDepot)`)}"\n---\n\n${body}\n`;
123
- await writeFile(join(dir, `${name}.md`), content, "utf8");
124
- n++;
125
- }
126
263
 
127
- console.log(`✓ Synced ${n} item${n === 1 ? "" : "s"} (${TYPES.join(", ")}) → ${dir}`);
128
- console.log(` Start a new chat and type /aistoragedepot to use them.`);
129
- console.log(` Skills run as-is; prompts take input — type values right after the command.`);
264
+ const used = new Set();
265
+ let n = 0;
266
+ let skipped = 0;
267
+ for (const it of items) {
268
+ // Clean names; on a title collision, the type breaks the tie before an id suffix.
269
+ let name = slug(it.title);
270
+ if (used.has(name)) {
271
+ const alt = slug(`${it.title} ${typeOf(it) || "item"}`);
272
+ name = used.has(alt) ? `${slug(it.title)}-${it.id.slice(-4)}` : alt;
273
+ }
274
+ while (used.has(name)) name = `${name}-${it.id.slice(-4)}`;
275
+ used.add(name);
276
+ const content = t.render(it);
277
+ if (t.maxBytes && content.length > t.maxBytes) {
278
+ console.log(` ! ${tname}: skipped "${it.title}" — over the ${t.maxBytes}-char limit`);
279
+ skipped++;
280
+ continue;
281
+ }
282
+ await writeFile(join(dir, `${t.prefix}${name}${t.ext}`), content, "utf8");
283
+ n++;
284
+ }
285
+ console.log(`✓ ${tname} — ${n} command${n === 1 ? "" : "s"}${skipped ? ` (${skipped} skipped)` : ""} → ${dir}`);
286
+ console.log(` ${t.tip}`);
287
+ }
130
288
  }