@aistoragedepot/mcp 0.3.0 → 0.5.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.
Files changed (4) hide show
  1. package/README.md +28 -7
  2. package/index.mjs +14 -4
  3. package/package.json +1 -1
  4. package/pull.mjs +232 -38
package/README.md CHANGED
@@ -52,22 +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 prompts as **native** slash-commands that always work, sync them 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 @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. Re-run any time; it only clears and rewrites its own
63
- namespaced folder.
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 —
74
+
75
+ ```
76
+ /aistoragedepot:cold-outreach Jane at Globex, about our payments API
77
+ ```
78
+
79
+ — with the AI asking for any value you left out.
64
80
 
65
81
  | Option | Default | |
66
82
  | --- | --- | --- |
83
+ | `--to=…` | `claude` | Which tools to write commands for — comma list, or `all`. |
67
84
  | `--token=…` | `AISD_TOKEN` env | Your API token. |
85
+ | `--types=…` | `skill,prompt` | Which item types become commands (e.g. `skill` for skills only). |
68
86
  | `--workspaces="A,B"` | your personal library | Which workspaces to pull (names, or `all`). |
69
- | `--project` | off | Write to `./.claude/commands/…` (this repo) instead of your home dir. |
70
- | `--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).
71
92
 
72
93
  ## Notes
73
94
 
package/index.mjs CHANGED
@@ -23,11 +23,21 @@ 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 prompts to native slash-command files, then exits.
26
+ // CLI mode: `aistoragedepot-mcp pull` syncs the library to native slash-command files, then
27
+ // exits. The brief settle delay lets undici finish closing its sockets (Connection: close)
28
+ // before process.exit — a hard exit mid-teardown trips a libuv assert on Windows (Node 24)
29
+ // that prints a scary-but-harmless message after the sync succeeds.
27
30
  if (process.argv[2] === "pull") {
28
31
  const { pull } = await import("./pull.mjs");
29
- await pull({ BASE_URL, argv: process.argv.slice(3) });
30
- process.exit(0);
32
+ let code = 0;
33
+ try {
34
+ await pull({ BASE_URL, argv: process.argv.slice(3) });
35
+ } catch (err) {
36
+ console.error(err?.message || err);
37
+ code = 1;
38
+ }
39
+ await new Promise((r) => setTimeout(r, 50));
40
+ process.exit(code);
31
41
  }
32
42
 
33
43
  const TOKEN = process.env.AISD_TOKEN;
@@ -131,7 +141,7 @@ function fillBody(body, placeholders, args) {
131
141
  }
132
142
 
133
143
  const server = new Server(
134
- { name: "aistoragedepot", version: "0.3.0" },
144
+ { name: "aistoragedepot", version: "0.5.0" },
135
145
  { capabilities: { resources: {}, prompts: {}, tools: {} } },
136
146
  );
137
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aistoragedepot/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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": {
package/pull.mjs CHANGED
@@ -1,17 +1,175 @@
1
- // `aistoragedepot-mcp pull` — sync your library into native slash-command files.
1
+ // `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** in your personal library
6
- // (self-contained instructions that run cleanly as commands); widen with --workspaces / include
7
- // prompts with --types. Re-run any time — it clears and rewrites only its own namespaced folder.
8
- 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";
9
20
  import { homedir } from "node:os";
10
21
  import { join, resolve } from "node:path";
11
22
 
12
23
  const slug = (s) =>
13
24
  String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "item";
14
25
  const typeOf = (it) => (it.type?.slug || it.type?.name || "").toLowerCase();
26
+ const isPromptItem = (it) => it.type?.format === "prompt" || typeOf(it) === "prompt";
27
+
28
+ const FIELD_RE = /\[([A-Za-z0-9 ]+)\]/g;
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.`;
63
+
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
+ };
172
+ }
15
173
 
16
174
  function parseArgs(argv) {
17
175
  const o = {};
@@ -32,59 +190,95 @@ export async function pull({ BASE_URL, argv }) {
32
190
  process.exit(1);
33
191
  }
34
192
  const api = async (p) => {
35
- const r = await fetch(BASE_URL + p, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } });
193
+ // Connection: close keep-alive sockets left open at exit trip a libuv assert on
194
+ // Windows (Node 24), printing a scary (but harmless) message after the sync finishes.
195
+ const r = await fetch(BASE_URL + p, {
196
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json", Connection: "close" },
197
+ });
36
198
  if (r.status === 401) throw new Error("Token rejected (401) — check it in Settings → API tokens.");
37
199
  if (!r.ok) throw new Error(`${p} → HTTP ${r.status}`);
38
200
  return r.json();
39
201
  };
40
202
 
41
- // Which item types to pull. Default: skills only. --types=skill,prompt | all.
42
- const TYPES = String(o.types || "skill").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
203
+ // Which tools to write for. Default: claude. --to=cursor,vscode | all.
204
+ const DEFS = targetDefs(!!o.project);
205
+ const toRaw = String(o.to || "claude").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
206
+ const targets = toRaw.includes("all") ? Object.keys(DEFS) : toRaw;
207
+ const unknown = targets.filter((t) => !DEFS[t]);
208
+ if (unknown.length) {
209
+ console.error(`Unknown --to target(s): ${unknown.join(", ")}. Valid: ${Object.keys(DEFS).join(", ")}, all.`);
210
+ process.exit(1);
211
+ }
212
+ if (o.dir && targets.length > 1) {
213
+ console.error("--dir only makes sense with a single --to target.");
214
+ process.exit(1);
215
+ }
216
+
217
+ // Which item types to pull. Default: skills + prompts. --types=skill | prompt | all.
218
+ const TYPES = String(o.types || "skill,prompt").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
43
219
  const wantType = (it) => {
44
220
  const t = typeOf(it);
45
221
  const isSkill = t === "skill";
46
- const isPrompt = it.type?.format === "prompt" || t === "prompt";
222
+ const isPrompt = isPromptItem(it);
47
223
  if (TYPES.includes("all")) return isSkill || isPrompt;
48
224
  return (TYPES.includes("skill") && isSkill) || (TYPES.includes("prompt") && isPrompt) || TYPES.includes(t);
49
225
  };
50
226
 
51
227
  const workspaces = await api("/api/workspaces");
52
- // Scope: default every workspace; --workspaces="A,B" | "personal" | "all".
228
+ // Scope: default your personal library; --workspaces="A,B" | "all".
53
229
  const names = String(o.workspaces || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
54
230
  let scope;
55
231
  if (names.includes("all") || names.includes("*")) scope = workspaces;
56
232
  else if (names.length) scope = workspaces.filter((w) => names.includes((w.name || "").toLowerCase()));
57
- else scope = workspaces.filter((w) => w.type === "PERSONAL"); // default: just your personal library
233
+ else scope = workspaces.filter((w) => w.type === "PERSONAL");
58
234
 
59
235
  const libs = await Promise.all(
60
236
  scope.map((w) => api(`/api/library?workspace=${encodeURIComponent(w.id)}`).catch(() => ({ items: [] }))),
61
237
  );
62
238
  const items = libs.flatMap((l) => (l.items || []).filter(wantType));
63
239
 
64
- const dir = o.dir
65
- ? resolve(String(o.dir))
66
- : o.project
67
- ? resolve(".claude", "commands", "aistoragedepot")
68
- : join(homedir(), ".claude", "commands", "aistoragedepot");
69
- await rm(dir, { recursive: true, force: true }); // clean re-sync of our namespaced folder only
70
- await mkdir(dir, { recursive: true });
71
-
72
- const used = new Set();
73
- let n = 0;
74
- for (const it of items) {
75
- let name = slug(it.title);
76
- while (used.has(name)) name = `${name}-${it.id.slice(-4)}`;
77
- used.add(name);
78
- const body = it.body || "";
79
- // Skills already carry their own YAML frontmatter (name/description) — keep it as-is; for anything
80
- // without frontmatter (e.g. prompts), add a description so it shows nicely in the / menu.
81
- const content = /^---\r?\n/.test(body)
82
- ? body.endsWith("\n") ? body : `${body}\n`
83
- : `---\ndescription: "${`${it.title} (AIStorageDepot)`.replace(/"/g, "'").replace(/\s+/g, " ").trim()}"\n---\n\n${body}\n`;
84
- await writeFile(join(dir, `${name}.md`), content, "utf8");
85
- n++;
86
- }
240
+ for (const tname of targets) {
241
+ const t = DEFS[tname];
242
+ if (t.noProject && o.project) console.log(` (${tname} has no per-project location — writing globally)`);
243
+ // Don't invent config folders for tools that aren't installed (claude is exempt: it's
244
+ // our home target and Claude Code tolerates a pre-made commands dir).
245
+ if (t.parent && !existsSync(t.parent)) {
246
+ console.log(`↷ ${tname} skipped ${t.parent} not found (is it installed? use --to without it, or install first)`);
247
+ continue;
248
+ }
249
+ const dir = o.dir && targets.length === 1 ? resolve(String(o.dir)) : t.dir;
250
+ if (t.ownsFolder) await rm(dir, { recursive: true, force: true }); // clean re-sync of OUR folder only
251
+ await mkdir(dir, { recursive: true });
252
+ if (!t.ownsFolder) {
253
+ // shared folder: remove only files we wrote before (our prefix)
254
+ const existing = await readdir(dir).catch(() => []);
255
+ await Promise.all(
256
+ existing.filter((n) => n.startsWith(t.prefix) && n.endsWith(t.ext)).map((n) => rm(join(dir, n), { force: true })),
257
+ );
258
+ }
87
259
 
88
- console.log(`✓ Synced ${n} item${n === 1 ? "" : "s"} (${TYPES.join(", ")}) → ${dir}`);
89
- console.log(` Start a new chat and type /aistoragedepot to use them.`);
260
+ const used = new Set();
261
+ let n = 0;
262
+ let skipped = 0;
263
+ for (const it of items) {
264
+ // Clean names; on a title collision, the type breaks the tie before an id suffix.
265
+ let name = slug(it.title);
266
+ if (used.has(name)) {
267
+ const alt = slug(`${it.title} ${typeOf(it) || "item"}`);
268
+ name = used.has(alt) ? `${slug(it.title)}-${it.id.slice(-4)}` : alt;
269
+ }
270
+ while (used.has(name)) name = `${name}-${it.id.slice(-4)}`;
271
+ used.add(name);
272
+ const content = t.render(it);
273
+ if (t.maxBytes && content.length > t.maxBytes) {
274
+ console.log(` ! ${tname}: skipped "${it.title}" — over the ${t.maxBytes}-char limit`);
275
+ skipped++;
276
+ continue;
277
+ }
278
+ await writeFile(join(dir, `${t.prefix}${name}${t.ext}`), content, "utf8");
279
+ n++;
280
+ }
281
+ console.log(`✓ ${tname} — ${n} command${n === 1 ? "" : "s"}${skipped ? ` (${skipped} skipped)` : ""} → ${dir}`);
282
+ console.log(` ${t.tip}`);
283
+ }
90
284
  }