membot 0.0.1 → 0.1.1

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 (78) hide show
  1. package/.claude/skills/membot.md +137 -0
  2. package/.cursor/rules/membot.mdc +137 -0
  3. package/README.md +131 -0
  4. package/package.json +83 -24
  5. package/patches/@huggingface%2Ftransformers@4.2.0.patch +137 -0
  6. package/scripts/apply-transformers-patch.sh +35 -0
  7. package/src/cli.ts +72 -0
  8. package/src/commands/check-update.ts +69 -0
  9. package/src/commands/mcpx.ts +112 -0
  10. package/src/commands/reindex.ts +53 -0
  11. package/src/commands/serve.ts +58 -0
  12. package/src/commands/skill.ts +131 -0
  13. package/src/commands/upgrade.ts +220 -0
  14. package/src/config/loader.ts +100 -0
  15. package/src/config/schemas.ts +39 -0
  16. package/src/constants.ts +42 -0
  17. package/src/context.ts +80 -0
  18. package/src/db/blobs.ts +53 -0
  19. package/src/db/chunks.ts +176 -0
  20. package/src/db/connection.ts +173 -0
  21. package/src/db/files.ts +325 -0
  22. package/src/db/migrations/001-init.ts +63 -0
  23. package/src/db/migrations/002-fts.ts +12 -0
  24. package/src/db/migrations.ts +45 -0
  25. package/src/errors.ts +87 -0
  26. package/src/ingest/chunker.ts +117 -0
  27. package/src/ingest/converter/docx.ts +15 -0
  28. package/src/ingest/converter/html.ts +20 -0
  29. package/src/ingest/converter/image.ts +71 -0
  30. package/src/ingest/converter/index.ts +119 -0
  31. package/src/ingest/converter/llm.ts +66 -0
  32. package/src/ingest/converter/ocr.ts +51 -0
  33. package/src/ingest/converter/pdf.ts +38 -0
  34. package/src/ingest/converter/text.ts +8 -0
  35. package/src/ingest/describer.ts +72 -0
  36. package/src/ingest/embedder.ts +98 -0
  37. package/src/ingest/fetcher.ts +280 -0
  38. package/src/ingest/ingest.ts +444 -0
  39. package/src/ingest/local-reader.ts +64 -0
  40. package/src/ingest/search-text.ts +18 -0
  41. package/src/ingest/source-resolver.ts +186 -0
  42. package/src/mcp/instructions.ts +34 -0
  43. package/src/mcp/server.ts +101 -0
  44. package/src/mount/commander.ts +174 -0
  45. package/src/mount/mcp.ts +111 -0
  46. package/src/mount/zod-to-cli.ts +158 -0
  47. package/src/operations/add.ts +69 -0
  48. package/src/operations/diff.ts +105 -0
  49. package/src/operations/index.ts +38 -0
  50. package/src/operations/info.ts +95 -0
  51. package/src/operations/list.ts +87 -0
  52. package/src/operations/move.ts +83 -0
  53. package/src/operations/prune.ts +80 -0
  54. package/src/operations/read.ts +102 -0
  55. package/src/operations/refresh.ts +72 -0
  56. package/src/operations/remove.ts +35 -0
  57. package/src/operations/search.ts +72 -0
  58. package/src/operations/tree.ts +103 -0
  59. package/src/operations/types.ts +81 -0
  60. package/src/operations/versions.ts +78 -0
  61. package/src/operations/write.ts +77 -0
  62. package/src/output/formatter.ts +68 -0
  63. package/src/output/logger.ts +114 -0
  64. package/src/output/progress.ts +78 -0
  65. package/src/output/tty.ts +91 -0
  66. package/src/refresh/runner.ts +296 -0
  67. package/src/refresh/scheduler.ts +54 -0
  68. package/src/sdk.ts +27 -0
  69. package/src/search/hybrid.ts +100 -0
  70. package/src/search/keyword.ts +62 -0
  71. package/src/search/semantic.ts +56 -0
  72. package/src/types/text-modules.d.ts +9 -0
  73. package/src/update/background.ts +73 -0
  74. package/src/update/cache.ts +40 -0
  75. package/src/update/checker.ts +117 -0
  76. package/.claude/settings.local.json +0 -7
  77. package/CLAUDE.md +0 -139
  78. package/docs/plan.md +0 -905
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Apply the @huggingface/transformers patch to node_modules so that
5
+ # `bun build --compile` produces a binary using the WASM backend
6
+ # (onnxruntime-web) instead of onnxruntime-node, whose native bindings
7
+ # can't be bundled into a single-binary distribution.
8
+ #
9
+ # We apply the patch imperatively (rather than via package.json
10
+ # `patchedDependencies`) because that field, when present in a
11
+ # published package, breaks `bun install` from a tarball.
12
+
13
+ PATCH="patches/@huggingface%2Ftransformers@4.2.0.patch"
14
+ TARGET="node_modules/@huggingface/transformers"
15
+ MARKER="$TARGET/.membot-transformers-patch-applied"
16
+
17
+ if [ ! -d "$TARGET" ]; then
18
+ echo "error: $TARGET not found — run \`bun install\` first" >&2
19
+ exit 1
20
+ fi
21
+
22
+ if [ ! -f "$PATCH" ]; then
23
+ echo "error: $PATCH not found" >&2
24
+ exit 1
25
+ fi
26
+
27
+ if [ -f "$MARKER" ]; then
28
+ echo "transformers patch already applied — skipping"
29
+ exit 0
30
+ fi
31
+
32
+ echo "Applying transformers patch ($PATCH) to $TARGET..."
33
+ git apply --directory="$TARGET" "$PATCH"
34
+ touch "$MARKER"
35
+ echo "Patch applied."
package/src/cli.ts ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { bold, cyan, dim, green, yellow } from "ansis";
4
+ import { program } from "commander";
5
+ import pkg from "../package.json" with { type: "json" };
6
+ import { registerCheckUpdateCommand } from "./commands/check-update.ts";
7
+ import { registerMcpxCommand } from "./commands/mcpx.ts";
8
+ import { registerReindexCommand } from "./commands/reindex.ts";
9
+ import { registerServeCommand } from "./commands/serve.ts";
10
+ import { registerSkillCommand } from "./commands/skill.ts";
11
+ import { registerUpgradeCommand } from "./commands/upgrade.ts";
12
+ import type { BuildContextOptions } from "./context.ts";
13
+ import { mountAsCommanderCommand } from "./mount/commander.ts";
14
+ import { OPERATIONS } from "./operations/index.ts";
15
+ import { logger } from "./output/logger.ts";
16
+ import { maybeCheckForUpdate } from "./update/background.ts";
17
+
18
+ program
19
+ .name("membot")
20
+ .description("Versioned context store with hybrid search for AI agents. Stdio + HTTP MCP server and CLI.")
21
+ .version(pkg.version)
22
+ .option("-c, --config <path>", "membot data dir (default ~/.membot)")
23
+ .option("-j, --json", "force JSON output")
24
+ .option("-v, --verbose", "verbose / debug logging")
25
+ .option("--no-color", "disable ANSI colors")
26
+ .option("--no-interactive", "force non-interactive mode (no spinners)");
27
+
28
+ program.configureHelp({
29
+ styleTitle: (str) => bold(str),
30
+ styleCommandText: (str) => cyan(str),
31
+ styleSubcommandText: (str) => cyan(str),
32
+ styleOptionText: (str) => yellow(str),
33
+ styleArgumentText: (str) => green(str),
34
+ styleDescriptionText: (str) => dim(str),
35
+ });
36
+
37
+ const getContextOptions = (): BuildContextOptions => {
38
+ const opts = program.opts<{
39
+ config?: string;
40
+ json?: boolean;
41
+ verbose?: boolean;
42
+ color?: boolean;
43
+ interactive?: boolean;
44
+ }>();
45
+ return {
46
+ configFlag: opts.config,
47
+ json: opts.json,
48
+ verbose: opts.verbose,
49
+ noColor: opts.color === false,
50
+ noInteractive: opts.interactive === false,
51
+ };
52
+ };
53
+
54
+ for (const op of OPERATIONS) {
55
+ mountAsCommanderCommand(program, op, getContextOptions);
56
+ }
57
+
58
+ registerServeCommand(program);
59
+ registerReindexCommand(program);
60
+ registerMcpxCommand(program);
61
+ registerSkillCommand(program);
62
+ registerCheckUpdateCommand(program);
63
+ registerUpgradeCommand(program);
64
+
65
+ const updateNotice = maybeCheckForUpdate();
66
+
67
+ program.parse();
68
+
69
+ process.on("beforeExit", async () => {
70
+ const notice = await updateNotice;
71
+ if (notice) logger.writeRaw(notice);
72
+ });
@@ -0,0 +1,69 @@
1
+ import { cyan, dim, green, yellow } from "ansis";
2
+ import type { Command } from "commander";
3
+ import { createSpinner } from "nanospinner";
4
+ import pkg from "../../package.json" with { type: "json" };
5
+ import { saveUpdateCache } from "../update/cache.ts";
6
+ import { checkForUpdate, type UpdateCache } from "../update/checker.ts";
7
+
8
+ /**
9
+ * Register `membot check-update`. Performs a non-destructive npm-registry check,
10
+ * caches the result, and prints the current/latest version (plus changelog when an
11
+ * update is available). Emits raw `UpdateInfo` JSON when `--json` is set.
12
+ */
13
+ export function registerCheckUpdateCommand(program: Command) {
14
+ program
15
+ .command("check-update")
16
+ .description("Check for a newer version of membot")
17
+ .action(async () => {
18
+ const opts = program.opts();
19
+ const json = !!(opts.json as boolean | undefined);
20
+ const isTTY = process.stderr.isTTY ?? false;
21
+
22
+ const spinner =
23
+ !json && isTTY ? createSpinner("Checking for updates...", { stream: process.stderr }).start() : null;
24
+
25
+ try {
26
+ const info = await checkForUpdate(pkg.version);
27
+
28
+ const cache: UpdateCache = {
29
+ lastCheckAt: new Date().toISOString(),
30
+ latestVersion: info.latestVersion,
31
+ hasUpdate: info.hasUpdate,
32
+ changelog: info.changelog,
33
+ };
34
+ await saveUpdateCache(cache);
35
+
36
+ spinner?.stop();
37
+
38
+ if (json) {
39
+ console.log(JSON.stringify(info, null, 2));
40
+ return;
41
+ }
42
+
43
+ if (!info.hasUpdate) {
44
+ if (info.aheadOfLatest) {
45
+ console.log(
46
+ yellow(`membot v${info.currentVersion} is ahead of latest published release (v${info.latestVersion})`),
47
+ );
48
+ } else {
49
+ console.log(green(`membot is up to date (v${info.currentVersion})`));
50
+ }
51
+ return;
52
+ }
53
+
54
+ console.log(yellow(`Update available: ${info.currentVersion} → ${info.latestVersion}`));
55
+
56
+ if (info.changelog) {
57
+ console.log("");
58
+ console.log(dim(info.changelog));
59
+ }
60
+
61
+ console.log("");
62
+ console.log(cyan("Run `membot upgrade` to update"));
63
+ } catch (err) {
64
+ spinner?.error({ text: "Failed to check for updates" });
65
+ console.error(String(err));
66
+ process.exit(1);
67
+ }
68
+ });
69
+ }
@@ -0,0 +1,112 @@
1
+ import { createRequire } from "node:module";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { Command } from "commander";
4
+ import { logger } from "../output/logger.ts";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ /**
9
+ * Resolve the path to the bundled mcpx CLI entrypoint. We spawn it as a
10
+ * child process rather than calling its functions directly so that the
11
+ * upstream's argv parsing, output formatting, and config conventions stay
12
+ * authoritative — `membot mcpx <subcmd>` behaves identically to the user
13
+ * running `mcpx <subcmd>` themselves, just with our project's `--config`
14
+ * resolution layered on top when applicable.
15
+ */
16
+ const MCPX_CLI = fileURLToPath(import.meta.resolve("@evantahler/mcpx/cli"));
17
+
18
+ /**
19
+ * Forward an argv slice to the bundled mcpx CLI. Inherits stdio so prompts
20
+ * and pretty output flow through as if mcpx were called directly. Exits
21
+ * the parent process with the child's status code on failure.
22
+ */
23
+ export async function runMcpx(args: string[]): Promise<void> {
24
+ const proc = Bun.spawn(["bun", MCPX_CLI, ...args], {
25
+ stdout: "inherit",
26
+ stderr: "inherit",
27
+ stdin: "inherit",
28
+ });
29
+ const code = await proc.exited;
30
+ if (code !== 0) process.exit(code);
31
+ }
32
+
33
+ /**
34
+ * Pull the verbatim argv tokens that follow `mcpx` in `process.argv`. We
35
+ * forward them unmodified so flags (`--help`, `-c`, etc.) reach the
36
+ * upstream CLI exactly as the user typed them.
37
+ */
38
+ function getRawMcpxArgs(): string[] {
39
+ const idx = process.argv.indexOf("mcpx");
40
+ return idx === -1 ? [] : process.argv.slice(idx + 1);
41
+ }
42
+
43
+ const PASSTHROUGH_SUBCOMMANDS: ReadonlyArray<[name: string, desc: string]> = [
44
+ ["servers", "List configured MCP server names"],
45
+ ["info", "Show server overview or schema for a specific tool"],
46
+ ["search", "Search tools by keyword and/or semantic similarity"],
47
+ ["exec", "Execute a tool call"],
48
+ ["add", "Add an MCP server"],
49
+ ["remove", "Remove an MCP server"],
50
+ ["ping", "Check connectivity to MCP servers"],
51
+ ["auth", "Authenticate with an HTTP MCP server"],
52
+ ["deauth", "Remove stored authentication for a server"],
53
+ ["resource", "List resources for a server, or read a specific resource"],
54
+ ["prompt", "List prompts for a server, or get a specific prompt"],
55
+ ["task", "Manage async tool tasks (list, get, result, cancel)"],
56
+ ["index", "Build the search index from all configured servers"],
57
+ ];
58
+
59
+ /**
60
+ * Register `membot mcpx <subcommand>` for every passthrough subcommand on
61
+ * the upstream CLI. `--help` and unknown options are forwarded so users
62
+ * always get authoritative mcpx help text.
63
+ */
64
+ export function registerMcpxCommand(program: Command): void {
65
+ const mcpx = program.command("mcpx").description("Forward to the bundled mcpx CLI for managing MCP servers");
66
+
67
+ const verifyVersion = (() => {
68
+ try {
69
+ const ourPkg = require("../../package.json") as { dependencies: Record<string, string> };
70
+ const mcpxPkg = require("@evantahler/mcpx/package.json") as { version: string };
71
+ const declared = ourPkg.dependencies["@evantahler/mcpx"];
72
+ if (!declared) return true;
73
+ return (
74
+ mcpxPkg.version === declared ||
75
+ declared.startsWith(mcpxPkg.version) ||
76
+ mcpxPkg.version.startsWith(declared.replace(/^[\^~]/, ""))
77
+ );
78
+ } catch {
79
+ return true;
80
+ }
81
+ })();
82
+ if (!verifyVersion) {
83
+ logger.warn("@evantahler/mcpx version mismatch — `membot mcpx` may behave unexpectedly.");
84
+ }
85
+
86
+ for (const [name, description] of PASSTHROUGH_SUBCOMMANDS) {
87
+ mcpx
88
+ .command(name)
89
+ .description(description)
90
+ .allowUnknownOption(true)
91
+ .helpOption(false)
92
+ .argument("[args...]", "arguments forwarded to mcpx")
93
+ .action(async () => {
94
+ await runMcpx(getRawMcpxArgs());
95
+ });
96
+ }
97
+
98
+ // Upstream mcpx's "list" is the default action when invoked with no
99
+ // subcommand — not a registered subcommand — so strip the "list" token
100
+ // before forwarding.
101
+ mcpx
102
+ .command("list")
103
+ .description("List all tools, resources, and prompts across all configured servers")
104
+ .allowUnknownOption(true)
105
+ .helpOption(false)
106
+ .argument("[args...]", "arguments forwarded to mcpx")
107
+ .action(async () => {
108
+ const raw = getRawMcpxArgs();
109
+ const args = raw[0] === "list" ? raw.slice(1) : raw;
110
+ await runMcpx(args);
111
+ });
112
+ }
@@ -0,0 +1,53 @@
1
+ import type { Command } from "commander";
2
+ import { buildContext, closeContext } from "../context.ts";
3
+ import { rebuildFts } from "../db/chunks.ts";
4
+ import { logger } from "../output/logger.ts";
5
+
6
+ /**
7
+ * `membot reindex`
8
+ *
9
+ * Rebuild the FTS index over `current_chunks`. Useful after manually
10
+ * editing the DB or upgrading after a schema change. Does NOT re-embed —
11
+ * embeddings are durable and are managed by the ingest/refresh pipelines.
12
+ */
13
+ export function registerReindexCommand(program: Command): void {
14
+ program
15
+ .command("reindex")
16
+ .description("Rebuild the FTS keyword index over current chunks")
17
+ .action(async () => {
18
+ const ctx = await buildContext({});
19
+ try {
20
+ const result = await rebuildFts(ctx.db);
21
+ switch (result.kind) {
22
+ case "rebuilt":
23
+ logger.info(`reindex: FTS index rebuilt over ${result.chunk_count} chunks`);
24
+ console.log(JSON.stringify({ ok: true, chunk_count: result.chunk_count }));
25
+ break;
26
+ case "no_chunks":
27
+ logger.info("reindex: no chunks to index — run `membot add <path>` to ingest content first");
28
+ console.log(JSON.stringify({ ok: true, chunk_count: 0 }));
29
+ break;
30
+ case "extension_unavailable":
31
+ logger.warn(
32
+ `reindex: FTS extension unavailable — search will degrade to semantic-only${
33
+ result.cause ? ` (${result.cause})` : ""
34
+ }`,
35
+ );
36
+ console.log(
37
+ JSON.stringify({
38
+ ok: false,
39
+ reason: "fts_extension_unavailable",
40
+ cause: result.cause,
41
+ }),
42
+ );
43
+ break;
44
+ case "rebuild_failed":
45
+ logger.warn(`reindex: FTS rebuild failed${result.cause ? ` (${result.cause})` : ""}`);
46
+ console.log(JSON.stringify({ ok: false, reason: "rebuild_failed", cause: result.cause }));
47
+ break;
48
+ }
49
+ } finally {
50
+ await closeContext(ctx);
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,58 @@
1
+ import type { Command } from "commander";
2
+ import { buildContext, closeContext } from "../context.ts";
3
+ import { startStdioServer } from "../mcp/server.ts";
4
+ import { logger } from "../output/logger.ts";
5
+ import { startDaemon } from "../refresh/scheduler.ts";
6
+
7
+ /**
8
+ * `membot serve [--http <port>] [--watch] [--tick <sec>]`
9
+ *
10
+ * Start the MCP server in stdio mode (default) or HTTP streamable mode
11
+ * (when `--http <port>` is given). Optionally also start the refresh
12
+ * daemon (`--watch`) which ticks every `--tick` seconds and refreshes
13
+ * any rows whose `refresh_frequency_sec` has elapsed.
14
+ */
15
+ export function registerServeCommand(program: Command): void {
16
+ program
17
+ .command("serve")
18
+ .description("Run the MCP server (stdio default, --http for streamable HTTP) and optionally the refresh daemon")
19
+ .option("--http <port>", "expose MCP over HTTP on this port instead of stdio")
20
+ .option("--watch", "also run the refresh daemon (auto-refresh due rows)")
21
+ .option("--tick <sec>", "daemon tick interval in seconds (default 60)")
22
+ .action(async (options: { http?: string; watch?: boolean; tick?: string }) => {
23
+ const httpPort = options.http ? Number(options.http) : null;
24
+ let stopServer: (() => Promise<void>) | null = null;
25
+ let stopDaemon: (() => void) | null = null;
26
+
27
+ const onShutdown = async () => {
28
+ if (stopDaemon) stopDaemon();
29
+ if (stopServer) await stopServer();
30
+ };
31
+ process.on("SIGINT", () => {
32
+ logger.info("shutting down...");
33
+ onShutdown().finally(() => process.exit(0));
34
+ });
35
+ process.on("SIGTERM", () => {
36
+ onShutdown().finally(() => process.exit(0));
37
+ });
38
+
39
+ if (httpPort && !Number.isFinite(httpPort)) {
40
+ logger.error(`invalid --http port: ${options.http}`);
41
+ process.exit(2);
42
+ }
43
+
44
+ if (options.watch) {
45
+ const ctx = await buildContext({});
46
+ const tickSec = options.tick ? Number(options.tick) : ctx.config.daemon.tick_interval_sec;
47
+ stopDaemon = startDaemon(ctx, Number.isFinite(tickSec) && tickSec > 0 ? tickSec : 60);
48
+ process.on("beforeExit", () => closeContext(ctx));
49
+ }
50
+
51
+ if (httpPort) {
52
+ const { startHttpServer } = await import("../mcp/server.ts");
53
+ stopServer = await startHttpServer(httpPort);
54
+ } else {
55
+ stopServer = await startStdioServer();
56
+ }
57
+ });
58
+ }
@@ -0,0 +1,131 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import type { Command } from "commander";
5
+ import claudeSkill from "../../.claude/skills/membot.md" with { type: "text" };
6
+ import cursorRule from "../../.cursor/rules/membot.mdc" with { type: "text" };
7
+ import { HelpfulError, isHelpfulError, mapKindToExit } from "../errors.ts";
8
+ import { renderCliError } from "../mount/commander.ts";
9
+ import { logger } from "../output/logger.ts";
10
+ import { detectMode, setMode } from "../output/tty.ts";
11
+
12
+ interface SkillTarget {
13
+ agentLabel: string;
14
+ scopeLabel: string;
15
+ dir: string;
16
+ filename: string;
17
+ content: string;
18
+ }
19
+
20
+ interface SkillInstallOptions {
21
+ claude?: boolean;
22
+ cursor?: boolean;
23
+ global?: boolean;
24
+ project?: boolean;
25
+ force?: boolean;
26
+ }
27
+
28
+ /**
29
+ * `membot skill install [--claude] [--cursor] [--global|--project] [-f]`
30
+ *
31
+ * Drop the membot agent skill into the right location for Claude Code
32
+ * (`.claude/skills/membot.md`) or Cursor (`.cursor/rules/membot.mdc`),
33
+ * either in the current project (default) or in the user's home directory
34
+ * (`--global`). Both flags can be combined to install for both targets at
35
+ * once. The skill files are bundled into the binary via Bun text imports
36
+ * so this works in the compiled distribution as well as in `bun run`.
37
+ */
38
+ export function registerSkillCommand(program: Command): void {
39
+ const skill = program.command("skill").description("Install agent skills (Claude Code, Cursor)");
40
+
41
+ skill
42
+ .command("install")
43
+ .description(
44
+ "Install the membot skill into Claude Code (.claude/skills/membot.md) and/or Cursor (.cursor/rules/membot.mdc)",
45
+ )
46
+ .option("--claude", "install for Claude Code")
47
+ .option("--cursor", "install for Cursor")
48
+ .option("--global", "install to the user's home directory (default: project)")
49
+ .option("--project", "install to the current working directory (default)")
50
+ .option("-f, --force", "overwrite if the skill file already exists")
51
+ .action((opts: SkillInstallOptions) => {
52
+ const globalOpts = program.optsWithGlobals<{ json?: boolean; verbose?: boolean; color?: boolean }>();
53
+ setMode(
54
+ detectMode({
55
+ json: globalOpts.json,
56
+ verbose: globalOpts.verbose,
57
+ noColor: globalOpts.color === false,
58
+ }),
59
+ );
60
+ try {
61
+ install(opts);
62
+ } catch (err) {
63
+ renderCliError(err);
64
+ process.exit(isHelpfulError(err) ? mapKindToExit(err.kind) : 1);
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Resolve and write every requested skill file. Throws `HelpfulError` on
71
+ * any input or conflict failure so the mount-style error renderer can
72
+ * surface a uniform JSON / colorized message.
73
+ */
74
+ function install(opts: SkillInstallOptions): void {
75
+ if (!opts.claude && !opts.cursor) {
76
+ throw new HelpfulError({
77
+ kind: "input_error",
78
+ message: "no agent target specified",
79
+ hint: "Pass --claude, --cursor, or both — e.g. `membot skill install --claude`",
80
+ });
81
+ }
82
+
83
+ const targets = computeTargets(opts);
84
+ for (const target of targets) {
85
+ const dest = join(target.dir, target.filename);
86
+ if (existsSync(dest) && !opts.force) {
87
+ throw new HelpfulError({
88
+ kind: "conflict",
89
+ message: `${dest} already exists`,
90
+ hint: "Re-run with --force to overwrite",
91
+ });
92
+ }
93
+ mkdirSync(target.dir, { recursive: true });
94
+ writeFileSync(dest, target.content, "utf-8");
95
+ logger.info(`installed ${target.agentLabel} skill (${target.scopeLabel}): ${dest}`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Materialise the (agent × scope) cartesian product of install targets the
101
+ * user asked for. Default scope is project when neither --global nor
102
+ * --project is passed; passing both installs to both locations.
103
+ */
104
+ function computeTargets(opts: SkillInstallOptions): SkillTarget[] {
105
+ const scopes: { label: string; resolveDir: (rel: string) => string }[] = [];
106
+ if (opts.global) scopes.push({ label: "global", resolveDir: (rel) => join(homedir(), rel) });
107
+ if (opts.project || !opts.global) scopes.push({ label: "project", resolveDir: (rel) => resolve(rel) });
108
+
109
+ const targets: SkillTarget[] = [];
110
+ for (const scope of scopes) {
111
+ if (opts.claude) {
112
+ targets.push({
113
+ agentLabel: "Claude Code",
114
+ scopeLabel: scope.label,
115
+ dir: scope.resolveDir(".claude/skills"),
116
+ filename: "membot.md",
117
+ content: claudeSkill,
118
+ });
119
+ }
120
+ if (opts.cursor) {
121
+ targets.push({
122
+ agentLabel: "Cursor",
123
+ scopeLabel: scope.label,
124
+ dir: scope.resolveDir(".cursor/rules"),
125
+ filename: "membot.mdc",
126
+ content: cursorRule,
127
+ });
128
+ }
129
+ }
130
+ return targets;
131
+ }