@knowsuchagency/fulcrum 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fulcrum.js CHANGED
@@ -43866,12 +43866,16 @@ function registerTools(server, client) {
43866
43866
  return handleToolError(err);
43867
43867
  }
43868
43868
  });
43869
- server.tool("get_task", "Get details of a specific task by ID", {
43869
+ server.tool("get_task", "Get details of a specific task by ID, including dependencies and attachments", {
43870
43870
  id: exports_external.string().describe("Task ID (UUID)")
43871
43871
  }, async ({ id }) => {
43872
43872
  try {
43873
- const task = await client.getTask(id);
43874
- return formatSuccess(task);
43873
+ const [task, dependencies, attachments] = await Promise.all([
43874
+ client.getTask(id),
43875
+ client.getTaskDependencies(id),
43876
+ client.listTaskAttachments(id)
43877
+ ]);
43878
+ return formatSuccess({ ...task, dependencies, attachments });
43875
43879
  } catch (err) {
43876
43880
  return handleToolError(err);
43877
43881
  }
@@ -46168,15 +46172,62 @@ var STATUS_MAP = {
46168
46172
  cancel: "CANCELED",
46169
46173
  "in-progress": "IN_PROGRESS"
46170
46174
  };
46171
- function formatTask(task) {
46175
+ function formatTask(task, dependencies, attachments) {
46172
46176
  console.log(`${task.title}`);
46173
- console.log(` ID: ${task.id}`);
46174
- console.log(` Status: ${task.status}`);
46175
- console.log(` Repo: ${task.repoName}`);
46177
+ console.log(` ID: ${task.id}`);
46178
+ console.log(` Status: ${task.status}`);
46179
+ if (task.description) {
46180
+ console.log(` Description: ${task.description}`);
46181
+ }
46182
+ if (task.repoName)
46183
+ console.log(` Repo: ${task.repoName}`);
46176
46184
  if (task.branch)
46177
- console.log(` Branch: ${task.branch}`);
46185
+ console.log(` Branch: ${task.branch}`);
46186
+ if (task.worktreePath)
46187
+ console.log(` Worktree: ${task.worktreePath}`);
46178
46188
  if (task.prUrl)
46179
- console.log(` PR: ${task.prUrl}`);
46189
+ console.log(` PR: ${task.prUrl}`);
46190
+ if (task.links && task.links.length > 0) {
46191
+ console.log(` Links: ${task.links.map((l2) => l2.label || l2.url).join(", ")}`);
46192
+ }
46193
+ if (task.labels && task.labels.length > 0) {
46194
+ console.log(` Labels: ${task.labels.join(", ")}`);
46195
+ }
46196
+ if (task.dueDate)
46197
+ console.log(` Due: ${task.dueDate}`);
46198
+ if (task.projectId)
46199
+ console.log(` Project: ${task.projectId}`);
46200
+ console.log(` Agent: ${task.agent}`);
46201
+ if (task.aiMode)
46202
+ console.log(` AI Mode: ${task.aiMode}`);
46203
+ if (task.agentOptions && Object.keys(task.agentOptions).length > 0) {
46204
+ console.log(` Options: ${JSON.stringify(task.agentOptions)}`);
46205
+ }
46206
+ if (dependencies) {
46207
+ if (dependencies.isBlocked) {
46208
+ console.log(` Blocked: Yes`);
46209
+ }
46210
+ if (dependencies.dependsOn.length > 0) {
46211
+ console.log(` Depends on: ${dependencies.dependsOn.length} task(s)`);
46212
+ for (const dep of dependencies.dependsOn) {
46213
+ if (dep.task) {
46214
+ console.log(` - ${dep.task.title} [${dep.task.status}]`);
46215
+ }
46216
+ }
46217
+ }
46218
+ if (dependencies.dependents.length > 0) {
46219
+ console.log(` Blocking: ${dependencies.dependents.length} task(s)`);
46220
+ }
46221
+ }
46222
+ if (attachments && attachments.length > 0) {
46223
+ console.log(` Attachments: ${attachments.length} file(s)`);
46224
+ }
46225
+ if (task.notes) {
46226
+ console.log(` Notes: ${task.notes}`);
46227
+ }
46228
+ console.log(` Created: ${task.createdAt}`);
46229
+ if (task.startedAt)
46230
+ console.log(` Started: ${task.startedAt}`);
46180
46231
  }
46181
46232
  async function findCurrentTask(client, pathOverride) {
46182
46233
  if (process.env.FULCRUM_TASK_ID) {
@@ -46204,9 +46255,17 @@ async function handleCurrentTaskCommand(action, rest, flags) {
46204
46255
  if (!action) {
46205
46256
  const task2 = await findCurrentTask(client, pathOverride);
46206
46257
  if (isJsonOutput()) {
46207
- output(task2);
46258
+ const [dependencies, attachments] = await Promise.all([
46259
+ client.getTaskDependencies(task2.id),
46260
+ client.listTaskAttachments(task2.id)
46261
+ ]);
46262
+ output({ ...task2, dependencies, attachments });
46208
46263
  } else {
46209
- formatTask(task2);
46264
+ const [dependencies, attachments] = await Promise.all([
46265
+ client.getTaskDependencies(task2.id),
46266
+ client.listTaskAttachments(task2.id)
46267
+ ]);
46268
+ formatTask(task2, dependencies, attachments);
46210
46269
  }
46211
46270
  return;
46212
46271
  }
@@ -46297,22 +46356,62 @@ init_client();
46297
46356
  import { basename as basename3 } from "path";
46298
46357
  init_errors();
46299
46358
  var VALID_STATUSES = ["TO_DO", "IN_PROGRESS", "IN_REVIEW", "CANCELED"];
46300
- function formatTask2(task) {
46359
+ function formatTask2(task, dependencies, attachments) {
46301
46360
  console.log(`${task.title}`);
46302
- console.log(` ID: ${task.id}`);
46303
- console.log(` Status: ${task.status}`);
46361
+ console.log(` ID: ${task.id}`);
46362
+ console.log(` Status: ${task.status}`);
46363
+ if (task.description) {
46364
+ console.log(` Description: ${task.description}`);
46365
+ }
46304
46366
  if (task.repoName)
46305
- console.log(` Repo: ${task.repoName}`);
46367
+ console.log(` Repo: ${task.repoName}`);
46306
46368
  if (task.branch)
46307
- console.log(` Branch: ${task.branch}`);
46369
+ console.log(` Branch: ${task.branch}`);
46370
+ if (task.worktreePath)
46371
+ console.log(` Worktree: ${task.worktreePath}`);
46308
46372
  if (task.prUrl)
46309
- console.log(` PR: ${task.prUrl}`);
46310
- if (task.projectId)
46311
- console.log(` Project: ${task.projectId}`);
46312
- if (task.labels && task.labels.length > 0)
46313
- console.log(` Labels: ${task.labels.join(", ")}`);
46373
+ console.log(` PR: ${task.prUrl}`);
46374
+ if (task.links && task.links.length > 0) {
46375
+ console.log(` Links: ${task.links.map((l2) => l2.label || l2.url).join(", ")}`);
46376
+ }
46377
+ if (task.labels && task.labels.length > 0) {
46378
+ console.log(` Labels: ${task.labels.join(", ")}`);
46379
+ }
46314
46380
  if (task.dueDate)
46315
- console.log(` Due: ${task.dueDate}`);
46381
+ console.log(` Due: ${task.dueDate}`);
46382
+ if (task.projectId)
46383
+ console.log(` Project: ${task.projectId}`);
46384
+ console.log(` Agent: ${task.agent}`);
46385
+ if (task.aiMode)
46386
+ console.log(` AI Mode: ${task.aiMode}`);
46387
+ if (task.agentOptions && Object.keys(task.agentOptions).length > 0) {
46388
+ console.log(` Options: ${JSON.stringify(task.agentOptions)}`);
46389
+ }
46390
+ if (dependencies) {
46391
+ if (dependencies.isBlocked) {
46392
+ console.log(` Blocked: Yes`);
46393
+ }
46394
+ if (dependencies.dependsOn.length > 0) {
46395
+ console.log(` Depends on: ${dependencies.dependsOn.length} task(s)`);
46396
+ for (const dep of dependencies.dependsOn) {
46397
+ if (dep.task) {
46398
+ console.log(` - ${dep.task.title} [${dep.task.status}]`);
46399
+ }
46400
+ }
46401
+ }
46402
+ if (dependencies.dependents.length > 0) {
46403
+ console.log(` Blocking: ${dependencies.dependents.length} task(s)`);
46404
+ }
46405
+ }
46406
+ if (attachments && attachments.length > 0) {
46407
+ console.log(` Attachments: ${attachments.length} file(s)`);
46408
+ }
46409
+ if (task.notes) {
46410
+ console.log(` Notes: ${task.notes}`);
46411
+ }
46412
+ console.log(` Created: ${task.createdAt}`);
46413
+ if (task.startedAt)
46414
+ console.log(` Started: ${task.startedAt}`);
46316
46415
  }
46317
46416
  function formatTaskList(tasks) {
46318
46417
  if (tasks.length === 0) {
@@ -46394,10 +46493,14 @@ async function handleTasksCommand(action, positional, flags) {
46394
46493
  throw new CliError("MISSING_ID", "Task ID required", ExitCodes.INVALID_ARGS);
46395
46494
  }
46396
46495
  const task = await client.getTask(id);
46496
+ const [dependencies, attachments] = await Promise.all([
46497
+ client.getTaskDependencies(id),
46498
+ client.listTaskAttachments(id)
46499
+ ]);
46397
46500
  if (isJsonOutput()) {
46398
- output(task);
46501
+ output({ ...task, dependencies, attachments });
46399
46502
  } else {
46400
- formatTask2(task);
46503
+ formatTask2(task, dependencies, attachments);
46401
46504
  }
46402
46505
  break;
46403
46506
  }
@@ -46649,8 +46752,98 @@ Labels:`);
46649
46752
  }
46650
46753
  break;
46651
46754
  }
46755
+ case "attachments": {
46756
+ const [subAction, taskIdOrFile, fileOrAttachmentId] = positional;
46757
+ if (!subAction || subAction === "help") {
46758
+ console.log("Usage:");
46759
+ console.log(" fulcrum tasks attachments list <task-id>");
46760
+ console.log(" fulcrum tasks attachments upload <task-id> <file-path>");
46761
+ console.log(" fulcrum tasks attachments delete <task-id> <attachment-id>");
46762
+ console.log(" fulcrum tasks attachments path <task-id> <attachment-id>");
46763
+ break;
46764
+ }
46765
+ if (subAction === "list") {
46766
+ const taskId = taskIdOrFile;
46767
+ if (!taskId) {
46768
+ throw new CliError("MISSING_ID", "Task ID required", ExitCodes.INVALID_ARGS);
46769
+ }
46770
+ const attachments = await client.listTaskAttachments(taskId);
46771
+ if (isJsonOutput()) {
46772
+ output(attachments);
46773
+ } else {
46774
+ if (attachments.length === 0) {
46775
+ console.log("No attachments");
46776
+ } else {
46777
+ console.log(`
46778
+ Attachments (${attachments.length}):`);
46779
+ for (const att of attachments) {
46780
+ console.log(` ${att.filename}`);
46781
+ console.log(` ID: ${att.id}`);
46782
+ console.log(` Type: ${att.mimeType}`);
46783
+ console.log(` Size: ${att.size} bytes`);
46784
+ }
46785
+ }
46786
+ }
46787
+ break;
46788
+ }
46789
+ if (subAction === "upload") {
46790
+ const taskId = taskIdOrFile;
46791
+ const filePath = fileOrAttachmentId;
46792
+ if (!taskId) {
46793
+ throw new CliError("MISSING_ID", "Task ID required", ExitCodes.INVALID_ARGS);
46794
+ }
46795
+ if (!filePath) {
46796
+ throw new CliError("MISSING_FILE", "File path required", ExitCodes.INVALID_ARGS);
46797
+ }
46798
+ const attachment = await client.uploadTaskAttachment(taskId, filePath);
46799
+ if (isJsonOutput()) {
46800
+ output(attachment);
46801
+ } else {
46802
+ console.log(`Uploaded: ${attachment.filename}`);
46803
+ console.log(` ID: ${attachment.id}`);
46804
+ }
46805
+ break;
46806
+ }
46807
+ if (subAction === "delete") {
46808
+ const taskId = taskIdOrFile;
46809
+ const attachmentId = fileOrAttachmentId;
46810
+ if (!taskId) {
46811
+ throw new CliError("MISSING_ID", "Task ID required", ExitCodes.INVALID_ARGS);
46812
+ }
46813
+ if (!attachmentId) {
46814
+ throw new CliError("MISSING_ATTACHMENT_ID", "Attachment ID required", ExitCodes.INVALID_ARGS);
46815
+ }
46816
+ await client.deleteTaskAttachment(taskId, attachmentId);
46817
+ if (isJsonOutput()) {
46818
+ output({ success: true, deleted: attachmentId });
46819
+ } else {
46820
+ console.log(`Deleted attachment: ${attachmentId}`);
46821
+ }
46822
+ break;
46823
+ }
46824
+ if (subAction === "path") {
46825
+ const taskId = taskIdOrFile;
46826
+ const attachmentId = fileOrAttachmentId;
46827
+ if (!taskId) {
46828
+ throw new CliError("MISSING_ID", "Task ID required", ExitCodes.INVALID_ARGS);
46829
+ }
46830
+ if (!attachmentId) {
46831
+ throw new CliError("MISSING_ATTACHMENT_ID", "Attachment ID required", ExitCodes.INVALID_ARGS);
46832
+ }
46833
+ const result = await client.getTaskAttachmentPath(taskId, attachmentId);
46834
+ if (isJsonOutput()) {
46835
+ output(result);
46836
+ } else {
46837
+ console.log(`Path: ${result.path}`);
46838
+ console.log(`Filename: ${result.filename}`);
46839
+ console.log(`Type: ${result.mimeType}`);
46840
+ }
46841
+ break;
46842
+ }
46843
+ throw new CliError("UNKNOWN_SUBACTION", `Unknown attachments action: ${subAction}. Valid: list, upload, delete, path`, ExitCodes.INVALID_ARGS);
46844
+ }
46652
46845
  default:
46653
- throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, get, create, update, move, delete, add-label, remove-label, set-due-date, add-dependency, remove-dependency, list-dependencies, labels`, ExitCodes.INVALID_ARGS);
46846
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, get, create, update, move, delete, add-label, remove-label, set-due-date, add-dependency, remove-dependency, list-dependencies, labels, attachments`, ExitCodes.INVALID_ARGS);
46654
46847
  }
46655
46848
  }
46656
46849
 
@@ -47617,8 +47810,8 @@ async function handleFsCommand(action, positional, flags) {
47617
47810
 
47618
47811
  // cli/src/commands/up.ts
47619
47812
  import { spawn } from "child_process";
47620
- import { existsSync as existsSync3 } from "fs";
47621
- import { dirname as dirname2, join as join3 } from "path";
47813
+ import { existsSync as existsSync4 } from "fs";
47814
+ import { dirname as dirname3, join as join4 } from "path";
47622
47815
  import { fileURLToPath } from "url";
47623
47816
  init_errors();
47624
47817
 
@@ -47899,1093 +48092,77 @@ function installUv() {
47899
48092
  return false;
47900
48093
  return installDependency(dep);
47901
48094
  }
47902
- // package.json
47903
- var package_default = {
47904
- name: "@knowsuchagency/fulcrum",
47905
- private: true,
47906
- version: "1.3.0",
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
48095
 
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;
48096
+ // cli/src/commands/claude.ts
48097
+ init_errors();
48098
+ import { spawnSync as spawnSync2 } from "child_process";
48099
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync, readFileSync as readFileSync4 } from "fs";
48100
+ import { homedir as homedir2 } from "os";
48101
+ import { dirname as dirname2, join as join3 } from "path";
48102
+
48103
+ // plugins/fulcrum/.claude-plugin/marketplace.json
48104
+ var marketplace_default = `{
48105
+ "name": "fulcrum",
48106
+ "owner": {
48107
+ "name": "Fulcrum"
48108
+ },
48109
+ "plugins": [
48110
+ {
48111
+ "name": "fulcrum",
48112
+ "source": "./",
48113
+ "description": "Task orchestration for Claude Code",
48114
+ "version": "1.5.0",
48115
+ "skills": [
48116
+ "./skills/fulcrum"
48117
+ ],
48118
+ "keywords": [
48119
+ "fulcrum",
48120
+ "task",
48121
+ "orchestration",
48122
+ "worktree"
48123
+ ]
48003
48124
  }
48004
- dir = dirname2(dir);
48125
+ ],
48126
+ "metadata": {
48127
+ "description": "Fulcrum task orchestration plugin marketplace",
48128
+ "version": "1.0.0"
48005
48129
  }
48006
- return dirname2(dirname2(dirname2(currentFile)));
48007
48130
  }
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);
48131
+ `;
48132
+
48133
+ // plugins/fulcrum/hooks/hooks.json
48134
+ var hooks_default = `{
48135
+ "hooks": {
48136
+ "Stop": [
48137
+ {
48138
+ "hooks": [
48139
+ {
48140
+ "type": "command",
48141
+ "command": "fulcrum current-task review 2>/dev/null || true"
48142
+ }
48143
+ ]
48027
48144
  }
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);
48145
+ ],
48146
+ "UserPromptSubmit": [
48147
+ {
48148
+ "hooks": [
48149
+ {
48150
+ "type": "command",
48151
+ "command": "fulcrum current-task in-progress 2>/dev/null || true"
48152
+ }
48153
+ ]
48043
48154
  }
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);
48059
- }
48060
- console.error("uv installed successfully!");
48061
- } else {
48062
- throw new CliError("MISSING_DEPENDENCY", `uv is required. Install manually: ${getInstallCommand(uvDep)}`, ExitCodes.ERROR);
48063
- }
48064
- }
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);
48084
- }
48085
- }
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
- }
48145
- function showGettingStartedTips(port, hasAgent) {
48146
- console.error(`
48147
- Fulcrum is running at http://localhost:${port}
48148
-
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
48154
-
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
- }
48167
-
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
- }
48206
-
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
- }
48263
-
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
- }
48313
-
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
- }
48377
-
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
- }
48420
-
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
- }
48488
-
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";
48502
-
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"
48509
-
48510
- declare const process: { env: Record<string, string | undefined> }
48511
-
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
- }
48526
-
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
48540
-
48541
- const child = spawn(FULCRUM_CMD, args, { shell: true })
48542
-
48543
- const cleanup = () => {
48544
- processExited = true
48545
- if (killTimeoutId) {
48546
- clearTimeout(killTimeoutId)
48547
- killTimeoutId = null
48548
- }
48549
- }
48550
-
48551
- child.stdout?.on('data', (data) => {
48552
- stdout += data.toString()
48553
- })
48554
-
48555
- child.stderr?.on('data', (data) => {
48556
- stderr += data.toString()
48557
- })
48558
-
48559
- child.on('close', (code) => {
48560
- cleanup()
48561
- if (!resolved) {
48562
- resolved = true
48563
- resolve({ exitCode: code || 0, stdout, stderr })
48564
- }
48565
- })
48566
-
48567
- child.on('error', (err) => {
48568
- cleanup()
48569
- if (!resolved) {
48570
- resolved = true
48571
- resolve({ exitCode: 1, stdout, stderr: err.message || '' })
48572
- }
48573
- })
48574
-
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)
48591
-
48592
- // Clear timeout if command completes
48593
- child.on('exit', () => clearTimeout(timeoutId))
48594
- })
48595
- }
48596
-
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" | "" = ""
48602
-
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
48607
-
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
48611
-
48612
- export const FulcrumPlugin: Plugin = async ({ $, directory }) => {
48613
- log("Plugin initializing...")
48614
-
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
- }
48637
-
48638
- log("Plugin hooks registered")
48639
-
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
- }
48649
-
48650
- const cancelPendingIdle = () => {
48651
- if (pendingIdleTimer) {
48652
- clearTimeout(pendingIdleTimer)
48653
- pendingIdleTimer = null
48654
- log("Cancelled pending idle transition")
48655
- }
48656
- }
48657
-
48658
- const setStatus = (status: "in-progress" | "review") => {
48659
- if (status === lastStatus) return
48660
-
48661
- cancelPendingIdle()
48662
-
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
- }
48668
-
48669
- lastStatus = status
48670
-
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
48677
-
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
- }
48687
-
48688
- const scheduleIdleTransition = () => {
48689
- cancelPendingIdle()
48690
- const currentVersion = ++activityVersion
48691
-
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)
48701
-
48702
- log(
48703
- \`Scheduled idle transition (version \${currentVersion}, delay \${IDLE_CONFIRMATION_DELAY_MS}ms)\`,
48704
- )
48705
- }
48706
-
48707
- const recordActivity = (reason: string) => {
48708
- activityVersion++
48709
- cancelPendingIdle()
48710
- log(\`Activity: \${reason} (version now \${activityVersion})\`)
48711
- }
48712
-
48713
- return {
48714
- "chat.message": async (_input, output) => {
48715
- if (!(await checkContext())) return
48716
-
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
- },
48724
-
48725
- event: async ({ event }) => {
48726
- if (!NOISY_EVENTS.has(event.type)) {
48727
- log(\`Event: \${event.type}\`)
48728
- }
48729
-
48730
- if (!(await checkContext())) return
48731
-
48732
- const props = (event.properties as Record<string, unknown>) || {}
48733
-
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
48738
-
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
- }
48746
-
48747
- recordActivity("session.created")
48748
- setStatus("in-progress")
48749
- return
48750
- }
48751
-
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
- }
48760
-
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
48768
-
48769
- if (sessionId && subagentSessions.has(sessionId)) {
48770
- log(\`Ignoring subagent idle: \${sessionId}\`)
48771
- return
48772
- }
48773
-
48774
- if (mainSessionId && sessionId && sessionId !== mainSessionId) {
48775
- log(\`Ignoring non-main idle: \${sessionId} (main: \${mainSessionId})\`)
48776
- return
48777
- }
48778
-
48779
- log(\`Main session idle detected: \${sessionId}\`)
48780
- scheduleIdleTransition()
48781
- }
48782
- },
48783
- }
48784
- }
48785
- `;
48786
-
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
- }
48937
-
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";
48943
-
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.3.0",
48949
- "author": {
48950
- "name": "Fulcrum"
48951
- },
48952
- "hooks": "./hooks/hooks.json"
48953
- }
48954
- `;
48955
-
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
- `;
48982
-
48983
- // plugins/fulcrum/mcp.json
48984
- var mcp_default = `{
48985
- "mcpServers": {
48986
- "fulcrum": {
48987
- "command": "fulcrum",
48988
- "args": ["mcp"]
48155
+ ]
48156
+ }
48157
+ }
48158
+ `;
48159
+
48160
+ // plugins/fulcrum/.mcp.json
48161
+ var _mcp_default = `{
48162
+ "mcpServers": {
48163
+ "fulcrum": {
48164
+ "command": "fulcrum",
48165
+ "args": ["mcp"]
48989
48166
  }
48990
48167
  }
48991
48168
  }
@@ -49016,38 +48193,25 @@ Send a notification: \`fulcrum notify $ARGUMENTS\`
49016
48193
  Format: fulcrum notify "Title" "Message body"
49017
48194
  `;
49018
48195
 
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
- `;
49025
-
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\`
49031
-
49032
- This sends a notification to the user.
49033
- `;
49034
-
49035
- // plugins/fulcrum/skills/vibora/SKILL.md
48196
+ // plugins/fulcrum/skills/fulcrum/SKILL.md
49036
48197
  var SKILL_default = `---
49037
48198
  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.
48199
+ description: AI orchestration and task management platform. Use this skill when working in a Fulcrum task worktree, managing tasks/projects, or interacting with the Fulcrum server.
49039
48200
  ---
49040
48201
 
49041
- # Fulcrum - AI Agent Orchestration
48202
+ # Fulcrum - AI Orchestration Platform
49042
48203
 
49043
48204
  ## Overview
49044
48205
 
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.
48206
+ Fulcrum is an AI orchestration and task management platform. It provides task tracking, project management, and tools for AI agents to work autonomously across isolated git worktrees.
49046
48207
 
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
48208
+ **Key Features:**
48209
+ - Task management with kanban boards and status tracking
48210
+ - Project and repository organization
48211
+ - Git worktree isolation for parallel development
48212
+ - Docker Compose app deployment
48213
+ - Multi-channel notifications
48214
+ - MCP tools for AI agent integration
49051
48215
 
49052
48216
  ## When to Use This Skill
49053
48217
 
@@ -49055,9 +48219,9 @@ Use the Fulcrum CLI when:
49055
48219
  - **Working in a task worktree** - Use \`current-task\` commands to manage your current task
49056
48220
  - **Updating task status** - Mark tasks as in-progress, ready for review, done, or canceled
49057
48221
  - **Linking PRs** - Associate a GitHub PR with the current task
49058
- - **Linking Linear tickets** - Connect a Linear issue to the current task
49059
48222
  - **Linking URLs** - Attach any relevant URLs (design docs, specs, external resources) to the task
49060
48223
  - **Sending notifications** - Alert the user when work is complete or needs attention
48224
+ - **Managing projects and repositories** - Create, update, and organize projects
49061
48225
 
49062
48226
  Use the Fulcrum MCP tools when:
49063
48227
  - **Executing commands remotely** - Run shell commands on the Fulcrum server from Claude Desktop
@@ -49070,7 +48234,7 @@ Use the Fulcrum MCP tools when:
49070
48234
  When running inside a Fulcrum task worktree, use these commands to manage the current task:
49071
48235
 
49072
48236
  \`\`\`bash
49073
- # Get current task info (JSON output)
48237
+ # Get current task info (comprehensive output with dependencies, attachments, etc.)
49074
48238
  fulcrum current-task
49075
48239
 
49076
48240
  # Update task status
@@ -49107,7 +48271,7 @@ fulcrum tasks list --label="bug" # Filter by label
49107
48271
  fulcrum tasks labels # Show all labels with counts
49108
48272
  fulcrum tasks labels --search="comm" # Find labels matching substring
49109
48273
 
49110
- # Get a specific task
48274
+ # Get a specific task (includes dependencies and attachments)
49111
48275
  fulcrum tasks get <task-id>
49112
48276
 
49113
48277
  # Create a new task
@@ -49122,6 +48286,25 @@ fulcrum tasks move <task-id> --status=IN_REVIEW
49122
48286
  # Delete a task
49123
48287
  fulcrum tasks delete <task-id>
49124
48288
  fulcrum tasks delete <task-id> --delete-worktree # Also delete worktree
48289
+
48290
+ # Labels
48291
+ fulcrum tasks add-label <task-id> <label>
48292
+ fulcrum tasks remove-label <task-id> <label>
48293
+
48294
+ # Due dates
48295
+ fulcrum tasks set-due-date <task-id> 2026-01-25 # Set due date
48296
+ fulcrum tasks set-due-date <task-id> none # Clear due date
48297
+
48298
+ # Dependencies
48299
+ fulcrum tasks add-dependency <task-id> <depends-on-task-id>
48300
+ fulcrum tasks remove-dependency <task-id> <dependency-id>
48301
+ fulcrum tasks list-dependencies <task-id>
48302
+
48303
+ # Attachments
48304
+ fulcrum tasks attachments list <task-id>
48305
+ fulcrum tasks attachments upload <task-id> <file-path>
48306
+ fulcrum tasks attachments delete <task-id> <attachment-id>
48307
+ fulcrum tasks attachments path <task-id> <attachment-id> # Get local file path
49125
48308
  \`\`\`
49126
48309
 
49127
48310
  ### notifications
@@ -49155,7 +48338,28 @@ fulcrum notifications set slack webhookUrl <url>
49155
48338
  fulcrum up # Start Fulcrum server daemon
49156
48339
  fulcrum down # Stop Fulcrum server
49157
48340
  fulcrum status # Check if server is running
49158
- fulcrum health # Check server health
48341
+ fulcrum doctor # Check all dependencies and versions
48342
+ \`\`\`
48343
+
48344
+ ### Configuration
48345
+
48346
+ \`\`\`bash
48347
+ fulcrum config list # List all config values
48348
+ fulcrum config get <key> # Get a specific value
48349
+ fulcrum config set <key> <value> # Set a value
48350
+ fulcrum config reset <key> # Reset to default
48351
+ \`\`\`
48352
+
48353
+ ### Agent Plugin Installation
48354
+
48355
+ \`\`\`bash
48356
+ # Claude Code integration
48357
+ fulcrum claude install # Install Fulcrum plugin for Claude Code
48358
+ fulcrum claude uninstall # Uninstall plugin
48359
+
48360
+ # OpenCode integration
48361
+ fulcrum opencode install # Install Fulcrum plugin for OpenCode
48362
+ fulcrum opencode uninstall # Uninstall plugin
49159
48363
  \`\`\`
49160
48364
 
49161
48365
  ### Git Operations
@@ -49163,7 +48367,15 @@ fulcrum health # Check server health
49163
48367
  \`\`\`bash
49164
48368
  fulcrum git status # Git status for current worktree
49165
48369
  fulcrum git diff # Git diff for current worktree
48370
+ fulcrum git diff --staged # Show staged changes only
48371
+ fulcrum git branches --repo=/path/to/repo # List branches
48372
+ \`\`\`
48373
+
48374
+ ### Worktrees
48375
+
48376
+ \`\`\`bash
49166
48377
  fulcrum worktrees list # List all worktrees
48378
+ fulcrum worktrees delete --path=/path/to/worktree # Delete a worktree
49167
48379
  \`\`\`
49168
48380
 
49169
48381
  ### projects
@@ -49316,302 +48528,1434 @@ fulcrum fs stat --path=/path/to/check
49316
48528
  4. **Ready for Review**: Agent marks complete: \`fulcrum current-task review\`
49317
48529
  5. **Notification**: User receives notification that work is ready
49318
48530
 
49319
- ### Linking External Resources
48531
+ ### Linking External Resources
48532
+
48533
+ \`\`\`bash
48534
+ # After creating a GitHub PR
48535
+ fulcrum current-task pr https://github.com/owner/repo/pull/123
48536
+
48537
+ # After identifying the relevant Linear ticket
48538
+ fulcrum current-task linear https://linear.app/team/issue/TEAM-123
48539
+
48540
+ # Add any URL link (design docs, figma, notion, external resources)
48541
+ fulcrum current-task link https://figma.com/file/abc123/design
48542
+ fulcrum current-task link https://notion.so/team/spec --label "Product Spec"
48543
+ \`\`\`
48544
+
48545
+ ### Notifying the User
48546
+
48547
+ \`\`\`bash
48548
+ # When work is complete
48549
+ fulcrum notify "Task Complete" "Implemented the new feature and created PR #123"
48550
+
48551
+ # When blocked or need input
48552
+ fulcrum notify "Need Input" "Which approach should I use for the database migration?"
48553
+ \`\`\`
48554
+
48555
+ ## Global Options
48556
+
48557
+ These flags work with most commands:
48558
+
48559
+ - \`--port=<port>\` - Server port (default: 7777)
48560
+ - \`--url=<url>\` - Override full server URL
48561
+ - \`--json\` - Output as JSON for programmatic use
48562
+
48563
+ ## Task Statuses
48564
+
48565
+ - \`TO_DO\` - Task not yet started
48566
+ - \`IN_PROGRESS\` - Task is being worked on
48567
+ - \`IN_REVIEW\` - Task is complete and awaiting review
48568
+ - \`DONE\` - Task is finished
48569
+ - \`CANCELED\` - Task was abandoned
48570
+
48571
+ ## MCP Tools
48572
+
48573
+ Fulcrum provides a comprehensive set of MCP tools for AI agents. Use \`search_tools\` to discover available tools.
48574
+
48575
+ ### Tool Discovery
48576
+
48577
+ #### search_tools
48578
+
48579
+ Search for available tools by keyword or category:
48580
+
48581
+ \`\`\`json
48582
+ {
48583
+ "query": "deploy", // Optional: Search term
48584
+ "category": "apps" // Optional: Filter by category
48585
+ }
48586
+ \`\`\`
48587
+
48588
+ **Categories:** core, tasks, projects, repositories, apps, filesystem, git, notifications, exec
48589
+
48590
+ **Example Usage:**
48591
+ \`\`\`
48592
+ search_tools { query: "project create" }
48593
+ \u2192 Returns tools for creating projects
48594
+
48595
+ search_tools { category: "filesystem" }
48596
+ \u2192 Returns all filesystem tools
48597
+ \`\`\`
48598
+
48599
+ ### Task Tools
48600
+
48601
+ - \`list_tasks\` - List tasks with flexible filtering (search, labels, statuses, date range, overdue)
48602
+ - \`get_task\` - Get task details by ID (includes dependencies and attachments)
48603
+ - \`create_task\` - Create a new task with worktree
48604
+ - \`update_task\` - Update task metadata
48605
+ - \`delete_task\` - Delete a task
48606
+ - \`move_task\` - Move task to different status
48607
+ - \`add_task_link\` - Add URL link to task
48608
+ - \`remove_task_link\` - Remove link from task
48609
+ - \`list_task_links\` - List all task links
48610
+ - \`add_task_tag\` - Add a label to a task (returns similar labels to catch typos)
48611
+ - \`remove_task_tag\` - Remove a label from a task
48612
+ - \`set_task_due_date\` - Set or clear task due date
48613
+ - \`list_tags\` - List all unique labels in use with optional search
48614
+
48615
+ #### Task Dependencies
48616
+
48617
+ - \`get_task_dependencies\` - Get dependencies and dependents of a task, and whether it is blocked
48618
+ - \`add_task_dependency\` - Add a dependency (task cannot start until dependency is done)
48619
+ - \`remove_task_dependency\` - Remove a dependency
48620
+ - \`get_task_dependency_graph\` - Get all tasks and dependencies as a graph for visualization
48621
+
48622
+ #### Task Attachments
48623
+
48624
+ - \`list_task_attachments\` - List all file attachments for a task
48625
+ - \`upload_task_attachment\` - Upload a file to a task from a local path
48626
+ - \`delete_task_attachment\` - Delete a file attachment from a task
48627
+ - \`get_task_attachment_path\` - Get the local file path for a task attachment
48628
+
48629
+ #### Task Search and Filtering
48630
+
48631
+ The \`list_tasks\` tool supports powerful filtering for AI agents:
48632
+
48633
+ \`\`\`json
48634
+ {
48635
+ "search": "ocai", // Text search across title, labels, project name
48636
+ "labels": ["bug", "urgent"], // Filter by multiple labels (OR logic)
48637
+ "statuses": ["TO_DO", "IN_PROGRESS"], // Filter by multiple statuses (OR logic)
48638
+ "dueDateStart": "2026-01-18", // Start of date range
48639
+ "dueDateEnd": "2026-01-25", // End of date range
48640
+ "overdue": true // Only show overdue tasks
48641
+ }
48642
+ \`\`\`
48643
+
48644
+ #### Label Discovery
48645
+
48646
+ Use \`list_labels\` to discover exact label names before filtering:
48647
+
48648
+ \`\`\`json
48649
+ // Find labels matching "communication"
48650
+ { "search": "communication" }
48651
+ // Returns: [{ "name": "communication required", "count": 5 }]
48652
+ \`\`\`
48653
+
48654
+ This helps handle typos and variations - search first, then use the exact label name.
48655
+
48656
+ ### Project Tools
48657
+
48658
+ - \`list_projects\` - List all projects
48659
+ - \`get_project\` - Get project details
48660
+ - \`create_project\` - Create from path, URL, or existing repo
48661
+ - \`update_project\` - Update name, description, status
48662
+ - \`delete_project\` - Delete project and optionally directory/app
48663
+ - \`scan_projects\` - Scan directory for git repos
48664
+
48665
+ #### Project Tags
48666
+
48667
+ - \`add_project_tag\` - Add a tag to a project (creates new if needed)
48668
+ - \`remove_project_tag\` - Remove a tag from a project
48669
+
48670
+ #### Project Attachments
48671
+
48672
+ - \`list_project_attachments\` - List all file attachments for a project
48673
+ - \`upload_project_attachment\` - Upload a file to a project from a local path
48674
+ - \`delete_project_attachment\` - Delete a file attachment from a project
48675
+ - \`get_project_attachment_path\` - Get the local file path for a project attachment
48676
+
48677
+ #### Project Links
48678
+
48679
+ - \`list_project_links\` - List all URL links attached to a project
48680
+ - \`add_project_link\` - Add a URL link to a project (auto-detects type)
48681
+ - \`remove_project_link\` - Remove a URL link from a project
48682
+
48683
+ ### Repository Tools
48684
+
48685
+ - \`list_repositories\` - List all repositories (supports orphans filter)
48686
+ - \`get_repository\` - Get repository details by ID
48687
+ - \`add_repository\` - Add repository from local path
48688
+ - \`update_repository\` - Update repository metadata (name, agent, startup script)
48689
+ - \`delete_repository\` - Delete orphaned repository (fails if linked to project)
48690
+ - \`link_repository_to_project\` - Link repo to project (errors if already linked elsewhere)
48691
+ - \`unlink_repository_from_project\` - Unlink repo from project
48692
+
48693
+ ### App/Deployment Tools
48694
+
48695
+ - \`list_apps\` - List all deployed apps
48696
+ - \`get_app\` - Get app details with services
48697
+ - \`create_app\` - Create app for deployment
48698
+ - \`deploy_app\` - Trigger deployment
48699
+ - \`stop_app\` - Stop running app
48700
+ - \`get_app_logs\` - Get container logs
48701
+ - \`get_app_status\` - Get container status
48702
+ - \`list_deployments\` - Get deployment history
48703
+ - \`delete_app\` - Delete app
48704
+
48705
+ ### Filesystem Tools
48706
+
48707
+ 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).
48708
+
48709
+ - \`list_directory\` - List directory contents
48710
+ - \`get_file_tree\` - Get recursive file tree
48711
+ - \`read_file\` - Read file contents (secured)
48712
+ - \`write_file\` - Write entire file content (secured)
48713
+ - \`edit_file\` - Edit file by replacing a unique string (secured)
48714
+ - \`file_stat\` - Get file/directory metadata
48715
+ - \`is_git_repo\` - Check if directory is git repo
48716
+
48717
+ ### Command Execution
48718
+
48719
+ 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.
48720
+
48721
+ #### execute_command
48722
+
48723
+ Execute shell commands with optional persistent session support:
48724
+
48725
+ \`\`\`json
48726
+ {
48727
+ "command": "echo hello world",
48728
+ "sessionId": "optional-session-id",
48729
+ "cwd": "/path/to/start",
48730
+ "timeout": 30000,
48731
+ "name": "my-session"
48732
+ }
48733
+ \`\`\`
48734
+
48735
+ **Parameters:**
48736
+ - \`command\` (required) \u2014 The shell command to execute
48737
+ - \`sessionId\` (optional) \u2014 Reuse a session to preserve env vars, cwd, and shell state
48738
+ - \`cwd\` (optional) \u2014 Initial working directory (only used when creating new session)
48739
+ - \`timeout\` (optional) \u2014 Timeout in milliseconds (default: 30000)
48740
+ - \`name\` (optional) \u2014 Session name for identification (only used when creating new session)
48741
+
48742
+ **Response:**
48743
+ \`\`\`json
48744
+ {
48745
+ "sessionId": "uuid",
48746
+ "stdout": "hello world",
48747
+ "stderr": "",
48748
+ "exitCode": 0,
48749
+ "timedOut": false
48750
+ }
48751
+ \`\`\`
48752
+
48753
+ ### Session Workflow Example
48754
+
48755
+ \`\`\`
48756
+ 1. First command (creates named session):
48757
+ execute_command { command: "cd /project && export API_KEY=secret", name: "dev-session" }
48758
+ \u2192 Returns sessionId: "abc-123"
48759
+
48760
+ 2. Subsequent commands (reuse session):
48761
+ execute_command { command: "echo $API_KEY", sessionId: "abc-123" }
48762
+ \u2192 Returns stdout: "secret" (env var preserved)
48763
+
48764
+ execute_command { command: "pwd", sessionId: "abc-123" }
48765
+ \u2192 Returns stdout: "/project" (cwd preserved)
48766
+
48767
+ 3. Rename session if needed:
48768
+ update_exec_session { sessionId: "abc-123", name: "new-name" }
48769
+
48770
+ 4. Cleanup when done:
48771
+ destroy_exec_session { sessionId: "abc-123" }
48772
+ \`\`\`
48773
+
48774
+ Sessions persist until manually destroyed.
48775
+
48776
+ ### list_exec_sessions
48777
+
48778
+ List all active sessions with their name, current working directory, and timestamps.
48779
+
48780
+ ### update_exec_session
48781
+
48782
+ Rename an existing session for identification.
48783
+
48784
+ ### destroy_exec_session
49320
48785
 
49321
- \`\`\`bash
49322
- # After creating a GitHub PR
49323
- fulcrum current-task pr https://github.com/owner/repo/pull/123
48786
+ Clean up a session when you're done to free resources.
49324
48787
 
49325
- # After identifying the relevant Linear ticket
49326
- fulcrum current-task linear https://linear.app/team/issue/TEAM-123
48788
+ ## Best Practices
49327
48789
 
49328
- # Add any URL link (design docs, figma, notion, external resources)
49329
- fulcrum current-task link https://figma.com/file/abc123/design
49330
- fulcrum current-task link https://notion.so/team/spec --label "Product Spec"
49331
- \`\`\`
48790
+ 1. **Use \`current-task\` inside worktrees** - It auto-detects which task you're in
48791
+ 2. **Link PRs immediately** - Run \`fulcrum current-task pr <url>\` right after creating a PR
48792
+ 3. **Link relevant resources** - Attach design docs, specs, or reference materials with \`fulcrum current-task link <url>\`
48793
+ 4. **Mark review when done** - \`fulcrum current-task review\` notifies the user
48794
+ 5. **Send notifications for blocking issues** - Keep the user informed of progress
48795
+ 6. **Name sessions for identification** - Use descriptive names to find sessions later
48796
+ 7. **Reuse sessions for related commands** - Preserve state across multiple execute_command calls
48797
+ 8. **Clean up sessions when done** - Use destroy_exec_session to free resources
48798
+ `;
49332
48799
 
49333
- ### Notifying the User
48800
+ // cli/src/commands/claude.ts
48801
+ var MARKETPLACE_DIR = join3(homedir2(), ".fulcrum", "claude-plugin");
48802
+ var MARKETPLACE_NAME = "fulcrum";
48803
+ var PLUGIN_NAME = "fulcrum";
48804
+ var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
48805
+ var PLUGIN_FILES = [
48806
+ { path: ".claude-plugin/marketplace.json", content: marketplace_default },
48807
+ { path: "hooks/hooks.json", content: hooks_default },
48808
+ { path: ".mcp.json", content: _mcp_default },
48809
+ { path: "commands/pr.md", content: pr_default },
48810
+ { path: "commands/task-info.md", content: task_info_default },
48811
+ { path: "commands/notify.md", content: notify_default },
48812
+ { path: "skills/fulcrum/SKILL.md", content: SKILL_default }
48813
+ ];
48814
+ var LEGACY_PLUGIN_DIR = join3(homedir2(), ".claude", "plugins", "fulcrum");
48815
+ var LEGACY_CACHE_DIR = join3(homedir2(), ".claude", "plugins", "cache", "fulcrum");
48816
+ function runClaude(args) {
48817
+ const result = spawnSync2("claude", args, { encoding: "utf-8" });
48818
+ return {
48819
+ success: result.status === 0,
48820
+ output: (result.stdout || "") + (result.stderr || "")
48821
+ };
48822
+ }
48823
+ function getBundledVersion() {
48824
+ try {
48825
+ const parsed = JSON.parse(marketplace_default);
48826
+ return parsed.plugins?.[0]?.version || "1.0.0";
48827
+ } catch {
48828
+ return "1.0.0";
48829
+ }
48830
+ }
48831
+ function getInstalledVersion() {
48832
+ const installedMarketplace = join3(MARKETPLACE_DIR, ".claude-plugin", "marketplace.json");
48833
+ if (!existsSync3(installedMarketplace)) {
48834
+ return null;
48835
+ }
48836
+ try {
48837
+ const installed = JSON.parse(readFileSync4(installedMarketplace, "utf-8"));
48838
+ return installed.plugins?.[0]?.version || null;
48839
+ } catch {
48840
+ return null;
48841
+ }
48842
+ }
48843
+ async function handleClaudeCommand(action) {
48844
+ if (action === "install") {
48845
+ await installClaudePlugin();
48846
+ return;
48847
+ }
48848
+ if (action === "uninstall") {
48849
+ await uninstallClaudePlugin();
48850
+ return;
48851
+ }
48852
+ throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum claude install | fulcrum claude uninstall", ExitCodes.INVALID_ARGS);
48853
+ }
48854
+ function needsPluginUpdate() {
48855
+ const installedVersion = getInstalledVersion();
48856
+ if (!installedVersion) {
48857
+ return true;
48858
+ }
48859
+ const bundledVersion = getBundledVersion();
48860
+ return installedVersion !== bundledVersion;
48861
+ }
48862
+ async function installClaudePlugin(options = {}) {
48863
+ const { silent = false } = options;
48864
+ const log = silent ? () => {} : console.log;
48865
+ try {
48866
+ log("Installing Claude Code plugin...");
48867
+ if (existsSync3(MARKETPLACE_DIR)) {
48868
+ rmSync(MARKETPLACE_DIR, { recursive: true });
48869
+ }
48870
+ for (const file2 of PLUGIN_FILES) {
48871
+ const fullPath = join3(MARKETPLACE_DIR, file2.path);
48872
+ mkdirSync3(dirname2(fullPath), { recursive: true });
48873
+ writeFileSync3(fullPath, file2.content, "utf-8");
48874
+ }
48875
+ log("\u2713 Created plugin files at " + MARKETPLACE_DIR);
48876
+ runClaude(["plugin", "marketplace", "remove", MARKETPLACE_NAME]);
48877
+ const addResult = runClaude(["plugin", "marketplace", "add", MARKETPLACE_DIR]);
48878
+ if (!addResult.success) {
48879
+ throw new Error("Failed to add marketplace: " + addResult.output);
48880
+ }
48881
+ log("\u2713 Registered marketplace");
48882
+ const installResult = runClaude(["plugin", "install", PLUGIN_ID, "--scope", "user"]);
48883
+ if (!installResult.success) {
48884
+ throw new Error("Failed to install plugin: " + installResult.output);
48885
+ }
48886
+ log("\u2713 Installed plugin");
48887
+ cleanupLegacyPaths(log);
48888
+ log("");
48889
+ log("Installation complete! Restart Claude Code to apply changes.");
48890
+ } catch (err) {
48891
+ throw new CliError("INSTALL_FAILED", `Failed to install Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
48892
+ }
48893
+ }
48894
+ async function uninstallClaudePlugin() {
48895
+ try {
48896
+ runClaude(["plugin", "uninstall", PLUGIN_ID]);
48897
+ console.log("\u2713 Uninstalled plugin");
48898
+ runClaude(["plugin", "marketplace", "remove", MARKETPLACE_NAME]);
48899
+ console.log("\u2713 Removed marketplace");
48900
+ if (existsSync3(MARKETPLACE_DIR)) {
48901
+ rmSync(MARKETPLACE_DIR, { recursive: true });
48902
+ console.log("\u2713 Removed plugin files from " + MARKETPLACE_DIR);
48903
+ }
48904
+ cleanupLegacyPaths(console.log);
48905
+ console.log("");
48906
+ console.log("Uninstall complete! Restart Claude Code to apply changes.");
48907
+ } catch (err) {
48908
+ throw new CliError("UNINSTALL_FAILED", `Failed to uninstall Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
48909
+ }
48910
+ }
48911
+ function cleanupLegacyPaths(log) {
48912
+ const legacyPaths = [LEGACY_PLUGIN_DIR, LEGACY_CACHE_DIR];
48913
+ for (const path of legacyPaths) {
48914
+ if (existsSync3(path)) {
48915
+ rmSync(path, { recursive: true });
48916
+ log("\u2713 Removed legacy files from " + path);
48917
+ }
48918
+ }
48919
+ }
48920
+ // package.json
48921
+ var package_default = {
48922
+ name: "@knowsuchagency/fulcrum",
48923
+ private: true,
48924
+ version: "1.5.0",
48925
+ description: "Harness Attention. Orchestrate Agents. Ship.",
48926
+ license: "PolyForm-Perimeter-1.0.0",
48927
+ type: "module",
48928
+ scripts: {
48929
+ dev: "vite --host",
48930
+ "dev:server": "mkdir -p ~/.fulcrum && bun --watch server/index.ts",
48931
+ build: "tsc -b && vite build",
48932
+ start: "NODE_ENV=production bun server/index.ts",
48933
+ lint: "eslint .",
48934
+ preview: "vite preview",
48935
+ "db:generate": "drizzle-kit generate",
48936
+ "db:migrate": "drizzle-kit migrate",
48937
+ "db:studio": "drizzle-kit studio"
48938
+ },
48939
+ dependencies: {
48940
+ "@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
48941
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
48942
+ "@azurity/pure-nerd-font": "^3.0.5",
48943
+ "@base-ui/react": "^1.0.0",
48944
+ "@dagrejs/dagre": "^1.1.8",
48945
+ "@fontsource-variable/jetbrains-mono": "^5.2.8",
48946
+ "@hono/node-server": "^1.19.7",
48947
+ "@hono/node-ws": "^1.2.0",
48948
+ "@hugeicons/core-free-icons": "^3.0.0",
48949
+ "@hugeicons/react": "^1.1.3",
48950
+ "@monaco-editor/react": "^4.7.0",
48951
+ "@octokit/rest": "^22.0.1",
48952
+ "@radix-ui/react-collapsible": "^1.1.12",
48953
+ "@tailwindcss/vite": "^4.1.17",
48954
+ "@tanstack/react-query": "^5.90.12",
48955
+ "@tanstack/react-router": "^1.141.8",
48956
+ "@uiw/react-markdown-preview": "^5.1.5",
48957
+ "@xterm/addon-clipboard": "^0.2.0",
48958
+ "@xterm/addon-fit": "^0.10.0",
48959
+ "@xterm/addon-web-links": "^0.11.0",
48960
+ "@xterm/xterm": "^5.5.0",
48961
+ "bun-pty": "^0.4.2",
48962
+ citty: "^0.1.6",
48963
+ "class-variance-authority": "^0.7.1",
48964
+ cloudflare: "^5.2.0",
48965
+ clsx: "^2.1.1",
48966
+ "date-fns": "^4.1.0",
48967
+ "drizzle-orm": "^0.45.1",
48968
+ "fancy-ansi": "^0.1.3",
48969
+ glob: "^13.0.0",
48970
+ hono: "^4.11.1",
48971
+ i18next: "^25.7.3",
48972
+ mobx: "^6.15.0",
48973
+ "mobx-react-lite": "^4.1.1",
48974
+ "mobx-state-tree": "^7.0.2",
48975
+ "next-themes": "^0.4.6",
48976
+ react: "^19.2.0",
48977
+ "react-day-picker": "^9.13.0",
48978
+ "react-dom": "^19.2.0",
48979
+ "react-i18next": "^16.5.0",
48980
+ "react-resizable-panels": "^4.0.11",
48981
+ reactflow: "^11.11.4",
48982
+ recharts: "2.15.4",
48983
+ shadcn: "^3.6.2",
48984
+ shiki: "^3.20.0",
48985
+ sonner: "^2.0.7",
48986
+ "tailwind-merge": "^3.4.0",
48987
+ tailwindcss: "^4.1.17",
48988
+ "tw-animate-css": "^1.4.0",
48989
+ ws: "^8.18.3",
48990
+ yaml: "^2.8.2"
48991
+ },
48992
+ devDependencies: {
48993
+ "@eslint/js": "^9.39.1",
48994
+ "@opencode-ai/plugin": "^1.1.8",
48995
+ "@tailwindcss/typography": "^0.5.19",
48996
+ "@tanstack/router-plugin": "^1.141.8",
48997
+ "@types/bun": "^1.2.14",
48998
+ "@types/node": "^24.10.1",
48999
+ "@types/react": "^19.2.5",
49000
+ "@types/react-dom": "^19.2.3",
49001
+ "@types/ws": "^8.18.1",
49002
+ "@vitejs/plugin-react": "^5.1.1",
49003
+ "drizzle-kit": "^0.31.8",
49004
+ eslint: "^9.39.1",
49005
+ "eslint-plugin-react-hooks": "^7.0.1",
49006
+ "eslint-plugin-react-refresh": "^0.4.24",
49007
+ globals: "^16.5.0",
49008
+ typescript: "~5.9.3",
49009
+ "typescript-eslint": "^8.46.4",
49010
+ vite: "^7.2.4"
49011
+ }
49012
+ };
49334
49013
 
49335
- \`\`\`bash
49336
- # When work is complete
49337
- fulcrum notify "Task Complete" "Implemented the new feature and created PR #123"
49014
+ // cli/src/commands/up.ts
49015
+ function getPackageRoot() {
49016
+ const currentFile = fileURLToPath(import.meta.url);
49017
+ let dir = dirname3(currentFile);
49018
+ for (let i2 = 0;i2 < 5; i2++) {
49019
+ if (existsSync4(join4(dir, "server", "index.js"))) {
49020
+ return dir;
49021
+ }
49022
+ dir = dirname3(dir);
49023
+ }
49024
+ return dirname3(dirname3(dirname3(currentFile)));
49025
+ }
49026
+ async function handleUpCommand(flags) {
49027
+ const autoYes = flags.yes === "true" || flags.y === "true";
49028
+ if (needsViboraMigration()) {
49029
+ const viboraDir = getLegacyViboraDir();
49030
+ console.error(`
49031
+ Found existing Vibora data at ${viboraDir}`);
49032
+ console.error('Run "fulcrum migrate-from-vibora" to copy your data to ~/.fulcrum');
49033
+ console.error("");
49034
+ }
49035
+ if (!isBunInstalled()) {
49036
+ const bunDep = getDependency("bun");
49037
+ const method = getInstallMethod(bunDep);
49038
+ console.error("Bun is required to run Fulcrum but is not installed.");
49039
+ console.error(" Bun is the JavaScript runtime that powers Fulcrum.");
49040
+ const shouldInstall = autoYes || await confirm(`Would you like to install bun via ${method}?`);
49041
+ if (shouldInstall) {
49042
+ const success2 = installBun();
49043
+ if (!success2) {
49044
+ throw new CliError("INSTALL_FAILED", "Failed to install bun", ExitCodes.ERROR);
49045
+ }
49046
+ console.error("Bun installed successfully!");
49047
+ } else {
49048
+ throw new CliError("MISSING_DEPENDENCY", `Bun is required. Install manually: ${getInstallCommand(bunDep)}`, ExitCodes.ERROR);
49049
+ }
49050
+ }
49051
+ if (!isDtachInstalled()) {
49052
+ const dtachDep = getDependency("dtach");
49053
+ const method = getInstallMethod(dtachDep);
49054
+ console.error("dtach is required for terminal persistence but is not installed.");
49055
+ console.error(" dtach enables persistent terminal sessions that survive disconnects.");
49056
+ const shouldInstall = autoYes || await confirm(`Would you like to install dtach via ${method}?`);
49057
+ if (shouldInstall) {
49058
+ const success2 = installDtach();
49059
+ if (!success2) {
49060
+ throw new CliError("INSTALL_FAILED", "Failed to install dtach", ExitCodes.ERROR);
49061
+ }
49062
+ console.error("dtach installed successfully!");
49063
+ } else {
49064
+ throw new CliError("MISSING_DEPENDENCY", `dtach is required. Install manually: ${getInstallCommand(dtachDep)}`, ExitCodes.ERROR);
49065
+ }
49066
+ }
49067
+ if (!isUvInstalled()) {
49068
+ const uvDep = getDependency("uv");
49069
+ const method = getInstallMethod(uvDep);
49070
+ console.error("uv is required but is not installed.");
49071
+ console.error(" uv is a fast Python package manager used by Claude Code.");
49072
+ const shouldInstall = autoYes || await confirm(`Would you like to install uv via ${method}?`);
49073
+ if (shouldInstall) {
49074
+ const success2 = installUv();
49075
+ if (!success2) {
49076
+ throw new CliError("INSTALL_FAILED", "Failed to install uv", ExitCodes.ERROR);
49077
+ }
49078
+ console.error("uv installed successfully!");
49079
+ } else {
49080
+ throw new CliError("MISSING_DEPENDENCY", `uv is required. Install manually: ${getInstallCommand(uvDep)}`, ExitCodes.ERROR);
49081
+ }
49082
+ }
49083
+ if (isClaudeInstalled() && needsPluginUpdate()) {
49084
+ console.error("Updating Fulcrum plugin for Claude Code...");
49085
+ await installClaudePlugin({ silent: true });
49086
+ console.error("\u2713 Fulcrum plugin updated");
49087
+ }
49088
+ const existingPid = readPid();
49089
+ if (existingPid && isProcessRunning(existingPid)) {
49090
+ console.error(`Fulcrum server is already running (PID: ${existingPid})`);
49091
+ const shouldReplace = autoYes || await confirm("Would you like to stop it and start a new instance?");
49092
+ if (shouldReplace) {
49093
+ console.error("Stopping existing instance...");
49094
+ process.kill(existingPid, "SIGTERM");
49095
+ let attempts = 0;
49096
+ while (attempts < 50 && isProcessRunning(existingPid)) {
49097
+ await new Promise((resolve) => setTimeout(resolve, 100));
49098
+ attempts++;
49099
+ }
49100
+ if (isProcessRunning(existingPid)) {
49101
+ process.kill(existingPid, "SIGKILL");
49102
+ }
49103
+ removePid();
49104
+ console.error("Existing instance stopped.");
49105
+ } else {
49106
+ throw new CliError("ALREADY_RUNNING", `Server already running at http://localhost:${getPort(flags.port)}`, ExitCodes.ERROR);
49107
+ }
49108
+ }
49109
+ const port = getPort(flags.port);
49110
+ if (flags.port) {
49111
+ updateSettingsPort(port);
49112
+ }
49113
+ const host = flags.host ? "0.0.0.0" : "localhost";
49114
+ const packageRoot = getPackageRoot();
49115
+ const serverPath = join4(packageRoot, "server", "index.js");
49116
+ const platform2 = process.platform;
49117
+ const arch = process.arch;
49118
+ let ptyLibName;
49119
+ if (platform2 === "darwin") {
49120
+ ptyLibName = arch === "arm64" ? "librust_pty_arm64.dylib" : "librust_pty.dylib";
49121
+ } else if (platform2 === "win32") {
49122
+ ptyLibName = "rust_pty.dll";
49123
+ } else {
49124
+ ptyLibName = arch === "arm64" ? "librust_pty_arm64.so" : "librust_pty.so";
49125
+ }
49126
+ const ptyLibPath = join4(packageRoot, "lib", ptyLibName);
49127
+ const fulcrumDir = getFulcrumDir();
49128
+ const debug = flags.debug === "true";
49129
+ console.error(`Starting Fulcrum server${debug ? " (debug mode)" : ""}...`);
49130
+ const serverProc = spawn("bun", [serverPath], {
49131
+ detached: true,
49132
+ stdio: "ignore",
49133
+ env: {
49134
+ ...process.env,
49135
+ NODE_ENV: "production",
49136
+ PORT: port.toString(),
49137
+ HOST: host,
49138
+ FULCRUM_DIR: fulcrumDir,
49139
+ FULCRUM_PACKAGE_ROOT: packageRoot,
49140
+ FULCRUM_VERSION: package_default.version,
49141
+ BUN_PTY_LIB: ptyLibPath,
49142
+ ...isClaudeInstalled() && { FULCRUM_CLAUDE_INSTALLED: "1" },
49143
+ ...isOpencodeInstalled() && { FULCRUM_OPENCODE_INSTALLED: "1" },
49144
+ ...debug && { LOG_LEVEL: "debug", DEBUG: "1" }
49145
+ }
49146
+ });
49147
+ serverProc.unref();
49148
+ const pid = serverProc.pid;
49149
+ if (!pid) {
49150
+ throw new CliError("START_FAILED", "Failed to start server process", ExitCodes.ERROR);
49151
+ }
49152
+ writePid(pid);
49153
+ await new Promise((resolve) => setTimeout(resolve, 1000));
49154
+ if (!isProcessRunning(pid)) {
49155
+ throw new CliError("START_FAILED", "Server process died immediately after starting", ExitCodes.ERROR);
49156
+ }
49157
+ if (isJsonOutput()) {
49158
+ output({
49159
+ pid,
49160
+ port,
49161
+ url: `http://localhost:${port}`
49162
+ });
49163
+ } else {
49164
+ const hasAgent = isClaudeInstalled() || isOpencodeInstalled();
49165
+ showGettingStartedTips(port, hasAgent);
49166
+ }
49167
+ }
49168
+ function showGettingStartedTips(port, hasAgent) {
49169
+ console.error(`
49170
+ Fulcrum is running at http://localhost:${port}
49338
49171
 
49339
- # When blocked or need input
49340
- fulcrum notify "Need Input" "Which approach should I use for the database migration?"
49341
- \`\`\`
49172
+ Getting Started:
49173
+ 1. Open http://localhost:${port} in your browser
49174
+ 2. Add a repository to get started
49175
+ 3. Create a task to spin up an isolated worktree
49176
+ 4. Run your AI agent in the task terminal
49342
49177
 
49343
- ## Global Options
49178
+ Commands:
49179
+ fulcrum status Check server status
49180
+ fulcrum doctor Check all dependencies
49181
+ fulcrum down Stop the server
49182
+ `);
49183
+ if (!hasAgent) {
49184
+ console.error(`Note: No AI agents detected. Install one to get started:
49185
+ Claude Code: curl -fsSL https://claude.ai/install.sh | bash
49186
+ OpenCode: curl -fsSL https://opencode.ai/install | bash
49187
+ `);
49188
+ }
49189
+ }
49344
49190
 
49345
- These flags work with most commands:
49191
+ // cli/src/commands/down.ts
49192
+ init_errors();
49193
+ async function handleDownCommand() {
49194
+ const pid = readPid();
49195
+ if (!pid) {
49196
+ throw new CliError("NOT_RUNNING", "No PID file found. Fulcrum server may not be running.", ExitCodes.ERROR);
49197
+ }
49198
+ if (!isProcessRunning(pid)) {
49199
+ removePid();
49200
+ if (isJsonOutput()) {
49201
+ output({ stopped: true, pid, wasRunning: false });
49202
+ } else {
49203
+ console.log(`Fulcrum was not running (stale PID file cleaned up)`);
49204
+ }
49205
+ return;
49206
+ }
49207
+ try {
49208
+ process.kill(pid, "SIGTERM");
49209
+ } catch (err) {
49210
+ throw new CliError("KILL_FAILED", `Failed to stop server (PID: ${pid}): ${err}`, ExitCodes.ERROR);
49211
+ }
49212
+ let attempts = 0;
49213
+ while (attempts < 50 && isProcessRunning(pid)) {
49214
+ await new Promise((resolve) => setTimeout(resolve, 100));
49215
+ attempts++;
49216
+ }
49217
+ if (isProcessRunning(pid)) {
49218
+ try {
49219
+ process.kill(pid, "SIGKILL");
49220
+ } catch {}
49221
+ }
49222
+ removePid();
49223
+ if (isJsonOutput()) {
49224
+ output({ stopped: true, pid, wasRunning: true });
49225
+ } else {
49226
+ console.log(`Fulcrum stopped (PID: ${pid})`);
49227
+ }
49228
+ }
49346
49229
 
49347
- - \`--port=<port>\` - Server port (default: 7777)
49348
- - \`--url=<url>\` - Override full server URL
49349
- - \`--pretty\` - Pretty-print JSON output for human readability
49230
+ // cli/src/commands/migrate-from-vibora.ts
49231
+ init_server();
49232
+ async function handleMigrateFromViboraCommand(flags) {
49233
+ const autoYes = flags.yes === "true" || flags.y === "true";
49234
+ if (!needsViboraMigration()) {
49235
+ if (isJsonOutput()) {
49236
+ output({ migrated: false, reason: "no_migration_needed" });
49237
+ } else {
49238
+ console.error("No migration needed.");
49239
+ console.error(` ~/.vibora does not exist or ~/.fulcrum already has data.`);
49240
+ }
49241
+ return;
49242
+ }
49243
+ const viboraDir = getLegacyViboraDir();
49244
+ const fulcrumDir = getFulcrumDir();
49245
+ if (!isJsonOutput()) {
49246
+ console.error(`
49247
+ Found existing Vibora data at ${viboraDir}`);
49248
+ console.error("Fulcrum (formerly Vibora) now uses ~/.fulcrum for data storage.");
49249
+ console.error("");
49250
+ console.error("Your existing data can be copied to the new location.");
49251
+ console.error("This is non-destructive - your ~/.vibora directory will be left untouched.");
49252
+ console.error("");
49253
+ }
49254
+ const shouldMigrate = autoYes || await confirm("Would you like to copy your data to ~/.fulcrum?");
49255
+ if (!shouldMigrate) {
49256
+ if (isJsonOutput()) {
49257
+ output({ migrated: false, reason: "user_declined" });
49258
+ } else {
49259
+ console.error("Migration skipped.");
49260
+ console.error("You can run this command again later to migrate.");
49261
+ }
49262
+ return;
49263
+ }
49264
+ if (!isJsonOutput()) {
49265
+ console.error("Copying data from ~/.vibora to ~/.fulcrum...");
49266
+ }
49267
+ const success2 = migrateFromVibora();
49268
+ if (success2) {
49269
+ if (isJsonOutput()) {
49270
+ output({ migrated: true, from: viboraDir, to: fulcrumDir });
49271
+ } else {
49272
+ console.error("Migration complete!");
49273
+ console.error(` Data copied from ${viboraDir} to ${fulcrumDir}`);
49274
+ console.error(" Your original ~/.vibora directory has been preserved.");
49275
+ }
49276
+ } else {
49277
+ if (isJsonOutput()) {
49278
+ output({ migrated: false, reason: "migration_failed" });
49279
+ } else {
49280
+ console.error("Migration failed.");
49281
+ console.error("You can manually copy files from ~/.vibora to ~/.fulcrum");
49282
+ }
49283
+ process.exitCode = 1;
49284
+ }
49285
+ }
49350
49286
 
49351
- ## Task Statuses
49287
+ // cli/src/commands/status.ts
49288
+ init_server();
49289
+ async function handleStatusCommand(flags) {
49290
+ const pid = readPid();
49291
+ const port = getPort(flags.port);
49292
+ const serverUrl = discoverServerUrl(flags.url, flags.port);
49293
+ const pidRunning = pid !== null && isProcessRunning(pid);
49294
+ let healthOk = false;
49295
+ let version3 = null;
49296
+ let uptime = null;
49297
+ if (pidRunning) {
49298
+ try {
49299
+ const res = await fetch(`${serverUrl}/health`, { signal: AbortSignal.timeout(2000) });
49300
+ healthOk = res.ok;
49301
+ if (res.ok) {
49302
+ const health = await res.json();
49303
+ version3 = health.version || null;
49304
+ uptime = health.uptime || null;
49305
+ }
49306
+ } catch {}
49307
+ }
49308
+ const data = {
49309
+ running: pidRunning,
49310
+ healthy: healthOk,
49311
+ pid: pid || null,
49312
+ port,
49313
+ url: serverUrl,
49314
+ version: version3,
49315
+ uptime
49316
+ };
49317
+ if (isJsonOutput()) {
49318
+ output(data);
49319
+ } else {
49320
+ if (pidRunning) {
49321
+ const healthStatus = healthOk ? "healthy" : "not responding";
49322
+ console.log(`Fulcrum is running (${healthStatus})`);
49323
+ console.log(` PID: ${pid}`);
49324
+ console.log(` URL: ${serverUrl}`);
49325
+ if (version3)
49326
+ console.log(` Version: ${version3}`);
49327
+ if (uptime)
49328
+ console.log(` Uptime: ${Math.floor(uptime / 1000)}s`);
49329
+ } else {
49330
+ console.log("Fulcrum is not running");
49331
+ console.log(`
49332
+ Start with: fulcrum up`);
49333
+ }
49334
+ }
49335
+ }
49352
49336
 
49353
- - \`IN_PROGRESS\` - Task is being worked on
49354
- - \`IN_REVIEW\` - Task is complete and awaiting review
49355
- - \`DONE\` - Task is finished
49356
- - \`CANCELED\` - Task was abandoned
49337
+ // cli/src/commands/git.ts
49338
+ init_client();
49339
+ init_errors();
49340
+ async function handleGitCommand(action, flags) {
49341
+ const client = new FulcrumClient(flags.url, flags.port);
49342
+ switch (action) {
49343
+ case "status": {
49344
+ const path = flags.path || process.cwd();
49345
+ const status = await client.getStatus(path);
49346
+ if (isJsonOutput()) {
49347
+ output(status);
49348
+ } else {
49349
+ console.log(`Branch: ${status.branch}`);
49350
+ if (status.ahead)
49351
+ console.log(` Ahead: ${status.ahead}`);
49352
+ if (status.behind)
49353
+ console.log(` Behind: ${status.behind}`);
49354
+ if (status.staged?.length)
49355
+ console.log(` Staged: ${status.staged.length} files`);
49356
+ if (status.modified?.length)
49357
+ console.log(` Modified: ${status.modified.length} files`);
49358
+ if (status.untracked?.length)
49359
+ console.log(` Untracked: ${status.untracked.length} files`);
49360
+ if (!status.staged?.length && !status.modified?.length && !status.untracked?.length) {
49361
+ console.log(" Working tree clean");
49362
+ }
49363
+ }
49364
+ break;
49365
+ }
49366
+ case "diff": {
49367
+ const path = flags.path || process.cwd();
49368
+ const diff = await client.getDiff(path, {
49369
+ staged: flags.staged === "true",
49370
+ ignoreWhitespace: flags["ignore-whitespace"] === "true",
49371
+ includeUntracked: flags["include-untracked"] === "true"
49372
+ });
49373
+ if (isJsonOutput()) {
49374
+ output(diff);
49375
+ } else {
49376
+ console.log(diff.diff || "No changes");
49377
+ }
49378
+ break;
49379
+ }
49380
+ case "branches": {
49381
+ const repo = flags.repo;
49382
+ if (!repo) {
49383
+ throw new CliError("MISSING_REPO", "--repo is required", ExitCodes.INVALID_ARGS);
49384
+ }
49385
+ const branches = await client.getBranches(repo);
49386
+ if (isJsonOutput()) {
49387
+ output(branches);
49388
+ } else {
49389
+ for (const branch of branches) {
49390
+ const current = branch.current ? "* " : " ";
49391
+ console.log(`${current}${branch.name}`);
49392
+ }
49393
+ }
49394
+ break;
49395
+ }
49396
+ default:
49397
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: status, diff, branches`, ExitCodes.INVALID_ARGS);
49398
+ }
49399
+ }
49357
49400
 
49358
- ## MCP Tools
49401
+ // cli/src/commands/worktrees.ts
49402
+ init_client();
49403
+ init_errors();
49404
+ async function handleWorktreesCommand(action, flags) {
49405
+ const client = new FulcrumClient(flags.url, flags.port);
49406
+ switch (action) {
49407
+ case "list": {
49408
+ const worktrees = await client.listWorktrees();
49409
+ if (isJsonOutput()) {
49410
+ output(worktrees);
49411
+ } else {
49412
+ if (worktrees.length === 0) {
49413
+ console.log("No worktrees found");
49414
+ } else {
49415
+ for (const wt of worktrees) {
49416
+ console.log(`${wt.path}`);
49417
+ console.log(` Branch: ${wt.branch}`);
49418
+ if (wt.taskId)
49419
+ console.log(` Task: ${wt.taskId}`);
49420
+ }
49421
+ }
49422
+ }
49423
+ break;
49424
+ }
49425
+ case "delete": {
49426
+ const worktreePath = flags.path;
49427
+ if (!worktreePath) {
49428
+ throw new CliError("MISSING_PATH", "--path is required", ExitCodes.INVALID_ARGS);
49429
+ }
49430
+ const deleteLinkedTask = flags["delete-task"] === "true" || flags["delete-task"] === "";
49431
+ const result = await client.deleteWorktree(worktreePath, flags.repo, deleteLinkedTask);
49432
+ if (isJsonOutput()) {
49433
+ output(result);
49434
+ } else {
49435
+ console.log(`Deleted worktree: ${worktreePath}`);
49436
+ }
49437
+ break;
49438
+ }
49439
+ default:
49440
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, delete`, ExitCodes.INVALID_ARGS);
49441
+ }
49442
+ }
49359
49443
 
49360
- Fulcrum provides a comprehensive set of MCP tools for AI agents. Use \`search_tools\` to discover available tools.
49444
+ // cli/src/commands/config.ts
49445
+ init_client();
49446
+ init_errors();
49447
+ async function handleConfigCommand(action, positional, flags) {
49448
+ const client = new FulcrumClient(flags.url, flags.port);
49449
+ switch (action) {
49450
+ case "list": {
49451
+ const config3 = await client.getAllConfig();
49452
+ if (isJsonOutput()) {
49453
+ output(config3);
49454
+ } else {
49455
+ console.log("Configuration:");
49456
+ for (const [key, value] of Object.entries(config3)) {
49457
+ const displayValue = value === null ? "(not set)" : value;
49458
+ console.log(` ${key}: ${displayValue}`);
49459
+ }
49460
+ }
49461
+ break;
49462
+ }
49463
+ case "get": {
49464
+ const [key] = positional;
49465
+ if (!key) {
49466
+ throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
49467
+ }
49468
+ const config3 = await client.getConfig(key);
49469
+ if (isJsonOutput()) {
49470
+ output(config3);
49471
+ } else {
49472
+ const value = config3.value === null ? "(not set)" : config3.value;
49473
+ console.log(`${key}: ${value}`);
49474
+ }
49475
+ break;
49476
+ }
49477
+ case "set": {
49478
+ const [key, value] = positional;
49479
+ if (!key) {
49480
+ throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
49481
+ }
49482
+ if (value === undefined) {
49483
+ throw new CliError("MISSING_VALUE", "Config value is required", ExitCodes.INVALID_ARGS);
49484
+ }
49485
+ const parsedValue = /^\d+$/.test(value) ? parseInt(value, 10) : value;
49486
+ const config3 = await client.setConfig(key, parsedValue);
49487
+ if (isJsonOutput()) {
49488
+ output(config3);
49489
+ } else {
49490
+ console.log(`Set ${key} = ${config3.value}`);
49491
+ }
49492
+ break;
49493
+ }
49494
+ case "reset": {
49495
+ const [key] = positional;
49496
+ if (!key) {
49497
+ throw new CliError("MISSING_KEY", "Config key is required", ExitCodes.INVALID_ARGS);
49498
+ }
49499
+ const config3 = await client.resetConfig(key);
49500
+ if (isJsonOutput()) {
49501
+ output(config3);
49502
+ } else {
49503
+ console.log(`Reset ${key} to default: ${config3.value}`);
49504
+ }
49505
+ break;
49506
+ }
49507
+ default:
49508
+ throw new CliError("UNKNOWN_ACTION", `Unknown action: ${action}. Valid: list, get, set, reset`, ExitCodes.INVALID_ARGS);
49509
+ }
49510
+ }
49361
49511
 
49362
- ### Tool Discovery
49512
+ // cli/src/commands/opencode.ts
49513
+ init_errors();
49514
+ import {
49515
+ mkdirSync as mkdirSync4,
49516
+ writeFileSync as writeFileSync4,
49517
+ existsSync as existsSync5,
49518
+ readFileSync as readFileSync5,
49519
+ unlinkSync as unlinkSync2,
49520
+ copyFileSync,
49521
+ renameSync
49522
+ } from "fs";
49523
+ import { homedir as homedir3 } from "os";
49524
+ import { join as join5 } from "path";
49363
49525
 
49364
- #### search_tools
49526
+ // plugins/fulcrum-opencode/index.ts
49527
+ var fulcrum_opencode_default = `import type { Plugin } from "@opencode-ai/plugin"
49528
+ import { appendFileSync } from "node:fs"
49529
+ import { spawn } from "node:child_process"
49530
+ import { tmpdir } from "node:os"
49531
+ import { join } from "node:path"
49365
49532
 
49366
- Search for available tools by keyword or category:
49533
+ declare const process: { env: Record<string, string | undefined> }
49367
49534
 
49368
- \`\`\`json
49369
- {
49370
- "query": "deploy", // Optional: Search term
49371
- "category": "apps" // Optional: Filter by category
49535
+ const LOG_FILE = join(tmpdir(), "fulcrum-opencode.log")
49536
+ const NOISY_EVENTS = new Set([
49537
+ "message.part.updated",
49538
+ "file.watcher.updated",
49539
+ "tui.toast.show",
49540
+ "config.updated",
49541
+ ])
49542
+ const log = (msg: string) => {
49543
+ try {
49544
+ appendFileSync(LOG_FILE, \`[\${new Date().toISOString()}] \${msg}\\n\`)
49545
+ } catch {
49546
+ // Silently ignore logging errors - logging is non-critical
49547
+ }
49372
49548
  }
49373
- \`\`\`
49374
-
49375
- **Categories:** core, tasks, projects, repositories, apps, filesystem, git, notifications, exec
49376
-
49377
- **Example Usage:**
49378
- \`\`\`
49379
- search_tools { query: "project create" }
49380
- \u2192 Returns tools for creating projects
49381
-
49382
- search_tools { category: "filesystem" }
49383
- \u2192 Returns all filesystem tools
49384
- \`\`\`
49385
49549
 
49386
- ### Task Tools
49550
+ /**
49551
+ * Execute fulcrum command using spawn with shell option for proper PATH resolution.
49552
+ * Using spawn with explicit args array prevents shell injection while shell:true
49553
+ * ensures PATH is properly resolved (for NVM, fnm, etc. managed node installations).
49554
+ * Includes 10 second timeout protection to prevent hanging.
49555
+ */
49556
+ async function runFulcrumCommand(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
49557
+ return new Promise((resolve) => {
49558
+ let stdout = ''
49559
+ let stderr = ''
49560
+ let resolved = false
49561
+ let processExited = false
49562
+ let killTimeoutId: ReturnType<typeof setTimeout> | null = null
49387
49563
 
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
49564
+ const child = spawn(FULCRUM_CMD, args, { shell: true })
49401
49565
 
49402
- #### Task Search and Filtering
49566
+ const cleanup = () => {
49567
+ processExited = true
49568
+ if (killTimeoutId) {
49569
+ clearTimeout(killTimeoutId)
49570
+ killTimeoutId = null
49571
+ }
49572
+ }
49403
49573
 
49404
- The \`list_tasks\` tool supports powerful filtering for AI agents:
49574
+ child.stdout?.on('data', (data) => {
49575
+ stdout += data.toString()
49576
+ })
49405
49577
 
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
- \`\`\`
49578
+ child.stderr?.on('data', (data) => {
49579
+ stderr += data.toString()
49580
+ })
49416
49581
 
49417
- #### Label Discovery
49582
+ child.on('close', (code) => {
49583
+ cleanup()
49584
+ if (!resolved) {
49585
+ resolved = true
49586
+ resolve({ exitCode: code || 0, stdout, stderr })
49587
+ }
49588
+ })
49418
49589
 
49419
- Use \`list_labels\` to discover exact label names before filtering:
49590
+ child.on('error', (err) => {
49591
+ cleanup()
49592
+ if (!resolved) {
49593
+ resolved = true
49594
+ resolve({ exitCode: 1, stdout, stderr: err.message || '' })
49595
+ }
49596
+ })
49420
49597
 
49421
- \`\`\`json
49422
- // Find labels matching "communication"
49423
- { "search": "communication" }
49424
- // Returns: [{ "name": "communication required", "count": 5 }]
49425
- \`\`\`
49598
+ // Add timeout protection to prevent hanging
49599
+ const timeoutId = setTimeout(() => {
49600
+ if (!resolved) {
49601
+ resolved = true
49602
+ log(\`Command timeout: \${FULCRUM_CMD} \${args.join(' ')}\`)
49603
+ child.kill('SIGTERM')
49604
+ // Schedule SIGKILL if process doesn't exit after SIGTERM
49605
+ killTimeoutId = setTimeout(() => {
49606
+ if (!processExited) {
49607
+ log(\`Process didn't exit after SIGTERM, sending SIGKILL\`)
49608
+ child.kill('SIGKILL')
49609
+ }
49610
+ }, 2000)
49611
+ resolve({ exitCode: -1, stdout, stderr: \`Command timed out after \${FULCRUM_COMMAND_TIMEOUT_MS}ms\` })
49612
+ }
49613
+ }, FULCRUM_COMMAND_TIMEOUT_MS)
49426
49614
 
49427
- This helps handle typos and variations - search first, then use the exact label name.
49615
+ // Clear timeout if command completes
49616
+ child.on('exit', () => clearTimeout(timeoutId))
49617
+ })
49618
+ }
49428
49619
 
49429
- ### Project Tools
49620
+ let mainSessionId: string | null = null
49621
+ const subagentSessions = new Set<string>()
49622
+ let pendingIdleTimer: ReturnType<typeof setTimeout> | null = null
49623
+ let activityVersion = 0
49624
+ let lastStatus: "in-progress" | "review" | "" = ""
49430
49625
 
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
49626
+ const FULCRUM_CMD = "fulcrum"
49627
+ const IDLE_CONFIRMATION_DELAY_MS = 1500
49628
+ const FULCRUM_COMMAND_TIMEOUT_MS = 10000
49629
+ const STATUS_CHANGE_DEBOUNCE_MS = 500
49440
49630
 
49441
- ### Repository Tools
49631
+ let deferredContextCheck: Promise<boolean> | null = null
49632
+ let isFulcrumContext: boolean | null = null
49633
+ let pendingStatusCommand: Promise<{ exitCode: number; stdout: string; stderr: string }> | null = null
49442
49634
 
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
49635
+ export const FulcrumPlugin: Plugin = async ({ $, directory }) => {
49636
+ log("Plugin initializing...")
49450
49637
 
49451
- ### App/Deployment Tools
49638
+ if (process.env.FULCRUM_TASK_ID) {
49639
+ isFulcrumContext = true
49640
+ log("Fulcrum context detected via env var")
49641
+ } else {
49642
+ deferredContextCheck = Promise.all([
49643
+ $\`\${FULCRUM_CMD} --version\`.quiet().nothrow().text(),
49644
+ runFulcrumCommand(['current-task', '--path', directory]),
49645
+ ])
49646
+ .then(([versionResult, taskResult]) => {
49647
+ if (!versionResult) {
49648
+ log("Fulcrum CLI not found")
49649
+ return false
49650
+ }
49651
+ const inContext = taskResult.exitCode === 0
49652
+ log(inContext ? "Fulcrum context active" : "Not a Fulcrum context")
49653
+ return inContext
49654
+ })
49655
+ .catch(() => {
49656
+ log("Fulcrum check failed")
49657
+ return false
49658
+ })
49659
+ }
49452
49660
 
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
49661
+ log("Plugin hooks registered")
49462
49662
 
49463
- ### Filesystem Tools
49663
+ const checkContext = async (): Promise<boolean> => {
49664
+ if (isFulcrumContext !== null) return isFulcrumContext
49665
+ if (deferredContextCheck) {
49666
+ isFulcrumContext = await deferredContextCheck
49667
+ deferredContextCheck = null
49668
+ return isFulcrumContext
49669
+ }
49670
+ return false
49671
+ }
49464
49672
 
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).
49673
+ const cancelPendingIdle = () => {
49674
+ if (pendingIdleTimer) {
49675
+ clearTimeout(pendingIdleTimer)
49676
+ pendingIdleTimer = null
49677
+ log("Cancelled pending idle transition")
49678
+ }
49679
+ }
49466
49680
 
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
49681
+ const setStatus = (status: "in-progress" | "review") => {
49682
+ if (status === lastStatus) return
49474
49683
 
49475
- ### Command Execution
49684
+ cancelPendingIdle()
49476
49685
 
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.
49686
+ if (pendingStatusCommand) {
49687
+ log(\`Status change already in progress, will retry after \${STATUS_CHANGE_DEBOUNCE_MS}ms\`)
49688
+ setTimeout(() => setStatus(status), STATUS_CHANGE_DEBOUNCE_MS)
49689
+ return
49690
+ }
49478
49691
 
49479
- #### execute_command
49692
+ lastStatus = status
49480
49693
 
49481
- Execute shell commands with optional persistent session support:
49694
+ ;(async () => {
49695
+ try {
49696
+ log(\`Setting status: \${status}\`)
49697
+ pendingStatusCommand = runFulcrumCommand(['current-task', status, '--path', directory])
49698
+ const res = await pendingStatusCommand
49699
+ pendingStatusCommand = null
49482
49700
 
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
- \`\`\`
49701
+ if (res.exitCode !== 0) {
49702
+ log(\`Status update failed: exitCode=\${res.exitCode}, stderr=\${res.stderr}\`)
49703
+ }
49704
+ } catch (e) {
49705
+ log(\`Status update error: \${e}\`)
49706
+ pendingStatusCommand = null
49707
+ }
49708
+ })()
49709
+ }
49492
49710
 
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)
49711
+ const scheduleIdleTransition = () => {
49712
+ cancelPendingIdle()
49713
+ const currentVersion = ++activityVersion
49499
49714
 
49500
- **Response:**
49501
- \`\`\`json
49502
- {
49503
- "sessionId": "uuid",
49504
- "stdout": "hello world",
49505
- "stderr": "",
49506
- "exitCode": 0,
49507
- "timedOut": false
49508
- }
49509
- \`\`\`
49715
+ pendingIdleTimer = setTimeout(() => {
49716
+ if (activityVersion !== currentVersion) {
49717
+ log(
49718
+ \`Stale idle transition (version \${currentVersion} vs \${activityVersion})\`,
49719
+ )
49720
+ return
49721
+ }
49722
+ setStatus("review")
49723
+ }, IDLE_CONFIRMATION_DELAY_MS)
49510
49724
 
49511
- ### Session Workflow Example
49725
+ log(
49726
+ \`Scheduled idle transition (version \${currentVersion}, delay \${IDLE_CONFIRMATION_DELAY_MS}ms)\`,
49727
+ )
49728
+ }
49512
49729
 
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"
49730
+ const recordActivity = (reason: string) => {
49731
+ activityVersion++
49732
+ cancelPendingIdle()
49733
+ log(\`Activity: \${reason} (version now \${activityVersion})\`)
49734
+ }
49517
49735
 
49518
- 2. Subsequent commands (reuse session):
49519
- execute_command { command: "echo $API_KEY", sessionId: "abc-123" }
49520
- \u2192 Returns stdout: "secret" (env var preserved)
49736
+ return {
49737
+ "chat.message": async (_input, output) => {
49738
+ if (!(await checkContext())) return
49521
49739
 
49522
- execute_command { command: "pwd", sessionId: "abc-123" }
49523
- \u2192 Returns stdout: "/project" (cwd preserved)
49740
+ if (output.message.role === "user") {
49741
+ recordActivity("user message")
49742
+ setStatus("in-progress")
49743
+ } else if (output.message.role === "assistant") {
49744
+ recordActivity("assistant message")
49745
+ }
49746
+ },
49524
49747
 
49525
- 3. Rename session if needed:
49526
- update_exec_session { sessionId: "abc-123", name: "new-name" }
49748
+ event: async ({ event }) => {
49749
+ if (!NOISY_EVENTS.has(event.type)) {
49750
+ log(\`Event: \${event.type}\`)
49751
+ }
49527
49752
 
49528
- 4. Cleanup when done:
49529
- destroy_exec_session { sessionId: "abc-123" }
49530
- \`\`\`
49753
+ if (!(await checkContext())) return
49531
49754
 
49532
- Sessions persist until manually destroyed.
49755
+ const props = (event.properties as Record<string, unknown>) || {}
49533
49756
 
49534
- ### list_exec_sessions
49757
+ if (event.type === "session.created") {
49758
+ const info = (props.info as Record<string, unknown>) || {}
49759
+ const sessionId = info.id as string | undefined
49760
+ const parentId = info.parentID as string | undefined
49535
49761
 
49536
- List all active sessions with their name, current working directory, and timestamps.
49762
+ if (parentId) {
49763
+ if (sessionId) subagentSessions.add(sessionId)
49764
+ log(\`Subagent session tracked: \${sessionId} (parent: \${parentId})\`)
49765
+ } else if (!mainSessionId && sessionId) {
49766
+ mainSessionId = sessionId
49767
+ log(\`Main session set: \${mainSessionId}\`)
49768
+ }
49537
49769
 
49538
- ### update_exec_session
49770
+ recordActivity("session.created")
49771
+ setStatus("in-progress")
49772
+ return
49773
+ }
49539
49774
 
49540
- Rename an existing session for identification.
49775
+ const status = props.status as Record<string, unknown> | undefined
49776
+ if (
49777
+ (event.type === "session.status" && status?.type === "busy") ||
49778
+ event.type.startsWith("tool.execute")
49779
+ ) {
49780
+ recordActivity(event.type)
49781
+ return
49782
+ }
49541
49783
 
49542
- ### destroy_exec_session
49784
+ if (
49785
+ event.type === "session.idle" ||
49786
+ (event.type === "session.status" && status?.type === "idle")
49787
+ ) {
49788
+ const info = (props.info as Record<string, unknown>) || {}
49789
+ const sessionId =
49790
+ (props.sessionID as string) || (info.id as string) || null
49543
49791
 
49544
- Clean up a session when you're done to free resources.
49792
+ if (sessionId && subagentSessions.has(sessionId)) {
49793
+ log(\`Ignoring subagent idle: \${sessionId}\`)
49794
+ return
49795
+ }
49545
49796
 
49546
- ## Best Practices
49797
+ if (mainSessionId && sessionId && sessionId !== mainSessionId) {
49798
+ log(\`Ignoring non-main idle: \${sessionId} (main: \${mainSessionId})\`)
49799
+ return
49800
+ }
49547
49801
 
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
49802
+ log(\`Main session idle detected: \${sessionId}\`)
49803
+ scheduleIdleTransition()
49804
+ }
49805
+ },
49806
+ }
49807
+ }
49556
49808
  `;
49557
49809
 
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) {
49810
+ // cli/src/commands/opencode.ts
49811
+ var OPENCODE_DIR = join5(homedir3(), ".opencode");
49812
+ var OPENCODE_CONFIG_PATH = join5(OPENCODE_DIR, "opencode.json");
49813
+ var PLUGIN_DIR = join5(homedir3(), ".config", "opencode", "plugin");
49814
+ var PLUGIN_PATH = join5(PLUGIN_DIR, "fulcrum.ts");
49815
+ var FULCRUM_MCP_CONFIG = {
49816
+ type: "local",
49817
+ command: ["fulcrum", "mcp"],
49818
+ enabled: true
49819
+ };
49820
+ async function handleOpenCodeCommand(action) {
49572
49821
  if (action === "install") {
49573
- await installClaudePlugin();
49822
+ await installOpenCodeIntegration();
49574
49823
  return;
49575
49824
  }
49576
49825
  if (action === "uninstall") {
49577
- await uninstallClaudePlugin();
49826
+ await uninstallOpenCodeIntegration();
49578
49827
  return;
49579
49828
  }
49580
- throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum claude install | fulcrum claude uninstall", ExitCodes.INVALID_ARGS);
49829
+ throw new CliError("INVALID_ACTION", "Unknown action. Usage: fulcrum opencode install | fulcrum opencode uninstall", ExitCodes.INVALID_ARGS);
49581
49830
  }
49582
- async function installClaudePlugin() {
49831
+ async function installOpenCodeIntegration() {
49583
49832
  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);
49833
+ console.log("Installing OpenCode plugin...");
49834
+ mkdirSync4(PLUGIN_DIR, { recursive: true });
49835
+ writeFileSync4(PLUGIN_PATH, fulcrum_opencode_default, "utf-8");
49836
+ console.log("\u2713 Installed plugin at " + PLUGIN_PATH);
49837
+ console.log("Configuring MCP server...");
49838
+ const mcpConfigured = addMcpServer();
49596
49839
  console.log("");
49597
- console.log("Installation complete! Restart Claude Code to apply changes.");
49840
+ if (mcpConfigured) {
49841
+ console.log("Installation complete! Restart OpenCode to apply changes.");
49842
+ } else {
49843
+ console.log("Plugin installed, but MCP configuration was skipped.");
49844
+ console.log("Please add the MCP server manually (see above).");
49845
+ }
49598
49846
  } catch (err) {
49599
- throw new CliError("INSTALL_FAILED", `Failed to install Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49847
+ throw new CliError("INSTALL_FAILED", `Failed to install OpenCode integration: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49600
49848
  }
49601
49849
  }
49602
- async function uninstallClaudePlugin() {
49850
+ async function uninstallOpenCodeIntegration() {
49603
49851
  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.");
49852
+ let removedPlugin = false;
49853
+ let removedMcp = false;
49854
+ if (existsSync5(PLUGIN_PATH)) {
49855
+ unlinkSync2(PLUGIN_PATH);
49856
+ console.log("\u2713 Removed plugin from " + PLUGIN_PATH);
49857
+ removedPlugin = true;
49858
+ } else {
49859
+ console.log("\u2022 Plugin not found (already removed)");
49860
+ }
49861
+ removedMcp = removeMcpServer();
49862
+ if (!removedPlugin && !removedMcp) {
49863
+ console.log("Nothing to uninstall.");
49609
49864
  } else {
49610
- console.log("Nothing to uninstall. Plugin not found at " + PLUGIN_DIR2);
49865
+ console.log("");
49866
+ console.log("Uninstall complete! Restart OpenCode to apply changes.");
49611
49867
  }
49612
49868
  } catch (err) {
49613
- throw new CliError("UNINSTALL_FAILED", `Failed to uninstall Claude plugin: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49869
+ throw new CliError("UNINSTALL_FAILED", `Failed to uninstall OpenCode integration: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49870
+ }
49871
+ }
49872
+ function getMcpObject(config3) {
49873
+ const mcp = config3.mcp;
49874
+ if (mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
49875
+ return mcp;
49876
+ }
49877
+ return {};
49878
+ }
49879
+ function addMcpServer() {
49880
+ mkdirSync4(OPENCODE_DIR, { recursive: true });
49881
+ let config3 = {};
49882
+ if (existsSync5(OPENCODE_CONFIG_PATH)) {
49883
+ try {
49884
+ const content = readFileSync5(OPENCODE_CONFIG_PATH, "utf-8");
49885
+ config3 = JSON.parse(content);
49886
+ } catch {
49887
+ console.log("\u26A0 Could not parse existing opencode.json, skipping MCP configuration");
49888
+ console.log(" Add manually to ~/.opencode/opencode.json:");
49889
+ console.log(' "mcp": { "fulcrum": { "type": "local", "command": ["fulcrum", "mcp"], "enabled": true } }');
49890
+ return false;
49891
+ }
49892
+ }
49893
+ const mcp = getMcpObject(config3);
49894
+ if (mcp.fulcrum) {
49895
+ console.log("\u2022 MCP server already configured, preserving existing configuration");
49896
+ return true;
49897
+ }
49898
+ if (existsSync5(OPENCODE_CONFIG_PATH)) {
49899
+ copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_CONFIG_PATH + ".backup");
49900
+ }
49901
+ config3.mcp = {
49902
+ ...mcp,
49903
+ fulcrum: FULCRUM_MCP_CONFIG
49904
+ };
49905
+ const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
49906
+ try {
49907
+ writeFileSync4(tempPath, JSON.stringify(config3, null, 2), "utf-8");
49908
+ renameSync(tempPath, OPENCODE_CONFIG_PATH);
49909
+ } catch (error46) {
49910
+ try {
49911
+ if (existsSync5(tempPath)) {
49912
+ unlinkSync2(tempPath);
49913
+ }
49914
+ } catch {}
49915
+ throw error46;
49916
+ }
49917
+ console.log("\u2713 Added MCP server to " + OPENCODE_CONFIG_PATH);
49918
+ return true;
49919
+ }
49920
+ function removeMcpServer() {
49921
+ if (!existsSync5(OPENCODE_CONFIG_PATH)) {
49922
+ console.log("\u2022 MCP config not found (already removed)");
49923
+ return false;
49924
+ }
49925
+ let config3;
49926
+ try {
49927
+ const content = readFileSync5(OPENCODE_CONFIG_PATH, "utf-8");
49928
+ config3 = JSON.parse(content);
49929
+ } catch {
49930
+ console.log("\u26A0 Could not parse opencode.json, skipping MCP removal");
49931
+ return false;
49932
+ }
49933
+ const mcp = getMcpObject(config3);
49934
+ if (!mcp.fulcrum) {
49935
+ console.log("\u2022 MCP server not configured (already removed)");
49936
+ return false;
49937
+ }
49938
+ copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_CONFIG_PATH + ".backup");
49939
+ delete mcp.fulcrum;
49940
+ if (Object.keys(mcp).length === 0) {
49941
+ delete config3.mcp;
49942
+ } else {
49943
+ config3.mcp = mcp;
49944
+ }
49945
+ const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
49946
+ try {
49947
+ writeFileSync4(tempPath, JSON.stringify(config3, null, 2), "utf-8");
49948
+ renameSync(tempPath, OPENCODE_CONFIG_PATH);
49949
+ } catch (error46) {
49950
+ try {
49951
+ if (existsSync5(tempPath)) {
49952
+ unlinkSync2(tempPath);
49953
+ }
49954
+ } catch {}
49955
+ throw error46;
49614
49956
  }
49957
+ console.log("\u2713 Removed MCP server from " + OPENCODE_CONFIG_PATH);
49958
+ return true;
49615
49959
  }
49616
49960
 
49617
49961
  // cli/src/commands/notifications.ts
@@ -50245,6 +50589,127 @@ var tasksListDependenciesCommand = defineCommand({
50245
50589
  await handleTasksCommand("list-dependencies", [args.id], toFlags(args));
50246
50590
  }
50247
50591
  });
50592
+ var tasksLabelsCommand = defineCommand({
50593
+ meta: {
50594
+ name: "labels",
50595
+ description: "List all labels in use across tasks"
50596
+ },
50597
+ args: {
50598
+ ...globalArgs,
50599
+ search: {
50600
+ type: "string",
50601
+ description: "Filter labels by substring match"
50602
+ }
50603
+ },
50604
+ async run({ args }) {
50605
+ if (args.json)
50606
+ setJsonOutput(true);
50607
+ await handleTasksCommand("labels", [], toFlags(args));
50608
+ }
50609
+ });
50610
+ var tasksAttachmentsListCommand = defineCommand({
50611
+ meta: {
50612
+ name: "list",
50613
+ description: "List attachments for a task"
50614
+ },
50615
+ args: {
50616
+ ...globalArgs,
50617
+ id: {
50618
+ type: "positional",
50619
+ description: "Task ID",
50620
+ required: true
50621
+ }
50622
+ },
50623
+ async run({ args }) {
50624
+ if (args.json)
50625
+ setJsonOutput(true);
50626
+ await handleTasksCommand("attachments", ["list", args.id], toFlags(args));
50627
+ }
50628
+ });
50629
+ var tasksAttachmentsUploadCommand = defineCommand({
50630
+ meta: {
50631
+ name: "upload",
50632
+ description: "Upload a file to a task"
50633
+ },
50634
+ args: {
50635
+ ...globalArgs,
50636
+ id: {
50637
+ type: "positional",
50638
+ description: "Task ID",
50639
+ required: true
50640
+ },
50641
+ file: {
50642
+ type: "positional",
50643
+ description: "File path to upload",
50644
+ required: true
50645
+ }
50646
+ },
50647
+ async run({ args }) {
50648
+ if (args.json)
50649
+ setJsonOutput(true);
50650
+ await handleTasksCommand("attachments", ["upload", args.id, args.file], toFlags(args));
50651
+ }
50652
+ });
50653
+ var tasksAttachmentsDeleteCommand = defineCommand({
50654
+ meta: {
50655
+ name: "delete",
50656
+ description: "Delete an attachment from a task"
50657
+ },
50658
+ args: {
50659
+ ...globalArgs,
50660
+ id: {
50661
+ type: "positional",
50662
+ description: "Task ID",
50663
+ required: true
50664
+ },
50665
+ "attachment-id": {
50666
+ type: "positional",
50667
+ description: "Attachment ID",
50668
+ required: true
50669
+ }
50670
+ },
50671
+ async run({ args }) {
50672
+ if (args.json)
50673
+ setJsonOutput(true);
50674
+ await handleTasksCommand("attachments", ["delete", args.id, args["attachment-id"]], toFlags(args));
50675
+ }
50676
+ });
50677
+ var tasksAttachmentsPathCommand = defineCommand({
50678
+ meta: {
50679
+ name: "path",
50680
+ description: "Get local file path for an attachment"
50681
+ },
50682
+ args: {
50683
+ ...globalArgs,
50684
+ id: {
50685
+ type: "positional",
50686
+ description: "Task ID",
50687
+ required: true
50688
+ },
50689
+ "attachment-id": {
50690
+ type: "positional",
50691
+ description: "Attachment ID",
50692
+ required: true
50693
+ }
50694
+ },
50695
+ async run({ args }) {
50696
+ if (args.json)
50697
+ setJsonOutput(true);
50698
+ await handleTasksCommand("attachments", ["path", args.id, args["attachment-id"]], toFlags(args));
50699
+ }
50700
+ });
50701
+ var tasksAttachmentsCommand = defineCommand({
50702
+ meta: {
50703
+ name: "attachments",
50704
+ description: "Manage task attachments"
50705
+ },
50706
+ subCommands: {
50707
+ list: tasksAttachmentsListCommand,
50708
+ upload: tasksAttachmentsUploadCommand,
50709
+ delete: tasksAttachmentsDeleteCommand,
50710
+ path: tasksAttachmentsPathCommand
50711
+ }
50712
+ });
50248
50713
  var tasksCommand = defineCommand({
50249
50714
  meta: {
50250
50715
  name: "tasks",
@@ -50262,7 +50727,9 @@ var tasksCommand = defineCommand({
50262
50727
  "set-due-date": tasksSetDueDateCommand,
50263
50728
  "add-dependency": tasksAddDependencyCommand,
50264
50729
  "remove-dependency": tasksRemoveDependencyCommand,
50265
- "list-dependencies": tasksListDependenciesCommand
50730
+ "list-dependencies": tasksListDependenciesCommand,
50731
+ labels: tasksLabelsCommand,
50732
+ attachments: tasksAttachmentsCommand
50266
50733
  }
50267
50734
  });
50268
50735
  var projectsListCommand = defineCommand({