@knowsuchagency/fulcrum 1.2.3 → 1.4.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/bin/fulcrum.js CHANGED
@@ -47617,8 +47617,8 @@ async function handleFsCommand(action, positional, flags) {
47617
47617
 
47618
47618
  // cli/src/commands/up.ts
47619
47619
  import { spawn } from "child_process";
47620
- import { existsSync as existsSync3 } from "fs";
47621
- import { dirname as dirname2, join as join3 } from "path";
47620
+ import { existsSync as existsSync4 } from "fs";
47621
+ import { dirname as dirname2, join as join4 } from "path";
47622
47622
  import { fileURLToPath } from "url";
47623
47623
  init_errors();
47624
47624
 
@@ -47899,1367 +47899,335 @@ function installUv() {
47899
47899
  return false;
47900
47900
  return installDependency(dep);
47901
47901
  }
47902
- // package.json
47903
- var package_default = {
47904
- name: "@knowsuchagency/fulcrum",
47905
- private: true,
47906
- version: "1.2.3",
47907
- description: "Harness Attention. Orchestrate Agents. Ship.",
47908
- license: "PolyForm-Perimeter-1.0.0",
47909
- type: "module",
47910
- scripts: {
47911
- dev: "vite --host",
47912
- "dev:server": "mkdir -p ~/.fulcrum && bun --watch server/index.ts",
47913
- build: "tsc -b && vite build",
47914
- start: "NODE_ENV=production bun server/index.ts",
47915
- lint: "eslint .",
47916
- preview: "vite preview",
47917
- "db:generate": "drizzle-kit generate",
47918
- "db:migrate": "drizzle-kit migrate",
47919
- "db:studio": "drizzle-kit studio"
47920
- },
47921
- dependencies: {
47922
- "@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
47923
- "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
47924
- "@azurity/pure-nerd-font": "^3.0.5",
47925
- "@base-ui/react": "^1.0.0",
47926
- "@dagrejs/dagre": "^1.1.8",
47927
- "@fontsource-variable/jetbrains-mono": "^5.2.8",
47928
- "@hono/node-server": "^1.19.7",
47929
- "@hono/node-ws": "^1.2.0",
47930
- "@hugeicons/core-free-icons": "^3.0.0",
47931
- "@hugeicons/react": "^1.1.3",
47932
- "@monaco-editor/react": "^4.7.0",
47933
- "@octokit/rest": "^22.0.1",
47934
- "@radix-ui/react-collapsible": "^1.1.12",
47935
- "@tailwindcss/vite": "^4.1.17",
47936
- "@tanstack/react-query": "^5.90.12",
47937
- "@tanstack/react-router": "^1.141.8",
47938
- "@uiw/react-markdown-preview": "^5.1.5",
47939
- "@xterm/addon-clipboard": "^0.2.0",
47940
- "@xterm/addon-fit": "^0.10.0",
47941
- "@xterm/addon-web-links": "^0.11.0",
47942
- "@xterm/xterm": "^5.5.0",
47943
- "bun-pty": "^0.4.2",
47944
- citty: "^0.1.6",
47945
- "class-variance-authority": "^0.7.1",
47946
- cloudflare: "^5.2.0",
47947
- clsx: "^2.1.1",
47948
- "date-fns": "^4.1.0",
47949
- "drizzle-orm": "^0.45.1",
47950
- "fancy-ansi": "^0.1.3",
47951
- glob: "^13.0.0",
47952
- hono: "^4.11.1",
47953
- i18next: "^25.7.3",
47954
- mobx: "^6.15.0",
47955
- "mobx-react-lite": "^4.1.1",
47956
- "mobx-state-tree": "^7.0.2",
47957
- "next-themes": "^0.4.6",
47958
- react: "^19.2.0",
47959
- "react-day-picker": "^9.13.0",
47960
- "react-dom": "^19.2.0",
47961
- "react-i18next": "^16.5.0",
47962
- "react-resizable-panels": "^4.0.11",
47963
- reactflow: "^11.11.4",
47964
- recharts: "2.15.4",
47965
- shadcn: "^3.6.2",
47966
- shiki: "^3.20.0",
47967
- sonner: "^2.0.7",
47968
- "tailwind-merge": "^3.4.0",
47969
- tailwindcss: "^4.1.17",
47970
- "tw-animate-css": "^1.4.0",
47971
- ws: "^8.18.3",
47972
- yaml: "^2.8.2"
47973
- },
47974
- devDependencies: {
47975
- "@eslint/js": "^9.39.1",
47976
- "@opencode-ai/plugin": "^1.1.8",
47977
- "@tailwindcss/typography": "^0.5.19",
47978
- "@tanstack/router-plugin": "^1.141.8",
47979
- "@types/bun": "^1.2.14",
47980
- "@types/node": "^24.10.1",
47981
- "@types/react": "^19.2.5",
47982
- "@types/react-dom": "^19.2.3",
47983
- "@types/ws": "^8.18.1",
47984
- "@vitejs/plugin-react": "^5.1.1",
47985
- "drizzle-kit": "^0.31.8",
47986
- eslint: "^9.39.1",
47987
- "eslint-plugin-react-hooks": "^7.0.1",
47988
- "eslint-plugin-react-refresh": "^0.4.24",
47989
- globals: "^16.5.0",
47990
- typescript: "~5.9.3",
47991
- "typescript-eslint": "^8.46.4",
47992
- vite: "^7.2.4"
47993
- }
47994
- };
47995
47902
 
47996
- // cli/src/commands/up.ts
47997
- function getPackageRoot() {
47998
- const currentFile = fileURLToPath(import.meta.url);
47999
- let dir = dirname2(currentFile);
48000
- for (let i2 = 0;i2 < 5; i2++) {
48001
- if (existsSync3(join3(dir, "server", "index.js"))) {
48002
- return dir;
48003
- }
48004
- dir = dirname2(dir);
48005
- }
48006
- return dirname2(dirname2(dirname2(currentFile)));
47903
+ // cli/src/commands/claude.ts
47904
+ init_errors();
47905
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync, readFileSync as readFileSync4 } from "fs";
47906
+ import { homedir as homedir2 } from "os";
47907
+ import { join as join3 } from "path";
47908
+
47909
+ // plugins/fulcrum/.claude-plugin/plugin.json
47910
+ var plugin_default = `{
47911
+ "name": "fulcrum",
47912
+ "description": "Fulcrum task orchestration for Claude Code",
47913
+ "version": "1.4.0",
47914
+ "author": {
47915
+ "name": "Fulcrum"
47916
+ },
47917
+ "hooks": "./hooks/hooks.json",
47918
+ "mcpServers": "./.mcp.json",
47919
+ "skills": "./skills/",
47920
+ "commands": "./commands/"
48007
47921
  }
48008
- async function handleUpCommand(flags) {
48009
- const autoYes = flags.yes === "true" || flags.y === "true";
48010
- if (needsViboraMigration()) {
48011
- const viboraDir = getLegacyViboraDir();
48012
- console.error(`
48013
- Found existing Vibora data at ${viboraDir}`);
48014
- console.error('Run "fulcrum migrate-from-vibora" to copy your data to ~/.fulcrum');
48015
- console.error("");
48016
- }
48017
- if (!isBunInstalled()) {
48018
- const bunDep = getDependency("bun");
48019
- const method = getInstallMethod(bunDep);
48020
- console.error("Bun is required to run Fulcrum but is not installed.");
48021
- console.error(" Bun is the JavaScript runtime that powers Fulcrum.");
48022
- const shouldInstall = autoYes || await confirm(`Would you like to install bun via ${method}?`);
48023
- if (shouldInstall) {
48024
- const success2 = installBun();
48025
- if (!success2) {
48026
- throw new CliError("INSTALL_FAILED", "Failed to install bun", ExitCodes.ERROR);
48027
- }
48028
- console.error("Bun installed successfully!");
48029
- } else {
48030
- throw new CliError("MISSING_DEPENDENCY", `Bun is required. Install manually: ${getInstallCommand(bunDep)}`, ExitCodes.ERROR);
48031
- }
48032
- }
48033
- if (!isDtachInstalled()) {
48034
- const dtachDep = getDependency("dtach");
48035
- const method = getInstallMethod(dtachDep);
48036
- console.error("dtach is required for terminal persistence but is not installed.");
48037
- console.error(" dtach enables persistent terminal sessions that survive disconnects.");
48038
- const shouldInstall = autoYes || await confirm(`Would you like to install dtach via ${method}?`);
48039
- if (shouldInstall) {
48040
- const success2 = installDtach();
48041
- if (!success2) {
48042
- throw new CliError("INSTALL_FAILED", "Failed to install dtach", ExitCodes.ERROR);
47922
+ `;
47923
+
47924
+ // plugins/fulcrum/hooks/hooks.json
47925
+ var hooks_default = `{
47926
+ "hooks": {
47927
+ "Stop": [
47928
+ {
47929
+ "hooks": [
47930
+ {
47931
+ "type": "command",
47932
+ "command": "fulcrum current-task review 2>/dev/null || true"
47933
+ }
47934
+ ]
48043
47935
  }
48044
- console.error("dtach installed successfully!");
48045
- } else {
48046
- throw new CliError("MISSING_DEPENDENCY", `dtach is required. Install manually: ${getInstallCommand(dtachDep)}`, ExitCodes.ERROR);
48047
- }
48048
- }
48049
- if (!isUvInstalled()) {
48050
- const uvDep = getDependency("uv");
48051
- const method = getInstallMethod(uvDep);
48052
- console.error("uv is required but is not installed.");
48053
- console.error(" uv is a fast Python package manager used by Claude Code.");
48054
- const shouldInstall = autoYes || await confirm(`Would you like to install uv via ${method}?`);
48055
- if (shouldInstall) {
48056
- const success2 = installUv();
48057
- if (!success2) {
48058
- throw new CliError("INSTALL_FAILED", "Failed to install uv", ExitCodes.ERROR);
47936
+ ],
47937
+ "UserPromptSubmit": [
47938
+ {
47939
+ "hooks": [
47940
+ {
47941
+ "type": "command",
47942
+ "command": "fulcrum current-task in-progress 2>/dev/null || true"
47943
+ }
47944
+ ]
48059
47945
  }
48060
- console.error("uv installed successfully!");
48061
- } else {
48062
- throw new CliError("MISSING_DEPENDENCY", `uv is required. Install manually: ${getInstallCommand(uvDep)}`, ExitCodes.ERROR);
48063
- }
47946
+ ]
48064
47947
  }
48065
- const existingPid = readPid();
48066
- if (existingPid && isProcessRunning(existingPid)) {
48067
- console.error(`Fulcrum server is already running (PID: ${existingPid})`);
48068
- const shouldReplace = autoYes || await confirm("Would you like to stop it and start a new instance?");
48069
- if (shouldReplace) {
48070
- console.error("Stopping existing instance...");
48071
- process.kill(existingPid, "SIGTERM");
48072
- let attempts = 0;
48073
- while (attempts < 50 && isProcessRunning(existingPid)) {
48074
- await new Promise((resolve) => setTimeout(resolve, 100));
48075
- attempts++;
48076
- }
48077
- if (isProcessRunning(existingPid)) {
48078
- process.kill(existingPid, "SIGKILL");
48079
- }
48080
- removePid();
48081
- console.error("Existing instance stopped.");
48082
- } else {
48083
- throw new CliError("ALREADY_RUNNING", `Server already running at http://localhost:${getPort(flags.port)}`, ExitCodes.ERROR);
47948
+ }
47949
+ `;
47950
+
47951
+ // plugins/fulcrum/.mcp.json
47952
+ var _mcp_default = `{
47953
+ "mcpServers": {
47954
+ "fulcrum": {
47955
+ "command": "fulcrum",
47956
+ "args": ["mcp"]
48084
47957
  }
48085
47958
  }
48086
- const port = getPort(flags.port);
48087
- if (flags.port) {
48088
- updateSettingsPort(port);
48089
- }
48090
- const host = flags.host ? "0.0.0.0" : "localhost";
48091
- const packageRoot = getPackageRoot();
48092
- const serverPath = join3(packageRoot, "server", "index.js");
48093
- const platform2 = process.platform;
48094
- const arch = process.arch;
48095
- let ptyLibName;
48096
- if (platform2 === "darwin") {
48097
- ptyLibName = arch === "arm64" ? "librust_pty_arm64.dylib" : "librust_pty.dylib";
48098
- } else if (platform2 === "win32") {
48099
- ptyLibName = "rust_pty.dll";
48100
- } else {
48101
- ptyLibName = arch === "arm64" ? "librust_pty_arm64.so" : "librust_pty.so";
48102
- }
48103
- const ptyLibPath = join3(packageRoot, "lib", ptyLibName);
48104
- const fulcrumDir = getFulcrumDir();
48105
- const debug = flags.debug === "true";
48106
- console.error(`Starting Fulcrum server${debug ? " (debug mode)" : ""}...`);
48107
- const serverProc = spawn("bun", [serverPath], {
48108
- detached: true,
48109
- stdio: "ignore",
48110
- env: {
48111
- ...process.env,
48112
- NODE_ENV: "production",
48113
- PORT: port.toString(),
48114
- HOST: host,
48115
- FULCRUM_DIR: fulcrumDir,
48116
- FULCRUM_PACKAGE_ROOT: packageRoot,
48117
- FULCRUM_VERSION: package_default.version,
48118
- BUN_PTY_LIB: ptyLibPath,
48119
- ...isClaudeInstalled() && { FULCRUM_CLAUDE_INSTALLED: "1" },
48120
- ...isOpencodeInstalled() && { FULCRUM_OPENCODE_INSTALLED: "1" },
48121
- ...debug && { LOG_LEVEL: "debug", DEBUG: "1" }
48122
- }
48123
- });
48124
- serverProc.unref();
48125
- const pid = serverProc.pid;
48126
- if (!pid) {
48127
- throw new CliError("START_FAILED", "Failed to start server process", ExitCodes.ERROR);
48128
- }
48129
- writePid(pid);
48130
- await new Promise((resolve) => setTimeout(resolve, 1000));
48131
- if (!isProcessRunning(pid)) {
48132
- throw new CliError("START_FAILED", "Server process died immediately after starting", ExitCodes.ERROR);
48133
- }
48134
- if (isJsonOutput()) {
48135
- output({
48136
- pid,
48137
- port,
48138
- url: `http://localhost:${port}`
48139
- });
48140
- } else {
48141
- const hasAgent = isClaudeInstalled() || isOpencodeInstalled();
48142
- showGettingStartedTips(port, hasAgent);
48143
- }
48144
47959
  }
48145
- function showGettingStartedTips(port, hasAgent) {
48146
- console.error(`
48147
- Fulcrum is running at http://localhost:${port}
47960
+ `;
48148
47961
 
48149
- Getting Started:
48150
- 1. Open http://localhost:${port} in your browser
48151
- 2. Add a repository to get started
48152
- 3. Create a task to spin up an isolated worktree
48153
- 4. Run your AI agent in the task terminal
47962
+ // plugins/fulcrum/commands/pr.md
47963
+ var pr_default = `---
47964
+ description: Link a GitHub PR to the current fulcrum task
47965
+ ---
47966
+ Link the PR to this task: \`fulcrum current-task pr $ARGUMENTS\`
48154
47967
 
48155
- Commands:
48156
- fulcrum status Check server status
48157
- fulcrum doctor Check all dependencies
48158
- fulcrum down Stop the server
48159
- `);
48160
- if (!hasAgent) {
48161
- console.error(`Note: No AI agents detected. Install one to get started:
48162
- Claude Code: curl -fsSL https://claude.ai/install.sh | bash
48163
- OpenCode: curl -fsSL https://opencode.ai/install | bash
48164
- `);
48165
- }
48166
- }
47968
+ This enables auto-completion when the PR is merged.
47969
+ `;
48167
47970
 
48168
- // cli/src/commands/down.ts
48169
- init_errors();
48170
- async function handleDownCommand() {
48171
- const pid = readPid();
48172
- if (!pid) {
48173
- throw new CliError("NOT_RUNNING", "No PID file found. Fulcrum server may not be running.", ExitCodes.ERROR);
48174
- }
48175
- if (!isProcessRunning(pid)) {
48176
- removePid();
48177
- if (isJsonOutput()) {
48178
- output({ stopped: true, pid, wasRunning: false });
48179
- } else {
48180
- console.log(`Fulcrum was not running (stale PID file cleaned up)`);
48181
- }
48182
- return;
48183
- }
48184
- try {
48185
- process.kill(pid, "SIGTERM");
48186
- } catch (err) {
48187
- throw new CliError("KILL_FAILED", `Failed to stop server (PID: ${pid}): ${err}`, ExitCodes.ERROR);
48188
- }
48189
- let attempts = 0;
48190
- while (attempts < 50 && isProcessRunning(pid)) {
48191
- await new Promise((resolve) => setTimeout(resolve, 100));
48192
- attempts++;
48193
- }
48194
- if (isProcessRunning(pid)) {
48195
- try {
48196
- process.kill(pid, "SIGKILL");
48197
- } catch {}
48198
- }
48199
- removePid();
48200
- if (isJsonOutput()) {
48201
- output({ stopped: true, pid, wasRunning: true });
48202
- } else {
48203
- console.log(`Fulcrum stopped (PID: ${pid})`);
48204
- }
48205
- }
47971
+ // plugins/fulcrum/commands/task-info.md
47972
+ var task_info_default = `---
47973
+ description: Show current fulcrum task info
47974
+ ---
47975
+ Show current task details: \`fulcrum current-task\`
47976
+ `;
48206
47977
 
48207
- // cli/src/commands/migrate-from-vibora.ts
48208
- init_server();
48209
- async function handleMigrateFromViboraCommand(flags) {
48210
- const autoYes = flags.yes === "true" || flags.y === "true";
48211
- if (!needsViboraMigration()) {
48212
- if (isJsonOutput()) {
48213
- output({ migrated: false, reason: "no_migration_needed" });
48214
- } else {
48215
- console.error("No migration needed.");
48216
- console.error(` ~/.vibora does not exist or ~/.fulcrum already has data.`);
48217
- }
48218
- return;
48219
- }
48220
- const viboraDir = getLegacyViboraDir();
48221
- const fulcrumDir = getFulcrumDir();
48222
- if (!isJsonOutput()) {
48223
- console.error(`
48224
- Found existing Vibora data at ${viboraDir}`);
48225
- console.error("Fulcrum (formerly Vibora) now uses ~/.fulcrum for data storage.");
48226
- console.error("");
48227
- console.error("Your existing data can be copied to the new location.");
48228
- console.error("This is non-destructive - your ~/.vibora directory will be left untouched.");
48229
- console.error("");
48230
- }
48231
- const shouldMigrate = autoYes || await confirm("Would you like to copy your data to ~/.fulcrum?");
48232
- if (!shouldMigrate) {
48233
- if (isJsonOutput()) {
48234
- output({ migrated: false, reason: "user_declined" });
48235
- } else {
48236
- console.error("Migration skipped.");
48237
- console.error("You can run this command again later to migrate.");
48238
- }
48239
- return;
48240
- }
48241
- if (!isJsonOutput()) {
48242
- console.error("Copying data from ~/.vibora to ~/.fulcrum...");
48243
- }
48244
- const success2 = migrateFromVibora();
48245
- if (success2) {
48246
- if (isJsonOutput()) {
48247
- output({ migrated: true, from: viboraDir, to: fulcrumDir });
48248
- } else {
48249
- console.error("Migration complete!");
48250
- console.error(` Data copied from ${viboraDir} to ${fulcrumDir}`);
48251
- console.error(" Your original ~/.vibora directory has been preserved.");
48252
- }
48253
- } else {
48254
- if (isJsonOutput()) {
48255
- output({ migrated: false, reason: "migration_failed" });
48256
- } else {
48257
- console.error("Migration failed.");
48258
- console.error("You can manually copy files from ~/.vibora to ~/.fulcrum");
48259
- }
48260
- process.exitCode = 1;
48261
- }
48262
- }
47978
+ // plugins/fulcrum/commands/notify.md
47979
+ var notify_default = `---
47980
+ description: Send a notification to the user
47981
+ ---
47982
+ Send a notification: \`fulcrum notify $ARGUMENTS\`
48263
47983
 
48264
- // cli/src/commands/status.ts
48265
- init_server();
48266
- async function handleStatusCommand(flags) {
48267
- const pid = readPid();
48268
- const port = getPort(flags.port);
48269
- const serverUrl = discoverServerUrl(flags.url, flags.port);
48270
- const pidRunning = pid !== null && isProcessRunning(pid);
48271
- let healthOk = false;
48272
- let version3 = null;
48273
- let uptime = null;
48274
- if (pidRunning) {
48275
- try {
48276
- const res = await fetch(`${serverUrl}/health`, { signal: AbortSignal.timeout(2000) });
48277
- healthOk = res.ok;
48278
- if (res.ok) {
48279
- const health = await res.json();
48280
- version3 = health.version || null;
48281
- uptime = health.uptime || null;
48282
- }
48283
- } catch {}
48284
- }
48285
- const data = {
48286
- running: pidRunning,
48287
- healthy: healthOk,
48288
- pid: pid || null,
48289
- port,
48290
- url: serverUrl,
48291
- version: version3,
48292
- uptime
48293
- };
48294
- if (isJsonOutput()) {
48295
- output(data);
48296
- } else {
48297
- if (pidRunning) {
48298
- const healthStatus = healthOk ? "healthy" : "not responding";
48299
- console.log(`Fulcrum is running (${healthStatus})`);
48300
- console.log(` PID: ${pid}`);
48301
- console.log(` URL: ${serverUrl}`);
48302
- if (version3)
48303
- console.log(` Version: ${version3}`);
48304
- if (uptime)
48305
- console.log(` Uptime: ${Math.floor(uptime / 1000)}s`);
48306
- } else {
48307
- console.log("Fulcrum is not running");
48308
- console.log(`
48309
- Start with: fulcrum up`);
48310
- }
48311
- }
48312
- }
47984
+ Format: fulcrum notify "Title" "Message body"
47985
+ `;
48313
47986
 
48314
- // cli/src/commands/git.ts
48315
- init_client();
48316
- init_errors();
48317
- async function handleGitCommand(action, flags) {
48318
- const client = new FulcrumClient(flags.url, flags.port);
48319
- switch (action) {
48320
- case "status": {
48321
- const path = flags.path || process.cwd();
48322
- const status = await client.getStatus(path);
48323
- if (isJsonOutput()) {
48324
- output(status);
48325
- } else {
48326
- console.log(`Branch: ${status.branch}`);
48327
- if (status.ahead)
48328
- console.log(` Ahead: ${status.ahead}`);
48329
- if (status.behind)
48330
- console.log(` Behind: ${status.behind}`);
48331
- if (status.staged?.length)
48332
- console.log(` Staged: ${status.staged.length} files`);
48333
- if (status.modified?.length)
48334
- console.log(` Modified: ${status.modified.length} files`);
48335
- if (status.untracked?.length)
48336
- console.log(` Untracked: ${status.untracked.length} files`);
48337
- if (!status.staged?.length && !status.modified?.length && !status.untracked?.length) {
48338
- console.log(" Working tree clean");
48339
- }
48340
- }
48341
- break;
48342
- }
48343
- case "diff": {
48344
- const path = flags.path || process.cwd();
48345
- const diff = await client.getDiff(path, {
48346
- staged: flags.staged === "true",
48347
- ignoreWhitespace: flags["ignore-whitespace"] === "true",
48348
- includeUntracked: flags["include-untracked"] === "true"
48349
- });
48350
- if (isJsonOutput()) {
48351
- output(diff);
48352
- } else {
48353
- console.log(diff.diff || "No changes");
48354
- }
48355
- break;
48356
- }
48357
- case "branches": {
48358
- const repo = flags.repo;
48359
- if (!repo) {
48360
- throw new CliError("MISSING_REPO", "--repo is required", ExitCodes.INVALID_ARGS);
48361
- }
48362
- const branches = await client.getBranches(repo);
48363
- if (isJsonOutput()) {
48364
- output(branches);
48365
- } else {
48366
- for (const branch of branches) {
48367
- const current = branch.current ? "* " : " ";
48368
- console.log(`${current}${branch.name}`);
48369
- }
48370
- }
48371
- break;
48372
- }
48373
- default:
48374
- throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: status, diff, branches`, ExitCodes.INVALID_ARGS);
48375
- }
48376
- }
47987
+ // plugins/fulcrum/commands/linear.md
47988
+ var linear_default = `---
47989
+ description: Link a Linear ticket to the current fulcrum task
47990
+ ---
47991
+ Link the Linear ticket to this task: \`fulcrum current-task linear $ARGUMENTS\`
47992
+ `;
48377
47993
 
48378
- // cli/src/commands/worktrees.ts
48379
- init_client();
48380
- init_errors();
48381
- async function handleWorktreesCommand(action, flags) {
48382
- const client = new FulcrumClient(flags.url, flags.port);
48383
- switch (action) {
48384
- case "list": {
48385
- const worktrees = await client.listWorktrees();
48386
- if (isJsonOutput()) {
48387
- output(worktrees);
48388
- } else {
48389
- if (worktrees.length === 0) {
48390
- console.log("No worktrees found");
48391
- } else {
48392
- for (const wt of worktrees) {
48393
- console.log(`${wt.path}`);
48394
- console.log(` Branch: ${wt.branch}`);
48395
- if (wt.taskId)
48396
- console.log(` Task: ${wt.taskId}`);
48397
- }
48398
- }
48399
- }
48400
- break;
48401
- }
48402
- case "delete": {
48403
- const worktreePath = flags.path;
48404
- if (!worktreePath) {
48405
- throw new CliError("MISSING_PATH", "--path is required", ExitCodes.INVALID_ARGS);
48406
- }
48407
- const deleteLinkedTask = flags["delete-task"] === "true" || flags["delete-task"] === "";
48408
- const result = await client.deleteWorktree(worktreePath, flags.repo, deleteLinkedTask);
48409
- if (isJsonOutput()) {
48410
- output(result);
48411
- } else {
48412
- console.log(`Deleted worktree: ${worktreePath}`);
48413
- }
48414
- break;
48415
- }
48416
- default:
48417
- throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, delete`, ExitCodes.INVALID_ARGS);
48418
- }
48419
- }
47994
+ // plugins/fulcrum/commands/review.md
47995
+ var review_default = `---
47996
+ description: Mark the current fulcrum task as ready for review
47997
+ ---
47998
+ Mark this task ready for review: \`fulcrum current-task review\`
48420
47999
 
48421
- // cli/src/commands/config.ts
48422
- init_client();
48423
- init_errors();
48424
- async function handleConfigCommand(action, positional, flags) {
48425
- const client = new FulcrumClient(flags.url, flags.port);
48426
- switch (action) {
48427
- case "list": {
48428
- const config3 = await client.getAllConfig();
48429
- if (isJsonOutput()) {
48430
- output(config3);
48431
- } else {
48432
- console.log("Configuration:");
48433
- for (const [key, value] of Object.entries(config3)) {
48434
- const displayValue = value === null ? "(not set)" : value;
48435
- console.log(` ${key}: ${displayValue}`);
48436
- }
48437
- }
48438
- break;
48439
- }
48440
- case "get": {
48441
- const [key] = positional;
48442
- if (!key) {
48443
- throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
48444
- }
48445
- const config3 = await client.getConfig(key);
48446
- if (isJsonOutput()) {
48447
- output(config3);
48448
- } else {
48449
- const value = config3.value === null ? "(not set)" : config3.value;
48450
- console.log(`${key}: ${value}`);
48451
- }
48452
- break;
48453
- }
48454
- case "set": {
48455
- const [key, value] = positional;
48456
- if (!key) {
48457
- throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
48458
- }
48459
- if (value === undefined) {
48460
- throw new CliError("MISSING_VALUE", "Config value is required", ExitCodes.INVALID_ARGS);
48461
- }
48462
- const parsedValue = /^\d+$/.test(value) ? parseInt(value, 10) : value;
48463
- const config3 = await client.setConfig(key, parsedValue);
48464
- if (isJsonOutput()) {
48465
- output(config3);
48466
- } else {
48467
- console.log(`Set ${key} = ${config3.value}`);
48468
- }
48469
- break;
48470
- }
48471
- case "reset": {
48472
- const [key] = positional;
48473
- if (!key) {
48474
- throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
48475
- }
48476
- const config3 = await client.resetConfig(key);
48477
- if (isJsonOutput()) {
48478
- output(config3);
48479
- } else {
48480
- console.log(`Reset ${key} to default: ${config3.value}`);
48481
- }
48482
- break;
48483
- }
48484
- default:
48485
- throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, get, set, reset`, ExitCodes.INVALID_ARGS);
48486
- }
48487
- }
48000
+ This sends a notification to the user.
48001
+ `;
48488
48002
 
48489
- // cli/src/commands/opencode.ts
48490
- init_errors();
48491
- import {
48492
- mkdirSync as mkdirSync3,
48493
- writeFileSync as writeFileSync3,
48494
- existsSync as existsSync4,
48495
- readFileSync as readFileSync4,
48496
- unlinkSync as unlinkSync2,
48497
- copyFileSync,
48498
- renameSync
48499
- } from "fs";
48500
- import { homedir as homedir2 } from "os";
48501
- import { join as join4 } from "path";
48003
+ // plugins/fulcrum/skills/vibora/SKILL.md
48004
+ var SKILL_default = `---
48005
+ name: fulcrum
48006
+ description: Fulcrum is a terminal-first tool for orchestrating AI coding agents across isolated git worktrees. Use this skill when working in a Fulcrum task worktree or managing tasks.
48007
+ ---
48502
48008
 
48503
- // plugins/fulcrum-opencode/index.ts
48504
- var fulcrum_opencode_default = `import type { Plugin } from "@opencode-ai/plugin"
48505
- import { appendFileSync } from "node:fs"
48506
- import { spawn } from "node:child_process"
48507
- import { tmpdir } from "node:os"
48508
- import { join } from "node:path"
48009
+ # Fulcrum - AI Agent Orchestration
48509
48010
 
48510
- declare const process: { env: Record<string, string | undefined> }
48011
+ ## Overview
48511
48012
 
48512
- const LOG_FILE = join(tmpdir(), "fulcrum-opencode.log")
48513
- const NOISY_EVENTS = new Set([
48514
- "message.part.updated",
48515
- "file.watcher.updated",
48516
- "tui.toast.show",
48517
- "config.updated",
48518
- ])
48519
- const log = (msg: string) => {
48520
- try {
48521
- appendFileSync(LOG_FILE, \`[\${new Date().toISOString()}] \${msg}\\n\`)
48522
- } catch {
48523
- // Silently ignore logging errors - logging is non-critical
48524
- }
48525
- }
48013
+ Fulcrum is a terminal-first tool for orchestrating AI coding agents (like Claude Code) across isolated git worktrees. Each task runs in its own worktree, enabling parallel work on multiple features or fixes without branch switching.
48526
48014
 
48527
- /**
48528
- * Execute fulcrum command using spawn with shell option for proper PATH resolution.
48529
- * Using spawn with explicit args array prevents shell injection while shell:true
48530
- * ensures PATH is properly resolved (for NVM, fnm, etc. managed node installations).
48531
- * Includes 10 second timeout protection to prevent hanging.
48532
- */
48533
- async function runFulcrumCommand(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
48534
- return new Promise((resolve) => {
48535
- let stdout = ''
48536
- let stderr = ''
48537
- let resolved = false
48538
- let processExited = false
48539
- let killTimeoutId: ReturnType<typeof setTimeout> | null = null
48015
+ **Philosophy:**
48016
+ - Agents run natively in terminals - no abstraction layer or wrapper APIs
48017
+ - Tasks create isolated git worktrees for clean separation
48018
+ - Persistent terminals organized in tabs across tasks
48540
48019
 
48541
- const child = spawn(FULCRUM_CMD, args, { shell: true })
48020
+ ## When to Use This Skill
48542
48021
 
48543
- const cleanup = () => {
48544
- processExited = true
48545
- if (killTimeoutId) {
48546
- clearTimeout(killTimeoutId)
48547
- killTimeoutId = null
48548
- }
48549
- }
48022
+ Use the Fulcrum CLI when:
48023
+ - **Working in a task worktree** - Use \`current-task\` commands to manage your current task
48024
+ - **Updating task status** - Mark tasks as in-progress, ready for review, done, or canceled
48025
+ - **Linking PRs** - Associate a GitHub PR with the current task
48026
+ - **Linking Linear tickets** - Connect a Linear issue to the current task
48027
+ - **Linking URLs** - Attach any relevant URLs (design docs, specs, external resources) to the task
48028
+ - **Sending notifications** - Alert the user when work is complete or needs attention
48550
48029
 
48551
- child.stdout?.on('data', (data) => {
48552
- stdout += data.toString()
48553
- })
48030
+ Use the Fulcrum MCP tools when:
48031
+ - **Executing commands remotely** - Run shell commands on the Fulcrum server from Claude Desktop
48032
+ - **Stateful workflows** - Use persistent sessions to maintain environment variables and working directory across commands
48554
48033
 
48555
- child.stderr?.on('data', (data) => {
48556
- stderr += data.toString()
48557
- })
48034
+ ## Core CLI Commands
48558
48035
 
48559
- child.on('close', (code) => {
48560
- cleanup()
48561
- if (!resolved) {
48562
- resolved = true
48563
- resolve({ exitCode: code || 0, stdout, stderr })
48564
- }
48565
- })
48036
+ ### current-task (Primary Agent Workflow)
48566
48037
 
48567
- child.on('error', (err) => {
48568
- cleanup()
48569
- if (!resolved) {
48570
- resolved = true
48571
- resolve({ exitCode: 1, stdout, stderr: err.message || '' })
48572
- }
48573
- })
48038
+ When running inside a Fulcrum task worktree, use these commands to manage the current task:
48574
48039
 
48575
- // Add timeout protection to prevent hanging
48576
- const timeoutId = setTimeout(() => {
48577
- if (!resolved) {
48578
- resolved = true
48579
- log(\`Command timeout: \${FULCRUM_CMD} \${args.join(' ')}\`)
48580
- child.kill('SIGTERM')
48581
- // Schedule SIGKILL if process doesn't exit after SIGTERM
48582
- killTimeoutId = setTimeout(() => {
48583
- if (!processExited) {
48584
- log(\`Process didn't exit after SIGTERM, sending SIGKILL\`)
48585
- child.kill('SIGKILL')
48586
- }
48587
- }, 2000)
48588
- resolve({ exitCode: -1, stdout, stderr: \`Command timed out after \${FULCRUM_COMMAND_TIMEOUT_MS}ms\` })
48589
- }
48590
- }, FULCRUM_COMMAND_TIMEOUT_MS)
48040
+ \`\`\`bash
48041
+ # Get current task info (JSON output)
48042
+ fulcrum current-task
48591
48043
 
48592
- // Clear timeout if command completes
48593
- child.on('exit', () => clearTimeout(timeoutId))
48594
- })
48595
- }
48044
+ # Update task status
48045
+ fulcrum current-task in-progress # Mark as IN_PROGRESS
48046
+ fulcrum current-task review # Mark as IN_REVIEW (notifies user)
48047
+ fulcrum current-task done # Mark as DONE
48048
+ fulcrum current-task cancel # Mark as CANCELED
48596
48049
 
48597
- let mainSessionId: string | null = null
48598
- const subagentSessions = new Set<string>()
48599
- let pendingIdleTimer: ReturnType<typeof setTimeout> | null = null
48600
- let activityVersion = 0
48601
- let lastStatus: "in-progress" | "review" | "" = ""
48050
+ # Link a GitHub PR to the current task
48051
+ fulcrum current-task pr <github-pr-url>
48602
48052
 
48603
- const FULCRUM_CMD = "fulcrum"
48604
- const IDLE_CONFIRMATION_DELAY_MS = 1500
48605
- const FULCRUM_COMMAND_TIMEOUT_MS = 10000
48606
- const STATUS_CHANGE_DEBOUNCE_MS = 500
48053
+ # Link a Linear ticket to the current task
48054
+ fulcrum current-task linear <linear-issue-url>
48607
48055
 
48608
- let deferredContextCheck: Promise<boolean> | null = null
48609
- let isFulcrumContext: boolean | null = null
48610
- let pendingStatusCommand: Promise<{ exitCode: number; stdout: string; stderr: string }> | null = null
48056
+ # Add arbitrary URL links to the task
48057
+ fulcrum current-task link <url> # Add link (auto-detects type/label)
48058
+ fulcrum current-task link <url> --label "Docs" # Add link with custom label
48059
+ fulcrum current-task link # List all links
48060
+ fulcrum current-task link --remove <url-or-id> # Remove a link
48061
+ \`\`\`
48611
48062
 
48612
- export const FulcrumPlugin: Plugin = async ({ $, directory }) => {
48613
- log("Plugin initializing...")
48063
+ ### tasks
48614
48064
 
48615
- if (process.env.FULCRUM_TASK_ID) {
48616
- isFulcrumContext = true
48617
- log("Fulcrum context detected via env var")
48618
- } else {
48619
- deferredContextCheck = Promise.all([
48620
- $\`\${FULCRUM_CMD} --version\`.quiet().nothrow().text(),
48621
- runFulcrumCommand(['current-task', '--path', directory]),
48622
- ])
48623
- .then(([versionResult, taskResult]) => {
48624
- if (!versionResult) {
48625
- log("Fulcrum CLI not found")
48626
- return false
48627
- }
48628
- const inContext = taskResult.exitCode === 0
48629
- log(inContext ? "Fulcrum context active" : "Not a Fulcrum context")
48630
- return inContext
48631
- })
48632
- .catch(() => {
48633
- log("Fulcrum check failed")
48634
- return false
48635
- })
48636
- }
48065
+ Manage tasks across the system:
48637
48066
 
48638
- log("Plugin hooks registered")
48067
+ \`\`\`bash
48068
+ # List all tasks
48069
+ fulcrum tasks list
48070
+ fulcrum tasks list --status=IN_PROGRESS # Filter by status
48071
+ fulcrum tasks list --search="ocai" # Search by title, labels
48072
+ fulcrum tasks list --label="bug" # Filter by label
48639
48073
 
48640
- const checkContext = async (): Promise<boolean> => {
48641
- if (isFulcrumContext !== null) return isFulcrumContext
48642
- if (deferredContextCheck) {
48643
- isFulcrumContext = await deferredContextCheck
48644
- deferredContextCheck = null
48645
- return isFulcrumContext
48646
- }
48647
- return false
48648
- }
48074
+ # List all labels in use
48075
+ fulcrum tasks labels # Show all labels with counts
48076
+ fulcrum tasks labels --search="comm" # Find labels matching substring
48649
48077
 
48650
- const cancelPendingIdle = () => {
48651
- if (pendingIdleTimer) {
48652
- clearTimeout(pendingIdleTimer)
48653
- pendingIdleTimer = null
48654
- log("Cancelled pending idle transition")
48655
- }
48656
- }
48078
+ # Get a specific task
48079
+ fulcrum tasks get <task-id>
48657
48080
 
48658
- const setStatus = (status: "in-progress" | "review") => {
48659
- if (status === lastStatus) return
48081
+ # Create a new task
48082
+ fulcrum tasks create --title="My Task" --repo=/path/to/repo
48660
48083
 
48661
- cancelPendingIdle()
48084
+ # Update task metadata
48085
+ fulcrum tasks update <task-id> --title="New Title"
48662
48086
 
48663
- if (pendingStatusCommand) {
48664
- log(\`Status change already in progress, will retry after \${STATUS_CHANGE_DEBOUNCE_MS}ms\`)
48665
- setTimeout(() => setStatus(status), STATUS_CHANGE_DEBOUNCE_MS)
48666
- return
48667
- }
48087
+ # Move task to different status
48088
+ fulcrum tasks move <task-id> --status=IN_REVIEW
48668
48089
 
48669
- lastStatus = status
48090
+ # Delete a task
48091
+ fulcrum tasks delete <task-id>
48092
+ fulcrum tasks delete <task-id> --delete-worktree # Also delete worktree
48093
+ \`\`\`
48670
48094
 
48671
- ;(async () => {
48672
- try {
48673
- log(\`Setting status: \${status}\`)
48674
- pendingStatusCommand = runFulcrumCommand(['current-task', status, '--path', directory])
48675
- const res = await pendingStatusCommand
48676
- pendingStatusCommand = null
48095
+ ### notifications
48677
48096
 
48678
- if (res.exitCode !== 0) {
48679
- log(\`Status update failed: exitCode=\${res.exitCode}, stderr=\${res.stderr}\`)
48680
- }
48681
- } catch (e) {
48682
- log(\`Status update error: \${e}\`)
48683
- pendingStatusCommand = null
48684
- }
48685
- })()
48686
- }
48097
+ Send notifications to the user:
48687
48098
 
48688
- const scheduleIdleTransition = () => {
48689
- cancelPendingIdle()
48690
- const currentVersion = ++activityVersion
48099
+ \`\`\`bash
48100
+ # Send a notification
48101
+ fulcrum notify "Title" "Message body"
48691
48102
 
48692
- pendingIdleTimer = setTimeout(() => {
48693
- if (activityVersion !== currentVersion) {
48694
- log(
48695
- \`Stale idle transition (version \${currentVersion} vs \${activityVersion})\`,
48696
- )
48697
- return
48698
- }
48699
- setStatus("review")
48700
- }, IDLE_CONFIRMATION_DELAY_MS)
48103
+ # Check notification settings
48104
+ fulcrum notifications
48701
48105
 
48702
- log(
48703
- \`Scheduled idle transition (version \${currentVersion}, delay \${IDLE_CONFIRMATION_DELAY_MS}ms)\`,
48704
- )
48705
- }
48106
+ # Enable/disable notifications
48107
+ fulcrum notifications enable
48108
+ fulcrum notifications disable
48706
48109
 
48707
- const recordActivity = (reason: string) => {
48708
- activityVersion++
48709
- cancelPendingIdle()
48710
- log(\`Activity: \${reason} (version now \${activityVersion})\`)
48711
- }
48110
+ # Test a notification channel
48111
+ fulcrum notifications test sound
48112
+ fulcrum notifications test slack
48113
+ fulcrum notifications test discord
48114
+ fulcrum notifications test pushover
48712
48115
 
48713
- return {
48714
- "chat.message": async (_input, output) => {
48715
- if (!(await checkContext())) return
48116
+ # Configure a channel
48117
+ fulcrum notifications set slack webhookUrl <url>
48118
+ \`\`\`
48716
48119
 
48717
- if (output.message.role === "user") {
48718
- recordActivity("user message")
48719
- setStatus("in-progress")
48720
- } else if (output.message.role === "assistant") {
48721
- recordActivity("assistant message")
48722
- }
48723
- },
48120
+ ### Server Management
48724
48121
 
48725
- event: async ({ event }) => {
48726
- if (!NOISY_EVENTS.has(event.type)) {
48727
- log(\`Event: \${event.type}\`)
48728
- }
48122
+ \`\`\`bash
48123
+ fulcrum up # Start Fulcrum server daemon
48124
+ fulcrum down # Stop Fulcrum server
48125
+ fulcrum status # Check if server is running
48126
+ fulcrum health # Check server health
48127
+ \`\`\`
48729
48128
 
48730
- if (!(await checkContext())) return
48129
+ ### Git Operations
48731
48130
 
48732
- const props = (event.properties as Record<string, unknown>) || {}
48131
+ \`\`\`bash
48132
+ fulcrum git status # Git status for current worktree
48133
+ fulcrum git diff # Git diff for current worktree
48134
+ fulcrum worktrees list # List all worktrees
48135
+ \`\`\`
48733
48136
 
48734
- if (event.type === "session.created") {
48735
- const info = (props.info as Record<string, unknown>) || {}
48736
- const sessionId = info.id as string | undefined
48737
- const parentId = info.parentID as string | undefined
48137
+ ### projects
48738
48138
 
48739
- if (parentId) {
48740
- if (sessionId) subagentSessions.add(sessionId)
48741
- log(\`Subagent session tracked: \${sessionId} (parent: \${parentId})\`)
48742
- } else if (!mainSessionId && sessionId) {
48743
- mainSessionId = sessionId
48744
- log(\`Main session set: \${mainSessionId}\`)
48745
- }
48139
+ Manage projects (repositories with metadata):
48746
48140
 
48747
- recordActivity("session.created")
48748
- setStatus("in-progress")
48749
- return
48750
- }
48141
+ \`\`\`bash
48142
+ # List all projects
48143
+ fulcrum projects list
48144
+ fulcrum projects list --status=active # Filter by status (active, archived)
48751
48145
 
48752
- const status = props.status as Record<string, unknown> | undefined
48753
- if (
48754
- (event.type === "session.status" && status?.type === "busy") ||
48755
- event.type.startsWith("tool.execute")
48756
- ) {
48757
- recordActivity(event.type)
48758
- return
48759
- }
48146
+ # Get project details
48147
+ fulcrum projects get <project-id>
48760
48148
 
48761
- if (
48762
- event.type === "session.idle" ||
48763
- (event.type === "session.status" && status?.type === "idle")
48764
- ) {
48765
- const info = (props.info as Record<string, unknown>) || {}
48766
- const sessionId =
48767
- (props.sessionID as string) || (info.id as string) || null
48149
+ # Create a new project
48150
+ fulcrum projects create --name="My Project" --path=/path/to/repo # From local path
48151
+ fulcrum projects create --name="My Project" --url=https://github.com/... # Clone from URL
48152
+ fulcrum projects create --name="My Project" --repository-id=<repo-id> # Link existing repo
48768
48153
 
48769
- if (sessionId && subagentSessions.has(sessionId)) {
48770
- log(\`Ignoring subagent idle: \${sessionId}\`)
48771
- return
48772
- }
48154
+ # Update project
48155
+ fulcrum projects update <project-id> --name="New Name"
48156
+ fulcrum projects update <project-id> --status=archived
48773
48157
 
48774
- if (mainSessionId && sessionId && sessionId !== mainSessionId) {
48775
- log(\`Ignoring non-main idle: \${sessionId} (main: \${mainSessionId})\`)
48776
- return
48777
- }
48158
+ # Delete project
48159
+ fulcrum projects delete <project-id>
48160
+ fulcrum projects delete <project-id> --delete-directory # Also delete directory
48161
+ fulcrum projects delete <project-id> --delete-app # Also delete linked app
48778
48162
 
48779
- log(\`Main session idle detected: \${sessionId}\`)
48780
- scheduleIdleTransition()
48781
- }
48782
- },
48783
- }
48784
- }
48785
- `;
48163
+ # Scan for git repositories
48164
+ fulcrum projects scan # Scan default directory
48165
+ fulcrum projects scan --directory=/path # Scan specific directory
48786
48166
 
48787
- // cli/src/commands/opencode.ts
48788
- var OPENCODE_DIR = join4(homedir2(), ".opencode");
48789
- var OPENCODE_CONFIG_PATH = join4(OPENCODE_DIR, "opencode.json");
48790
- var PLUGIN_DIR = join4(homedir2(), ".config", "opencode", "plugin");
48791
- var PLUGIN_PATH = join4(PLUGIN_DIR, "fulcrum.ts");
48792
- var FULCRUM_MCP_CONFIG = {
48793
- type: "local",
48794
- command: ["fulcrum", "mcp"],
48795
- enabled: true
48796
- };
48797
- async function handleOpenCodeCommand(action) {
48798
- if (action === "install") {
48799
- await installOpenCodeIntegration();
48800
- return;
48801
- }
48802
- if (action === "uninstall") {
48803
- await uninstallOpenCodeIntegration();
48804
- return;
48805
- }
48806
- throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum opencode install | fulcrum opencode uninstall", ExitCodes.INVALID_ARGS);
48807
- }
48808
- async function installOpenCodeIntegration() {
48809
- try {
48810
- console.log("Installing OpenCode plugin...");
48811
- mkdirSync3(PLUGIN_DIR, { recursive: true });
48812
- writeFileSync3(PLUGIN_PATH, fulcrum_opencode_default, "utf-8");
48813
- console.log("\u2713 Installed plugin at " + PLUGIN_PATH);
48814
- console.log("Configuring MCP server...");
48815
- const mcpConfigured = addMcpServer();
48816
- console.log("");
48817
- if (mcpConfigured) {
48818
- console.log("Installation complete! Restart OpenCode to apply changes.");
48819
- } else {
48820
- console.log("Plugin installed, but MCP configuration was skipped.");
48821
- console.log("Please add the MCP server manually (see above).");
48822
- }
48823
- } catch (err) {
48824
- throw new CliError("INSTALL_FAILED", `Failed to install OpenCode integration: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
48825
- }
48826
- }
48827
- async function uninstallOpenCodeIntegration() {
48828
- try {
48829
- let removedPlugin = false;
48830
- let removedMcp = false;
48831
- if (existsSync4(PLUGIN_PATH)) {
48832
- unlinkSync2(PLUGIN_PATH);
48833
- console.log("\u2713 Removed plugin from " + PLUGIN_PATH);
48834
- removedPlugin = true;
48835
- } else {
48836
- console.log("\u2022 Plugin not found (already removed)");
48837
- }
48838
- removedMcp = removeMcpServer();
48839
- if (!removedPlugin && !removedMcp) {
48840
- console.log("Nothing to uninstall.");
48841
- } else {
48842
- console.log("");
48843
- console.log("Uninstall complete! Restart OpenCode to apply changes.");
48844
- }
48845
- } catch (err) {
48846
- throw new CliError("UNINSTALL_FAILED", `Failed to uninstall OpenCode integration: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
48847
- }
48848
- }
48849
- function getMcpObject(config3) {
48850
- const mcp = config3.mcp;
48851
- if (mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
48852
- return mcp;
48853
- }
48854
- return {};
48855
- }
48856
- function addMcpServer() {
48857
- mkdirSync3(OPENCODE_DIR, { recursive: true });
48858
- let config3 = {};
48859
- if (existsSync4(OPENCODE_CONFIG_PATH)) {
48860
- try {
48861
- const content = readFileSync4(OPENCODE_CONFIG_PATH, "utf-8");
48862
- config3 = JSON.parse(content);
48863
- } catch {
48864
- console.log("\u26A0 Could not parse existing opencode.json, skipping MCP configuration");
48865
- console.log(" Add manually to ~/.opencode/opencode.json:");
48866
- console.log(' "mcp": { "fulcrum": { "type": "local", "command": ["fulcrum", "mcp"], "enabled": true } }');
48867
- return false;
48868
- }
48869
- }
48870
- const mcp = getMcpObject(config3);
48871
- if (mcp.fulcrum) {
48872
- console.log("\u2022 MCP server already configured, preserving existing configuration");
48873
- return true;
48874
- }
48875
- if (existsSync4(OPENCODE_CONFIG_PATH)) {
48876
- copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_CONFIG_PATH + ".backup");
48877
- }
48878
- config3.mcp = {
48879
- ...mcp,
48880
- fulcrum: FULCRUM_MCP_CONFIG
48881
- };
48882
- const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
48883
- try {
48884
- writeFileSync3(tempPath, JSON.stringify(config3, null, 2), "utf-8");
48885
- renameSync(tempPath, OPENCODE_CONFIG_PATH);
48886
- } catch (error46) {
48887
- try {
48888
- if (existsSync4(tempPath)) {
48889
- unlinkSync2(tempPath);
48890
- }
48891
- } catch {}
48892
- throw error46;
48893
- }
48894
- console.log("\u2713 Added MCP server to " + OPENCODE_CONFIG_PATH);
48895
- return true;
48896
- }
48897
- function removeMcpServer() {
48898
- if (!existsSync4(OPENCODE_CONFIG_PATH)) {
48899
- console.log("\u2022 MCP config not found (already removed)");
48900
- return false;
48901
- }
48902
- let config3;
48903
- try {
48904
- const content = readFileSync4(OPENCODE_CONFIG_PATH, "utf-8");
48905
- config3 = JSON.parse(content);
48906
- } catch {
48907
- console.log("\u26A0 Could not parse opencode.json, skipping MCP removal");
48908
- return false;
48909
- }
48910
- const mcp = getMcpObject(config3);
48911
- if (!mcp.fulcrum) {
48912
- console.log("\u2022 MCP server not configured (already removed)");
48913
- return false;
48914
- }
48915
- copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_CONFIG_PATH + ".backup");
48916
- delete mcp.fulcrum;
48917
- if (Object.keys(mcp).length === 0) {
48918
- delete config3.mcp;
48919
- } else {
48920
- config3.mcp = mcp;
48921
- }
48922
- const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
48923
- try {
48924
- writeFileSync3(tempPath, JSON.stringify(config3, null, 2), "utf-8");
48925
- renameSync(tempPath, OPENCODE_CONFIG_PATH);
48926
- } catch (error46) {
48927
- try {
48928
- if (existsSync4(tempPath)) {
48929
- unlinkSync2(tempPath);
48930
- }
48931
- } catch {}
48932
- throw error46;
48933
- }
48934
- console.log("\u2713 Removed MCP server from " + OPENCODE_CONFIG_PATH);
48935
- return true;
48936
- }
48167
+ # Manage project links (URLs)
48168
+ fulcrum projects links list <project-id>
48169
+ fulcrum projects links add <project-id> <url> --label="Custom Label"
48170
+ fulcrum projects links remove <project-id> <link-id>
48171
+ \`\`\`
48937
48172
 
48938
- // cli/src/commands/claude.ts
48939
- init_errors();
48940
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, existsSync as existsSync5, rmSync } from "fs";
48941
- import { homedir as homedir3 } from "os";
48942
- import { join as join5 } from "path";
48173
+ ### repositories
48943
48174
 
48944
- // plugins/fulcrum/.claude-plugin/plugin.json
48945
- var plugin_default = `{
48946
- "name": "fulcrum",
48947
- "description": "Fulcrum task orchestration for Claude Code",
48948
- "version": "1.2.3",
48949
- "author": {
48950
- "name": "Fulcrum"
48951
- },
48952
- "hooks": "./hooks/hooks.json"
48953
- }
48954
- `;
48175
+ Manage repositories (code sources that can be linked to projects):
48955
48176
 
48956
- // plugins/fulcrum/hooks/hooks.json
48957
- var hooks_default = `{
48958
- "hooks": {
48959
- "Stop": [
48960
- {
48961
- "hooks": [
48962
- {
48963
- "type": "command",
48964
- "command": "fulcrum current-task review 2>/dev/null || true"
48965
- }
48966
- ]
48967
- }
48968
- ],
48969
- "UserPromptSubmit": [
48970
- {
48971
- "hooks": [
48972
- {
48973
- "type": "command",
48974
- "command": "fulcrum current-task in-progress 2>/dev/null || true"
48975
- }
48976
- ]
48977
- }
48978
- ]
48979
- }
48980
- }
48981
- `;
48177
+ \`\`\`bash
48178
+ # List repositories
48179
+ fulcrum repositories list
48180
+ fulcrum repositories list --orphans # Unlinked repos only
48181
+ fulcrum repositories list --project-id=<id> # Filter by project
48982
48182
 
48983
- // plugins/fulcrum/mcp.json
48984
- var mcp_default = `{
48985
- "mcpServers": {
48986
- "fulcrum": {
48987
- "command": "fulcrum",
48988
- "args": ["mcp"]
48989
- }
48990
- }
48991
- }
48992
- `;
48183
+ # Get repository details
48184
+ fulcrum repositories get <repo-id>
48993
48185
 
48994
- // plugins/fulcrum/commands/pr.md
48995
- var pr_default = `---
48996
- description: Link a GitHub PR to the current fulcrum task
48997
- ---
48998
- Link the PR to this task: \`fulcrum current-task pr $ARGUMENTS\`
48186
+ # Add a new repository from local path
48187
+ fulcrum repositories add --path=/path/to/repo
48188
+ fulcrum repositories add --path=/path/to/repo --display-name="My Repo"
48999
48189
 
49000
- This enables auto-completion when the PR is merged.
49001
- `;
48190
+ # Update repository
48191
+ fulcrum repositories update <repo-id> --display-name="New Name"
48192
+ fulcrum repositories update <repo-id> --default-agent=claude
48193
+ fulcrum repositories update <repo-id> --startup-script="mise run dev"
48194
+ fulcrum repositories update <repo-id> --copy-files=".env,.env.local"
49002
48195
 
49003
- // plugins/fulcrum/commands/task-info.md
49004
- var task_info_default = `---
49005
- description: Show current fulcrum task info
49006
- ---
49007
- Show current task details: \`fulcrum current-task\`
49008
- `;
48196
+ # Delete orphaned repository (fails if linked to a project)
48197
+ fulcrum repositories delete <repo-id>
49009
48198
 
49010
- // plugins/fulcrum/commands/notify.md
49011
- var notify_default = `---
49012
- description: Send a notification to the user
49013
- ---
49014
- Send a notification: \`fulcrum notify $ARGUMENTS\`
48199
+ # Link repository to project (repos can only be linked to one project)
48200
+ fulcrum repositories link <repo-id> <project-id>
48201
+ fulcrum repositories link <repo-id> <project-id> --as-primary
48202
+ fulcrum repositories link <repo-id> <project-id> --force # Move from existing project
49015
48203
 
49016
- Format: fulcrum notify "Title" "Message body"
49017
- `;
48204
+ # Unlink repository from project
48205
+ fulcrum repositories unlink <repo-id> <project-id>
48206
+ \`\`\`
49018
48207
 
49019
- // plugins/fulcrum/commands/linear.md
49020
- var linear_default = `---
49021
- description: Link a Linear ticket to the current fulcrum task
49022
- ---
49023
- Link the Linear ticket to this task: \`fulcrum current-task linear $ARGUMENTS\`
49024
- `;
48208
+ ### apps
49025
48209
 
49026
- // plugins/fulcrum/commands/review.md
49027
- var review_default = `---
49028
- description: Mark the current fulcrum task as ready for review
49029
- ---
49030
- Mark this task ready for review: \`fulcrum current-task review\`
48210
+ Manage Docker Compose app deployments:
49031
48211
 
49032
- This sends a notification to the user.
49033
- `;
48212
+ \`\`\`bash
48213
+ # List all apps
48214
+ fulcrum apps list
48215
+ fulcrum apps list --status=running # Filter by status (stopped, building, running, failed)
49034
48216
 
49035
- // plugins/fulcrum/skills/vibora/SKILL.md
49036
- var SKILL_default = `---
49037
- name: fulcrum
49038
- description: Fulcrum is a terminal-first tool for orchestrating AI coding agents across isolated git worktrees. Use this skill when working in a Fulcrum task worktree or managing tasks.
49039
- ---
48217
+ # Get app details
48218
+ fulcrum apps get <app-id>
49040
48219
 
49041
- # Fulcrum - AI Agent Orchestration
48220
+ # Create a new app
48221
+ fulcrum apps create --name="My App" --repository-id=<repo-id>
48222
+ fulcrum apps create --name="My App" --repository-id=<repo-id> --branch=develop --auto-deploy
49042
48223
 
49043
- ## Overview
48224
+ # Update app
48225
+ fulcrum apps update <app-id> --name="New Name"
48226
+ fulcrum apps update <app-id> --auto-deploy # Enable auto-deploy
48227
+ fulcrum apps update <app-id> --no-cache # Enable no-cache builds
49044
48228
 
49045
- Fulcrum is a terminal-first tool for orchestrating AI coding agents (like Claude Code) across isolated git worktrees. Each task runs in its own worktree, enabling parallel work on multiple features or fixes without branch switching.
49046
-
49047
- **Philosophy:**
49048
- - Agents run natively in terminals - no abstraction layer or wrapper APIs
49049
- - Tasks create isolated git worktrees for clean separation
49050
- - Persistent terminals organized in tabs across tasks
49051
-
49052
- ## When to Use This Skill
49053
-
49054
- Use the Fulcrum CLI when:
49055
- - **Working in a task worktree** - Use \`current-task\` commands to manage your current task
49056
- - **Updating task status** - Mark tasks as in-progress, ready for review, done, or canceled
49057
- - **Linking PRs** - Associate a GitHub PR with the current task
49058
- - **Linking Linear tickets** - Connect a Linear issue to the current task
49059
- - **Linking URLs** - Attach any relevant URLs (design docs, specs, external resources) to the task
49060
- - **Sending notifications** - Alert the user when work is complete or needs attention
49061
-
49062
- Use the Fulcrum MCP tools when:
49063
- - **Executing commands remotely** - Run shell commands on the Fulcrum server from Claude Desktop
49064
- - **Stateful workflows** - Use persistent sessions to maintain environment variables and working directory across commands
49065
-
49066
- ## Core CLI Commands
49067
-
49068
- ### current-task (Primary Agent Workflow)
49069
-
49070
- When running inside a Fulcrum task worktree, use these commands to manage the current task:
49071
-
49072
- \`\`\`bash
49073
- # Get current task info (JSON output)
49074
- fulcrum current-task
49075
-
49076
- # Update task status
49077
- fulcrum current-task in-progress # Mark as IN_PROGRESS
49078
- fulcrum current-task review # Mark as IN_REVIEW (notifies user)
49079
- fulcrum current-task done # Mark as DONE
49080
- fulcrum current-task cancel # Mark as CANCELED
49081
-
49082
- # Link a GitHub PR to the current task
49083
- fulcrum current-task pr <github-pr-url>
49084
-
49085
- # Link a Linear ticket to the current task
49086
- fulcrum current-task linear <linear-issue-url>
49087
-
49088
- # Add arbitrary URL links to the task
49089
- fulcrum current-task link <url> # Add link (auto-detects type/label)
49090
- fulcrum current-task link <url> --label "Docs" # Add link with custom label
49091
- fulcrum current-task link # List all links
49092
- fulcrum current-task link --remove <url-or-id> # Remove a link
49093
- \`\`\`
49094
-
49095
- ### tasks
49096
-
49097
- Manage tasks across the system:
49098
-
49099
- \`\`\`bash
49100
- # List all tasks
49101
- fulcrum tasks list
49102
- fulcrum tasks list --status=IN_PROGRESS # Filter by status
49103
- fulcrum tasks list --search="ocai" # Search by title, labels
49104
- fulcrum tasks list --label="bug" # Filter by label
49105
-
49106
- # List all labels in use
49107
- fulcrum tasks labels # Show all labels with counts
49108
- fulcrum tasks labels --search="comm" # Find labels matching substring
49109
-
49110
- # Get a specific task
49111
- fulcrum tasks get <task-id>
49112
-
49113
- # Create a new task
49114
- fulcrum tasks create --title="My Task" --repo=/path/to/repo
49115
-
49116
- # Update task metadata
49117
- fulcrum tasks update <task-id> --title="New Title"
49118
-
49119
- # Move task to different status
49120
- fulcrum tasks move <task-id> --status=IN_REVIEW
49121
-
49122
- # Delete a task
49123
- fulcrum tasks delete <task-id>
49124
- fulcrum tasks delete <task-id> --delete-worktree # Also delete worktree
49125
- \`\`\`
49126
-
49127
- ### notifications
49128
-
49129
- Send notifications to the user:
49130
-
49131
- \`\`\`bash
49132
- # Send a notification
49133
- fulcrum notify "Title" "Message body"
49134
-
49135
- # Check notification settings
49136
- fulcrum notifications
49137
-
49138
- # Enable/disable notifications
49139
- fulcrum notifications enable
49140
- fulcrum notifications disable
49141
-
49142
- # Test a notification channel
49143
- fulcrum notifications test sound
49144
- fulcrum notifications test slack
49145
- fulcrum notifications test discord
49146
- fulcrum notifications test pushover
49147
-
49148
- # Configure a channel
49149
- fulcrum notifications set slack webhookUrl <url>
49150
- \`\`\`
49151
-
49152
- ### Server Management
49153
-
49154
- \`\`\`bash
49155
- fulcrum up # Start Fulcrum server daemon
49156
- fulcrum down # Stop Fulcrum server
49157
- fulcrum status # Check if server is running
49158
- fulcrum health # Check server health
49159
- \`\`\`
49160
-
49161
- ### Git Operations
49162
-
49163
- \`\`\`bash
49164
- fulcrum git status # Git status for current worktree
49165
- fulcrum git diff # Git diff for current worktree
49166
- fulcrum worktrees list # List all worktrees
49167
- \`\`\`
49168
-
49169
- ### projects
49170
-
49171
- Manage projects (repositories with metadata):
49172
-
49173
- \`\`\`bash
49174
- # List all projects
49175
- fulcrum projects list
49176
- fulcrum projects list --status=active # Filter by status (active, archived)
49177
-
49178
- # Get project details
49179
- fulcrum projects get <project-id>
49180
-
49181
- # Create a new project
49182
- fulcrum projects create --name="My Project" --path=/path/to/repo # From local path
49183
- fulcrum projects create --name="My Project" --url=https://github.com/... # Clone from URL
49184
- fulcrum projects create --name="My Project" --repository-id=<repo-id> # Link existing repo
49185
-
49186
- # Update project
49187
- fulcrum projects update <project-id> --name="New Name"
49188
- fulcrum projects update <project-id> --status=archived
49189
-
49190
- # Delete project
49191
- fulcrum projects delete <project-id>
49192
- fulcrum projects delete <project-id> --delete-directory # Also delete directory
49193
- fulcrum projects delete <project-id> --delete-app # Also delete linked app
49194
-
49195
- # Scan for git repositories
49196
- fulcrum projects scan # Scan default directory
49197
- fulcrum projects scan --directory=/path # Scan specific directory
49198
-
49199
- # Manage project links (URLs)
49200
- fulcrum projects links list <project-id>
49201
- fulcrum projects links add <project-id> <url> --label="Custom Label"
49202
- fulcrum projects links remove <project-id> <link-id>
49203
- \`\`\`
49204
-
49205
- ### repositories
49206
-
49207
- Manage repositories (code sources that can be linked to projects):
49208
-
49209
- \`\`\`bash
49210
- # List repositories
49211
- fulcrum repositories list
49212
- fulcrum repositories list --orphans # Unlinked repos only
49213
- fulcrum repositories list --project-id=<id> # Filter by project
49214
-
49215
- # Get repository details
49216
- fulcrum repositories get <repo-id>
49217
-
49218
- # Add a new repository from local path
49219
- fulcrum repositories add --path=/path/to/repo
49220
- fulcrum repositories add --path=/path/to/repo --display-name="My Repo"
49221
-
49222
- # Update repository
49223
- fulcrum repositories update <repo-id> --display-name="New Name"
49224
- fulcrum repositories update <repo-id> --default-agent=claude
49225
- fulcrum repositories update <repo-id> --startup-script="mise run dev"
49226
- fulcrum repositories update <repo-id> --copy-files=".env,.env.local"
49227
-
49228
- # Delete orphaned repository (fails if linked to a project)
49229
- fulcrum repositories delete <repo-id>
49230
-
49231
- # Link repository to project (repos can only be linked to one project)
49232
- fulcrum repositories link <repo-id> <project-id>
49233
- fulcrum repositories link <repo-id> <project-id> --as-primary
49234
- fulcrum repositories link <repo-id> <project-id> --force # Move from existing project
49235
-
49236
- # Unlink repository from project
49237
- fulcrum repositories unlink <repo-id> <project-id>
49238
- \`\`\`
49239
-
49240
- ### apps
49241
-
49242
- Manage Docker Compose app deployments:
49243
-
49244
- \`\`\`bash
49245
- # List all apps
49246
- fulcrum apps list
49247
- fulcrum apps list --status=running # Filter by status (stopped, building, running, failed)
49248
-
49249
- # Get app details
49250
- fulcrum apps get <app-id>
49251
-
49252
- # Create a new app
49253
- fulcrum apps create --name="My App" --repository-id=<repo-id>
49254
- fulcrum apps create --name="My App" --repository-id=<repo-id> --branch=develop --auto-deploy
49255
-
49256
- # Update app
49257
- fulcrum apps update <app-id> --name="New Name"
49258
- fulcrum apps update <app-id> --auto-deploy # Enable auto-deploy
49259
- fulcrum apps update <app-id> --no-cache # Enable no-cache builds
49260
-
49261
- # Deploy an app
49262
- fulcrum apps deploy <app-id>
48229
+ # Deploy an app
48230
+ fulcrum apps deploy <app-id>
49263
48231
 
49264
48232
  # Stop an app
49265
48233
  fulcrum apps stop <app-id>
@@ -49361,257 +48329,1395 @@ Fulcrum provides a comprehensive set of MCP tools for AI agents. Use \`search_to
49361
48329
 
49362
48330
  ### Tool Discovery
49363
48331
 
49364
- #### search_tools
48332
+ #### search_tools
48333
+
48334
+ Search for available tools by keyword or category:
48335
+
48336
+ \`\`\`json
48337
+ {
48338
+ "query": "deploy", // Optional: Search term
48339
+ "category": "apps" // Optional: Filter by category
48340
+ }
48341
+ \`\`\`
48342
+
48343
+ **Categories:** core, tasks, projects, repositories, apps, filesystem, git, notifications, exec
48344
+
48345
+ **Example Usage:**
48346
+ \`\`\`
48347
+ search_tools { query: "project create" }
48348
+ \u2192 Returns tools for creating projects
48349
+
48350
+ search_tools { category: "filesystem" }
48351
+ \u2192 Returns all filesystem tools
48352
+ \`\`\`
48353
+
48354
+ ### Task Tools
48355
+
48356
+ - \`list_tasks\` - List tasks with flexible filtering (search, labels, statuses, date range, overdue)
48357
+ - \`get_task\` - Get task details by ID
48358
+ - \`create_task\` - Create a new task with worktree
48359
+ - \`update_task\` - Update task metadata
48360
+ - \`delete_task\` - Delete a task
48361
+ - \`move_task\` - Move task to different status
48362
+ - \`add_task_link\` - Add URL link to task
48363
+ - \`remove_task_link\` - Remove link from task
48364
+ - \`list_task_links\` - List all task links
48365
+ - \`add_task_label\` - Add a label to a task (returns similar labels to catch typos)
48366
+ - \`remove_task_label\` - Remove a label from a task
48367
+ - \`set_task_due_date\` - Set or clear task due date
48368
+ - \`list_labels\` - List all unique labels in use with optional search
48369
+
48370
+ #### Task Search and Filtering
48371
+
48372
+ The \`list_tasks\` tool supports powerful filtering for AI agents:
48373
+
48374
+ \`\`\`json
48375
+ {
48376
+ "search": "ocai", // Text search across title, labels, project name
48377
+ "labels": ["bug", "urgent"], // Filter by multiple labels (OR logic)
48378
+ "statuses": ["TO_DO", "IN_PROGRESS"], // Filter by multiple statuses (OR logic)
48379
+ "dueDateStart": "2026-01-18", // Start of date range
48380
+ "dueDateEnd": "2026-01-25", // End of date range
48381
+ "overdue": true // Only show overdue tasks
48382
+ }
48383
+ \`\`\`
48384
+
48385
+ #### Label Discovery
48386
+
48387
+ Use \`list_labels\` to discover exact label names before filtering:
48388
+
48389
+ \`\`\`json
48390
+ // Find labels matching "communication"
48391
+ { "search": "communication" }
48392
+ // Returns: [{ "name": "communication required", "count": 5 }]
48393
+ \`\`\`
48394
+
48395
+ This helps handle typos and variations - search first, then use the exact label name.
48396
+
48397
+ ### Project Tools
48398
+
48399
+ - \`list_projects\` - List all projects
48400
+ - \`get_project\` - Get project details
48401
+ - \`create_project\` - Create from path, URL, or existing repo
48402
+ - \`update_project\` - Update name, description, status
48403
+ - \`delete_project\` - Delete project and optionally directory/app
48404
+ - \`scan_projects\` - Scan directory for git repos
48405
+ - \`list_project_links\` - List all URL links attached to a project
48406
+ - \`add_project_link\` - Add a URL link to a project (auto-detects type)
48407
+ - \`remove_project_link\` - Remove a URL link from a project
48408
+
48409
+ ### Repository Tools
48410
+
48411
+ - \`list_repositories\` - List all repositories (supports orphans filter)
48412
+ - \`get_repository\` - Get repository details by ID
48413
+ - \`add_repository\` - Add repository from local path
48414
+ - \`update_repository\` - Update repository metadata (name, agent, startup script)
48415
+ - \`delete_repository\` - Delete orphaned repository (fails if linked to project)
48416
+ - \`link_repository_to_project\` - Link repo to project (errors if already linked elsewhere)
48417
+ - \`unlink_repository_from_project\` - Unlink repo from project
48418
+
48419
+ ### App/Deployment Tools
48420
+
48421
+ - \`list_apps\` - List all deployed apps
48422
+ - \`get_app\` - Get app details with services
48423
+ - \`create_app\` - Create app for deployment
48424
+ - \`deploy_app\` - Trigger deployment
48425
+ - \`stop_app\` - Stop running app
48426
+ - \`get_app_logs\` - Get container logs
48427
+ - \`get_app_status\` - Get container status
48428
+ - \`list_deployments\` - Get deployment history
48429
+ - \`delete_app\` - Delete app
48430
+
48431
+ ### Filesystem Tools
48432
+
48433
+ Remote filesystem tools for working with files on the Fulcrum server. Useful when the agent runs on a different machine than the server (e.g., via SSH tunneling to Claude Desktop).
48434
+
48435
+ - \`list_directory\` - List directory contents
48436
+ - \`get_file_tree\` - Get recursive file tree
48437
+ - \`read_file\` - Read file contents (secured)
48438
+ - \`write_file\` - Write entire file content (secured)
48439
+ - \`edit_file\` - Edit file by replacing a unique string (secured)
48440
+ - \`file_stat\` - Get file/directory metadata
48441
+ - \`is_git_repo\` - Check if directory is git repo
48442
+
48443
+ ### Command Execution
48444
+
48445
+ When using Claude Desktop with Fulcrum's MCP server, you can execute commands on the remote Fulcrum server. This is useful when connecting to Fulcrum via SSH port forwarding.
48446
+
48447
+ #### execute_command
48448
+
48449
+ Execute shell commands with optional persistent session support:
48450
+
48451
+ \`\`\`json
48452
+ {
48453
+ "command": "echo hello world",
48454
+ "sessionId": "optional-session-id",
48455
+ "cwd": "/path/to/start",
48456
+ "timeout": 30000,
48457
+ "name": "my-session"
48458
+ }
48459
+ \`\`\`
48460
+
48461
+ **Parameters:**
48462
+ - \`command\` (required) \u2014 The shell command to execute
48463
+ - \`sessionId\` (optional) \u2014 Reuse a session to preserve env vars, cwd, and shell state
48464
+ - \`cwd\` (optional) \u2014 Initial working directory (only used when creating new session)
48465
+ - \`timeout\` (optional) \u2014 Timeout in milliseconds (default: 30000)
48466
+ - \`name\` (optional) \u2014 Session name for identification (only used when creating new session)
48467
+
48468
+ **Response:**
48469
+ \`\`\`json
48470
+ {
48471
+ "sessionId": "uuid",
48472
+ "stdout": "hello world",
48473
+ "stderr": "",
48474
+ "exitCode": 0,
48475
+ "timedOut": false
48476
+ }
48477
+ \`\`\`
48478
+
48479
+ ### Session Workflow Example
48480
+
48481
+ \`\`\`
48482
+ 1. First command (creates named session):
48483
+ execute_command { command: "cd /project && export API_KEY=secret", name: "dev-session" }
48484
+ \u2192 Returns sessionId: "abc-123"
48485
+
48486
+ 2. Subsequent commands (reuse session):
48487
+ execute_command { command: "echo $API_KEY", sessionId: "abc-123" }
48488
+ \u2192 Returns stdout: "secret" (env var preserved)
48489
+
48490
+ execute_command { command: "pwd", sessionId: "abc-123" }
48491
+ \u2192 Returns stdout: "/project" (cwd preserved)
48492
+
48493
+ 3. Rename session if needed:
48494
+ update_exec_session { sessionId: "abc-123", name: "new-name" }
48495
+
48496
+ 4. Cleanup when done:
48497
+ destroy_exec_session { sessionId: "abc-123" }
48498
+ \`\`\`
48499
+
48500
+ Sessions persist until manually destroyed.
48501
+
48502
+ ### list_exec_sessions
48503
+
48504
+ List all active sessions with their name, current working directory, and timestamps.
48505
+
48506
+ ### update_exec_session
48507
+
48508
+ Rename an existing session for identification.
48509
+
48510
+ ### destroy_exec_session
48511
+
48512
+ Clean up a session when you're done to free resources.
48513
+
48514
+ ## Best Practices
48515
+
48516
+ 1. **Use \`current-task\` inside worktrees** - It auto-detects which task you're in
48517
+ 2. **Link PRs immediately** - Run \`fulcrum current-task pr <url>\` right after creating a PR
48518
+ 3. **Link relevant resources** - Attach design docs, specs, or reference materials with \`fulcrum current-task link <url>\`
48519
+ 4. **Mark review when done** - \`fulcrum current-task review\` notifies the user
48520
+ 5. **Send notifications for blocking issues** - Keep the user informed of progress
48521
+ 6. **Name sessions for identification** - Use descriptive names to find sessions later
48522
+ 7. **Reuse sessions for related commands** - Preserve state across multiple execute_command calls
48523
+ 8. **Clean up sessions when done** - Use destroy_exec_session to free resources
48524
+ `;
48525
+
48526
+ // cli/src/commands/claude.ts
48527
+ var PLUGIN_DIR = join3(homedir2(), ".claude", "plugins", "fulcrum");
48528
+ var PLUGIN_FILES = [
48529
+ { path: ".claude-plugin/plugin.json", content: plugin_default },
48530
+ { path: "hooks/hooks.json", content: hooks_default },
48531
+ { path: ".mcp.json", content: _mcp_default },
48532
+ { path: "commands/pr.md", content: pr_default },
48533
+ { path: "commands/task-info.md", content: task_info_default },
48534
+ { path: "commands/notify.md", content: notify_default },
48535
+ { path: "commands/linear.md", content: linear_default },
48536
+ { path: "commands/review.md", content: review_default },
48537
+ { path: "skills/vibora/SKILL.md", content: SKILL_default }
48538
+ ];
48539
+ var PLUGIN_ID = "fulcrum@fulcrum";
48540
+ var INSTALLED_PLUGINS_PATH = join3(homedir2(), ".claude", "plugins", "installed_plugins.json");
48541
+ var CLAUDE_SETTINGS_PATH = join3(homedir2(), ".claude", "settings.json");
48542
+ function getPluginVersion() {
48543
+ try {
48544
+ const parsed = JSON.parse(plugin_default);
48545
+ return parsed.version || "1.0.0";
48546
+ } catch {
48547
+ return "1.0.0";
48548
+ }
48549
+ }
48550
+ function registerPlugin() {
48551
+ const version3 = getPluginVersion();
48552
+ const now = new Date().toISOString();
48553
+ let data = { version: 2, plugins: {} };
48554
+ if (existsSync3(INSTALLED_PLUGINS_PATH)) {
48555
+ try {
48556
+ data = JSON.parse(readFileSync4(INSTALLED_PLUGINS_PATH, "utf-8"));
48557
+ } catch {}
48558
+ }
48559
+ data.plugins = data.plugins || {};
48560
+ data.plugins[PLUGIN_ID] = [
48561
+ {
48562
+ scope: "user",
48563
+ installPath: PLUGIN_DIR,
48564
+ version: version3,
48565
+ installedAt: now,
48566
+ lastUpdated: now
48567
+ }
48568
+ ];
48569
+ const dir = INSTALLED_PLUGINS_PATH.substring(0, INSTALLED_PLUGINS_PATH.lastIndexOf("/"));
48570
+ mkdirSync3(dir, { recursive: true });
48571
+ writeFileSync3(INSTALLED_PLUGINS_PATH, JSON.stringify(data, null, 2), "utf-8");
48572
+ }
48573
+ function enablePlugin() {
48574
+ let data = {};
48575
+ if (existsSync3(CLAUDE_SETTINGS_PATH)) {
48576
+ try {
48577
+ data = JSON.parse(readFileSync4(CLAUDE_SETTINGS_PATH, "utf-8"));
48578
+ } catch {}
48579
+ }
48580
+ const enabledPlugins = data.enabledPlugins || {};
48581
+ enabledPlugins[PLUGIN_ID] = true;
48582
+ data.enabledPlugins = enabledPlugins;
48583
+ const dir = CLAUDE_SETTINGS_PATH.substring(0, CLAUDE_SETTINGS_PATH.lastIndexOf("/"));
48584
+ mkdirSync3(dir, { recursive: true });
48585
+ writeFileSync3(CLAUDE_SETTINGS_PATH, JSON.stringify(data, null, 2), "utf-8");
48586
+ }
48587
+ function unregisterPlugin() {
48588
+ if (!existsSync3(INSTALLED_PLUGINS_PATH))
48589
+ return;
48590
+ try {
48591
+ const data = JSON.parse(readFileSync4(INSTALLED_PLUGINS_PATH, "utf-8"));
48592
+ if (data.plugins && data.plugins[PLUGIN_ID]) {
48593
+ delete data.plugins[PLUGIN_ID];
48594
+ writeFileSync3(INSTALLED_PLUGINS_PATH, JSON.stringify(data, null, 2), "utf-8");
48595
+ }
48596
+ } catch {}
48597
+ }
48598
+ function disablePlugin() {
48599
+ if (!existsSync3(CLAUDE_SETTINGS_PATH))
48600
+ return;
48601
+ try {
48602
+ const data = JSON.parse(readFileSync4(CLAUDE_SETTINGS_PATH, "utf-8"));
48603
+ if (data.enabledPlugins && data.enabledPlugins[PLUGIN_ID] !== undefined) {
48604
+ delete data.enabledPlugins[PLUGIN_ID];
48605
+ writeFileSync3(CLAUDE_SETTINGS_PATH, JSON.stringify(data, null, 2), "utf-8");
48606
+ }
48607
+ } catch {}
48608
+ }
48609
+ async function handleClaudeCommand(action) {
48610
+ if (action === "install") {
48611
+ await installClaudePlugin();
48612
+ return;
48613
+ }
48614
+ if (action === "uninstall") {
48615
+ await uninstallClaudePlugin();
48616
+ return;
48617
+ }
48618
+ throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum claude install | fulcrum claude uninstall", ExitCodes.INVALID_ARGS);
48619
+ }
48620
+ function needsPluginUpdate() {
48621
+ const installedPluginJson = join3(PLUGIN_DIR, ".claude-plugin", "plugin.json");
48622
+ if (!existsSync3(installedPluginJson)) {
48623
+ return true;
48624
+ }
48625
+ try {
48626
+ const installed = JSON.parse(readFileSync4(installedPluginJson, "utf-8"));
48627
+ const bundled = JSON.parse(plugin_default);
48628
+ return installed.version !== bundled.version;
48629
+ } catch {
48630
+ return true;
48631
+ }
48632
+ }
48633
+ async function installClaudePlugin(options = {}) {
48634
+ const { silent = false } = options;
48635
+ const log = silent ? () => {} : console.log;
48636
+ try {
48637
+ log("Installing Claude Code plugin...");
48638
+ if (existsSync3(PLUGIN_DIR)) {
48639
+ log("\u2022 Removing existing plugin installation...");
48640
+ rmSync(PLUGIN_DIR, { recursive: true });
48641
+ }
48642
+ for (const file2 of PLUGIN_FILES) {
48643
+ const fullPath = join3(PLUGIN_DIR, file2.path);
48644
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
48645
+ mkdirSync3(dir, { recursive: true });
48646
+ writeFileSync3(fullPath, file2.content, "utf-8");
48647
+ }
48648
+ log("\u2713 Installed plugin files at " + PLUGIN_DIR);
48649
+ registerPlugin();
48650
+ log("\u2713 Registered plugin in installed_plugins.json");
48651
+ enablePlugin();
48652
+ log("\u2713 Enabled plugin in settings.json");
48653
+ log("");
48654
+ log("Installation complete! Restart Claude Code to apply changes.");
48655
+ } catch (err) {
48656
+ throw new CliError("INSTALL_FAILED", `Failed to install Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
48657
+ }
48658
+ }
48659
+ async function uninstallClaudePlugin() {
48660
+ try {
48661
+ let didSomething = false;
48662
+ if (existsSync3(PLUGIN_DIR)) {
48663
+ rmSync(PLUGIN_DIR, { recursive: true });
48664
+ console.log("\u2713 Removed plugin files from " + PLUGIN_DIR);
48665
+ didSomething = true;
48666
+ }
48667
+ unregisterPlugin();
48668
+ console.log("\u2713 Unregistered plugin from installed_plugins.json");
48669
+ disablePlugin();
48670
+ console.log("\u2713 Disabled plugin in settings.json");
48671
+ if (didSomething) {
48672
+ console.log("");
48673
+ console.log("Uninstall complete! Restart Claude Code to apply changes.");
48674
+ } else {
48675
+ console.log("");
48676
+ console.log("Plugin files were not found, but registration entries have been cleaned up.");
48677
+ }
48678
+ } catch (err) {
48679
+ throw new CliError("UNINSTALL_FAILED", `Failed to uninstall Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
48680
+ }
48681
+ }
48682
+ // package.json
48683
+ var package_default = {
48684
+ name: "@knowsuchagency/fulcrum",
48685
+ private: true,
48686
+ version: "1.4.0",
48687
+ description: "Harness Attention. Orchestrate Agents. Ship.",
48688
+ license: "PolyForm-Perimeter-1.0.0",
48689
+ type: "module",
48690
+ scripts: {
48691
+ dev: "vite --host",
48692
+ "dev:server": "mkdir -p ~/.fulcrum && bun --watch server/index.ts",
48693
+ build: "tsc -b && vite build",
48694
+ start: "NODE_ENV=production bun server/index.ts",
48695
+ lint: "eslint .",
48696
+ preview: "vite preview",
48697
+ "db:generate": "drizzle-kit generate",
48698
+ "db:migrate": "drizzle-kit migrate",
48699
+ "db:studio": "drizzle-kit studio"
48700
+ },
48701
+ dependencies: {
48702
+ "@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
48703
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
48704
+ "@azurity/pure-nerd-font": "^3.0.5",
48705
+ "@base-ui/react": "^1.0.0",
48706
+ "@dagrejs/dagre": "^1.1.8",
48707
+ "@fontsource-variable/jetbrains-mono": "^5.2.8",
48708
+ "@hono/node-server": "^1.19.7",
48709
+ "@hono/node-ws": "^1.2.0",
48710
+ "@hugeicons/core-free-icons": "^3.0.0",
48711
+ "@hugeicons/react": "^1.1.3",
48712
+ "@monaco-editor/react": "^4.7.0",
48713
+ "@octokit/rest": "^22.0.1",
48714
+ "@radix-ui/react-collapsible": "^1.1.12",
48715
+ "@tailwindcss/vite": "^4.1.17",
48716
+ "@tanstack/react-query": "^5.90.12",
48717
+ "@tanstack/react-router": "^1.141.8",
48718
+ "@uiw/react-markdown-preview": "^5.1.5",
48719
+ "@xterm/addon-clipboard": "^0.2.0",
48720
+ "@xterm/addon-fit": "^0.10.0",
48721
+ "@xterm/addon-web-links": "^0.11.0",
48722
+ "@xterm/xterm": "^5.5.0",
48723
+ "bun-pty": "^0.4.2",
48724
+ citty: "^0.1.6",
48725
+ "class-variance-authority": "^0.7.1",
48726
+ cloudflare: "^5.2.0",
48727
+ clsx: "^2.1.1",
48728
+ "date-fns": "^4.1.0",
48729
+ "drizzle-orm": "^0.45.1",
48730
+ "fancy-ansi": "^0.1.3",
48731
+ glob: "^13.0.0",
48732
+ hono: "^4.11.1",
48733
+ i18next: "^25.7.3",
48734
+ mobx: "^6.15.0",
48735
+ "mobx-react-lite": "^4.1.1",
48736
+ "mobx-state-tree": "^7.0.2",
48737
+ "next-themes": "^0.4.6",
48738
+ react: "^19.2.0",
48739
+ "react-day-picker": "^9.13.0",
48740
+ "react-dom": "^19.2.0",
48741
+ "react-i18next": "^16.5.0",
48742
+ "react-resizable-panels": "^4.0.11",
48743
+ reactflow: "^11.11.4",
48744
+ recharts: "2.15.4",
48745
+ shadcn: "^3.6.2",
48746
+ shiki: "^3.20.0",
48747
+ sonner: "^2.0.7",
48748
+ "tailwind-merge": "^3.4.0",
48749
+ tailwindcss: "^4.1.17",
48750
+ "tw-animate-css": "^1.4.0",
48751
+ ws: "^8.18.3",
48752
+ yaml: "^2.8.2"
48753
+ },
48754
+ devDependencies: {
48755
+ "@eslint/js": "^9.39.1",
48756
+ "@opencode-ai/plugin": "^1.1.8",
48757
+ "@tailwindcss/typography": "^0.5.19",
48758
+ "@tanstack/router-plugin": "^1.141.8",
48759
+ "@types/bun": "^1.2.14",
48760
+ "@types/node": "^24.10.1",
48761
+ "@types/react": "^19.2.5",
48762
+ "@types/react-dom": "^19.2.3",
48763
+ "@types/ws": "^8.18.1",
48764
+ "@vitejs/plugin-react": "^5.1.1",
48765
+ "drizzle-kit": "^0.31.8",
48766
+ eslint: "^9.39.1",
48767
+ "eslint-plugin-react-hooks": "^7.0.1",
48768
+ "eslint-plugin-react-refresh": "^0.4.24",
48769
+ globals: "^16.5.0",
48770
+ typescript: "~5.9.3",
48771
+ "typescript-eslint": "^8.46.4",
48772
+ vite: "^7.2.4"
48773
+ }
48774
+ };
48775
+
48776
+ // cli/src/commands/up.ts
48777
+ function getPackageRoot() {
48778
+ const currentFile = fileURLToPath(import.meta.url);
48779
+ let dir = dirname2(currentFile);
48780
+ for (let i2 = 0;i2 < 5; i2++) {
48781
+ if (existsSync4(join4(dir, "server", "index.js"))) {
48782
+ return dir;
48783
+ }
48784
+ dir = dirname2(dir);
48785
+ }
48786
+ return dirname2(dirname2(dirname2(currentFile)));
48787
+ }
48788
+ async function handleUpCommand(flags) {
48789
+ const autoYes = flags.yes === "true" || flags.y === "true";
48790
+ if (needsViboraMigration()) {
48791
+ const viboraDir = getLegacyViboraDir();
48792
+ console.error(`
48793
+ Found existing Vibora data at ${viboraDir}`);
48794
+ console.error('Run "fulcrum migrate-from-vibora" to copy your data to ~/.fulcrum');
48795
+ console.error("");
48796
+ }
48797
+ if (!isBunInstalled()) {
48798
+ const bunDep = getDependency("bun");
48799
+ const method = getInstallMethod(bunDep);
48800
+ console.error("Bun is required to run Fulcrum but is not installed.");
48801
+ console.error(" Bun is the JavaScript runtime that powers Fulcrum.");
48802
+ const shouldInstall = autoYes || await confirm(`Would you like to install bun via ${method}?`);
48803
+ if (shouldInstall) {
48804
+ const success2 = installBun();
48805
+ if (!success2) {
48806
+ throw new CliError("INSTALL_FAILED", "Failed to install bun", ExitCodes.ERROR);
48807
+ }
48808
+ console.error("Bun installed successfully!");
48809
+ } else {
48810
+ throw new CliError("MISSING_DEPENDENCY", `Bun is required. Install manually: ${getInstallCommand(bunDep)}`, ExitCodes.ERROR);
48811
+ }
48812
+ }
48813
+ if (!isDtachInstalled()) {
48814
+ const dtachDep = getDependency("dtach");
48815
+ const method = getInstallMethod(dtachDep);
48816
+ console.error("dtach is required for terminal persistence but is not installed.");
48817
+ console.error(" dtach enables persistent terminal sessions that survive disconnects.");
48818
+ const shouldInstall = autoYes || await confirm(`Would you like to install dtach via ${method}?`);
48819
+ if (shouldInstall) {
48820
+ const success2 = installDtach();
48821
+ if (!success2) {
48822
+ throw new CliError("INSTALL_FAILED", "Failed to install dtach", ExitCodes.ERROR);
48823
+ }
48824
+ console.error("dtach installed successfully!");
48825
+ } else {
48826
+ throw new CliError("MISSING_DEPENDENCY", `dtach is required. Install manually: ${getInstallCommand(dtachDep)}`, ExitCodes.ERROR);
48827
+ }
48828
+ }
48829
+ if (!isUvInstalled()) {
48830
+ const uvDep = getDependency("uv");
48831
+ const method = getInstallMethod(uvDep);
48832
+ console.error("uv is required but is not installed.");
48833
+ console.error(" uv is a fast Python package manager used by Claude Code.");
48834
+ const shouldInstall = autoYes || await confirm(`Would you like to install uv via ${method}?`);
48835
+ if (shouldInstall) {
48836
+ const success2 = installUv();
48837
+ if (!success2) {
48838
+ throw new CliError("INSTALL_FAILED", "Failed to install uv", ExitCodes.ERROR);
48839
+ }
48840
+ console.error("uv installed successfully!");
48841
+ } else {
48842
+ throw new CliError("MISSING_DEPENDENCY", `uv is required. Install manually: ${getInstallCommand(uvDep)}`, ExitCodes.ERROR);
48843
+ }
48844
+ }
48845
+ if (isClaudeInstalled() && needsPluginUpdate()) {
48846
+ console.error("Updating Fulcrum plugin for Claude Code...");
48847
+ await installClaudePlugin({ silent: true });
48848
+ console.error("\u2713 Fulcrum plugin updated");
48849
+ }
48850
+ const existingPid = readPid();
48851
+ if (existingPid && isProcessRunning(existingPid)) {
48852
+ console.error(`Fulcrum server is already running (PID: ${existingPid})`);
48853
+ const shouldReplace = autoYes || await confirm("Would you like to stop it and start a new instance?");
48854
+ if (shouldReplace) {
48855
+ console.error("Stopping existing instance...");
48856
+ process.kill(existingPid, "SIGTERM");
48857
+ let attempts = 0;
48858
+ while (attempts < 50 && isProcessRunning(existingPid)) {
48859
+ await new Promise((resolve) => setTimeout(resolve, 100));
48860
+ attempts++;
48861
+ }
48862
+ if (isProcessRunning(existingPid)) {
48863
+ process.kill(existingPid, "SIGKILL");
48864
+ }
48865
+ removePid();
48866
+ console.error("Existing instance stopped.");
48867
+ } else {
48868
+ throw new CliError("ALREADY_RUNNING", `Server already running at http://localhost:${getPort(flags.port)}`, ExitCodes.ERROR);
48869
+ }
48870
+ }
48871
+ const port = getPort(flags.port);
48872
+ if (flags.port) {
48873
+ updateSettingsPort(port);
48874
+ }
48875
+ const host = flags.host ? "0.0.0.0" : "localhost";
48876
+ const packageRoot = getPackageRoot();
48877
+ const serverPath = join4(packageRoot, "server", "index.js");
48878
+ const platform2 = process.platform;
48879
+ const arch = process.arch;
48880
+ let ptyLibName;
48881
+ if (platform2 === "darwin") {
48882
+ ptyLibName = arch === "arm64" ? "librust_pty_arm64.dylib" : "librust_pty.dylib";
48883
+ } else if (platform2 === "win32") {
48884
+ ptyLibName = "rust_pty.dll";
48885
+ } else {
48886
+ ptyLibName = arch === "arm64" ? "librust_pty_arm64.so" : "librust_pty.so";
48887
+ }
48888
+ const ptyLibPath = join4(packageRoot, "lib", ptyLibName);
48889
+ const fulcrumDir = getFulcrumDir();
48890
+ const debug = flags.debug === "true";
48891
+ console.error(`Starting Fulcrum server${debug ? " (debug mode)" : ""}...`);
48892
+ const serverProc = spawn("bun", [serverPath], {
48893
+ detached: true,
48894
+ stdio: "ignore",
48895
+ env: {
48896
+ ...process.env,
48897
+ NODE_ENV: "production",
48898
+ PORT: port.toString(),
48899
+ HOST: host,
48900
+ FULCRUM_DIR: fulcrumDir,
48901
+ FULCRUM_PACKAGE_ROOT: packageRoot,
48902
+ FULCRUM_VERSION: package_default.version,
48903
+ BUN_PTY_LIB: ptyLibPath,
48904
+ ...isClaudeInstalled() && { FULCRUM_CLAUDE_INSTALLED: "1" },
48905
+ ...isOpencodeInstalled() && { FULCRUM_OPENCODE_INSTALLED: "1" },
48906
+ ...debug && { LOG_LEVEL: "debug", DEBUG: "1" }
48907
+ }
48908
+ });
48909
+ serverProc.unref();
48910
+ const pid = serverProc.pid;
48911
+ if (!pid) {
48912
+ throw new CliError("START_FAILED", "Failed to start server process", ExitCodes.ERROR);
48913
+ }
48914
+ writePid(pid);
48915
+ await new Promise((resolve) => setTimeout(resolve, 1000));
48916
+ if (!isProcessRunning(pid)) {
48917
+ throw new CliError("START_FAILED", "Server process died immediately after starting", ExitCodes.ERROR);
48918
+ }
48919
+ if (isJsonOutput()) {
48920
+ output({
48921
+ pid,
48922
+ port,
48923
+ url: `http://localhost:${port}`
48924
+ });
48925
+ } else {
48926
+ const hasAgent = isClaudeInstalled() || isOpencodeInstalled();
48927
+ showGettingStartedTips(port, hasAgent);
48928
+ }
48929
+ }
48930
+ function showGettingStartedTips(port, hasAgent) {
48931
+ console.error(`
48932
+ Fulcrum is running at http://localhost:${port}
48933
+
48934
+ Getting Started:
48935
+ 1. Open http://localhost:${port} in your browser
48936
+ 2. Add a repository to get started
48937
+ 3. Create a task to spin up an isolated worktree
48938
+ 4. Run your AI agent in the task terminal
48939
+
48940
+ Commands:
48941
+ fulcrum status Check server status
48942
+ fulcrum doctor Check all dependencies
48943
+ fulcrum down Stop the server
48944
+ `);
48945
+ if (!hasAgent) {
48946
+ console.error(`Note: No AI agents detected. Install one to get started:
48947
+ Claude Code: curl -fsSL https://claude.ai/install.sh | bash
48948
+ OpenCode: curl -fsSL https://opencode.ai/install | bash
48949
+ `);
48950
+ }
48951
+ }
48952
+
48953
+ // cli/src/commands/down.ts
48954
+ init_errors();
48955
+ async function handleDownCommand() {
48956
+ const pid = readPid();
48957
+ if (!pid) {
48958
+ throw new CliError("NOT_RUNNING", "No PID file found. Fulcrum server may not be running.", ExitCodes.ERROR);
48959
+ }
48960
+ if (!isProcessRunning(pid)) {
48961
+ removePid();
48962
+ if (isJsonOutput()) {
48963
+ output({ stopped: true, pid, wasRunning: false });
48964
+ } else {
48965
+ console.log(`Fulcrum was not running (stale PID file cleaned up)`);
48966
+ }
48967
+ return;
48968
+ }
48969
+ try {
48970
+ process.kill(pid, "SIGTERM");
48971
+ } catch (err) {
48972
+ throw new CliError("KILL_FAILED", `Failed to stop server (PID: ${pid}): ${err}`, ExitCodes.ERROR);
48973
+ }
48974
+ let attempts = 0;
48975
+ while (attempts < 50 && isProcessRunning(pid)) {
48976
+ await new Promise((resolve) => setTimeout(resolve, 100));
48977
+ attempts++;
48978
+ }
48979
+ if (isProcessRunning(pid)) {
48980
+ try {
48981
+ process.kill(pid, "SIGKILL");
48982
+ } catch {}
48983
+ }
48984
+ removePid();
48985
+ if (isJsonOutput()) {
48986
+ output({ stopped: true, pid, wasRunning: true });
48987
+ } else {
48988
+ console.log(`Fulcrum stopped (PID: ${pid})`);
48989
+ }
48990
+ }
48991
+
48992
+ // cli/src/commands/migrate-from-vibora.ts
48993
+ init_server();
48994
+ async function handleMigrateFromViboraCommand(flags) {
48995
+ const autoYes = flags.yes === "true" || flags.y === "true";
48996
+ if (!needsViboraMigration()) {
48997
+ if (isJsonOutput()) {
48998
+ output({ migrated: false, reason: "no_migration_needed" });
48999
+ } else {
49000
+ console.error("No migration needed.");
49001
+ console.error(` ~/.vibora does not exist or ~/.fulcrum already has data.`);
49002
+ }
49003
+ return;
49004
+ }
49005
+ const viboraDir = getLegacyViboraDir();
49006
+ const fulcrumDir = getFulcrumDir();
49007
+ if (!isJsonOutput()) {
49008
+ console.error(`
49009
+ Found existing Vibora data at ${viboraDir}`);
49010
+ console.error("Fulcrum (formerly Vibora) now uses ~/.fulcrum for data storage.");
49011
+ console.error("");
49012
+ console.error("Your existing data can be copied to the new location.");
49013
+ console.error("This is non-destructive - your ~/.vibora directory will be left untouched.");
49014
+ console.error("");
49015
+ }
49016
+ const shouldMigrate = autoYes || await confirm("Would you like to copy your data to ~/.fulcrum?");
49017
+ if (!shouldMigrate) {
49018
+ if (isJsonOutput()) {
49019
+ output({ migrated: false, reason: "user_declined" });
49020
+ } else {
49021
+ console.error("Migration skipped.");
49022
+ console.error("You can run this command again later to migrate.");
49023
+ }
49024
+ return;
49025
+ }
49026
+ if (!isJsonOutput()) {
49027
+ console.error("Copying data from ~/.vibora to ~/.fulcrum...");
49028
+ }
49029
+ const success2 = migrateFromVibora();
49030
+ if (success2) {
49031
+ if (isJsonOutput()) {
49032
+ output({ migrated: true, from: viboraDir, to: fulcrumDir });
49033
+ } else {
49034
+ console.error("Migration complete!");
49035
+ console.error(` Data copied from ${viboraDir} to ${fulcrumDir}`);
49036
+ console.error(" Your original ~/.vibora directory has been preserved.");
49037
+ }
49038
+ } else {
49039
+ if (isJsonOutput()) {
49040
+ output({ migrated: false, reason: "migration_failed" });
49041
+ } else {
49042
+ console.error("Migration failed.");
49043
+ console.error("You can manually copy files from ~/.vibora to ~/.fulcrum");
49044
+ }
49045
+ process.exitCode = 1;
49046
+ }
49047
+ }
49048
+
49049
+ // cli/src/commands/status.ts
49050
+ init_server();
49051
+ async function handleStatusCommand(flags) {
49052
+ const pid = readPid();
49053
+ const port = getPort(flags.port);
49054
+ const serverUrl = discoverServerUrl(flags.url, flags.port);
49055
+ const pidRunning = pid !== null && isProcessRunning(pid);
49056
+ let healthOk = false;
49057
+ let version3 = null;
49058
+ let uptime = null;
49059
+ if (pidRunning) {
49060
+ try {
49061
+ const res = await fetch(`${serverUrl}/health`, { signal: AbortSignal.timeout(2000) });
49062
+ healthOk = res.ok;
49063
+ if (res.ok) {
49064
+ const health = await res.json();
49065
+ version3 = health.version || null;
49066
+ uptime = health.uptime || null;
49067
+ }
49068
+ } catch {}
49069
+ }
49070
+ const data = {
49071
+ running: pidRunning,
49072
+ healthy: healthOk,
49073
+ pid: pid || null,
49074
+ port,
49075
+ url: serverUrl,
49076
+ version: version3,
49077
+ uptime
49078
+ };
49079
+ if (isJsonOutput()) {
49080
+ output(data);
49081
+ } else {
49082
+ if (pidRunning) {
49083
+ const healthStatus = healthOk ? "healthy" : "not responding";
49084
+ console.log(`Fulcrum is running (${healthStatus})`);
49085
+ console.log(` PID: ${pid}`);
49086
+ console.log(` URL: ${serverUrl}`);
49087
+ if (version3)
49088
+ console.log(` Version: ${version3}`);
49089
+ if (uptime)
49090
+ console.log(` Uptime: ${Math.floor(uptime / 1000)}s`);
49091
+ } else {
49092
+ console.log("Fulcrum is not running");
49093
+ console.log(`
49094
+ Start with: fulcrum up`);
49095
+ }
49096
+ }
49097
+ }
49365
49098
 
49366
- Search for available tools by keyword or category:
49099
+ // cli/src/commands/git.ts
49100
+ init_client();
49101
+ init_errors();
49102
+ async function handleGitCommand(action, flags) {
49103
+ const client = new FulcrumClient(flags.url, flags.port);
49104
+ switch (action) {
49105
+ case "status": {
49106
+ const path = flags.path || process.cwd();
49107
+ const status = await client.getStatus(path);
49108
+ if (isJsonOutput()) {
49109
+ output(status);
49110
+ } else {
49111
+ console.log(`Branch: ${status.branch}`);
49112
+ if (status.ahead)
49113
+ console.log(` Ahead: ${status.ahead}`);
49114
+ if (status.behind)
49115
+ console.log(` Behind: ${status.behind}`);
49116
+ if (status.staged?.length)
49117
+ console.log(` Staged: ${status.staged.length} files`);
49118
+ if (status.modified?.length)
49119
+ console.log(` Modified: ${status.modified.length} files`);
49120
+ if (status.untracked?.length)
49121
+ console.log(` Untracked: ${status.untracked.length} files`);
49122
+ if (!status.staged?.length && !status.modified?.length && !status.untracked?.length) {
49123
+ console.log(" Working tree clean");
49124
+ }
49125
+ }
49126
+ break;
49127
+ }
49128
+ case "diff": {
49129
+ const path = flags.path || process.cwd();
49130
+ const diff = await client.getDiff(path, {
49131
+ staged: flags.staged === "true",
49132
+ ignoreWhitespace: flags["ignore-whitespace"] === "true",
49133
+ includeUntracked: flags["include-untracked"] === "true"
49134
+ });
49135
+ if (isJsonOutput()) {
49136
+ output(diff);
49137
+ } else {
49138
+ console.log(diff.diff || "No changes");
49139
+ }
49140
+ break;
49141
+ }
49142
+ case "branches": {
49143
+ const repo = flags.repo;
49144
+ if (!repo) {
49145
+ throw new CliError("MISSING_REPO", "--repo is required", ExitCodes.INVALID_ARGS);
49146
+ }
49147
+ const branches = await client.getBranches(repo);
49148
+ if (isJsonOutput()) {
49149
+ output(branches);
49150
+ } else {
49151
+ for (const branch of branches) {
49152
+ const current = branch.current ? "* " : " ";
49153
+ console.log(`${current}${branch.name}`);
49154
+ }
49155
+ }
49156
+ break;
49157
+ }
49158
+ default:
49159
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: status, diff, branches`, ExitCodes.INVALID_ARGS);
49160
+ }
49161
+ }
49367
49162
 
49368
- \`\`\`json
49369
- {
49370
- "query": "deploy", // Optional: Search term
49371
- "category": "apps" // Optional: Filter by category
49163
+ // cli/src/commands/worktrees.ts
49164
+ init_client();
49165
+ init_errors();
49166
+ async function handleWorktreesCommand(action, flags) {
49167
+ const client = new FulcrumClient(flags.url, flags.port);
49168
+ switch (action) {
49169
+ case "list": {
49170
+ const worktrees = await client.listWorktrees();
49171
+ if (isJsonOutput()) {
49172
+ output(worktrees);
49173
+ } else {
49174
+ if (worktrees.length === 0) {
49175
+ console.log("No worktrees found");
49176
+ } else {
49177
+ for (const wt of worktrees) {
49178
+ console.log(`${wt.path}`);
49179
+ console.log(` Branch: ${wt.branch}`);
49180
+ if (wt.taskId)
49181
+ console.log(` Task: ${wt.taskId}`);
49182
+ }
49183
+ }
49184
+ }
49185
+ break;
49186
+ }
49187
+ case "delete": {
49188
+ const worktreePath = flags.path;
49189
+ if (!worktreePath) {
49190
+ throw new CliError("MISSING_PATH", "--path is required", ExitCodes.INVALID_ARGS);
49191
+ }
49192
+ const deleteLinkedTask = flags["delete-task"] === "true" || flags["delete-task"] === "";
49193
+ const result = await client.deleteWorktree(worktreePath, flags.repo, deleteLinkedTask);
49194
+ if (isJsonOutput()) {
49195
+ output(result);
49196
+ } else {
49197
+ console.log(`Deleted worktree: ${worktreePath}`);
49198
+ }
49199
+ break;
49200
+ }
49201
+ default:
49202
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, delete`, ExitCodes.INVALID_ARGS);
49203
+ }
49372
49204
  }
49373
- \`\`\`
49374
49205
 
49375
- **Categories:** core, tasks, projects, repositories, apps, filesystem, git, notifications, exec
49206
+ // cli/src/commands/config.ts
49207
+ init_client();
49208
+ init_errors();
49209
+ async function handleConfigCommand(action, positional, flags) {
49210
+ const client = new FulcrumClient(flags.url, flags.port);
49211
+ switch (action) {
49212
+ case "list": {
49213
+ const config3 = await client.getAllConfig();
49214
+ if (isJsonOutput()) {
49215
+ output(config3);
49216
+ } else {
49217
+ console.log("Configuration:");
49218
+ for (const [key, value] of Object.entries(config3)) {
49219
+ const displayValue = value === null ? "(not set)" : value;
49220
+ console.log(` ${key}: ${displayValue}`);
49221
+ }
49222
+ }
49223
+ break;
49224
+ }
49225
+ case "get": {
49226
+ const [key] = positional;
49227
+ if (!key) {
49228
+ throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
49229
+ }
49230
+ const config3 = await client.getConfig(key);
49231
+ if (isJsonOutput()) {
49232
+ output(config3);
49233
+ } else {
49234
+ const value = config3.value === null ? "(not set)" : config3.value;
49235
+ console.log(`${key}: ${value}`);
49236
+ }
49237
+ break;
49238
+ }
49239
+ case "set": {
49240
+ const [key, value] = positional;
49241
+ if (!key) {
49242
+ throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
49243
+ }
49244
+ if (value === undefined) {
49245
+ throw new CliError("MISSING_VALUE", "Config value is required", ExitCodes.INVALID_ARGS);
49246
+ }
49247
+ const parsedValue = /^\d+$/.test(value) ? parseInt(value, 10) : value;
49248
+ const config3 = await client.setConfig(key, parsedValue);
49249
+ if (isJsonOutput()) {
49250
+ output(config3);
49251
+ } else {
49252
+ console.log(`Set ${key} = ${config3.value}`);
49253
+ }
49254
+ break;
49255
+ }
49256
+ case "reset": {
49257
+ const [key] = positional;
49258
+ if (!key) {
49259
+ throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
49260
+ }
49261
+ const config3 = await client.resetConfig(key);
49262
+ if (isJsonOutput()) {
49263
+ output(config3);
49264
+ } else {
49265
+ console.log(`Reset ${key} to default: ${config3.value}`);
49266
+ }
49267
+ break;
49268
+ }
49269
+ default:
49270
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, get, set, reset`, ExitCodes.INVALID_ARGS);
49271
+ }
49272
+ }
49376
49273
 
49377
- **Example Usage:**
49378
- \`\`\`
49379
- search_tools { query: "project create" }
49380
- \u2192 Returns tools for creating projects
49274
+ // cli/src/commands/opencode.ts
49275
+ init_errors();
49276
+ import {
49277
+ mkdirSync as mkdirSync4,
49278
+ writeFileSync as writeFileSync4,
49279
+ existsSync as existsSync5,
49280
+ readFileSync as readFileSync5,
49281
+ unlinkSync as unlinkSync2,
49282
+ copyFileSync,
49283
+ renameSync
49284
+ } from "fs";
49285
+ import { homedir as homedir3 } from "os";
49286
+ import { join as join5 } from "path";
49381
49287
 
49382
- search_tools { category: "filesystem" }
49383
- \u2192 Returns all filesystem tools
49384
- \`\`\`
49288
+ // plugins/fulcrum-opencode/index.ts
49289
+ var fulcrum_opencode_default = `import type { Plugin } from "@opencode-ai/plugin"
49290
+ import { appendFileSync } from "node:fs"
49291
+ import { spawn } from "node:child_process"
49292
+ import { tmpdir } from "node:os"
49293
+ import { join } from "node:path"
49385
49294
 
49386
- ### Task Tools
49295
+ declare const process: { env: Record<string, string | undefined> }
49387
49296
 
49388
- - \`list_tasks\` - List tasks with flexible filtering (search, labels, statuses, date range, overdue)
49389
- - \`get_task\` - Get task details by ID
49390
- - \`create_task\` - Create a new task with worktree
49391
- - \`update_task\` - Update task metadata
49392
- - \`delete_task\` - Delete a task
49393
- - \`move_task\` - Move task to different status
49394
- - \`add_task_link\` - Add URL link to task
49395
- - \`remove_task_link\` - Remove link from task
49396
- - \`list_task_links\` - List all task links
49397
- - \`add_task_label\` - Add a label to a task (returns similar labels to catch typos)
49398
- - \`remove_task_label\` - Remove a label from a task
49399
- - \`set_task_due_date\` - Set or clear task due date
49400
- - \`list_labels\` - List all unique labels in use with optional search
49297
+ const LOG_FILE = join(tmpdir(), "fulcrum-opencode.log")
49298
+ const NOISY_EVENTS = new Set([
49299
+ "message.part.updated",
49300
+ "file.watcher.updated",
49301
+ "tui.toast.show",
49302
+ "config.updated",
49303
+ ])
49304
+ const log = (msg: string) => {
49305
+ try {
49306
+ appendFileSync(LOG_FILE, \`[\${new Date().toISOString()}] \${msg}\\n\`)
49307
+ } catch {
49308
+ // Silently ignore logging errors - logging is non-critical
49309
+ }
49310
+ }
49401
49311
 
49402
- #### Task Search and Filtering
49312
+ /**
49313
+ * Execute fulcrum command using spawn with shell option for proper PATH resolution.
49314
+ * Using spawn with explicit args array prevents shell injection while shell:true
49315
+ * ensures PATH is properly resolved (for NVM, fnm, etc. managed node installations).
49316
+ * Includes 10 second timeout protection to prevent hanging.
49317
+ */
49318
+ async function runFulcrumCommand(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
49319
+ return new Promise((resolve) => {
49320
+ let stdout = ''
49321
+ let stderr = ''
49322
+ let resolved = false
49323
+ let processExited = false
49324
+ let killTimeoutId: ReturnType<typeof setTimeout> | null = null
49403
49325
 
49404
- The \`list_tasks\` tool supports powerful filtering for AI agents:
49326
+ const child = spawn(FULCRUM_CMD, args, { shell: true })
49405
49327
 
49406
- \`\`\`json
49407
- {
49408
- "search": "ocai", // Text search across title, labels, project name
49409
- "labels": ["bug", "urgent"], // Filter by multiple labels (OR logic)
49410
- "statuses": ["TO_DO", "IN_PROGRESS"], // Filter by multiple statuses (OR logic)
49411
- "dueDateStart": "2026-01-18", // Start of date range
49412
- "dueDateEnd": "2026-01-25", // End of date range
49413
- "overdue": true // Only show overdue tasks
49414
- }
49415
- \`\`\`
49328
+ const cleanup = () => {
49329
+ processExited = true
49330
+ if (killTimeoutId) {
49331
+ clearTimeout(killTimeoutId)
49332
+ killTimeoutId = null
49333
+ }
49334
+ }
49416
49335
 
49417
- #### Label Discovery
49336
+ child.stdout?.on('data', (data) => {
49337
+ stdout += data.toString()
49338
+ })
49418
49339
 
49419
- Use \`list_labels\` to discover exact label names before filtering:
49340
+ child.stderr?.on('data', (data) => {
49341
+ stderr += data.toString()
49342
+ })
49420
49343
 
49421
- \`\`\`json
49422
- // Find labels matching "communication"
49423
- { "search": "communication" }
49424
- // Returns: [{ "name": "communication required", "count": 5 }]
49425
- \`\`\`
49344
+ child.on('close', (code) => {
49345
+ cleanup()
49346
+ if (!resolved) {
49347
+ resolved = true
49348
+ resolve({ exitCode: code || 0, stdout, stderr })
49349
+ }
49350
+ })
49426
49351
 
49427
- This helps handle typos and variations - search first, then use the exact label name.
49352
+ child.on('error', (err) => {
49353
+ cleanup()
49354
+ if (!resolved) {
49355
+ resolved = true
49356
+ resolve({ exitCode: 1, stdout, stderr: err.message || '' })
49357
+ }
49358
+ })
49428
49359
 
49429
- ### Project Tools
49360
+ // Add timeout protection to prevent hanging
49361
+ const timeoutId = setTimeout(() => {
49362
+ if (!resolved) {
49363
+ resolved = true
49364
+ log(\`Command timeout: \${FULCRUM_CMD} \${args.join(' ')}\`)
49365
+ child.kill('SIGTERM')
49366
+ // Schedule SIGKILL if process doesn't exit after SIGTERM
49367
+ killTimeoutId = setTimeout(() => {
49368
+ if (!processExited) {
49369
+ log(\`Process didn't exit after SIGTERM, sending SIGKILL\`)
49370
+ child.kill('SIGKILL')
49371
+ }
49372
+ }, 2000)
49373
+ resolve({ exitCode: -1, stdout, stderr: \`Command timed out after \${FULCRUM_COMMAND_TIMEOUT_MS}ms\` })
49374
+ }
49375
+ }, FULCRUM_COMMAND_TIMEOUT_MS)
49430
49376
 
49431
- - \`list_projects\` - List all projects
49432
- - \`get_project\` - Get project details
49433
- - \`create_project\` - Create from path, URL, or existing repo
49434
- - \`update_project\` - Update name, description, status
49435
- - \`delete_project\` - Delete project and optionally directory/app
49436
- - \`scan_projects\` - Scan directory for git repos
49437
- - \`list_project_links\` - List all URL links attached to a project
49438
- - \`add_project_link\` - Add a URL link to a project (auto-detects type)
49439
- - \`remove_project_link\` - Remove a URL link from a project
49377
+ // Clear timeout if command completes
49378
+ child.on('exit', () => clearTimeout(timeoutId))
49379
+ })
49380
+ }
49440
49381
 
49441
- ### Repository Tools
49382
+ let mainSessionId: string | null = null
49383
+ const subagentSessions = new Set<string>()
49384
+ let pendingIdleTimer: ReturnType<typeof setTimeout> | null = null
49385
+ let activityVersion = 0
49386
+ let lastStatus: "in-progress" | "review" | "" = ""
49442
49387
 
49443
- - \`list_repositories\` - List all repositories (supports orphans filter)
49444
- - \`get_repository\` - Get repository details by ID
49445
- - \`add_repository\` - Add repository from local path
49446
- - \`update_repository\` - Update repository metadata (name, agent, startup script)
49447
- - \`delete_repository\` - Delete orphaned repository (fails if linked to project)
49448
- - \`link_repository_to_project\` - Link repo to project (errors if already linked elsewhere)
49449
- - \`unlink_repository_from_project\` - Unlink repo from project
49388
+ const FULCRUM_CMD = "fulcrum"
49389
+ const IDLE_CONFIRMATION_DELAY_MS = 1500
49390
+ const FULCRUM_COMMAND_TIMEOUT_MS = 10000
49391
+ const STATUS_CHANGE_DEBOUNCE_MS = 500
49450
49392
 
49451
- ### App/Deployment Tools
49393
+ let deferredContextCheck: Promise<boolean> | null = null
49394
+ let isFulcrumContext: boolean | null = null
49395
+ let pendingStatusCommand: Promise<{ exitCode: number; stdout: string; stderr: string }> | null = null
49452
49396
 
49453
- - \`list_apps\` - List all deployed apps
49454
- - \`get_app\` - Get app details with services
49455
- - \`create_app\` - Create app for deployment
49456
- - \`deploy_app\` - Trigger deployment
49457
- - \`stop_app\` - Stop running app
49458
- - \`get_app_logs\` - Get container logs
49459
- - \`get_app_status\` - Get container status
49460
- - \`list_deployments\` - Get deployment history
49461
- - \`delete_app\` - Delete app
49397
+ export const FulcrumPlugin: Plugin = async ({ $, directory }) => {
49398
+ log("Plugin initializing...")
49462
49399
 
49463
- ### Filesystem Tools
49400
+ if (process.env.FULCRUM_TASK_ID) {
49401
+ isFulcrumContext = true
49402
+ log("Fulcrum context detected via env var")
49403
+ } else {
49404
+ deferredContextCheck = Promise.all([
49405
+ $\`\${FULCRUM_CMD} --version\`.quiet().nothrow().text(),
49406
+ runFulcrumCommand(['current-task', '--path', directory]),
49407
+ ])
49408
+ .then(([versionResult, taskResult]) => {
49409
+ if (!versionResult) {
49410
+ log("Fulcrum CLI not found")
49411
+ return false
49412
+ }
49413
+ const inContext = taskResult.exitCode === 0
49414
+ log(inContext ? "Fulcrum context active" : "Not a Fulcrum context")
49415
+ return inContext
49416
+ })
49417
+ .catch(() => {
49418
+ log("Fulcrum check failed")
49419
+ return false
49420
+ })
49421
+ }
49464
49422
 
49465
- Remote filesystem tools for working with files on the Fulcrum server. Useful when the agent runs on a different machine than the server (e.g., via SSH tunneling to Claude Desktop).
49423
+ log("Plugin hooks registered")
49466
49424
 
49467
- - \`list_directory\` - List directory contents
49468
- - \`get_file_tree\` - Get recursive file tree
49469
- - \`read_file\` - Read file contents (secured)
49470
- - \`write_file\` - Write entire file content (secured)
49471
- - \`edit_file\` - Edit file by replacing a unique string (secured)
49472
- - \`file_stat\` - Get file/directory metadata
49473
- - \`is_git_repo\` - Check if directory is git repo
49425
+ const checkContext = async (): Promise<boolean> => {
49426
+ if (isFulcrumContext !== null) return isFulcrumContext
49427
+ if (deferredContextCheck) {
49428
+ isFulcrumContext = await deferredContextCheck
49429
+ deferredContextCheck = null
49430
+ return isFulcrumContext
49431
+ }
49432
+ return false
49433
+ }
49474
49434
 
49475
- ### Command Execution
49435
+ const cancelPendingIdle = () => {
49436
+ if (pendingIdleTimer) {
49437
+ clearTimeout(pendingIdleTimer)
49438
+ pendingIdleTimer = null
49439
+ log("Cancelled pending idle transition")
49440
+ }
49441
+ }
49476
49442
 
49477
- When using Claude Desktop with Fulcrum's MCP server, you can execute commands on the remote Fulcrum server. This is useful when connecting to Fulcrum via SSH port forwarding.
49443
+ const setStatus = (status: "in-progress" | "review") => {
49444
+ if (status === lastStatus) return
49478
49445
 
49479
- #### execute_command
49446
+ cancelPendingIdle()
49480
49447
 
49481
- Execute shell commands with optional persistent session support:
49448
+ if (pendingStatusCommand) {
49449
+ log(\`Status change already in progress, will retry after \${STATUS_CHANGE_DEBOUNCE_MS}ms\`)
49450
+ setTimeout(() => setStatus(status), STATUS_CHANGE_DEBOUNCE_MS)
49451
+ return
49452
+ }
49482
49453
 
49483
- \`\`\`json
49484
- {
49485
- "command": "echo hello world",
49486
- "sessionId": "optional-session-id",
49487
- "cwd": "/path/to/start",
49488
- "timeout": 30000,
49489
- "name": "my-session"
49490
- }
49491
- \`\`\`
49454
+ lastStatus = status
49492
49455
 
49493
- **Parameters:**
49494
- - \`command\` (required) \u2014 The shell command to execute
49495
- - \`sessionId\` (optional) \u2014 Reuse a session to preserve env vars, cwd, and shell state
49496
- - \`cwd\` (optional) \u2014 Initial working directory (only used when creating new session)
49497
- - \`timeout\` (optional) \u2014 Timeout in milliseconds (default: 30000)
49498
- - \`name\` (optional) \u2014 Session name for identification (only used when creating new session)
49456
+ ;(async () => {
49457
+ try {
49458
+ log(\`Setting status: \${status}\`)
49459
+ pendingStatusCommand = runFulcrumCommand(['current-task', status, '--path', directory])
49460
+ const res = await pendingStatusCommand
49461
+ pendingStatusCommand = null
49499
49462
 
49500
- **Response:**
49501
- \`\`\`json
49502
- {
49503
- "sessionId": "uuid",
49504
- "stdout": "hello world",
49505
- "stderr": "",
49506
- "exitCode": 0,
49507
- "timedOut": false
49508
- }
49509
- \`\`\`
49463
+ if (res.exitCode !== 0) {
49464
+ log(\`Status update failed: exitCode=\${res.exitCode}, stderr=\${res.stderr}\`)
49465
+ }
49466
+ } catch (e) {
49467
+ log(\`Status update error: \${e}\`)
49468
+ pendingStatusCommand = null
49469
+ }
49470
+ })()
49471
+ }
49510
49472
 
49511
- ### Session Workflow Example
49473
+ const scheduleIdleTransition = () => {
49474
+ cancelPendingIdle()
49475
+ const currentVersion = ++activityVersion
49512
49476
 
49513
- \`\`\`
49514
- 1. First command (creates named session):
49515
- execute_command { command: "cd /project && export API_KEY=secret", name: "dev-session" }
49516
- \u2192 Returns sessionId: "abc-123"
49477
+ pendingIdleTimer = setTimeout(() => {
49478
+ if (activityVersion !== currentVersion) {
49479
+ log(
49480
+ \`Stale idle transition (version \${currentVersion} vs \${activityVersion})\`,
49481
+ )
49482
+ return
49483
+ }
49484
+ setStatus("review")
49485
+ }, IDLE_CONFIRMATION_DELAY_MS)
49517
49486
 
49518
- 2. Subsequent commands (reuse session):
49519
- execute_command { command: "echo $API_KEY", sessionId: "abc-123" }
49520
- \u2192 Returns stdout: "secret" (env var preserved)
49487
+ log(
49488
+ \`Scheduled idle transition (version \${currentVersion}, delay \${IDLE_CONFIRMATION_DELAY_MS}ms)\`,
49489
+ )
49490
+ }
49521
49491
 
49522
- execute_command { command: "pwd", sessionId: "abc-123" }
49523
- \u2192 Returns stdout: "/project" (cwd preserved)
49492
+ const recordActivity = (reason: string) => {
49493
+ activityVersion++
49494
+ cancelPendingIdle()
49495
+ log(\`Activity: \${reason} (version now \${activityVersion})\`)
49496
+ }
49524
49497
 
49525
- 3. Rename session if needed:
49526
- update_exec_session { sessionId: "abc-123", name: "new-name" }
49498
+ return {
49499
+ "chat.message": async (_input, output) => {
49500
+ if (!(await checkContext())) return
49527
49501
 
49528
- 4. Cleanup when done:
49529
- destroy_exec_session { sessionId: "abc-123" }
49530
- \`\`\`
49502
+ if (output.message.role === "user") {
49503
+ recordActivity("user message")
49504
+ setStatus("in-progress")
49505
+ } else if (output.message.role === "assistant") {
49506
+ recordActivity("assistant message")
49507
+ }
49508
+ },
49531
49509
 
49532
- Sessions persist until manually destroyed.
49510
+ event: async ({ event }) => {
49511
+ if (!NOISY_EVENTS.has(event.type)) {
49512
+ log(\`Event: \${event.type}\`)
49513
+ }
49533
49514
 
49534
- ### list_exec_sessions
49515
+ if (!(await checkContext())) return
49535
49516
 
49536
- List all active sessions with their name, current working directory, and timestamps.
49517
+ const props = (event.properties as Record<string, unknown>) || {}
49537
49518
 
49538
- ### update_exec_session
49519
+ if (event.type === "session.created") {
49520
+ const info = (props.info as Record<string, unknown>) || {}
49521
+ const sessionId = info.id as string | undefined
49522
+ const parentId = info.parentID as string | undefined
49539
49523
 
49540
- Rename an existing session for identification.
49524
+ if (parentId) {
49525
+ if (sessionId) subagentSessions.add(sessionId)
49526
+ log(\`Subagent session tracked: \${sessionId} (parent: \${parentId})\`)
49527
+ } else if (!mainSessionId && sessionId) {
49528
+ mainSessionId = sessionId
49529
+ log(\`Main session set: \${mainSessionId}\`)
49530
+ }
49541
49531
 
49542
- ### destroy_exec_session
49532
+ recordActivity("session.created")
49533
+ setStatus("in-progress")
49534
+ return
49535
+ }
49543
49536
 
49544
- Clean up a session when you're done to free resources.
49537
+ const status = props.status as Record<string, unknown> | undefined
49538
+ if (
49539
+ (event.type === "session.status" && status?.type === "busy") ||
49540
+ event.type.startsWith("tool.execute")
49541
+ ) {
49542
+ recordActivity(event.type)
49543
+ return
49544
+ }
49545
49545
 
49546
- ## Best Practices
49546
+ if (
49547
+ event.type === "session.idle" ||
49548
+ (event.type === "session.status" && status?.type === "idle")
49549
+ ) {
49550
+ const info = (props.info as Record<string, unknown>) || {}
49551
+ const sessionId =
49552
+ (props.sessionID as string) || (info.id as string) || null
49547
49553
 
49548
- 1. **Use \`current-task\` inside worktrees** - It auto-detects which task you're in
49549
- 2. **Link PRs immediately** - Run \`fulcrum current-task pr <url>\` right after creating a PR
49550
- 3. **Link relevant resources** - Attach design docs, specs, or reference materials with \`fulcrum current-task link <url>\`
49551
- 4. **Mark review when done** - \`fulcrum current-task review\` notifies the user
49552
- 5. **Send notifications for blocking issues** - Keep the user informed of progress
49553
- 6. **Name sessions for identification** - Use descriptive names to find sessions later
49554
- 7. **Reuse sessions for related commands** - Preserve state across multiple execute_command calls
49555
- 8. **Clean up sessions when done** - Use destroy_exec_session to free resources
49554
+ if (sessionId && subagentSessions.has(sessionId)) {
49555
+ log(\`Ignoring subagent idle: \${sessionId}\`)
49556
+ return
49557
+ }
49558
+
49559
+ if (mainSessionId && sessionId && sessionId !== mainSessionId) {
49560
+ log(\`Ignoring non-main idle: \${sessionId} (main: \${mainSessionId})\`)
49561
+ return
49562
+ }
49563
+
49564
+ log(\`Main session idle detected: \${sessionId}\`)
49565
+ scheduleIdleTransition()
49566
+ }
49567
+ },
49568
+ }
49569
+ }
49556
49570
  `;
49557
49571
 
49558
- // cli/src/commands/claude.ts
49559
- var PLUGIN_DIR2 = join5(homedir3(), ".claude", "plugins", "fulcrum");
49560
- var PLUGIN_FILES = [
49561
- { path: ".claude-plugin/plugin.json", content: plugin_default },
49562
- { path: "hooks/hooks.json", content: hooks_default },
49563
- { path: "mcp.json", content: mcp_default },
49564
- { path: "commands/pr.md", content: pr_default },
49565
- { path: "commands/task-info.md", content: task_info_default },
49566
- { path: "commands/notify.md", content: notify_default },
49567
- { path: "commands/linear.md", content: linear_default },
49568
- { path: "commands/review.md", content: review_default },
49569
- { path: "skills/vibora/SKILL.md", content: SKILL_default }
49570
- ];
49571
- async function handleClaudeCommand(action) {
49572
+ // cli/src/commands/opencode.ts
49573
+ var OPENCODE_DIR = join5(homedir3(), ".opencode");
49574
+ var OPENCODE_CONFIG_PATH = join5(OPENCODE_DIR, "opencode.json");
49575
+ var PLUGIN_DIR2 = join5(homedir3(), ".config", "opencode", "plugin");
49576
+ var PLUGIN_PATH = join5(PLUGIN_DIR2, "fulcrum.ts");
49577
+ var FULCRUM_MCP_CONFIG = {
49578
+ type: "local",
49579
+ command: ["fulcrum", "mcp"],
49580
+ enabled: true
49581
+ };
49582
+ async function handleOpenCodeCommand(action) {
49572
49583
  if (action === "install") {
49573
- await installClaudePlugin();
49584
+ await installOpenCodeIntegration();
49574
49585
  return;
49575
49586
  }
49576
49587
  if (action === "uninstall") {
49577
- await uninstallClaudePlugin();
49588
+ await uninstallOpenCodeIntegration();
49578
49589
  return;
49579
49590
  }
49580
- throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum claude install | fulcrum claude uninstall", ExitCodes.INVALID_ARGS);
49591
+ throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum opencode install | fulcrum opencode uninstall", ExitCodes.INVALID_ARGS);
49581
49592
  }
49582
- async function installClaudePlugin() {
49593
+ async function installOpenCodeIntegration() {
49583
49594
  try {
49584
- console.log("Installing Claude Code plugin...");
49585
- if (existsSync5(PLUGIN_DIR2)) {
49586
- console.log("\u2022 Removing existing plugin installation...");
49587
- rmSync(PLUGIN_DIR2, { recursive: true });
49588
- }
49589
- for (const file2 of PLUGIN_FILES) {
49590
- const fullPath = join5(PLUGIN_DIR2, file2.path);
49591
- const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
49592
- mkdirSync4(dir, { recursive: true });
49593
- writeFileSync4(fullPath, file2.content, "utf-8");
49594
- }
49595
- console.log("\u2713 Installed plugin at " + PLUGIN_DIR2);
49595
+ console.log("Installing OpenCode plugin...");
49596
+ mkdirSync4(PLUGIN_DIR2, { recursive: true });
49597
+ writeFileSync4(PLUGIN_PATH, fulcrum_opencode_default, "utf-8");
49598
+ console.log("\u2713 Installed plugin at " + PLUGIN_PATH);
49599
+ console.log("Configuring MCP server...");
49600
+ const mcpConfigured = addMcpServer();
49596
49601
  console.log("");
49597
- console.log("Installation complete! Restart Claude Code to apply changes.");
49602
+ if (mcpConfigured) {
49603
+ console.log("Installation complete! Restart OpenCode to apply changes.");
49604
+ } else {
49605
+ console.log("Plugin installed, but MCP configuration was skipped.");
49606
+ console.log("Please add the MCP server manually (see above).");
49607
+ }
49598
49608
  } catch (err) {
49599
- throw new CliError("INSTALL_FAILED", `Failed to install Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49609
+ throw new CliError("INSTALL_FAILED", `Failed to install OpenCode integration: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49600
49610
  }
49601
49611
  }
49602
- async function uninstallClaudePlugin() {
49612
+ async function uninstallOpenCodeIntegration() {
49603
49613
  try {
49604
- if (existsSync5(PLUGIN_DIR2)) {
49605
- rmSync(PLUGIN_DIR2, { recursive: true });
49606
- console.log("\u2713 Removed plugin from " + PLUGIN_DIR2);
49607
- console.log("");
49608
- console.log("Uninstall complete! Restart Claude Code to apply changes.");
49614
+ let removedPlugin = false;
49615
+ let removedMcp = false;
49616
+ if (existsSync5(PLUGIN_PATH)) {
49617
+ unlinkSync2(PLUGIN_PATH);
49618
+ console.log("\u2713 Removed plugin from " + PLUGIN_PATH);
49619
+ removedPlugin = true;
49609
49620
  } else {
49610
- console.log("Nothing to uninstall. Plugin not found at " + PLUGIN_DIR2);
49621
+ console.log("\u2022 Plugin not found (already removed)");
49622
+ }
49623
+ removedMcp = removeMcpServer();
49624
+ if (!removedPlugin && !removedMcp) {
49625
+ console.log("Nothing to uninstall.");
49626
+ } else {
49627
+ console.log("");
49628
+ console.log("Uninstall complete! Restart OpenCode to apply changes.");
49611
49629
  }
49612
49630
  } catch (err) {
49613
- throw new CliError("UNINSTALL_FAILED", `Failed to uninstall Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49631
+ throw new CliError("UNINSTALL_FAILED", `Failed to uninstall OpenCode integration: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49632
+ }
49633
+ }
49634
+ function getMcpObject(config3) {
49635
+ const mcp = config3.mcp;
49636
+ if (mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
49637
+ return mcp;
49638
+ }
49639
+ return {};
49640
+ }
49641
+ function addMcpServer() {
49642
+ mkdirSync4(OPENCODE_DIR, { recursive: true });
49643
+ let config3 = {};
49644
+ if (existsSync5(OPENCODE_CONFIG_PATH)) {
49645
+ try {
49646
+ const content = readFileSync5(OPENCODE_CONFIG_PATH, "utf-8");
49647
+ config3 = JSON.parse(content);
49648
+ } catch {
49649
+ console.log("\u26A0 Could not parse existing opencode.json, skipping MCP configuration");
49650
+ console.log(" Add manually to ~/.opencode/opencode.json:");
49651
+ console.log(' "mcp": { "fulcrum": { "type": "local", "command": ["fulcrum", "mcp"], "enabled": true } }');
49652
+ return false;
49653
+ }
49654
+ }
49655
+ const mcp = getMcpObject(config3);
49656
+ if (mcp.fulcrum) {
49657
+ console.log("\u2022 MCP server already configured, preserving existing configuration");
49658
+ return true;
49659
+ }
49660
+ if (existsSync5(OPENCODE_CONFIG_PATH)) {
49661
+ copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_CONFIG_PATH + ".backup");
49662
+ }
49663
+ config3.mcp = {
49664
+ ...mcp,
49665
+ fulcrum: FULCRUM_MCP_CONFIG
49666
+ };
49667
+ const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
49668
+ try {
49669
+ writeFileSync4(tempPath, JSON.stringify(config3, null, 2), "utf-8");
49670
+ renameSync(tempPath, OPENCODE_CONFIG_PATH);
49671
+ } catch (error46) {
49672
+ try {
49673
+ if (existsSync5(tempPath)) {
49674
+ unlinkSync2(tempPath);
49675
+ }
49676
+ } catch {}
49677
+ throw error46;
49678
+ }
49679
+ console.log("\u2713 Added MCP server to " + OPENCODE_CONFIG_PATH);
49680
+ return true;
49681
+ }
49682
+ function removeMcpServer() {
49683
+ if (!existsSync5(OPENCODE_CONFIG_PATH)) {
49684
+ console.log("\u2022 MCP config not found (already removed)");
49685
+ return false;
49686
+ }
49687
+ let config3;
49688
+ try {
49689
+ const content = readFileSync5(OPENCODE_CONFIG_PATH, "utf-8");
49690
+ config3 = JSON.parse(content);
49691
+ } catch {
49692
+ console.log("\u26A0 Could not parse opencode.json, skipping MCP removal");
49693
+ return false;
49694
+ }
49695
+ const mcp = getMcpObject(config3);
49696
+ if (!mcp.fulcrum) {
49697
+ console.log("\u2022 MCP server not configured (already removed)");
49698
+ return false;
49699
+ }
49700
+ copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_CONFIG_PATH + ".backup");
49701
+ delete mcp.fulcrum;
49702
+ if (Object.keys(mcp).length === 0) {
49703
+ delete config3.mcp;
49704
+ } else {
49705
+ config3.mcp = mcp;
49706
+ }
49707
+ const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
49708
+ try {
49709
+ writeFileSync4(tempPath, JSON.stringify(config3, null, 2), "utf-8");
49710
+ renameSync(tempPath, OPENCODE_CONFIG_PATH);
49711
+ } catch (error46) {
49712
+ try {
49713
+ if (existsSync5(tempPath)) {
49714
+ unlinkSync2(tempPath);
49715
+ }
49716
+ } catch {}
49717
+ throw error46;
49614
49718
  }
49719
+ console.log("\u2713 Removed MCP server from " + OPENCODE_CONFIG_PATH);
49720
+ return true;
49615
49721
  }
49616
49722
 
49617
49723
  // cli/src/commands/notifications.ts