@arkhera30/cli 0.3.16 → 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/dist/index.js +402 -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
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,
|
|
@@ -1088,6 +1089,25 @@ function mergeAndWriteConfig(configPath, mcpServers) {
|
|
|
1088
1089
|
mkdirSync2(dir, { recursive: true });
|
|
1089
1090
|
writeFileSync3(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
1090
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
|
+
}
|
|
1091
1111
|
async function isClaudeCliAvailable() {
|
|
1092
1112
|
try {
|
|
1093
1113
|
const result = await execa2("claude", ["--version"], { reject: false });
|
|
@@ -1186,7 +1206,8 @@ async function runConnect(config, runtime, targets, host = "localhost") {
|
|
|
1186
1206
|
const desktopSpinner = ora(`Configuring ${chalk.cyan("claude-desktop")}...`).start();
|
|
1187
1207
|
try {
|
|
1188
1208
|
const configPath = getConfigPath(target);
|
|
1189
|
-
|
|
1209
|
+
const desktopServers = buildClaudeDesktopServers(config, host);
|
|
1210
|
+
mergeAndWriteConfig(configPath, desktopServers);
|
|
1190
1211
|
desktopSpinner.succeed(`Configured ${chalk.cyan("claude-desktop")} \u2014 ${chalk.dim(configPath)}`);
|
|
1191
1212
|
configured.push(target);
|
|
1192
1213
|
} catch (error) {
|
|
@@ -1338,7 +1359,7 @@ function extractHostname(url) {
|
|
|
1338
1359
|
return "github.com";
|
|
1339
1360
|
}
|
|
1340
1361
|
}
|
|
1341
|
-
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) => {
|
|
1342
1363
|
console.log("");
|
|
1343
1364
|
console.log(chalk2.bold("Horus Setup"));
|
|
1344
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"));
|
|
@@ -1483,6 +1504,7 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
|
|
|
1483
1504
|
vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
|
|
1484
1505
|
vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
|
|
1485
1506
|
vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
|
|
1507
|
+
ui: DEFAULT_PORTS.ui,
|
|
1486
1508
|
forge: forge ?? DEFAULT_PORTS.forge,
|
|
1487
1509
|
typesense: DEFAULT_PORTS.typesense,
|
|
1488
1510
|
neo4j_http: DEFAULT_PORTS.neo4j_http,
|
|
@@ -1736,11 +1758,28 @@ ${example(`${vaultName.trim()}-knowledge`)}
|
|
|
1736
1758
|
const detectedClients = detectInstalledClients();
|
|
1737
1759
|
if (detectedClients.length > 0) {
|
|
1738
1760
|
console.log(chalk2.bold("Configuring AI clients..."));
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
+
}
|
|
1744
1783
|
}
|
|
1745
1784
|
} else {
|
|
1746
1785
|
console.log(chalk2.dim(`No AI clients detected. Run ${chalk2.cyan("horus connect")} after installing Claude Desktop, Claude Code, or Cursor.`));
|
|
@@ -3376,8 +3415,357 @@ testEnvCommand.command("seed").description("Populate a slot with test fixtures (
|
|
|
3376
3415
|
console.log(chalk10.dim("Services will re-index automatically. Allow ~10s before running tests."));
|
|
3377
3416
|
});
|
|
3378
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
|
+
|
|
3379
3767
|
// src/index.ts
|
|
3380
|
-
var program = new
|
|
3768
|
+
var program = new Command13();
|
|
3381
3769
|
program.name("horus").description("CLI for managing the Horus Docker Compose stack").version(CLI_VERSION);
|
|
3382
3770
|
program.addCommand(setupCommand);
|
|
3383
3771
|
program.addCommand(upCommand);
|
|
@@ -3389,6 +3777,8 @@ program.addCommand(updateCommand);
|
|
|
3389
3777
|
program.addCommand(doctorCommand);
|
|
3390
3778
|
program.addCommand(backupCommand);
|
|
3391
3779
|
program.addCommand(testEnvCommand);
|
|
3780
|
+
program.addCommand(helpCommand);
|
|
3781
|
+
program.addCommand(guideCommand);
|
|
3392
3782
|
program.exitOverride();
|
|
3393
3783
|
try {
|
|
3394
3784
|
await program.parseAsync(process.argv);
|
|
@@ -3397,9 +3787,9 @@ try {
|
|
|
3397
3787
|
process.exit(0);
|
|
3398
3788
|
}
|
|
3399
3789
|
if (error instanceof Error) {
|
|
3400
|
-
console.error(
|
|
3790
|
+
console.error(chalk13.red(`Error: ${error.message}`));
|
|
3401
3791
|
} else {
|
|
3402
|
-
console.error(
|
|
3792
|
+
console.error(chalk13.red("An unexpected error occurred."));
|
|
3403
3793
|
}
|
|
3404
3794
|
process.exit(1);
|
|
3405
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
|