@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.
- package/compose/docker-compose.yml +1 -1
- package/dist/index.js +419 -12
- package/guides/core-concepts.md +111 -0
- package/guides/first-note.md +127 -0
- package/guides/first-session.md +125 -0
- package/guides/first-workspace.md +108 -0
- package/guides/getting-started.md +94 -0
- package/guides/index.json +2301 -0
- package/package.json +5 -3
|
@@ -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}/
|
|
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
|
|
5
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
|
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(
|
|
3790
|
+
console.error(chalk13.red(`Error: ${error.message}`));
|
|
3384
3791
|
} else {
|
|
3385
|
-
console.error(
|
|
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
|