@knowsuchagency/fulcrum 3.15.2 → 4.0.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/README.md CHANGED
@@ -78,7 +78,7 @@ Every hour, your assistant reviews pending events, checks on blocked or overdue
78
78
  npx @knowsuchagency/fulcrum@latest up
79
79
  ```
80
80
 
81
- Fulcrum will check for dependencies (bun, dtach, AI agent CLI), offer to install any that are missing, and start the server on http://localhost:7777.
81
+ Fulcrum will check for dependencies (bun, dtach, fnox, age, AI agent CLI), offer to install any that are missing, set up encrypted secret storage, and start the server on http://localhost:7777.
82
82
 
83
83
  ### Desktop App
84
84
 
@@ -216,7 +216,7 @@ A two-tier memory system gives agents both always-on context and on-demand recal
216
216
 
217
217
  ### System Monitoring
218
218
 
219
- Track CPU, memory, and disk usage while your agents work. Jobs is a top-level page (`/jobs`, Cmd+6) for managing systemd (Linux) or launchd (macOS) timers. The Messages tab under Monitoring shows all channel messages (WhatsApp, Discord, Telegram, Slack, Email) with filtering by channel and direction.
219
+ Track CPU, memory, and disk usage while your agents work. Jobs is a top-level page (`/jobs`, Cmd+6) for managing systemd (Linux) or launchd (macOS) timers. The Messages tab under Monitoring shows all channel messages (WhatsApp, Discord, Telegram, Slack, Email) with filtering by channel and direction. The Observer tab tracks every observe-only message processing attempt with circuit breaker status, aggregate stats, and a filterable invocations list.
220
220
 
221
221
  ![System Monitoring](https://raw.githubusercontent.com/knowsuchagency/fulcrum/main/screenshots/system-monitoring-dark.png)
222
222
 
@@ -315,6 +315,8 @@ For browser-only access, use Tailscale or Cloudflare Tunnels to expose your serv
315
315
  <details>
316
316
  <summary><strong>Configuration</strong></summary>
317
317
 
318
+ Sensitive credentials (API keys, tokens, webhook URLs) are encrypted using [fnox](https://github.com/yarlson/fnox) with age encryption. The age key and encrypted secrets live in the fulcrum directory (`age.txt` and `fnox.toml`). Non-sensitive settings are stored in `settings.json`. Existing plain-text secrets are automatically migrated to fnox on server start.
319
+
318
320
  Settings are stored in `.fulcrum/settings.json`. The fulcrum directory is resolved in this order:
319
321
 
320
322
  1. `FULCRUM_DIR` environment variable
package/bin/fulcrum.js CHANGED
@@ -785,31 +785,31 @@ var init_prompt = __esm(() => {
785
785
  });
786
786
 
787
787
  // cli/src/utils/server.ts
788
- import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync } from "fs";
788
+ import { existsSync, mkdirSync, cpSync } from "fs";
789
+ import { execSync } from "child_process";
789
790
  import { join } from "path";
790
791
  import { homedir } from "os";
791
- function getPortFromSettings(settings) {
792
- if (!settings)
793
- return null;
794
- if (settings.server?.port) {
795
- return settings.server.port;
796
- }
797
- if (settings.port) {
798
- return settings.port;
799
- }
800
- return null;
801
- }
802
792
  function expandPath(p) {
803
793
  if (p.startsWith("~/")) {
804
794
  return join(homedir(), p.slice(2));
805
795
  }
806
796
  return p;
807
797
  }
808
- function readSettingsFile(path) {
798
+ function getPortFromFnox(fulcrumDir) {
799
+ const fnoxConfigPath = join(fulcrumDir, "fnox.toml");
800
+ const ageKeyPath = join(fulcrumDir, "age.txt");
801
+ if (!existsSync(fnoxConfigPath) || !existsSync(ageKeyPath))
802
+ return null;
809
803
  try {
810
- if (existsSync(path)) {
811
- const content = readFileSync(path, "utf-8");
812
- return JSON.parse(content);
804
+ const value = execSync(`fnox get FULCRUM_SERVER_PORT -c "${fnoxConfigPath}" --if-missing ignore`, {
805
+ env: { ...process.env, FNOX_AGE_KEY_FILE: ageKeyPath },
806
+ encoding: "utf-8",
807
+ stdio: ["pipe", "pipe", "ignore"]
808
+ }).trim();
809
+ if (value) {
810
+ const port = parseInt(value, 10);
811
+ if (!isNaN(port) && port > 0)
812
+ return port;
813
813
  }
814
814
  } catch {}
815
815
  return null;
@@ -825,44 +825,39 @@ function discoverServerUrl(urlOverride, portOverride) {
825
825
  return process.env.FULCRUM_URL;
826
826
  }
827
827
  if (process.env.FULCRUM_DIR) {
828
- const fulcrumDirSettings = join(expandPath(process.env.FULCRUM_DIR), "settings.json");
829
- const settings = readSettingsFile(fulcrumDirSettings);
830
- const port = getPortFromSettings(settings);
831
- if (port) {
832
- return `http://localhost:${port}`;
833
- }
834
- }
835
- const cwdSettings = join(process.cwd(), ".fulcrum", "settings.json");
836
- const localSettings = readSettingsFile(cwdSettings);
837
- const localPort = getPortFromSettings(localSettings);
838
- if (localPort) {
839
- return `http://localhost:${localPort}`;
840
- }
841
- const globalSettings = join(homedir(), ".fulcrum", "settings.json");
842
- const homeSettings = readSettingsFile(globalSettings);
843
- const homePort = getPortFromSettings(homeSettings);
844
- if (homePort) {
845
- return `http://localhost:${homePort}`;
846
- }
828
+ const port2 = getPortFromFnox(expandPath(process.env.FULCRUM_DIR));
829
+ if (port2)
830
+ return `http://localhost:${port2}`;
831
+ }
832
+ const cwdFulcrum = join(process.cwd(), ".fulcrum");
833
+ if (existsSync(cwdFulcrum)) {
834
+ const port2 = getPortFromFnox(cwdFulcrum);
835
+ if (port2)
836
+ return `http://localhost:${port2}`;
837
+ }
838
+ const globalFulcrum = join(homedir(), ".fulcrum");
839
+ const port = getPortFromFnox(globalFulcrum);
840
+ if (port)
841
+ return `http://localhost:${port}`;
847
842
  return `http://localhost:${DEFAULT_PORT}`;
848
843
  }
849
844
  function updateSettingsPort(port) {
850
845
  const fulcrumDir = getFulcrumDir();
851
- const settingsPath = join(fulcrumDir, "settings.json");
852
- let settings = {};
853
- try {
854
- if (existsSync(settingsPath)) {
855
- settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
856
- }
857
- } catch {}
858
- if (!settings.server || typeof settings.server !== "object") {
859
- settings.server = {};
860
- }
861
- settings.server.port = port;
846
+ const fnoxConfigPath = join(fulcrumDir, "fnox.toml");
847
+ const ageKeyPath = join(fulcrumDir, "age.txt");
862
848
  if (!existsSync(fulcrumDir)) {
863
849
  mkdirSync(fulcrumDir, { recursive: true });
864
850
  }
865
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
851
+ if (!existsSync(fnoxConfigPath) || !existsSync(ageKeyPath)) {
852
+ return;
853
+ }
854
+ try {
855
+ execSync(`fnox set FULCRUM_SERVER_PORT -p plain -c "${fnoxConfigPath}"`, {
856
+ env: { ...process.env, FNOX_AGE_KEY_FILE: ageKeyPath },
857
+ input: String(port),
858
+ stdio: ["pipe", "ignore", "ignore"]
859
+ });
860
+ } catch {}
866
861
  }
867
862
  function getFulcrumDir() {
868
863
  if (process.env.FULCRUM_DIR) {
@@ -895,15 +890,6 @@ function migrateFromVibora() {
895
890
  mkdirSync(fulcrumDir, { recursive: true });
896
891
  }
897
892
  cpSync(viboraDir, fulcrumDir, { recursive: true });
898
- const settingsPath = join(fulcrumDir, "settings.json");
899
- if (existsSync(settingsPath)) {
900
- try {
901
- let content = readFileSync(settingsPath, "utf-8");
902
- content = content.replace(/\.vibora/g, ".fulcrum");
903
- content = content.replace(/vibora\.(db|log|pid)/g, "fulcrum.$1");
904
- writeFileSync(settingsPath, content, "utf-8");
905
- } catch {}
906
- }
907
893
  const oldDbPath = join(fulcrumDir, "vibora.db");
908
894
  const newDbPath = join(fulcrumDir, "fulcrum.db");
909
895
  if (existsSync(oldDbPath) && !existsSync(newDbPath)) {
@@ -965,7 +951,7 @@ var init_errors = __esm(() => {
965
951
  });
966
952
 
967
953
  // cli/src/client.ts
968
- import { readFileSync as readFileSync2 } from "fs";
954
+ import { readFileSync } from "fs";
969
955
  import { basename } from "path";
970
956
 
971
957
  class FulcrumClient {
@@ -1228,7 +1214,7 @@ class FulcrumClient {
1228
1214
  return this.fetch(`/api/tasks/${taskId}/attachments`);
1229
1215
  }
1230
1216
  async uploadTaskAttachment(taskId, filePath) {
1231
- const fileContent = readFileSync2(filePath);
1217
+ const fileContent = readFileSync(filePath);
1232
1218
  const filename = basename(filePath);
1233
1219
  const formData = new FormData;
1234
1220
  const blob = new Blob([fileContent]);
@@ -1315,7 +1301,7 @@ class FulcrumClient {
1315
1301
  return this.fetch(`/api/projects/${projectId}/attachments`);
1316
1302
  }
1317
1303
  async uploadProjectAttachment(projectId, filePath) {
1318
- const fileContent = readFileSync2(filePath);
1304
+ const fileContent = readFileSync(filePath);
1319
1305
  const filename = basename(filePath);
1320
1306
  const formData = new FormData;
1321
1307
  const blob = new Blob([fileContent]);
@@ -46466,7 +46452,7 @@ async function runMcpServer(urlOverride, portOverride) {
46466
46452
  const client = new FulcrumClient(urlOverride, portOverride);
46467
46453
  const server = new McpServer({
46468
46454
  name: "fulcrum",
46469
- version: "3.15.2"
46455
+ version: "4.0.0"
46470
46456
  });
46471
46457
  registerTools(server, client);
46472
46458
  const transport = new StdioServerTransport;
@@ -48330,9 +48316,9 @@ var configCommand = defineCommand({
48330
48316
  // cli/src/commands/opencode.ts
48331
48317
  import {
48332
48318
  mkdirSync as mkdirSync2,
48333
- writeFileSync as writeFileSync2,
48319
+ writeFileSync,
48334
48320
  existsSync as existsSync2,
48335
- readFileSync as readFileSync3,
48321
+ readFileSync as readFileSync2,
48336
48322
  unlinkSync,
48337
48323
  copyFileSync,
48338
48324
  renameSync
@@ -48650,7 +48636,7 @@ async function installOpenCodeIntegration() {
48650
48636
  try {
48651
48637
  console.log("Installing OpenCode plugin...");
48652
48638
  mkdirSync2(PLUGIN_DIR, { recursive: true });
48653
- writeFileSync2(PLUGIN_PATH, fulcrum_opencode_default, "utf-8");
48639
+ writeFileSync(PLUGIN_PATH, fulcrum_opencode_default, "utf-8");
48654
48640
  console.log("\u2713 Installed plugin at " + PLUGIN_PATH);
48655
48641
  console.log("Configuring MCP server...");
48656
48642
  const mcpConfigured = addMcpServer();
@@ -48699,7 +48685,7 @@ function addMcpServer() {
48699
48685
  let config = {};
48700
48686
  if (existsSync2(OPENCODE_CONFIG_PATH)) {
48701
48687
  try {
48702
- const content = readFileSync3(OPENCODE_CONFIG_PATH, "utf-8");
48688
+ const content = readFileSync2(OPENCODE_CONFIG_PATH, "utf-8");
48703
48689
  config = JSON.parse(content);
48704
48690
  } catch {
48705
48691
  console.log("\u26A0 Could not parse existing opencode.json, skipping MCP configuration");
@@ -48722,7 +48708,7 @@ function addMcpServer() {
48722
48708
  };
48723
48709
  const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
48724
48710
  try {
48725
- writeFileSync2(tempPath, JSON.stringify(config, null, 2), "utf-8");
48711
+ writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8");
48726
48712
  renameSync(tempPath, OPENCODE_CONFIG_PATH);
48727
48713
  } catch (error) {
48728
48714
  try {
@@ -48742,7 +48728,7 @@ function removeMcpServer() {
48742
48728
  }
48743
48729
  let config;
48744
48730
  try {
48745
- const content = readFileSync3(OPENCODE_CONFIG_PATH, "utf-8");
48731
+ const content = readFileSync2(OPENCODE_CONFIG_PATH, "utf-8");
48746
48732
  config = JSON.parse(content);
48747
48733
  } catch {
48748
48734
  console.log("\u26A0 Could not parse opencode.json, skipping MCP removal");
@@ -48762,7 +48748,7 @@ function removeMcpServer() {
48762
48748
  }
48763
48749
  const tempPath = OPENCODE_CONFIG_PATH + ".tmp";
48764
48750
  try {
48765
- writeFileSync2(tempPath, JSON.stringify(config, null, 2), "utf-8");
48751
+ writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8");
48766
48752
  renameSync(tempPath, OPENCODE_CONFIG_PATH);
48767
48753
  } catch (error) {
48768
48754
  try {
@@ -48799,7 +48785,7 @@ var opencodeCommand = defineCommand({
48799
48785
 
48800
48786
  // cli/src/commands/claude.ts
48801
48787
  import { spawnSync } from "child_process";
48802
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync, readFileSync as readFileSync4 } from "fs";
48788
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, existsSync as existsSync3, rmSync, readFileSync as readFileSync3 } from "fs";
48803
48789
  import { homedir as homedir3 } from "os";
48804
48790
  import { dirname, join as join3 } from "path";
48805
48791
  init_errors();
@@ -48815,7 +48801,7 @@ var marketplace_default = `{
48815
48801
  "name": "fulcrum",
48816
48802
  "source": "./",
48817
48803
  "description": "Task orchestration for Claude Code",
48818
- "version": "3.15.2",
48804
+ "version": "4.0.0",
48819
48805
  "skills": [
48820
48806
  "./skills/fulcrum"
48821
48807
  ],
@@ -49335,7 +49321,7 @@ function getInstalledVersion() {
49335
49321
  return null;
49336
49322
  }
49337
49323
  try {
49338
- const installed = JSON.parse(readFileSync4(installedMarketplace, "utf-8"));
49324
+ const installed = JSON.parse(readFileSync3(installedMarketplace, "utf-8"));
49339
49325
  return installed.plugins?.[0]?.version || null;
49340
49326
  } catch {
49341
49327
  return null;
@@ -49371,7 +49357,7 @@ async function installClaudePlugin(options = {}) {
49371
49357
  for (const file of PLUGIN_FILES) {
49372
49358
  const fullPath = join3(MARKETPLACE_DIR, file.path);
49373
49359
  mkdirSync3(dirname(fullPath), { recursive: true });
49374
- writeFileSync3(fullPath, file.content, "utf-8");
49360
+ writeFileSync2(fullPath, file.content, "utf-8");
49375
49361
  }
49376
49362
  log("\u2713 Created plugin files at " + MARKETPLACE_DIR);
49377
49363
  runClaude(["plugin", "marketplace", "remove", MARKETPLACE_NAME]);
@@ -49658,14 +49644,14 @@ var notifyCommand = defineCommand({
49658
49644
 
49659
49645
  // cli/src/commands/up.ts
49660
49646
  import { spawn as spawn2 } from "child_process";
49661
- import { existsSync as existsSync5 } from "fs";
49662
- import { dirname as dirname3, join as join5 } from "path";
49647
+ import { existsSync as existsSync6 } from "fs";
49648
+ import { dirname as dirname3, join as join6 } from "path";
49663
49649
  import { fileURLToPath } from "url";
49664
49650
  init_errors();
49665
49651
 
49666
49652
  // cli/src/utils/process.ts
49667
49653
  init_server();
49668
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2, mkdirSync as mkdirSync4 } from "fs";
49654
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync4 } from "fs";
49669
49655
  import { join as join4, dirname as dirname2 } from "path";
49670
49656
  function getPidPath() {
49671
49657
  return join4(getFulcrumDir(), "fulcrum.pid");
@@ -49676,13 +49662,13 @@ function writePid(pid) {
49676
49662
  if (!existsSync4(dir)) {
49677
49663
  mkdirSync4(dir, { recursive: true });
49678
49664
  }
49679
- writeFileSync4(pidPath, pid.toString(), "utf-8");
49665
+ writeFileSync3(pidPath, pid.toString(), "utf-8");
49680
49666
  }
49681
49667
  function readPid() {
49682
49668
  const pidPath = getPidPath();
49683
49669
  try {
49684
49670
  if (existsSync4(pidPath)) {
49685
- const content = readFileSync5(pidPath, "utf-8").trim();
49671
+ const content = readFileSync4(pidPath, "utf-8").trim();
49686
49672
  const pid = parseInt(content, 10);
49687
49673
  return isNaN(pid) ? null : pid;
49688
49674
  }
@@ -49738,7 +49724,7 @@ async function confirm(message) {
49738
49724
  init_server();
49739
49725
 
49740
49726
  // cli/src/utils/dependencies.ts
49741
- import { execSync, spawnSync as spawnSync2 } from "child_process";
49727
+ import { execSync as execSync2, spawnSync as spawnSync2 } from "child_process";
49742
49728
  var DEPENDENCIES = [
49743
49729
  {
49744
49730
  name: "bun",
@@ -49801,13 +49787,34 @@ var DEPENDENCIES = [
49801
49787
  dnf: "sudo dnf install -y gh",
49802
49788
  pacman: "sudo pacman -S --noconfirm github-cli"
49803
49789
  }
49790
+ },
49791
+ {
49792
+ name: "fnox",
49793
+ command: "fnox",
49794
+ description: "Encrypted secrets management",
49795
+ required: true,
49796
+ install: {
49797
+ brew: "brew install fnox"
49798
+ }
49799
+ },
49800
+ {
49801
+ name: "age",
49802
+ command: "age-keygen",
49803
+ description: "Age encryption key generation",
49804
+ required: true,
49805
+ install: {
49806
+ brew: "brew install age",
49807
+ apt: "sudo apt install -y age",
49808
+ dnf: "sudo dnf install -y age",
49809
+ pacman: "sudo pacman -S --noconfirm age"
49810
+ }
49804
49811
  }
49805
49812
  ];
49806
49813
  function detectPackageManager() {
49807
49814
  const managers = ["brew", "apt", "dnf", "pacman"];
49808
49815
  for (const pm of managers) {
49809
49816
  try {
49810
- execSync(`which ${pm}`, { stdio: "ignore" });
49817
+ execSync2(`which ${pm}`, { stdio: "ignore" });
49811
49818
  return pm;
49812
49819
  } catch {}
49813
49820
  }
@@ -49815,7 +49822,7 @@ function detectPackageManager() {
49815
49822
  }
49816
49823
  function isCommandInstalled(command) {
49817
49824
  try {
49818
- execSync(`which ${command}`, { stdio: "ignore" });
49825
+ execSync2(`which ${command}`, { stdio: "ignore" });
49819
49826
  return true;
49820
49827
  } catch {}
49821
49828
  return isShellAlias(command);
@@ -49824,7 +49831,7 @@ function isShellAlias(command) {
49824
49831
  const shell = process.env.SHELL || "/bin/bash";
49825
49832
  const shellName = shell.split("/").pop() || "bash";
49826
49833
  try {
49827
- execSync(`${shellName} -ic "type ${command}" 2>/dev/null`, { stdio: "ignore" });
49834
+ execSync2(`${shellName} -ic "type ${command}" 2>/dev/null`, { stdio: "ignore" });
49828
49835
  return true;
49829
49836
  } catch {
49830
49837
  return false;
@@ -49832,14 +49839,14 @@ function isShellAlias(command) {
49832
49839
  }
49833
49840
  function getCommandVersion(command) {
49834
49841
  try {
49835
- const output2 = execSync(`${command} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
49842
+ const output2 = execSync2(`${command} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
49836
49843
  return output2.trim().split(`
49837
49844
  `)[0];
49838
49845
  } catch {}
49839
49846
  const shell = process.env.SHELL || "/bin/bash";
49840
49847
  const shellName = shell.split("/").pop() || "bash";
49841
49848
  try {
49842
- const output2 = execSync(`${shellName} -ic "${command} --version" 2>/dev/null`, {
49849
+ const output2 = execSync2(`${shellName} -ic "${command} --version" 2>/dev/null`, {
49843
49850
  encoding: "utf-8",
49844
49851
  stdio: ["pipe", "pipe", "ignore"]
49845
49852
  });
@@ -49940,6 +49947,94 @@ function installUv() {
49940
49947
  return false;
49941
49948
  return installDependency(dep);
49942
49949
  }
49950
+ function isFnoxInstalled() {
49951
+ return isCommandInstalled("fnox");
49952
+ }
49953
+ function installFnox() {
49954
+ const dep = getDependency("fnox");
49955
+ if (!dep)
49956
+ return false;
49957
+ return installDependency(dep);
49958
+ }
49959
+ function isAgeInstalled() {
49960
+ return isCommandInstalled("age-keygen");
49961
+ }
49962
+ function installAge() {
49963
+ const dep = getDependency("age");
49964
+ if (!dep)
49965
+ return false;
49966
+ return installDependency(dep);
49967
+ }
49968
+
49969
+ // cli/src/utils/fnox-setup.ts
49970
+ init_errors();
49971
+ import { execSync as execSync3 } from "child_process";
49972
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, chmodSync } from "fs";
49973
+ import { join as join5 } from "path";
49974
+ function ensureFnoxSetup(fulcrumDir) {
49975
+ const ageKeyPath = join5(fulcrumDir, "age.txt");
49976
+ const fnoxConfigPath = join5(fulcrumDir, "fnox.toml");
49977
+ let publicKey;
49978
+ if (!existsSync5(ageKeyPath)) {
49979
+ console.error("Generating age encryption key...");
49980
+ try {
49981
+ const output2 = execSync3(`age-keygen -o "${ageKeyPath}" 2>&1`, { encoding: "utf-8" });
49982
+ const match = output2.match(/Public key: (age1\S+)/);
49983
+ if (!match) {
49984
+ throw new Error(`Could not parse public key from age-keygen output: ${output2}`);
49985
+ }
49986
+ publicKey = match[1];
49987
+ } catch (err) {
49988
+ throw new CliError("FNOX_SETUP_FAILED", `Failed to generate age key: ${err instanceof Error ? err.message : String(err)}`, ExitCodes.ERROR);
49989
+ }
49990
+ chmodSync(ageKeyPath, 384);
49991
+ console.error("Age encryption key generated.");
49992
+ } else {
49993
+ const content = readFileSync5(ageKeyPath, "utf-8");
49994
+ const match = content.match(/# public key: (age1\S+)/);
49995
+ if (!match) {
49996
+ throw new CliError("FNOX_SETUP_FAILED", `Could not parse public key from existing ${ageKeyPath}`, ExitCodes.ERROR);
49997
+ }
49998
+ publicKey = match[1];
49999
+ }
50000
+ if (!existsSync5(fnoxConfigPath)) {
50001
+ console.error("Creating fnox configuration...");
50002
+ const config = `[providers.plain]
50003
+ type = "plain"
50004
+
50005
+ [providers.age]
50006
+ type = "age"
50007
+ recipients = ["${publicKey}"]
50008
+ `;
50009
+ writeFileSync4(fnoxConfigPath, config, "utf-8");
50010
+ console.error("fnox configuration created.");
50011
+ } else {
50012
+ const existingConfig = readFileSync5(fnoxConfigPath, "utf-8");
50013
+ if (!existingConfig.includes("[providers.plain]")) {
50014
+ const updatedConfig = `[providers.plain]
50015
+ type = "plain"
50016
+
50017
+ ${existingConfig}`;
50018
+ writeFileSync4(fnoxConfigPath, updatedConfig, "utf-8");
50019
+ console.error("Added plain provider to fnox configuration.");
50020
+ }
50021
+ }
50022
+ const env2 = { ...process.env, FNOX_AGE_KEY_FILE: ageKeyPath };
50023
+ const fnoxArgs = `-c "${fnoxConfigPath}"`;
50024
+ try {
50025
+ execSync3(`fnox set FULCRUM_SETUP_TEST test_value ${fnoxArgs}`, { env: env2, stdio: "ignore" });
50026
+ const value = execSync3(`fnox get FULCRUM_SETUP_TEST ${fnoxArgs}`, { env: env2, encoding: "utf-8" }).trim();
50027
+ execSync3(`fnox remove FULCRUM_SETUP_TEST ${fnoxArgs}`, { env: env2, stdio: "ignore" });
50028
+ if (value !== "test_value") {
50029
+ throw new Error(`Round-trip test failed: expected "test_value", got "${value}"`);
50030
+ }
50031
+ } catch (err) {
50032
+ throw new CliError("FNOX_SETUP_FAILED", `fnox verification failed: ${err instanceof Error ? err.message : String(err)}
50033
+ ` + ` Age key: ${ageKeyPath}
50034
+ ` + ` Config: ${fnoxConfigPath}
50035
+ ` + ` Ensure fnox and age are properly installed.`, ExitCodes.ERROR);
50036
+ }
50037
+ }
49943
50038
 
49944
50039
  // cli/src/commands/update.ts
49945
50040
  import { spawn, spawnSync as spawnSync3 } from "child_process";
@@ -50019,7 +50114,7 @@ function compareVersions(v1, v2) {
50019
50114
  var package_default = {
50020
50115
  name: "@knowsuchagency/fulcrum",
50021
50116
  private: true,
50022
- version: "3.15.2",
50117
+ version: "4.0.0",
50023
50118
  description: "Harness Attention. Orchestrate Agents. Ship.",
50024
50119
  license: "PolyForm-Perimeter-1.0.0",
50025
50120
  type: "module",
@@ -50295,7 +50390,7 @@ function getPackageRoot() {
50295
50390
  const currentFile = fileURLToPath(import.meta.url);
50296
50391
  let dir = dirname3(currentFile);
50297
50392
  for (let i2 = 0;i2 < 5; i2++) {
50298
- if (existsSync5(join5(dir, "server", "index.js"))) {
50393
+ if (existsSync6(join6(dir, "server", "index.js"))) {
50299
50394
  return dir;
50300
50395
  }
50301
50396
  dir = dirname3(dir);
@@ -50377,6 +50472,38 @@ Found existing Vibora data at ${viboraDir}`);
50377
50472
  throw new CliError("MISSING_DEPENDENCY", `uv is required. Install manually: ${getInstallCommand(uvDep)}`, ExitCodes.ERROR);
50378
50473
  }
50379
50474
  }
50475
+ if (!isFnoxInstalled()) {
50476
+ const fnoxDep = getDependency("fnox");
50477
+ const method = getInstallMethod(fnoxDep);
50478
+ console.error("fnox is required for encrypted secrets management but is not installed.");
50479
+ console.error(" fnox encrypts sensitive settings like API keys and tokens.");
50480
+ const shouldInstall = autoYes || await confirm(`Would you like to install fnox via ${method}?`);
50481
+ if (shouldInstall) {
50482
+ const success = installFnox();
50483
+ if (!success) {
50484
+ throw new CliError("INSTALL_FAILED", "Failed to install fnox", ExitCodes.ERROR);
50485
+ }
50486
+ console.error("fnox installed successfully!");
50487
+ } else {
50488
+ throw new CliError("MISSING_DEPENDENCY", `fnox is required. Install manually: ${getInstallCommand(fnoxDep)}`, ExitCodes.ERROR);
50489
+ }
50490
+ }
50491
+ if (!isAgeInstalled()) {
50492
+ const ageDep = getDependency("age");
50493
+ const method = getInstallMethod(ageDep);
50494
+ console.error("age is required for encryption but is not installed.");
50495
+ console.error(" age generates encryption keys used by fnox to encrypt secrets.");
50496
+ const shouldInstall = autoYes || await confirm(`Would you like to install age via ${method}?`);
50497
+ if (shouldInstall) {
50498
+ const success = installAge();
50499
+ if (!success) {
50500
+ throw new CliError("INSTALL_FAILED", "Failed to install age", ExitCodes.ERROR);
50501
+ }
50502
+ console.error("age installed successfully!");
50503
+ } else {
50504
+ throw new CliError("MISSING_DEPENDENCY", `age is required. Install manually: ${getInstallCommand(ageDep)}`, ExitCodes.ERROR);
50505
+ }
50506
+ }
50380
50507
  if (isClaudeInstalled() && needsPluginUpdate()) {
50381
50508
  console.error("Updating Fulcrum plugin for Claude Code...");
50382
50509
  await installClaudePlugin({ silent: true });
@@ -50409,7 +50536,7 @@ Found existing Vibora data at ${viboraDir}`);
50409
50536
  }
50410
50537
  const host = flags.host ? "0.0.0.0" : "localhost";
50411
50538
  const packageRoot = getPackageRoot();
50412
- const serverPath = join5(packageRoot, "server", "index.js");
50539
+ const serverPath = join6(packageRoot, "server", "index.js");
50413
50540
  const platform2 = process.platform;
50414
50541
  const arch = process.arch;
50415
50542
  let ptyLibName;
@@ -50420,8 +50547,9 @@ Found existing Vibora data at ${viboraDir}`);
50420
50547
  } else {
50421
50548
  ptyLibName = arch === "arm64" ? "librust_pty_arm64.so" : "librust_pty.so";
50422
50549
  }
50423
- const ptyLibPath = join5(packageRoot, "lib", ptyLibName);
50550
+ const ptyLibPath = join6(packageRoot, "lib", ptyLibName);
50424
50551
  const fulcrumDir = getFulcrumDir();
50552
+ ensureFnoxSetup(fulcrumDir);
50425
50553
  const debug = flags.debug === "true";
50426
50554
  console.error(`Starting Fulcrum server${debug ? " (debug mode)" : ""}...`);
50427
50555
  const serverProc = spawn2("bun", [serverPath], {
@@ -50436,6 +50564,8 @@ Found existing Vibora data at ${viboraDir}`);
50436
50564
  FULCRUM_PACKAGE_ROOT: packageRoot,
50437
50565
  FULCRUM_VERSION: package_default.version,
50438
50566
  BUN_PTY_LIB: ptyLibPath,
50567
+ FNOX_AGE_KEY_FILE: join6(fulcrumDir, "age.txt"),
50568
+ FULCRUM_FNOX_INSTALLED: "1",
50439
50569
  ...isClaudeInstalled() && { FULCRUM_CLAUDE_INSTALLED: "1" },
50440
50570
  ...isOpencodeInstalled() && { FULCRUM_OPENCODE_INSTALLED: "1" },
50441
50571
  ...debug && { LOG_LEVEL: "debug", DEBUG: "1" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowsuchagency/fulcrum",
3
- "version": "3.15.2",
3
+ "version": "4.0.0",
4
4
  "description": "Harness Attention. Orchestrate Agents. Ship.",
5
5
  "license": "PolyForm-Perimeter-1.0.0",
6
6
  "repository": {