@arkhera30/cli 0.3.15 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,7 +60,7 @@ services:
60
60
  - "${VAULT_PORT:-8000}:8000"
61
61
  volumes:
62
62
  # Knowledge-base repo — read/write; Vault clones on first boot if empty
63
- - ${HORUS_DATA_PATH}/knowledge-base:/data/knowledge-repo:rw
63
+ - ${HORUS_DATA_PATH}/vaults/default:/data/knowledge-repo:rw
64
64
  # Write-path workspace: staging area for draft pages before PR
65
65
  - vault-workspace:/data/workspace
66
66
  environment:
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command11 } from "commander";
5
- import chalk11 from "chalk";
4
+ import { Command as Command13 } from "commander";
5
+ import chalk13 from "chalk";
6
6
 
7
7
  // src/commands/setup.ts
8
8
  import { Command as Command2 } from "commander";
@@ -139,6 +139,7 @@ function buildConfigFromParsed(parsed) {
139
139
  vault_rest: parsedPorts?.vault_rest ?? defaults.ports.vault_rest,
140
140
  vault_mcp: parsedPorts?.vault_mcp ?? defaults.ports.vault_mcp,
141
141
  vault_router: parsedPorts?.vault_router ?? defaults.ports.vault_router,
142
+ ui: parsedPorts?.ui ?? defaults.ports.ui,
142
143
  forge: parsedPorts?.forge ?? defaults.ports.forge,
143
144
  typesense: parsedPorts?.typesense ?? defaults.ports.typesense,
144
145
  neo4j_http: parsedPorts?.neo4j_http ?? defaults.ports.neo4j_http,
@@ -672,6 +673,11 @@ var FORGE_SERVICE = ` # \u2500\u2500 Forge \u2500\u2500\u2500\u2500\u2500\u2500
672
673
  - FORGE_SCAN_PATHS=\${FORGE_SCAN_PATHS:-/data/repos}
673
674
  - FORGE_SESSION_TTL_MS=\${FORGE_SESSION_TTL_MS:-1800000}
674
675
  - GITHUB_TOKEN=\${GITHUB_TOKEN:-}
676
+ # Fix git "dubious ownership" on bind-mounted repos (bug 4a32728f).
677
+ # Container UID differs from host UID that owns mounted repos.
678
+ - GIT_CONFIG_COUNT=1
679
+ - GIT_CONFIG_KEY_0=safe.directory
680
+ - GIT_CONFIG_VALUE_0=*
675
681
  - TYPESENSE_HOST=typesense
676
682
  - TYPESENSE_PORT=8108
677
683
  - TYPESENSE_API_KEY=\${TYPESENSE_API_KEY:-horus-local-key}
@@ -1083,6 +1089,25 @@ function mergeAndWriteConfig(configPath, mcpServers) {
1083
1089
  mkdirSync2(dir, { recursive: true });
1084
1090
  writeFileSync3(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
1085
1091
  }
1092
+ function detectNpxPath() {
1093
+ const candidates = ["/opt/homebrew/bin/npx", "/usr/local/bin/npx"];
1094
+ for (const candidate of candidates) {
1095
+ if (existsSync4(candidate)) {
1096
+ return candidate;
1097
+ }
1098
+ }
1099
+ return "npx";
1100
+ }
1101
+ function buildClaudeDesktopServers(config, host) {
1102
+ const npxPath = detectNpxPath();
1103
+ const npxDir = npxPath === "npx" ? "/usr/local/bin" : npxPath.substring(0, npxPath.lastIndexOf("/"));
1104
+ const envPath = `${npxDir}:/usr/local/bin:/usr/bin:/bin`;
1105
+ return {
1106
+ anvil: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.anvil}/mcp`], env: { PATH: envPath } },
1107
+ vault: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.vault_mcp}/mcp`], env: { PATH: envPath } },
1108
+ forge: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.forge}/mcp`], env: { PATH: envPath } }
1109
+ };
1110
+ }
1086
1111
  async function isClaudeCliAvailable() {
1087
1112
  try {
1088
1113
  const result = await execa2("claude", ["--version"], { reject: false });
@@ -1181,7 +1206,8 @@ async function runConnect(config, runtime, targets, host = "localhost") {
1181
1206
  const desktopSpinner = ora(`Configuring ${chalk.cyan("claude-desktop")}...`).start();
1182
1207
  try {
1183
1208
  const configPath = getConfigPath(target);
1184
- mergeAndWriteConfig(configPath, httpServers);
1209
+ const desktopServers = buildClaudeDesktopServers(config, host);
1210
+ mergeAndWriteConfig(configPath, desktopServers);
1185
1211
  desktopSpinner.succeed(`Configured ${chalk.cyan("claude-desktop")} \u2014 ${chalk.dim(configPath)}`);
1186
1212
  configured.push(target);
1187
1213
  } catch (error) {
@@ -1333,7 +1359,7 @@ function extractHostname(url) {
1333
1359
  return "github.com";
1334
1360
  }
1335
1361
  }
1336
- var setupCommand = new Command2("setup").description("Interactive first-run setup for Horus").option("-y, --yes", "Non-interactive mode (use defaults + env vars)").option("--runtime <runtime>", "Container runtime to use: docker or podman (non-interactive only)").option("--data-dir <path>", "Data directory path").option("--repos-path <path>", "Host repos path for Forge scanning").option("--anvil-repo <url>", "Anvil notes repository URL").option("--vault-name <name>", "Vault name (can be specified multiple times)").option("--vault-repo <url>", "Vault knowledge-base repository URL (matches positionally with --vault-name)").option("--forge-repo <url>", "Forge registry repository URL").option("--github-token <token>", "GitHub personal access token for private repos (primary host)").action(async (opts) => {
1362
+ var setupCommand = new Command2("setup").description("Interactive first-run setup for Horus").option("-y, --yes", "Non-interactive mode (use defaults + env vars)").option("--runtime <runtime>", "Container runtime to use: docker or podman (non-interactive only)").option("--data-dir <path>", "Data directory path").option("--repos-path <path>", "Host repos path for Forge scanning").option("--anvil-repo <url>", "Anvil notes repository URL").option("--vault-name <name>", "Vault name (can be specified multiple times)").option("--vault-repo <url>", "Vault knowledge-base repository URL (matches positionally with --vault-name)").option("--forge-repo <url>", "Forge registry repository URL").option("--github-token <token>", "GitHub personal access token for private repos (primary host)").option("--claude-desktop", "Configure Claude Desktop MCP servers during setup (non-interactive opt-in)").action(async (opts) => {
1337
1363
  console.log("");
1338
1364
  console.log(chalk2.bold("Horus Setup"));
1339
1365
  console.log(chalk2.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
@@ -1478,6 +1504,7 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
1478
1504
  vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
1479
1505
  vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
1480
1506
  vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
1507
+ ui: DEFAULT_PORTS.ui,
1481
1508
  forge: forge ?? DEFAULT_PORTS.forge,
1482
1509
  typesense: DEFAULT_PORTS.typesense,
1483
1510
  neo4j_http: DEFAULT_PORTS.neo4j_http,
@@ -1731,11 +1758,28 @@ ${example(`${vaultName.trim()}-knowledge`)}
1731
1758
  const detectedClients = detectInstalledClients();
1732
1759
  if (detectedClients.length > 0) {
1733
1760
  console.log(chalk2.bold("Configuring AI clients..."));
1734
- try {
1735
- await runConnect(config, runtime, detectedClients, "localhost");
1736
- } catch (error) {
1737
- console.log(chalk2.yellow("Could not configure AI clients automatically."));
1738
- console.log(chalk2.dim(`Run ${chalk2.cyan("horus connect")} to configure them manually.`));
1761
+ let clientsToConnect = [...detectedClients];
1762
+ if (clientsToConnect.includes("claude-desktop")) {
1763
+ let configureDesktop;
1764
+ if (opts.yes) {
1765
+ configureDesktop = opts.claudeDesktop === true;
1766
+ if (!configureDesktop) {
1767
+ console.log(chalk2.dim("Skipping Claude Desktop (pass --claude-desktop to configure it)."));
1768
+ }
1769
+ } else {
1770
+ configureDesktop = opts.claudeDesktop === false ? false : await confirm({ message: "Setup for Claude Desktop?", default: true });
1771
+ }
1772
+ if (!configureDesktop) {
1773
+ clientsToConnect = clientsToConnect.filter((c) => c !== "claude-desktop");
1774
+ }
1775
+ }
1776
+ if (clientsToConnect.length > 0) {
1777
+ try {
1778
+ await runConnect(config, runtime, clientsToConnect, "localhost");
1779
+ } catch (error) {
1780
+ console.log(chalk2.yellow("Could not configure AI clients automatically."));
1781
+ console.log(chalk2.dim(`Run ${chalk2.cyan("horus connect")} to configure them manually.`));
1782
+ }
1739
1783
  }
1740
1784
  } else {
1741
1785
  console.log(chalk2.dim(`No AI clients detected. Run ${chalk2.cyan("horus connect")} after installing Claude Desktop, Claude Code, or Cursor.`));
@@ -2461,6 +2505,16 @@ function checkDataDir(dataDir) {
2461
2505
  };
2462
2506
  }
2463
2507
  }
2508
+ function checkGhostDataDir() {
2509
+ const ghostDir = join5(HORUS_DIR, "horus-data");
2510
+ if (!existsSync7(ghostDir)) return null;
2511
+ return {
2512
+ status: "warn",
2513
+ label: "Ghost data directory",
2514
+ message: `Legacy directory ~/Horus/horus-data/ exists \u2014 this is not used by Horus`,
2515
+ hint: `Delete it: rm -rf "${ghostDir}"`
2516
+ };
2517
+ }
2464
2518
  function checkDiskSpace(dataDir) {
2465
2519
  const checkDir = existsSync7(dataDir) ? dataDir : join5(dataDir, "..");
2466
2520
  try {
@@ -2585,6 +2639,8 @@ var doctorCommand = new Command8("doctor").description("Diagnose common Horus is
2585
2639
  allResults.push(checkPort(ports.forge, "Forge"));
2586
2640
  allResults.push(checkDataDir(dataDir));
2587
2641
  allResults.push(checkDiskSpace(dataDir));
2642
+ const ghostCheck = checkGhostDataDir();
2643
+ if (ghostCheck) allResults.push(ghostCheck);
2588
2644
  const runtimeOk = allResults[0].status !== "fail";
2589
2645
  const composeOk = allResults[1].status !== "fail";
2590
2646
  if (runtimeOk && composeOk) {
@@ -3359,8 +3415,357 @@ testEnvCommand.command("seed").description("Populate a slot with test fixtures (
3359
3415
  console.log(chalk10.dim("Services will re-index automatically. Allow ~10s before running tests."));
3360
3416
  });
3361
3417
 
3418
+ // src/commands/help.ts
3419
+ import { Command as Command11 } from "commander";
3420
+ import chalk11 from "chalk";
3421
+ import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
3422
+ import { join as join9, dirname as dirname3 } from "path";
3423
+ import { fileURLToPath as fileURLToPath3 } from "url";
3424
+
3425
+ // src/lib/guide-retrieval.ts
3426
+ var STOP_WORDS = /* @__PURE__ */ new Set([
3427
+ "the",
3428
+ "a",
3429
+ "an",
3430
+ "and",
3431
+ "or",
3432
+ "but",
3433
+ "if",
3434
+ "then",
3435
+ "else",
3436
+ "when",
3437
+ "while",
3438
+ "of",
3439
+ "in",
3440
+ "on",
3441
+ "at",
3442
+ "to",
3443
+ "from",
3444
+ "for",
3445
+ "with",
3446
+ "by",
3447
+ "as",
3448
+ "about",
3449
+ "this",
3450
+ "that",
3451
+ "these",
3452
+ "those",
3453
+ "it",
3454
+ "its",
3455
+ "they",
3456
+ "them",
3457
+ "their",
3458
+ "he",
3459
+ "she",
3460
+ "we",
3461
+ "you",
3462
+ "your",
3463
+ "our",
3464
+ "my",
3465
+ "me",
3466
+ "us",
3467
+ "his",
3468
+ "her",
3469
+ "him",
3470
+ "is",
3471
+ "are",
3472
+ "was",
3473
+ "were",
3474
+ "be",
3475
+ "been",
3476
+ "being",
3477
+ "am",
3478
+ "have",
3479
+ "has",
3480
+ "had",
3481
+ "having",
3482
+ "do",
3483
+ "does",
3484
+ "did",
3485
+ "doing",
3486
+ "will",
3487
+ "would",
3488
+ "could",
3489
+ "should",
3490
+ "can",
3491
+ "may",
3492
+ "might",
3493
+ "must",
3494
+ "shall",
3495
+ "not",
3496
+ "no",
3497
+ "yes",
3498
+ "so",
3499
+ "too",
3500
+ "very",
3501
+ "just",
3502
+ "only",
3503
+ "also",
3504
+ "all",
3505
+ "any",
3506
+ "some",
3507
+ "each",
3508
+ "every",
3509
+ "more",
3510
+ "most",
3511
+ "much",
3512
+ "many",
3513
+ "one",
3514
+ "two",
3515
+ "three",
3516
+ "here",
3517
+ "there",
3518
+ "where",
3519
+ "how",
3520
+ "why",
3521
+ "what",
3522
+ "who",
3523
+ "which"
3524
+ ]);
3525
+ function tokenizeQuery(text) {
3526
+ return text.toLowerCase().replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((t) => t.length >= 2 && !STOP_WORDS.has(t));
3527
+ }
3528
+ var K1 = 1.5;
3529
+ var B = 0.75;
3530
+ function termFreq(token, tokens) {
3531
+ let n = 0;
3532
+ for (const t of tokens) if (t === token) n++;
3533
+ return n;
3534
+ }
3535
+ function docFreq(token, guides) {
3536
+ let n = 0;
3537
+ for (const g of guides) {
3538
+ if (g.tokens.includes(token)) n++;
3539
+ }
3540
+ return n;
3541
+ }
3542
+ function bm25Score(queryTokens, doc, avgDocLen, n, guides) {
3543
+ let score = 0;
3544
+ const docLen = doc.tokens.length;
3545
+ for (const q of queryTokens) {
3546
+ const f = termFreq(q, doc.tokens);
3547
+ if (f === 0) continue;
3548
+ const df = docFreq(q, guides);
3549
+ const idf = Math.log((n - df + 0.5) / (df + 0.5) + 1);
3550
+ const norm = f * (K1 + 1) / (f + K1 * (1 - B + B * docLen / avgDocLen));
3551
+ score += idf * norm;
3552
+ }
3553
+ return score;
3554
+ }
3555
+ function retrieve(query, index, maxAlternates = 3) {
3556
+ const queryTokens = tokenizeQuery(query);
3557
+ if (queryTokens.length === 0 || index.guides.length === 0) {
3558
+ return { primary: null, alternates: [] };
3559
+ }
3560
+ const n = index.guides.length;
3561
+ const totalLen = index.guides.reduce((s, g) => s + g.tokens.length, 0);
3562
+ const avgDocLen = totalLen / n;
3563
+ const scored = index.guides.map((g) => ({ guide: g, score: bm25Score(queryTokens, g, avgDocLen, n, index.guides) })).filter((s) => s.score > 0).sort((a, b) => b.score - a.score);
3564
+ if (scored.length === 0) {
3565
+ return { primary: null, alternates: [] };
3566
+ }
3567
+ const primary = scored[0].guide;
3568
+ const alternates = scored.slice(1, 1 + maxAlternates).map((s) => s.guide);
3569
+ return { primary, alternates };
3570
+ }
3571
+
3572
+ // src/commands/help.ts
3573
+ function findGuidesDir() {
3574
+ let dir = dirname3(fileURLToPath3(import.meta.url));
3575
+ const seen = /* @__PURE__ */ new Set();
3576
+ while (dir !== dirname3(dir) && !seen.has(dir)) {
3577
+ seen.add(dir);
3578
+ const candidate = join9(dir, "guides");
3579
+ if (existsSync10(candidate) && existsSync10(join9(candidate, "index.json"))) {
3580
+ return candidate;
3581
+ }
3582
+ dir = dirname3(dir);
3583
+ }
3584
+ throw new Error(
3585
+ "Could not find the bundled Horus guides directory. Try reinstalling: npm install -g @arkhera30/cli"
3586
+ );
3587
+ }
3588
+ function loadIndex(guidesDir) {
3589
+ const indexPath = join9(guidesDir, "index.json");
3590
+ return JSON.parse(readFileSync7(indexPath, "utf8"));
3591
+ }
3592
+ function stripFrontMatter(content) {
3593
+ return content.replace(/^---\n[\s\S]*?\n---\n?/, "");
3594
+ }
3595
+ function printTopicIndex(index) {
3596
+ console.log("");
3597
+ console.log(chalk11.bold("Horus Help \u2014 Available Guides"));
3598
+ console.log(chalk11.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
3599
+ console.log("");
3600
+ for (const g of index.guides) {
3601
+ console.log(` ${chalk11.cyan(g.slug.padEnd(20))} ${g.title}`);
3602
+ console.log(` ${" ".repeat(20)} ${chalk11.dim(g.description)}`);
3603
+ console.log("");
3604
+ }
3605
+ console.log(chalk11.dim("Example queries:"));
3606
+ console.log(chalk11.dim(" horus help how do I start"));
3607
+ console.log(chalk11.dim(" horus help what is a forge workspace"));
3608
+ console.log(chalk11.dim(" horus help create my first anvil note"));
3609
+ console.log("");
3610
+ console.log(chalk11.dim("To print a specific guide directly:"));
3611
+ console.log(chalk11.dim(" horus guide <slug>"));
3612
+ console.log("");
3613
+ }
3614
+ function printGuideBody(guidesDir, file) {
3615
+ const path = join9(guidesDir, file);
3616
+ const content = readFileSync7(path, "utf8");
3617
+ console.log(stripFrontMatter(content));
3618
+ }
3619
+ function printSeeAlso(alternates, guidesDir) {
3620
+ if (alternates.length === 0) return;
3621
+ console.log(chalk11.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
3622
+ console.log(chalk11.bold("See also:"));
3623
+ for (const a of alternates) {
3624
+ console.log(` ${chalk11.cyan(a.slug.padEnd(20))} ${a.title}`);
3625
+ console.log(` ${" ".repeat(20)} ${chalk11.dim(join9(guidesDir, a.file))}`);
3626
+ }
3627
+ console.log("");
3628
+ }
3629
+ function printFooter() {
3630
+ console.log(
3631
+ chalk11.dim(
3632
+ "Run `horus guide <slug>` to print a specific guide without retrieval, or `horus guide` to list all."
3633
+ )
3634
+ );
3635
+ console.log("");
3636
+ }
3637
+ var helpCommand = new Command11("help").description("Search and print bundled Horus getting-started guides").argument("[query...]", "Natural-language query. Omit to see the topic index.").action((query) => {
3638
+ const guidesDir = findGuidesDir();
3639
+ const index = loadIndex(guidesDir);
3640
+ if (!query || query.length === 0) {
3641
+ printTopicIndex(index);
3642
+ return;
3643
+ }
3644
+ const queryStr = query.join(" ");
3645
+ const result = retrieve(queryStr, index, 3);
3646
+ if (!result.primary) {
3647
+ console.log("");
3648
+ console.log(chalk11.yellow(`No guide matched "${queryStr}".`));
3649
+ console.log("");
3650
+ console.log(chalk11.dim("Try `horus help` with no arguments to see the full topic index,"));
3651
+ console.log(chalk11.dim("or pick a slug directly with `horus guide <slug>`."));
3652
+ console.log("");
3653
+ return;
3654
+ }
3655
+ console.log("");
3656
+ console.log(chalk11.dim(`# ${result.primary.title} (${result.primary.slug})`));
3657
+ console.log("");
3658
+ printGuideBody(guidesDir, result.primary.file);
3659
+ console.log("");
3660
+ printSeeAlso(result.alternates, guidesDir);
3661
+ printFooter();
3662
+ });
3663
+
3664
+ // src/commands/guide.ts
3665
+ import { Command as Command12 } from "commander";
3666
+ import chalk12 from "chalk";
3667
+ import { readFileSync as readFileSync8, existsSync as existsSync11 } from "fs";
3668
+ import { join as join10, dirname as dirname4 } from "path";
3669
+ import { fileURLToPath as fileURLToPath4 } from "url";
3670
+ function findGuidesDir2() {
3671
+ let dir = dirname4(fileURLToPath4(import.meta.url));
3672
+ const seen = /* @__PURE__ */ new Set();
3673
+ while (dir !== dirname4(dir) && !seen.has(dir)) {
3674
+ seen.add(dir);
3675
+ const candidate = join10(dir, "guides");
3676
+ if (existsSync11(candidate) && existsSync11(join10(candidate, "index.json"))) {
3677
+ return candidate;
3678
+ }
3679
+ dir = dirname4(dir);
3680
+ }
3681
+ throw new Error(
3682
+ "Could not find the bundled Horus guides directory. Try reinstalling: npm install -g @arkhera30/cli"
3683
+ );
3684
+ }
3685
+ function loadIndex2(guidesDir) {
3686
+ return JSON.parse(readFileSync8(join10(guidesDir, "index.json"), "utf8"));
3687
+ }
3688
+ function lookupTopic(topic, index) {
3689
+ const t = topic.trim().toLowerCase();
3690
+ if (!t) return { tier: "none", matches: [] };
3691
+ const exact = index.guides.filter((g) => g.slug === t);
3692
+ if (exact.length > 0) return { tier: "exact-slug", matches: exact };
3693
+ const prefix = index.guides.filter((g) => g.slug.startsWith(t));
3694
+ if (prefix.length > 0) return { tier: "slug-prefix", matches: prefix };
3695
+ const titleHits = index.guides.filter((g) => g.title.toLowerCase().includes(t));
3696
+ if (titleHits.length > 0) return { tier: "title-fuzzy", matches: titleHits };
3697
+ const kwHits = index.guides.filter(
3698
+ (g) => g.keywords.some((k) => k.toLowerCase() === t)
3699
+ );
3700
+ if (kwHits.length > 0) return { tier: "keyword", matches: kwHits };
3701
+ return { tier: "none", matches: [] };
3702
+ }
3703
+ function printGuideList(index) {
3704
+ console.log("");
3705
+ console.log(chalk12.bold("Bundled Horus Guides"));
3706
+ console.log(chalk12.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
3707
+ console.log("");
3708
+ for (const g of index.guides) {
3709
+ console.log(` ${chalk12.cyan(g.slug.padEnd(20))} ${g.title}`);
3710
+ console.log(` ${" ".repeat(20)} ${chalk12.dim(g.description)}`);
3711
+ console.log("");
3712
+ }
3713
+ console.log(chalk12.dim("Print a guide: horus guide <slug>"));
3714
+ console.log(chalk12.dim("Print a guide file path: horus guide <slug> --path"));
3715
+ console.log(chalk12.dim("Print the guides root: horus guide --path"));
3716
+ console.log("");
3717
+ }
3718
+ function printGuideBody2(guidesDir, file) {
3719
+ const content = readFileSync8(join10(guidesDir, file), "utf8");
3720
+ console.log(content.replace(/^---\n[\s\S]*?\n---\n?/, ""));
3721
+ }
3722
+ function printDisambiguation(tier, matches) {
3723
+ console.log("");
3724
+ console.log(chalk12.yellow(`Multiple guides matched (tier: ${tier}):`));
3725
+ console.log("");
3726
+ for (const m of matches) {
3727
+ console.log(` ${chalk12.cyan(m.slug.padEnd(20))} ${m.title}`);
3728
+ }
3729
+ console.log("");
3730
+ console.log(chalk12.dim("Pick one: horus guide <slug>"));
3731
+ console.log("");
3732
+ }
3733
+ var guideCommand = new Command12("guide").description("Print a bundled Horus guide, or list all guides").argument("[topic]", "Slug, slug prefix, or search term. Omit to list all guides.").option("--path", "Print the file path instead of the body (or the guides dir root if no topic)").action((topic, opts) => {
3734
+ const guidesDir = findGuidesDir2();
3735
+ const index = loadIndex2(guidesDir);
3736
+ if (!topic) {
3737
+ if (opts.path) {
3738
+ console.log(guidesDir);
3739
+ return;
3740
+ }
3741
+ printGuideList(index);
3742
+ return;
3743
+ }
3744
+ const result = lookupTopic(topic, index);
3745
+ if (result.matches.length === 0) {
3746
+ console.log("");
3747
+ console.log(chalk12.yellow(`No guide matched "${topic}".`));
3748
+ console.log("");
3749
+ console.log(chalk12.dim("Run `horus guide` to see all available guides."));
3750
+ console.log("");
3751
+ process.exitCode = 1;
3752
+ return;
3753
+ }
3754
+ if (result.matches.length > 1) {
3755
+ printDisambiguation(result.tier, result.matches);
3756
+ process.exitCode = 1;
3757
+ return;
3758
+ }
3759
+ const match = result.matches[0];
3760
+ if (opts.path) {
3761
+ console.log(join10(guidesDir, match.file));
3762
+ return;
3763
+ }
3764
+ printGuideBody2(guidesDir, match.file);
3765
+ });
3766
+
3362
3767
  // src/index.ts
3363
- var program = new Command11();
3768
+ var program = new Command13();
3364
3769
  program.name("horus").description("CLI for managing the Horus Docker Compose stack").version(CLI_VERSION);
3365
3770
  program.addCommand(setupCommand);
3366
3771
  program.addCommand(upCommand);
@@ -3372,6 +3777,8 @@ program.addCommand(updateCommand);
3372
3777
  program.addCommand(doctorCommand);
3373
3778
  program.addCommand(backupCommand);
3374
3779
  program.addCommand(testEnvCommand);
3780
+ program.addCommand(helpCommand);
3781
+ program.addCommand(guideCommand);
3375
3782
  program.exitOverride();
3376
3783
  try {
3377
3784
  await program.parseAsync(process.argv);
@@ -3380,9 +3787,9 @@ try {
3380
3787
  process.exit(0);
3381
3788
  }
3382
3789
  if (error instanceof Error) {
3383
- console.error(chalk11.red(`Error: ${error.message}`));
3790
+ console.error(chalk13.red(`Error: ${error.message}`));
3384
3791
  } else {
3385
- console.error(chalk11.red("An unexpected error occurred."));
3792
+ console.error(chalk13.red("An unexpected error occurred."));
3386
3793
  }
3387
3794
  process.exit(1);
3388
3795
  }
@@ -0,0 +1,111 @@
1
+ ---
2
+ title: Horus Core Concepts
3
+ description: The mental model for Anvil, Vault, Forge, and the supporting services Horus depends on.
4
+ slug: core-concepts
5
+ tags: [concepts, architecture, onboarding, anvil, vault, forge]
6
+ schema_version: 1
7
+ keywords: [concepts, architecture, mental-model, anvil, vault, forge, neo4j, typesense, mcp, services, how-it-works, overview]
8
+ related_commands: [horus status, horus config]
9
+ sidebar_position: 2
10
+ ---
11
+
12
+ # Horus Core Concepts
13
+
14
+ Horus is three primary systems (Anvil, Vault, Forge) backed by supporting services (Typesense, Neo4j, a web UI). This guide explains what each one does, why it exists as a separate service, and how they connect.
15
+
16
+ ## The three primary systems
17
+
18
+ ### Anvil — live state
19
+
20
+ Anvil stores your structured, **changing** data: tasks, stories, projects, journals, notes, conversation state. Every entity is a markdown file with YAML front-matter, indexed by an embedded SQLite database for fast full-text search.
21
+
22
+ Anvil has a **dynamic type system** — type definitions are YAML files, not hardcoded in the server. That means you can add new note types or extend existing ones without changing code. Claude always queries the current type schema before creating a note, so your types stay authoritative.
23
+
24
+ **Use Anvil for:** tasks, projects, meeting notes, daily journals, research notes, anything you'd put in a personal knowledge-management app.
25
+
26
+ **MCP endpoint:** `http://localhost:8100`
27
+
28
+ ### Vault — durable knowledge
29
+
30
+ Vault stores your **long-lived**, structured documentation: repo profiles, architecture decisions, how-to guides, procedures, and learnings. Unlike Anvil (which tracks changing state), Vault is the place for knowledge you want to reference across sessions, weeks, and projects.
31
+
32
+ Vault is a FastAPI knowledge service backed by a git repository per vault. You can run **multiple vaults** (e.g. `personal`, `work`, `client-acme`) — each is a separate repo with its own access controls and git history. A separate `vault-router` service fans out read queries to all vaults and routes writes to the correct one by UUID.
33
+
34
+ Search is powered by both Typesense (full-text, typo-tolerant) and Neo4j (graph traversal for "what's related to what"). You get semantic-ish retrieval without a GPU or an external API.
35
+
36
+ **Use Vault for:** how does this codebase work, what conventions does that team follow, what's the decision history for this architecture choice.
37
+
38
+ **MCP endpoint:** `http://localhost:8300` (via the `vault-mcp` adapter)
39
+
40
+ ### Forge — execution environment
41
+
42
+ Forge is the **workspace and session manager**. It does three distinct things:
43
+
44
+ 1. **Workspaces** — isolated contexts (MCP configs, skills, permissions) for a particular line of work. A workspace doesn't clone any code; it's just the agent environment.
45
+ 2. **Sessions** — git worktrees tied to a work item, created on demand via `forge_develop`. A session is where code changes actually happen.
46
+ 3. **Repo index** — a scanned index of your local Git repositories, made available to Claude so it can resolve and search across them.
47
+
48
+ Forge also manages **plugins and skills** — reusable packages that install into the registry and add capabilities to your workspaces.
49
+
50
+ **Use Forge for:** starting coded work, managing isolated contexts per project, discovering which repos exist on your machine.
51
+
52
+ **MCP endpoint:** `http://localhost:8200`
53
+
54
+ ## Supporting services
55
+
56
+ ### Typesense
57
+
58
+ A full-text and vector search engine that runs inside the Horus stack. Both Anvil and Vault use Typesense under the hood for fast, typo-tolerant search. You don't interact with Typesense directly — it's internal plumbing exposed only inside the Docker network.
59
+
60
+ ### Neo4j
61
+
62
+ A graph database for relationship-aware queries. Vault uses Neo4j to store and traverse the graph of entities and edges between knowledge pages — useful for questions like "what's related to this decision" or "walk me from this ADR to the procedures that implement it". Runs on ports **7474** (browser / HTTP) and **7687** (Bolt protocol).
63
+
64
+ ### Horus UI
65
+
66
+ A React web interface at **`http://localhost:8400`** that lets you browse Anvil notes, Vault pages, and Forge workspaces without going through Claude. It's served by an Express proxy that fans requests out to Anvil, Vault MCP, and Forge over the internal Docker network. Optional — Claude works fine without it — but handy for a visual sanity check.
67
+
68
+ ## How they connect
69
+
70
+ Everything runs in a single Docker Compose stack on a bridge network named `horus-net`. Claude (Desktop, Code, or Cursor) connects via the Model Context Protocol (MCP) to three endpoints on your host:
71
+
72
+ | Service | Host endpoint | Who talks to it |
73
+ |---|---|---|
74
+ | Anvil | `http://localhost:8100` | Claude, Horus UI |
75
+ | Vault MCP | `http://localhost:8300` | Claude, Horus UI |
76
+ | Forge | `http://localhost:8200` | Claude, Horus UI |
77
+
78
+ Internally, Vault MCP proxies to the `vault-router`, which fans out to each `vault` instance on ports `8001`, `8002`, etc. Anvil and Vault both query Typesense over the internal network. Forge reads repo metadata from the host filesystem via a read-only bind mount.
79
+
80
+ ## Data layout
81
+
82
+ All durable state lives under your data directory (default `~/Horus/data`). On a fresh install you'll see:
83
+
84
+ ```
85
+ ~/Horus/data/
86
+ notes/ Anvil notes (git repo)
87
+ vaults/
88
+ <name>/ Each vault is a separate git repo
89
+ registry/ Forge plugin registry (git repo)
90
+ workspaces/ Forge workspace directories
91
+ sessions/ Forge session git worktrees
92
+ repos/ Managed clone pool (created by forge_repo_clone)
93
+ config/ Forge-managed config (forge.yaml, repos.json, workspaces.json)
94
+ typesense-data/ Typesense index (internal)
95
+ ```
96
+
97
+ User config lives at `~/Horus/config.yaml` and `~/Horus/.env`. The Docker Compose file is installed at `~/Horus/docker-compose.yml` — edit it only if you know what you're doing; `horus setup` regenerates it from the config.
98
+
99
+ ## Key principles
100
+
101
+ 1. **Everything is local.** All data stays on your machine. Search runs in-process. The only network traffic is to Anthropic (when Claude talks to the API) and to your own Git remotes for sync.
102
+ 2. **Data is durable.** Notes, knowledge, and workspaces are files on disk backed by git. Stopping the stack or removing containers never deletes your data.
103
+ 3. **Routing is automatic.** You don't pick Anvil vs Vault vs Forge — Claude reads the routing rules from the `horus-*` skills and picks the right system based on intent.
104
+ 4. **Types are dynamic.** Anvil's type system is runtime-defined. New types and fields land without a code release.
105
+
106
+ ## What's next
107
+
108
+ - **`horus guide getting-started`** if you haven't installed yet
109
+ - **`horus guide first-note`** to create your first Anvil entity
110
+ - **`horus guide first-workspace`** to set up your first isolated working context
111
+ - **`horus guide first-session`** to start your first isolated code session