@arkhera30/cli 0.1.16 → 0.2.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.
@@ -1,5 +1,6 @@
1
+ # NOTE: This file is superseded by dynamic compose generation in cli/src/lib/compose.ts
1
2
  # ─────────────────────────────────────────────────────────────────────────────
2
- # Horus — Production Docker Compose
3
+ # Horus — Production Docker Compose (DEPRECATED static template)
3
4
  # Managed by @arkhera30/cli. Do not edit manually.
4
5
  #
5
6
  # This file uses pre-built images from ghcr.io. Once CI pipelines are set up,
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import chalk2 from "chalk";
10
10
  import ora2 from "ora";
11
11
  import { execSync } from "child_process";
12
12
  import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
13
- import { join as join4 } from "path";
13
+ import { join as join3 } from "path";
14
14
  import { input, confirm, number, select, password } from "@inquirer/prompts";
15
15
 
16
16
  // src/lib/config.ts
@@ -38,26 +38,26 @@ function findPackageJson() {
38
38
  }
39
39
  var pkg = JSON.parse(readFileSync(findPackageJson(), "utf-8"));
40
40
  var CLI_VERSION = pkg.version;
41
- var HORUS_DIR = join(homedir(), ".horus");
41
+ var HORUS_DIR = join(homedir(), "Horus");
42
+ var LEGACY_HORUS_DIR = join(homedir(), ".horus");
42
43
  var CONFIG_PATH = join(HORUS_DIR, "config.yaml");
43
44
  var ENV_PATH = join(HORUS_DIR, ".env");
44
45
  var COMPOSE_PATH = join(HORUS_DIR, "docker-compose.yml");
45
46
  var DEFAULT_PORTS = {
46
47
  anvil: 8100,
47
48
  vault_rest: 8e3,
49
+ // keep for individual vault instances
48
50
  vault_mcp: 8300,
51
+ vault_router: 8400,
52
+ // new
49
53
  forge: 8200
50
54
  };
51
- var DEFAULT_REPOS = {
52
- anvil_notes: "",
53
- vault_knowledge: "",
54
- forge_registry: ""
55
- };
56
- var DEFAULT_DATA_DIR = join(homedir(), ".horus", "data");
55
+ var DEFAULT_DATA_DIR = join(homedir(), "Horus", "data");
57
56
  var SERVICES = [
58
57
  "qmd-daemon",
59
58
  "anvil",
60
- "vault",
59
+ "vault-router",
60
+ // replaces 'vault'
61
61
  "vault-mcp",
62
62
  "forge"
63
63
  ];
@@ -70,11 +70,14 @@ function defaultConfig() {
70
70
  data_dir: DEFAULT_DATA_DIR,
71
71
  runtime: "docker",
72
72
  ports: { ...DEFAULT_PORTS },
73
- git_host: "github.com",
74
- repos: { ...DEFAULT_REPOS },
73
+ repos: {
74
+ anvil_notes: "",
75
+ forge_registry: ""
76
+ },
77
+ vaults: {},
78
+ github_hosts: {},
75
79
  host_repos_path: "",
76
- host_repos_extra_scan_dirs: [],
77
- github_token: ""
80
+ host_repos_extra_scan_dirs: []
78
81
  };
79
82
  }
80
83
  function ensureHorusDir() {
@@ -85,30 +88,52 @@ function configExists() {
85
88
  }
86
89
  function loadConfig() {
87
90
  if (!existsSync2(CONFIG_PATH)) {
91
+ const legacyConfigPath = pathJoin(LEGACY_HORUS_DIR, "config.yaml");
92
+ if (existsSync2(legacyConfigPath)) {
93
+ console.warn(
94
+ `
95
+ Warning: Horus config found at ~/.horus/config.yaml (legacy location).
96
+ The new default is ~/Horus. Run \`horus setup\` to migrate.
97
+ `
98
+ );
99
+ const raw2 = readFileSync2(legacyConfigPath, "utf-8");
100
+ const parsed2 = parseYaml(raw2);
101
+ return buildConfigFromParsed(parsed2);
102
+ }
88
103
  return defaultConfig();
89
104
  }
90
105
  const raw = readFileSync2(CONFIG_PATH, "utf-8");
91
106
  const parsed = parseYaml(raw);
107
+ return buildConfigFromParsed(parsed);
108
+ }
109
+ function buildConfigFromParsed(parsed) {
110
+ const repos = parsed.repos;
111
+ if (repos && "vault_knowledge" in repos) {
112
+ throw new Error(
113
+ "config.yaml uses the old single-vault format (repos.vault_knowledge). This version requires the new multi-vault format. Please delete ~/Horus/config.yaml and run `horus setup` to reconfigure."
114
+ );
115
+ }
92
116
  const defaults = defaultConfig();
117
+ const parsedPorts = parsed.ports;
93
118
  return {
94
119
  version: parsed.version ?? defaults.version,
95
120
  data_dir: parsed.data_dir ?? defaults.data_dir,
96
121
  runtime: parsed.runtime ?? defaults.runtime,
97
122
  ports: {
98
- anvil: parsed.ports?.anvil ?? defaults.ports.anvil,
99
- vault_rest: parsed.ports?.vault_rest ?? defaults.ports.vault_rest,
100
- vault_mcp: parsed.ports?.vault_mcp ?? defaults.ports.vault_mcp,
101
- forge: parsed.ports?.forge ?? defaults.ports.forge
123
+ anvil: parsedPorts?.anvil ?? defaults.ports.anvil,
124
+ vault_rest: parsedPorts?.vault_rest ?? defaults.ports.vault_rest,
125
+ vault_mcp: parsedPorts?.vault_mcp ?? defaults.ports.vault_mcp,
126
+ vault_router: parsedPorts?.vault_router ?? defaults.ports.vault_router,
127
+ forge: parsedPorts?.forge ?? defaults.ports.forge
102
128
  },
103
- git_host: parsed.git_host ?? defaults.git_host,
104
129
  repos: {
105
- anvil_notes: parsed.repos?.anvil_notes ?? defaults.repos.anvil_notes,
106
- vault_knowledge: parsed.repos?.vault_knowledge ?? defaults.repos.vault_knowledge,
107
- forge_registry: parsed.repos?.forge_registry ?? defaults.repos.forge_registry
130
+ anvil_notes: repos?.anvil_notes ?? defaults.repos.anvil_notes,
131
+ forge_registry: repos?.forge_registry ?? defaults.repos.forge_registry
108
132
  },
133
+ vaults: parsed.vaults ?? defaults.vaults,
134
+ github_hosts: parsed.github_hosts ?? defaults.github_hosts,
109
135
  host_repos_path: parsed.host_repos_path ?? defaults.host_repos_path,
110
- host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs,
111
- github_token: parsed.github_token ?? defaults.github_token
136
+ host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs
112
137
  };
113
138
  }
114
139
  function saveConfig(config) {
@@ -116,6 +141,14 @@ function saveConfig(config) {
116
141
  const yaml = stringifyYaml(config, { lineWidth: 0 });
117
142
  writeFileSync(CONFIG_PATH, yaml, "utf-8");
118
143
  }
144
+ function resolveGitHubHost(repoUrl, github_hosts) {
145
+ try {
146
+ const hostname = new URL(repoUrl).hostname;
147
+ return Object.values(github_hosts).find((h) => h.host === hostname);
148
+ } catch {
149
+ return void 0;
150
+ }
151
+ }
119
152
  function resolvePath(p) {
120
153
  if (p.startsWith("~")) {
121
154
  return resolve(homedir2(), p.slice(2));
@@ -185,15 +218,12 @@ function generateEnv(config) {
185
218
  `ANVIL_PORT=${config.ports.anvil}`,
186
219
  `VAULT_PORT=${config.ports.vault_rest}`,
187
220
  `VAULT_MCP_PORT=${config.ports.vault_mcp}`,
221
+ `VAULT_ROUTER_PORT=${config.ports.vault_router}`,
188
222
  `FORGE_PORT=${config.ports.forge}`,
189
223
  "",
190
224
  "# Repository URLs (must be HTTPS \u2014 container services do not have SSH keys)",
191
225
  `ANVIL_REPO_URL=${config.repos.anvil_notes}`,
192
- `VAULT_KNOWLEDGE_REPO_URL=${config.repos.vault_knowledge}`,
193
226
  `FORGE_REGISTRY_REPO_URL=${config.repos.forge_registry}`,
194
- "",
195
- "# Authentication",
196
- `GITHUB_TOKEN=${config.github_token}`,
197
227
  ""
198
228
  ];
199
229
  return lines.join("\n");
@@ -211,11 +241,9 @@ var CONFIG_KEYS = [
211
241
  "port.anvil",
212
242
  "port.vault-rest",
213
243
  "port.vault-mcp",
244
+ "port.vault-router",
214
245
  "port.forge",
215
- "github-token",
216
- "git-host",
217
246
  "repo.anvil-notes",
218
- "repo.vault-knowledge",
219
247
  "repo.forge-registry"
220
248
  ];
221
249
  function getConfigValue(config, key) {
@@ -234,16 +262,12 @@ function getConfigValue(config, key) {
234
262
  return String(config.ports.vault_rest);
235
263
  case "port.vault-mcp":
236
264
  return String(config.ports.vault_mcp);
265
+ case "port.vault-router":
266
+ return String(config.ports.vault_router);
237
267
  case "port.forge":
238
268
  return String(config.ports.forge);
239
- case "github-token":
240
- return config.github_token;
241
- case "git-host":
242
- return config.git_host;
243
269
  case "repo.anvil-notes":
244
270
  return config.repos.anvil_notes;
245
- case "repo.vault-knowledge":
246
- return config.repos.vault_knowledge;
247
271
  case "repo.forge-registry":
248
272
  return config.repos.forge_registry;
249
273
  }
@@ -275,21 +299,15 @@ function setConfigValue(config, key, value) {
275
299
  case "port.vault-mcp":
276
300
  updated.ports = { ...updated.ports, vault_mcp: parseInt(value, 10) };
277
301
  break;
302
+ case "port.vault-router":
303
+ updated.ports = { ...updated.ports, vault_router: parseInt(value, 10) };
304
+ break;
278
305
  case "port.forge":
279
306
  updated.ports = { ...updated.ports, forge: parseInt(value, 10) };
280
307
  break;
281
- case "github-token":
282
- updated.github_token = value;
283
- break;
284
- case "git-host":
285
- updated.git_host = value;
286
- break;
287
308
  case "repo.anvil-notes":
288
309
  updated.repos = { ...updated.repos, anvil_notes: value };
289
310
  break;
290
- case "repo.vault-knowledge":
291
- updated.repos = { ...updated.repos, vault_knowledge: value };
292
- break;
293
311
  case "repo.forge-registry":
294
312
  updated.repos = { ...updated.repos, forge_registry: value };
295
313
  break;
@@ -502,7 +520,7 @@ async function pollUntilHealthy(runtime, onUpdate, timeoutMs = 3e5, intervalMs =
502
520
  const unhealthyServices = states.filter((s) => s.status === "unhealthy").map((s) => s.name).join(", ");
503
521
  throw new Error(
504
522
  `Services failed health check: ${unhealthyServices}
505
- Run '${runtime.name} compose logs <service>' from ~/.horus/ to investigate.`
523
+ Run '${runtime.name} compose logs <service>' from ~/Horus/ to investigate.`
506
524
  );
507
525
  }
508
526
  const elapsed = Date.now() - startTime;
@@ -510,7 +528,7 @@ Run '${runtime.name} compose logs <service>' from ~/.horus/ to investigate.`
510
528
  const notReady = states.filter((s) => s.status !== "healthy").map((s) => `${s.name} (${s.status})`).join(", ");
511
529
  throw new Error(
512
530
  `Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for services: ${notReady}
513
- Run '${runtime.name} compose logs' from ~/.horus/ to investigate.`
531
+ Run '${runtime.name} compose logs' from ~/Horus/ to investigate.`
514
532
  );
515
533
  }
516
534
  await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
@@ -519,41 +537,280 @@ Run '${runtime.name} compose logs' from ~/.horus/ to investigate.`
519
537
 
520
538
  // src/lib/compose.ts
521
539
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
522
- import { join as join2, dirname as dirname2 } from "path";
523
- import { fileURLToPath as fileURLToPath2 } from "url";
524
- var __filename = fileURLToPath2(import.meta.url);
525
- var __dirname = dirname2(__filename);
526
- function getBundledComposePath() {
527
- const candidates = [
528
- join2(__dirname, "..", "..", "compose", "docker-compose.yml"),
529
- join2(__dirname, "..", "compose", "docker-compose.yml")
530
- ];
531
- for (const candidate of candidates) {
532
- if (existsSync3(candidate)) {
533
- return candidate;
534
- }
535
- }
536
- throw new Error(
537
- `Bundled docker-compose.yml not found. The CLI package may be corrupted.
538
- Searched: ${candidates.join(", ")}`
539
- );
540
- }
541
540
  function applyPodmanUserOverride(compose) {
542
541
  return compose.replace(
543
542
  /^( image: .+)$/gm,
544
543
  '$1\n user: "0:0"'
545
544
  );
546
545
  }
546
+ var QMD_DAEMON_SERVICE = ` # \u2500\u2500 QMD Daemon \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
547
+ # Shared QMD MCP HTTP server. Keeps GGUF models warm in memory so Anvil and
548
+ # Vault pay the model-load cost only once.
549
+ qmd-daemon:
550
+ image: ghcr.io/arjunkhera/horus/qmd-daemon:latest
551
+ environment:
552
+ - QMD_DAEMON_PORT=8181
553
+ - HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
554
+ volumes:
555
+ - qmd-daemon-data:/home/qmd/.cache/qmd
556
+ networks:
557
+ - horus-net
558
+ restart: unless-stopped
559
+ stop_grace_period: 15s
560
+ deploy:
561
+ resources:
562
+ limits:
563
+ memory: 4g
564
+ reservations:
565
+ memory: 512m
566
+ healthcheck:
567
+ test: ["CMD", "curl", "-f", "http://localhost:8181/health"]
568
+ interval: 30s
569
+ timeout: 10s
570
+ start_period: 600s
571
+ retries: 3`;
572
+ var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \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\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
573
+ # Notes system and MCP server. Indexes markdown files from the Notes repo.
574
+ anvil:
575
+ image: ghcr.io/arjunkhera/horus/anvil:latest
576
+ ports:
577
+ - "\${ANVIL_PORT:-8100}:8100"
578
+ volumes:
579
+ - \${HORUS_DATA_PATH}/notes:/data/notes:rw
580
+ - qmd-daemon-data:/home/anvil/.cache/qmd
581
+ environment:
582
+ - HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
583
+ - ANVIL_TRANSPORT=http
584
+ - ANVIL_PORT=8100
585
+ - ANVIL_HOST=0.0.0.0
586
+ - ANVIL_NOTES_PATH=/data/notes
587
+ - ANVIL_REPO_URL=\${ANVIL_REPO_URL:-}
588
+ - ANVIL_QMD_COLLECTION=\${ANVIL_QMD_COLLECTION:-anvil}
589
+ - ANVIL_SYNC_INTERVAL=\${ANVIL_SYNC_INTERVAL:-300}
590
+ - ANVIL_DEBOUNCE_SECONDS=\${ANVIL_DEBOUNCE_SECONDS:-5}
591
+ - GITHUB_TOKEN=\${GITHUB_TOKEN:-}
592
+ - QMD_DAEMON_URL=http://qmd-daemon:8181
593
+ depends_on:
594
+ qmd-daemon:
595
+ condition: service_healthy
596
+ networks:
597
+ - horus-net
598
+ restart: unless-stopped
599
+ stop_grace_period: 15s
600
+ deploy:
601
+ resources:
602
+ limits:
603
+ memory: 512m
604
+ reservations:
605
+ memory: 256m
606
+ healthcheck:
607
+ test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
608
+ interval: 30s
609
+ timeout: 5s
610
+ start_period: 600s
611
+ retries: 3`;
612
+ var FORGE_SERVICE = ` # \u2500\u2500 Forge \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\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
613
+ # Workspace manager and package registry MCP server.
614
+ forge:
615
+ image: ghcr.io/arjunkhera/horus/forge:latest
616
+ ports:
617
+ - "\${FORGE_PORT:-8200}:8200"
618
+ volumes:
619
+ - \${HORUS_DATA_PATH}/registry:/data/registry:rw
620
+ - \${HORUS_DATA_PATH}/workspaces:/data/workspaces:rw
621
+ - \${HOST_REPOS_PATH}:/data/repos:ro
622
+ environment:
623
+ - HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
624
+ - FORGE_PORT=8200
625
+ - FORGE_HOST=0.0.0.0
626
+ - FORGE_REGISTRY_PATH=/data/registry
627
+ - FORGE_WORKSPACES_PATH=/data/workspaces
628
+ - FORGE_REGISTRY_REPO_URL=\${FORGE_REGISTRY_REPO_URL:-}
629
+ - FORGE_SYNC_INTERVAL=\${FORGE_SYNC_INTERVAL:-300}
630
+ - FORGE_ANVIL_URL=http://anvil:8100
631
+ - FORGE_VAULT_URL=http://vault-mcp:8300
632
+ - FORGE_HOST_WORKSPACES_PATH=\${HORUS_DATA_PATH}/workspaces
633
+ - FORGE_HOST_REPOS_PATH=\${HOST_REPOS_PATH}
634
+ - FORGE_HOST_ANVIL_URL=http://localhost:\${ANVIL_PORT:-8100}
635
+ - FORGE_HOST_VAULT_URL=http://localhost:\${VAULT_MCP_PORT:-8300}
636
+ - FORGE_HOST_FORGE_URL=http://localhost:\${FORGE_PORT:-8200}
637
+ - FORGE_SCAN_PATHS=\${FORGE_SCAN_PATHS:-/data/repos}
638
+ - GITHUB_TOKEN=\${GITHUB_TOKEN:-}
639
+ depends_on:
640
+ anvil:
641
+ condition: service_healthy
642
+ vault-router:
643
+ condition: service_healthy
644
+ networks:
645
+ - horus-net
646
+ restart: unless-stopped
647
+ stop_grace_period: 15s
648
+ deploy:
649
+ resources:
650
+ limits:
651
+ memory: 512m
652
+ reservations:
653
+ memory: 128m
654
+ healthcheck:
655
+ test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
656
+ interval: 30s
657
+ timeout: 5s
658
+ start_period: 60s
659
+ retries: 3`;
660
+ function generateComposeFile(config, runtime) {
661
+ const vaultEntries = Object.entries(config.vaults).sort(([a], [b]) => a.localeCompare(b));
662
+ const vaultServices = vaultEntries.map(([name, vault], index) => {
663
+ const hostPort = `800${index + 1}`;
664
+ const envVarName = `VAULT_REST_PORT_${name.toUpperCase().replace(/-/g, "_")}`;
665
+ const githubHost = resolveGitHubHost(vault.repo, config.github_hosts);
666
+ const token = githubHost?.token ?? "";
667
+ const apiHost = githubHost?.host ?? "github.com";
668
+ return ` # \u2500\u2500 Vault: ${name} \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
669
+ vault-${name}:
670
+ image: ghcr.io/arjunkhera/horus/vault:latest
671
+ ports:
672
+ - "\${${envVarName}:-${hostPort}}:8000"
673
+ volumes:
674
+ - \${HORUS_DATA_PATH}/vaults/${name}:/data/knowledge-repo:rw
675
+ - vault-${name}-workspace:/data/workspace
676
+ - qmd-daemon-data:/home/appuser/.cache/qmd
677
+ environment:
678
+ - HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
679
+ - KNOWLEDGE_REPO_PATH=/data/knowledge-repo
680
+ - WORKSPACE_PATH=/data/workspace
681
+ - VAULT_KNOWLEDGE_REPO_URL=${vault.repo}
682
+ - QMD_INDEX_NAME=vault-${name}
683
+ - SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
684
+ - VAULT_SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
685
+ - LOG_LEVEL=\${LOG_LEVEL:-info}
686
+ - HOST=0.0.0.0
687
+ - PORT=8000
688
+ - GITHUB_TOKEN=${token}
689
+ - GITHUB_API_HOST=${apiHost}
690
+ - QMD_DAEMON_URL=http://qmd-daemon:8181
691
+ depends_on:
692
+ qmd-daemon:
693
+ condition: service_healthy
694
+ networks:
695
+ - horus-net
696
+ restart: unless-stopped
697
+ deploy:
698
+ resources:
699
+ limits:
700
+ memory: 512m
701
+ reservations:
702
+ memory: 256m
703
+ healthcheck:
704
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
705
+ interval: 30s
706
+ timeout: 10s
707
+ start_period: 600s
708
+ retries: 3`;
709
+ });
710
+ const defaultVaultEntry = vaultEntries.find(([, v]) => v.default);
711
+ const defaultVaultName = defaultVaultEntry ? defaultVaultEntry[0] : vaultEntries[0]?.[0] ?? "";
712
+ const vaultEndpoints = vaultEntries.map(([name]) => `${name}=http://vault-${name}:8000`).join(",");
713
+ const vaultRouterDependsOn = vaultEntries.map(([name]) => ` vault-${name}:
714
+ condition: service_healthy`).join("\n");
715
+ const vaultRouterService = ` # \u2500\u2500 Vault Router \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
716
+ # Routes requests to the appropriate vault instance by name.
717
+ vault-router:
718
+ image: ghcr.io/arjunkhera/horus/vault-router:latest
719
+ ports:
720
+ - "\${VAULT_ROUTER_PORT:-8400}:8400"
721
+ environment:
722
+ - VAULT_ENDPOINTS=${vaultEndpoints}
723
+ - VAULT_DEFAULT=${defaultVaultName}
724
+ depends_on:
725
+ ${vaultRouterDependsOn}
726
+ networks:
727
+ - horus-net
728
+ restart: unless-stopped
729
+ deploy:
730
+ resources:
731
+ limits:
732
+ memory: 256m
733
+ reservations:
734
+ memory: 64m
735
+ healthcheck:
736
+ test: ["CMD", "curl", "-f", "http://localhost:8400/health"]
737
+ interval: 30s
738
+ timeout: 10s
739
+ start_period: 30s
740
+ retries: 3`;
741
+ const vaultMcpService = ` # \u2500\u2500 Vault MCP \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
742
+ # Thin MCP adapter that translates MCP tool calls to Vault REST API calls.
743
+ vault-mcp:
744
+ image: ghcr.io/arjunkhera/horus/vault-mcp:latest
745
+ ports:
746
+ - "\${VAULT_MCP_PORT:-8300}:8300"
747
+ environment:
748
+ - VAULT_MCP_HTTP=true
749
+ - VAULT_MCP_PORT=8300
750
+ - VAULT_MCP_HOST=0.0.0.0
751
+ - KNOWLEDGE_SERVICE_URL=http://vault-router:8400
752
+ depends_on:
753
+ vault-router:
754
+ condition: service_healthy
755
+ networks:
756
+ - horus-net
757
+ restart: unless-stopped
758
+ stop_grace_period: 15s
759
+ deploy:
760
+ resources:
761
+ limits:
762
+ memory: 256m
763
+ reservations:
764
+ memory: 64m
765
+ healthcheck:
766
+ test: ["CMD", "curl", "-f", "http://localhost:8300/health"]
767
+ interval: 30s
768
+ timeout: 5s
769
+ start_period: 30s
770
+ retries: 3`;
771
+ const vaultVolumeEntries = vaultEntries.map(([name]) => ` vault-${name}-workspace:`).join("\n");
772
+ const sections = [
773
+ "# \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\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\u2500",
774
+ "# Horus \u2014 Generated Docker Compose",
775
+ "# Managed by @arkhera30/cli. Do not edit manually.",
776
+ "# Generated dynamically from ~/Horus/config.yaml by `horus setup`.",
777
+ "# \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\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\u2500",
778
+ "",
779
+ "services:",
780
+ "",
781
+ QMD_DAEMON_SERVICE,
782
+ "",
783
+ ANVIL_SERVICE,
784
+ "",
785
+ ...vaultServices.map((s) => s + "\n"),
786
+ vaultRouterService,
787
+ "",
788
+ vaultMcpService,
789
+ "",
790
+ FORGE_SERVICE,
791
+ "",
792
+ "# \u2500\u2500 Networks \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\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",
793
+ "networks:",
794
+ " horus-net:",
795
+ " driver: bridge",
796
+ "",
797
+ "# \u2500\u2500 Volumes \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\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",
798
+ "volumes:",
799
+ " qmd-daemon-data:",
800
+ vaultVolumeEntries
801
+ ];
802
+ let content = sections.join("\n");
803
+ if (runtime === "podman") {
804
+ content = applyPodmanUserOverride(content);
805
+ }
806
+ return content;
807
+ }
547
808
  function composeFileExists() {
548
809
  return existsSync3(COMPOSE_PATH);
549
810
  }
550
- function installComposeFile(runtime) {
811
+ function installComposeFile(config, runtime) {
551
812
  ensureHorusDir();
552
- const bundledPath = getBundledComposePath();
553
- let content = readFileSync3(bundledPath, "utf-8");
554
- if (runtime === "podman") {
555
- content = applyPodmanUserOverride(content);
556
- }
813
+ const content = generateComposeFile(config, runtime);
557
814
  writeFileSync2(COMPOSE_PATH, content, "utf-8");
558
815
  }
559
816
 
@@ -563,22 +820,22 @@ import chalk from "chalk";
563
820
  import ora from "ora";
564
821
  import { checkbox } from "@inquirer/prompts";
565
822
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
566
- import { join as join3 } from "path";
823
+ import { join as join2 } from "path";
567
824
  import { homedir as homedir3 } from "os";
568
825
  import { execa as execa2 } from "execa";
569
826
  function detectInstalledClients() {
570
827
  const detected = [];
571
828
  const home = homedir3();
572
- const claudeDesktopDir = join3(home, "Library", "Application Support", "Claude");
829
+ const claudeDesktopDir = join2(home, "Library", "Application Support", "Claude");
573
830
  if (existsSync4(claudeDesktopDir)) {
574
831
  detected.push("claude-desktop");
575
832
  }
576
- const claudeCodeDir = join3(home, ".claude");
833
+ const claudeCodeDir = join2(home, ".claude");
577
834
  if (existsSync4(claudeCodeDir)) {
578
835
  detected.push("claude-code");
579
836
  }
580
- const cursorDir = join3(home, ".cursor");
581
- const cursorAppDir = join3(home, "Library", "Application Support", "Cursor");
837
+ const cursorDir = join2(home, ".cursor");
838
+ const cursorAppDir = join2(home, "Library", "Application Support", "Cursor");
582
839
  if (existsSync4(cursorDir) || existsSync4(cursorAppDir)) {
583
840
  detected.push("cursor");
584
841
  }
@@ -588,11 +845,11 @@ function getConfigPath(target) {
588
845
  const home = homedir3();
589
846
  switch (target) {
590
847
  case "claude-desktop":
591
- return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
848
+ return join2(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
592
849
  case "claude-code":
593
- return join3(home, ".claude", "settings.json");
850
+ return join2(home, ".claude", "settings.json");
594
851
  case "cursor":
595
- return join3(home, ".cursor", "mcp.json");
852
+ return join2(home, ".cursor", "mcp.json");
596
853
  }
597
854
  }
598
855
  function mergeAndWriteConfig(configPath, mcpServers) {
@@ -612,13 +869,13 @@ function mergeAndWriteConfig(configPath, mcpServers) {
612
869
  writeFileSync3(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
613
870
  }
614
871
  function getMcpRemoteWrapperPath() {
615
- return join3(homedir3(), ".forge", "bin", "mcp-remote-wrapper");
872
+ return join2(homedir3(), ".forge", "bin", "mcp-remote-wrapper");
616
873
  }
617
874
  function buildStdioServers(config, wrapperPath, host) {
618
875
  return {
619
- anvil: { command: wrapperPath, args: [`http://${host}:${config.ports.anvil}/mcp`] },
620
- vault: { command: wrapperPath, args: [`http://${host}:${config.ports.vault_mcp}/mcp`] },
621
- forge: { command: wrapperPath, args: [`http://${host}:${config.ports.forge}/mcp`] }
876
+ anvil: { command: wrapperPath, args: [`http://${host}:${config.ports.anvil}/mcp`, "--transport", "http-only"] },
877
+ vault: { command: wrapperPath, args: [`http://${host}:${config.ports.vault_mcp}/mcp`, "--transport", "http-only"] },
878
+ forge: { command: wrapperPath, args: [`http://${host}:${config.ports.forge}/mcp`, "--transport", "http-only"] }
622
879
  };
623
880
  }
624
881
  async function isClaudeCliAvailable() {
@@ -650,14 +907,14 @@ async function registerWithClaudeCode(mcpServers) {
650
907
  }
651
908
  async function syncSkills(runtime) {
652
909
  const home = homedir3();
653
- const skillsBase = join3(home, ".claude", "skills");
910
+ const skillsBase = join2(home, ".claude", "skills");
654
911
  const skills = ["horus-anvil", "horus-vault", "horus-forge"];
655
912
  const forgeContainer = "horus-forge-1";
656
913
  for (const skill of skills) {
657
- const destDir = join3(skillsBase, skill);
914
+ const destDir = join2(skillsBase, skill);
658
915
  mkdirSync2(destDir, { recursive: true });
659
916
  const src = `/home/forge/.claude/skills/${skill}/SKILL.md`;
660
- const dest = join3(destDir, "SKILL.md");
917
+ const dest = join2(destDir, "SKILL.md");
661
918
  const result = await runtime.exec(forgeContainer, "cat", src);
662
919
  if (result.exitCode === 0 && result.stdout.trim()) {
663
920
  writeFileSync3(dest, result.stdout, "utf-8");
@@ -666,8 +923,8 @@ async function syncSkills(runtime) {
666
923
  }
667
924
  async function syncSkillsForCursor(runtime) {
668
925
  const home = homedir3();
669
- const rulesDir = join3(home, ".cursor", "rules");
670
- const skillsBase = join3(home, ".cursor", "skills-cursor");
926
+ const rulesDir = join2(home, ".cursor", "rules");
927
+ const skillsBase = join2(home, ".cursor", "skills-cursor");
671
928
  const skills = ["horus-anvil", "horus-vault", "horus-forge"];
672
929
  const forgeContainer = "horus-forge-1";
673
930
  mkdirSync2(rulesDir, { recursive: true });
@@ -675,7 +932,7 @@ async function syncSkillsForCursor(runtime) {
675
932
  const src = `/home/forge/.claude/skills/${skill}/SKILL.md`;
676
933
  const result = await runtime.exec(forgeContainer, "cat", src);
677
934
  if (result.exitCode === 0 && result.stdout.trim()) {
678
- const ruleDest = join3(rulesDir, `${skill}.mdc`);
935
+ const ruleDest = join2(rulesDir, `${skill}.mdc`);
679
936
  const frontmatter = `---
680
937
  description: Horus ${skill} reference
681
938
  alwaysApply: true
@@ -683,9 +940,9 @@ alwaysApply: true
683
940
 
684
941
  `;
685
942
  writeFileSync3(ruleDest, frontmatter + result.stdout, "utf-8");
686
- const skillDir = join3(skillsBase, skill);
943
+ const skillDir = join2(skillsBase, skill);
687
944
  mkdirSync2(skillDir, { recursive: true });
688
- writeFileSync3(join3(skillDir, "SKILL.md"), result.stdout, "utf-8");
945
+ writeFileSync3(join2(skillDir, "SKILL.md"), result.stdout, "utf-8");
689
946
  }
690
947
  }
691
948
  }
@@ -873,7 +1130,14 @@ function injectToken(url, token) {
873
1130
  return url;
874
1131
  }
875
1132
  }
876
- 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("--git-host <host>", "Git server hostname (e.g., github.com, gitlab.corp.com)").option("--anvil-repo <url>", "Anvil notes repository URL").option("--vault-repo <url>", "Vault knowledge-base repository URL").option("--forge-repo <url>", "Forge registry repository URL").option("--github-token <token>", "GitHub personal access token for private repos").action(async (opts) => {
1133
+ function extractHostname(url) {
1134
+ try {
1135
+ return new URL(url).hostname;
1136
+ } catch {
1137
+ return "github.com";
1138
+ }
1139
+ }
1140
+ 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) => {
877
1141
  console.log("");
878
1142
  console.log(chalk2.bold("Horus Setup"));
879
1143
  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"));
@@ -935,18 +1199,47 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
935
1199
  let config;
936
1200
  if (opts.yes) {
937
1201
  const defaults = defaultConfig();
1202
+ const vaultNames = opts.vaultName ? Array.isArray(opts.vaultName) ? opts.vaultName : [opts.vaultName] : ["default"];
1203
+ const vaultRepos = opts.vaultRepo ? Array.isArray(opts.vaultRepo) ? opts.vaultRepo : [opts.vaultRepo] : [process.env.VAULT_KNOWLEDGE_REPO_URL ?? ""];
1204
+ const vaults = {};
1205
+ vaultNames.forEach((name, i) => {
1206
+ vaults[name] = {
1207
+ repo: vaultRepos[i] ?? vaultRepos[0] ?? "",
1208
+ default: i === 0
1209
+ };
1210
+ });
1211
+ const primaryToken = opts.githubToken || process.env.GITHUB_TOKEN || "";
1212
+ const anvilRepo = opts.anvilRepo || process.env.ANVIL_REPO_URL || defaults.repos.anvil_notes;
1213
+ const allRepoUrls = [anvilRepo, ...Object.values(vaults).map((v) => v.repo)].filter(Boolean);
1214
+ const seenHosts = /* @__PURE__ */ new Set();
1215
+ const github_hosts = {};
1216
+ let hostIndex = 0;
1217
+ for (const url of allRepoUrls) {
1218
+ const hostname = extractHostname(url);
1219
+ if (!seenHosts.has(hostname)) {
1220
+ seenHosts.add(hostname);
1221
+ const hostKey = hostIndex === 0 ? "default" : hostname;
1222
+ github_hosts[hostKey] = {
1223
+ host: hostname,
1224
+ token: primaryToken
1225
+ };
1226
+ hostIndex++;
1227
+ }
1228
+ }
1229
+ if (Object.keys(github_hosts).length === 0) {
1230
+ github_hosts["default"] = { host: "github.com", token: primaryToken };
1231
+ }
938
1232
  config = {
939
1233
  ...defaults,
940
1234
  runtime: runtime.name,
941
1235
  data_dir: opts.dataDir || DEFAULT_DATA_DIR,
942
1236
  host_repos_path: opts.reposPath || "",
943
- git_host: opts.gitHost || defaults.git_host,
944
1237
  repos: {
945
- anvil_notes: opts.anvilRepo || process.env.ANVIL_REPO_URL || defaults.repos.anvil_notes,
946
- vault_knowledge: opts.vaultRepo || process.env.VAULT_KNOWLEDGE_REPO_URL || defaults.repos.vault_knowledge,
1238
+ anvil_notes: anvilRepo,
947
1239
  forge_registry: opts.forgeRepo || process.env.FORGE_REGISTRY_REPO_URL || defaults.repos.forge_registry
948
1240
  },
949
- github_token: opts.githubToken || process.env.GITHUB_TOKEN || ""
1241
+ vaults,
1242
+ github_hosts
950
1243
  };
951
1244
  } else {
952
1245
  const data_dir = await input({
@@ -969,13 +1262,17 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
969
1262
  default: DEFAULT_PORTS.anvil
970
1263
  });
971
1264
  const vault_rest = await number({
972
- message: "Vault REST port:",
1265
+ message: "Vault REST port (per-vault instances):",
973
1266
  default: DEFAULT_PORTS.vault_rest
974
1267
  });
975
1268
  const vault_mcp = await number({
976
1269
  message: "Vault MCP port:",
977
1270
  default: DEFAULT_PORTS.vault_mcp
978
1271
  });
1272
+ const vault_router = await number({
1273
+ message: "Vault Router port:",
1274
+ default: DEFAULT_PORTS.vault_router
1275
+ });
979
1276
  const forge = await number({
980
1277
  message: "Forge port:",
981
1278
  default: DEFAULT_PORTS.forge
@@ -984,6 +1281,7 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
984
1281
  anvil: anvil ?? DEFAULT_PORTS.anvil,
985
1282
  vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
986
1283
  vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
1284
+ vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
987
1285
  forge: forge ?? DEFAULT_PORTS.forge
988
1286
  };
989
1287
  }
@@ -995,12 +1293,11 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
995
1293
  console.log(chalk2.yellow(" Use HTTPS URLs \u2014 container services do not have SSH keys."));
996
1294
  console.log(chalk2.dim(" SSH URLs (git@github.com:...) will fail at runtime inside Docker/Podman."));
997
1295
  console.log("");
998
- const git_host = await input({
999
- message: "Git server hostname:",
1296
+ const primaryHost = await input({
1297
+ message: "Primary Git server hostname:",
1000
1298
  default: "github.com"
1001
1299
  });
1002
- const host = git_host.trim();
1003
- const example = (repo) => chalk2.dim(` e.g., https://${host}/<owner>/${repo}`);
1300
+ const example = (repo) => chalk2.dim(` e.g., https://${primaryHost}/<owner>/${repo}`);
1004
1301
  console.log("");
1005
1302
  const anvil_notes = await input({
1006
1303
  message: `Anvil notes repo URL:
@@ -1008,12 +1305,6 @@ ${example("horus-notes")}
1008
1305
  `,
1009
1306
  validate: (v) => v.trim().length > 0 || "Anvil needs a notes repo to store your data."
1010
1307
  });
1011
- const vault_knowledge = await input({
1012
- message: `Vault knowledge-base repo URL:
1013
- ${example("knowledge-base")}
1014
- `,
1015
- validate: (v) => v.trim().length > 0 || "Vault needs a knowledge-base repo."
1016
- });
1017
1308
  const forge_registry = await input({
1018
1309
  message: `Forge registry repo URL:
1019
1310
  ${example("forge-registry")}
@@ -1021,13 +1312,75 @@ ${example("forge-registry")}
1021
1312
  validate: (v) => v.trim().length > 0 || "Forge needs a registry repo."
1022
1313
  });
1023
1314
  console.log("");
1315
+ console.log(chalk2.bold("Vault Configuration"));
1316
+ console.log(chalk2.dim("Add one or more knowledge-base vaults. Each vault is a separate Git repo."));
1317
+ console.log("");
1318
+ const vaults = {};
1319
+ let addingVaults = true;
1320
+ let isFirstVault = true;
1321
+ while (addingVaults) {
1322
+ const vaultName = await input({
1323
+ message: "Add vault name (e.g., personal):",
1324
+ validate: (v) => {
1325
+ const trimmed = v.trim();
1326
+ if (!trimmed) return "Vault name cannot be empty.";
1327
+ if (!/^[a-z0-9-]+$/.test(trimmed)) return "Vault name must be lowercase alphanumeric with hyphens only.";
1328
+ if (trimmed in vaults) return `Vault "${trimmed}" already added.`;
1329
+ return true;
1330
+ }
1331
+ });
1332
+ const vaultRepo = await input({
1333
+ message: `Vault repo URL:
1334
+ ${example(`${vaultName.trim()}-knowledge`)}
1335
+ `,
1336
+ validate: (v) => v.trim().length > 0 || "Vault repo URL cannot be empty."
1337
+ });
1338
+ let isDefault = isFirstVault;
1339
+ if (!isFirstVault) {
1340
+ isDefault = await confirm({
1341
+ message: `Is "${vaultName.trim()}" the default vault?`,
1342
+ default: false
1343
+ });
1344
+ }
1345
+ if (isDefault) {
1346
+ for (const v of Object.values(vaults)) {
1347
+ v.default = false;
1348
+ }
1349
+ }
1350
+ vaults[vaultName.trim()] = {
1351
+ repo: vaultRepo.trim(),
1352
+ default: isDefault || isFirstVault
1353
+ };
1354
+ isFirstVault = false;
1355
+ addingVaults = await confirm({
1356
+ message: "Add another vault?",
1357
+ default: false
1358
+ });
1359
+ }
1360
+ const defaultCount = Object.values(vaults).filter((v) => v.default).length;
1361
+ if (defaultCount === 0 && Object.keys(vaults).length > 0) {
1362
+ const firstKey = Object.keys(vaults)[0];
1363
+ vaults[firstKey].default = true;
1364
+ }
1365
+ console.log("");
1024
1366
  console.log(chalk2.bold("Authentication"));
1025
- console.log(chalk2.dim("A personal access token is required for private repositories."));
1367
+ console.log(chalk2.dim("A personal access token is required per Git server for private repositories."));
1026
1368
  console.log("");
1027
- const github_token = await password({
1028
- message: "GitHub personal access token (leave empty to skip):",
1029
- mask: "*"
1030
- });
1369
+ const allRepoUrls = [anvil_notes.trim(), ...Object.values(vaults).map((v) => v.repo)].filter(Boolean);
1370
+ const uniqueHostnames = [...new Set(allRepoUrls.map(extractHostname))];
1371
+ const github_hosts = {};
1372
+ for (let i = 0; i < uniqueHostnames.length; i++) {
1373
+ const hostname = uniqueHostnames[i];
1374
+ const token = await password({
1375
+ message: `GitHub token for ${chalk2.cyan(hostname)} (leave empty to skip):`,
1376
+ mask: "*"
1377
+ });
1378
+ const hostKey = i === 0 ? "default" : hostname;
1379
+ github_hosts[hostKey] = {
1380
+ host: hostname,
1381
+ token: token.trim()
1382
+ };
1383
+ }
1031
1384
  config = {
1032
1385
  ...defaultConfig(),
1033
1386
  data_dir,
@@ -1035,19 +1388,18 @@ ${example("forge-registry")}
1035
1388
  host_repos_extra_scan_dirs,
1036
1389
  runtime: runtime.name,
1037
1390
  ports,
1038
- git_host: git_host.trim(),
1039
1391
  repos: {
1040
1392
  anvil_notes: anvil_notes.trim(),
1041
- vault_knowledge: vault_knowledge.trim(),
1042
1393
  forge_registry: forge_registry.trim()
1043
1394
  },
1044
- github_token: github_token.trim()
1395
+ vaults,
1396
+ github_hosts
1045
1397
  };
1046
1398
  }
1047
1399
  const configSpinner = ora2("Saving configuration...").start();
1048
1400
  try {
1049
1401
  saveConfig(config);
1050
- configSpinner.succeed("Configuration saved to ~/.horus/config.yaml");
1402
+ configSpinner.succeed("Configuration saved to ~/Horus/config.yaml");
1051
1403
  } catch (error) {
1052
1404
  configSpinner.fail("Failed to save configuration");
1053
1405
  console.error(error.message);
@@ -1056,7 +1408,7 @@ ${example("forge-registry")}
1056
1408
  const envSpinner = ora2("Generating .env file...").start();
1057
1409
  try {
1058
1410
  writeEnvFile(config);
1059
- envSpinner.succeed("Environment file written to ~/.horus/.env");
1411
+ envSpinner.succeed("Environment file written to ~/Horus/.env");
1060
1412
  } catch (error) {
1061
1413
  envSpinner.fail("Failed to generate .env");
1062
1414
  console.error(error.message);
@@ -1064,32 +1416,54 @@ ${example("forge-registry")}
1064
1416
  }
1065
1417
  const composeSpinner = ora2("Installing docker-compose.yml...").start();
1066
1418
  try {
1067
- installComposeFile(runtime.name);
1068
- composeSpinner.succeed("Compose file installed to ~/.horus/docker-compose.yml");
1419
+ installComposeFile(config, runtime.name);
1420
+ composeSpinner.succeed("Compose file installed to ~/Horus/docker-compose.yml");
1069
1421
  } catch (error) {
1070
1422
  composeSpinner.fail("Failed to install compose file");
1071
1423
  console.error(error.message);
1072
1424
  process.exit(1);
1073
1425
  }
1074
1426
  const dataDir = resolvePath(config.data_dir);
1427
+ const anvilToken = resolveGitHubHost(config.repos.anvil_notes, config.github_hosts)?.token ?? "";
1428
+ const forgeToken = resolveGitHubHost(config.repos.forge_registry, config.github_hosts)?.token ?? "";
1075
1429
  const reposToClone = [
1076
- { url: config.repos.anvil_notes, dest: join4(dataDir, "notes"), label: "Anvil notes" },
1077
- { url: config.repos.vault_knowledge, dest: join4(dataDir, "knowledge-base"), label: "Vault knowledge-base" },
1078
- { url: config.repos.forge_registry, dest: join4(dataDir, "registry"), label: "Forge registry" }
1430
+ {
1431
+ url: config.repos.anvil_notes,
1432
+ dest: join3(dataDir, "notes"),
1433
+ label: "Anvil notes",
1434
+ token: anvilToken
1435
+ },
1436
+ {
1437
+ url: config.repos.forge_registry,
1438
+ dest: join3(dataDir, "registry"),
1439
+ label: "Forge registry",
1440
+ token: forgeToken
1441
+ }
1079
1442
  ].filter((r) => r.url);
1443
+ for (const [name, vault] of Object.entries(config.vaults)) {
1444
+ if (vault.repo) {
1445
+ const vaultToken = resolveGitHubHost(vault.repo, config.github_hosts)?.token ?? "";
1446
+ reposToClone.push({
1447
+ url: vault.repo,
1448
+ dest: join3(dataDir, "vaults", name),
1449
+ label: `Vault: ${name}`,
1450
+ token: vaultToken
1451
+ });
1452
+ }
1453
+ }
1080
1454
  if (reposToClone.length > 0) {
1081
1455
  console.log("");
1082
1456
  console.log(chalk2.bold("Cloning repositories..."));
1083
1457
  mkdirSync3(dataDir, { recursive: true });
1084
1458
  for (const repo of reposToClone) {
1085
1459
  const spinner = ora2(`Cloning ${repo.label}...`).start();
1086
- if (existsSync5(join4(repo.dest, ".git"))) {
1460
+ if (existsSync5(join3(repo.dest, ".git"))) {
1087
1461
  spinner.succeed(`${repo.label} already cloned`);
1088
1462
  continue;
1089
1463
  }
1090
1464
  try {
1091
1465
  mkdirSync3(repo.dest, { recursive: true });
1092
- const cloneUrl = injectToken(repo.url, config.github_token);
1466
+ const cloneUrl = injectToken(repo.url, repo.token);
1093
1467
  execSync(`git clone "${cloneUrl}" "${repo.dest}"`, {
1094
1468
  stdio: "pipe",
1095
1469
  timeout: 6e4
@@ -1104,7 +1478,7 @@ ${example("forge-registry")}
1104
1478
  console.log(chalk2.dim(` ${msg.split("\n")[0]}`));
1105
1479
  }
1106
1480
  console.log(chalk2.dim(` URL: ${repo.url}`));
1107
- if (!config.github_token) {
1481
+ if (!repo.token) {
1108
1482
  console.log(chalk2.dim(" Tip: Re-run setup and provide a GitHub token if the repo is private."));
1109
1483
  }
1110
1484
  process.exit(1);
@@ -1151,7 +1525,7 @@ ${example("forge-registry")}
1151
1525
  healthSpinner.fail("Some services did not become healthy");
1152
1526
  console.log(chalk2.dim(error.message));
1153
1527
  console.log("");
1154
- console.log(chalk2.dim("Tip: Check logs with `docker compose logs` from ~/.horus/"));
1528
+ console.log(chalk2.dim("Tip: Check logs with `docker compose logs` from ~/Horus/"));
1155
1529
  process.exit(1);
1156
1530
  }
1157
1531
  console.log("");
@@ -1172,15 +1546,23 @@ ${example("forge-registry")}
1172
1546
  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"));
1173
1547
  console.log("");
1174
1548
  console.log(` ${chalk2.bold("Runtime:")} ${runtime.name}`);
1175
- console.log(` ${chalk2.bold("Config:")} ~/.horus/config.yaml`);
1549
+ console.log(` ${chalk2.bold("Config:")} ~/Horus/config.yaml`);
1176
1550
  console.log(` ${chalk2.bold("Data:")} ${config.data_dir}`);
1177
1551
  console.log("");
1178
1552
  console.log(chalk2.bold(" Service URLs:"));
1179
- console.log(` Anvil: http://localhost:${config.ports.anvil}`);
1180
- console.log(` Vault REST: http://localhost:${config.ports.vault_rest}`);
1181
- console.log(` Vault MCP: http://localhost:${config.ports.vault_mcp}`);
1182
- console.log(` Forge: http://localhost:${config.ports.forge}`);
1553
+ console.log(` Anvil: http://localhost:${config.ports.anvil}`);
1554
+ console.log(` Vault Router: http://localhost:${config.ports.vault_router}`);
1555
+ console.log(` Vault MCP: http://localhost:${config.ports.vault_mcp}`);
1556
+ console.log(` Forge: http://localhost:${config.ports.forge}`);
1183
1557
  console.log("");
1558
+ console.log(chalk2.bold(" Vault instances:"));
1559
+ Object.entries(config.vaults).sort(([a], [b]) => a.localeCompare(b)).forEach(([name, vault], index) => {
1560
+ const port = `800${index + 1}`;
1561
+ const defaultLabel = vault.default ? chalk2.dim(" (default)") : "";
1562
+ console.log(` ${name}${defaultLabel}: http://localhost:${port}`);
1563
+ });
1564
+ console.log("");
1565
+ void lastStates;
1184
1566
  });
1185
1567
 
1186
1568
  // src/commands/up.ts
@@ -1314,7 +1696,7 @@ var statusCommand = new Command5("status").description("Show status of Horus ser
1314
1696
  console.log(chalk5.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"));
1315
1697
  console.log(` ${chalk5.bold("Version:")} ${CLI_VERSION}`);
1316
1698
  console.log(` ${chalk5.bold("Runtime:")} ${runtime.name}`);
1317
- console.log(` ${chalk5.bold("Config:")} ~/.horus/config.yaml`);
1699
+ console.log(` ${chalk5.bold("Config:")} ~/Horus/config.yaml`);
1318
1700
  console.log("");
1319
1701
  if (containers.length === 0) {
1320
1702
  console.log(chalk5.yellow(" No services are running."));
@@ -1385,21 +1767,38 @@ var configCommand = new Command6("config").description("View or modify Horus con
1385
1767
  console.log(` ${chalk6.bold("host-repos-path:")} ${config.host_repos_path || chalk6.dim("(not set)")}`);
1386
1768
  const extraDirs = (config.host_repos_extra_scan_dirs ?? []).join(", ");
1387
1769
  console.log(` ${chalk6.bold("host-repos-extra-scan-dirs:")} ${extraDirs || chalk6.dim("(not set)")}`);
1388
- console.log(` ${chalk6.bold("git-host:")} ${config.git_host || chalk6.dim("(not set)")}`);
1389
- console.log(` ${chalk6.bold("github-token:")} ${config.github_token ? maskApiKey(config.github_token) : chalk6.dim("(not set)")}`);
1390
1770
  console.log("");
1391
1771
  console.log(chalk6.bold(" Ports:"));
1392
- console.log(` ${chalk6.bold("anvil:")} ${config.ports.anvil}`);
1393
- console.log(` ${chalk6.bold("vault-rest:")} ${config.ports.vault_rest}`);
1394
- console.log(` ${chalk6.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
1395
- console.log(` ${chalk6.bold("forge:")} ${config.ports.forge}`);
1772
+ console.log(` ${chalk6.bold("anvil:")} ${config.ports.anvil}`);
1773
+ console.log(` ${chalk6.bold("vault-rest:")} ${config.ports.vault_rest}`);
1774
+ console.log(` ${chalk6.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
1775
+ console.log(` ${chalk6.bold("vault-router:")} ${config.ports.vault_router}`);
1776
+ console.log(` ${chalk6.bold("forge:")} ${config.ports.forge}`);
1396
1777
  console.log("");
1397
1778
  console.log(chalk6.bold(" Repos:"));
1398
- console.log(` ${chalk6.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk6.dim("(not set)")}`);
1399
- console.log(` ${chalk6.bold("vault-knowledge:")} ${config.repos.vault_knowledge || chalk6.dim("(not set)")}`);
1400
- console.log(` ${chalk6.bold("forge-registry:")} ${config.repos.forge_registry || chalk6.dim("(not set)")}`);
1779
+ console.log(` ${chalk6.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk6.dim("(not set)")}`);
1780
+ console.log(` ${chalk6.bold("forge-registry:")} ${config.repos.forge_registry || chalk6.dim("(not set)")}`);
1781
+ console.log("");
1782
+ console.log(chalk6.bold(" Vaults:"));
1783
+ if (Object.keys(config.vaults ?? {}).length === 0) {
1784
+ console.log(chalk6.dim(" (none configured)"));
1785
+ } else {
1786
+ for (const [name, vault] of Object.entries(config.vaults)) {
1787
+ const defaultLabel = vault.default ? chalk6.dim(" (default)") : "";
1788
+ console.log(` ${chalk6.bold(name)}${defaultLabel}: ${vault.repo || chalk6.dim("(no repo)")}`);
1789
+ }
1790
+ }
1791
+ console.log("");
1792
+ console.log(chalk6.bold(" GitHub Hosts:"));
1793
+ if (Object.keys(config.github_hosts ?? {}).length === 0) {
1794
+ console.log(chalk6.dim(" (none configured)"));
1795
+ } else {
1796
+ for (const [key, gh] of Object.entries(config.github_hosts)) {
1797
+ console.log(` ${chalk6.bold(key)}: ${gh.host} token: ${gh.token ? maskApiKey(gh.token) : chalk6.dim("(not set)")}`);
1798
+ }
1799
+ }
1401
1800
  console.log("");
1402
- console.log(chalk6.dim(` Config file: ~/.horus/config.yaml`));
1801
+ console.log(chalk6.dim(` Config file: ~/Horus/config.yaml`));
1403
1802
  console.log(chalk6.dim(` Use 'horus config get <key>' or 'horus config set <key> <value>'`));
1404
1803
  console.log("");
1405
1804
  });
@@ -1416,11 +1815,7 @@ configCommand.command("get <key>").description("Get a configuration value").acti
1416
1815
  }
1417
1816
  const config = loadConfig();
1418
1817
  const value = getConfigValue(config, key);
1419
- if (key === "github-token") {
1420
- console.log(maskApiKey(value));
1421
- } else {
1422
- console.log(value || "");
1423
- }
1818
+ console.log(value || "");
1424
1819
  });
1425
1820
  configCommand.command("set <key> <value>").description("Set a configuration value").action(async (key, value) => {
1426
1821
  if (!configExists()) {
@@ -1451,6 +1846,7 @@ configCommand.command("set <key> <value>").description("Set a configuration valu
1451
1846
  "port.anvil",
1452
1847
  "port.vault-rest",
1453
1848
  "port.vault-mcp",
1849
+ "port.vault-router",
1454
1850
  "port.forge"
1455
1851
  ];
1456
1852
  if (needsRestart.includes(key)) {
@@ -1478,10 +1874,10 @@ import chalk7 from "chalk";
1478
1874
  import ora6 from "ora";
1479
1875
  import { select as select2, confirm as confirm3 } from "@inquirer/prompts";
1480
1876
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync2, existsSync as existsSync6 } from "fs";
1481
- import { join as join5 } from "path";
1877
+ import { join as join4 } from "path";
1482
1878
  import { createHash } from "crypto";
1483
1879
  import { stringify as stringifyYaml2, parse as parseYaml2 } from "yaml";
1484
- var SNAPSHOTS_DIR = join5(HORUS_DIR, "snapshots");
1880
+ var SNAPSHOTS_DIR = join4(HORUS_DIR, "snapshots");
1485
1881
  function ensureSnapshotsDir() {
1486
1882
  mkdirSync4(SNAPSHOTS_DIR, { recursive: true });
1487
1883
  }
@@ -1516,14 +1912,14 @@ function saveSnapshot(images) {
1516
1912
  images,
1517
1913
  compose_hash: composeFileHash()
1518
1914
  };
1519
- const filePath = join5(SNAPSHOTS_DIR, `${timestamp}.yaml`);
1915
+ const filePath = join4(SNAPSHOTS_DIR, `${timestamp}.yaml`);
1520
1916
  writeFileSync4(filePath, stringifyYaml2(snapshot, { lineWidth: 0 }), "utf-8");
1521
1917
  return filePath;
1522
1918
  }
1523
1919
  function listSnapshots() {
1524
1920
  if (!existsSync6(SNAPSHOTS_DIR)) return [];
1525
1921
  return readdirSync2(SNAPSHOTS_DIR).filter((f) => f.endsWith(".yaml")).sort().reverse().map((f) => {
1526
- const file = join5(SNAPSHOTS_DIR, f);
1922
+ const file = join4(SNAPSHOTS_DIR, f);
1527
1923
  const snapshot = parseYaml2(readFileSync5(file, "utf-8"));
1528
1924
  return { file, snapshot };
1529
1925
  });
@@ -1734,7 +2130,7 @@ import { Command as Command8 } from "commander";
1734
2130
  import chalk8 from "chalk";
1735
2131
  import { execSync as execSync2 } from "child_process";
1736
2132
  import { existsSync as existsSync7, accessSync, statfsSync, constants } from "fs";
1737
- import { join as join6 } from "path";
2133
+ import { join as join5 } from "path";
1738
2134
  function symbol(status) {
1739
2135
  switch (status) {
1740
2136
  case "pass":
@@ -1790,23 +2186,23 @@ async function checkCompose(preferred) {
1790
2186
  }
1791
2187
  function checkConfig() {
1792
2188
  if (configExists()) {
1793
- return { status: "pass", label: "Config", message: "Configuration file exists (~/.horus/config.yaml)" };
2189
+ return { status: "pass", label: "Config", message: "Configuration file exists (~/Horus/config.yaml)" };
1794
2190
  }
1795
2191
  return {
1796
2192
  status: "fail",
1797
2193
  label: "Config",
1798
- message: "Configuration file missing (~/.horus/config.yaml)",
2194
+ message: "Configuration file missing (~/Horus/config.yaml)",
1799
2195
  hint: "Run `horus setup` to create the configuration"
1800
2196
  };
1801
2197
  }
1802
2198
  function checkComposeFile() {
1803
2199
  if (existsSync7(COMPOSE_PATH)) {
1804
- return { status: "pass", label: "Compose file", message: "Compose file installed (~/.horus/docker-compose.yml)" };
2200
+ return { status: "pass", label: "Compose file", message: "Compose file installed (~/Horus/docker-compose.yml)" };
1805
2201
  }
1806
2202
  return {
1807
2203
  status: "fail",
1808
2204
  label: "Compose file",
1809
- message: "Compose file missing (~/.horus/docker-compose.yml)",
2205
+ message: "Compose file missing (~/Horus/docker-compose.yml)",
1810
2206
  hint: "Run `horus setup` to install the compose file"
1811
2207
  };
1812
2208
  }
@@ -1862,7 +2258,7 @@ function checkDataDir(dataDir) {
1862
2258
  }
1863
2259
  }
1864
2260
  function checkDiskSpace(dataDir) {
1865
- const checkDir = existsSync7(dataDir) ? dataDir : join6(dataDir, "..");
2261
+ const checkDir = existsSync7(dataDir) ? dataDir : join5(dataDir, "..");
1866
2262
  try {
1867
2263
  const stats = statfsSync(checkDir);
1868
2264
  const freeBytes = stats.bfree * stats.bsize;
@@ -1939,7 +2335,7 @@ var doctorCommand = new Command8("doctor").description("Diagnose common Horus is
1939
2335
  allResults.push(checkConfig());
1940
2336
  allResults.push(checkComposeFile());
1941
2337
  const ports = config?.ports ?? DEFAULT_PORTS;
1942
- const dataDir = config?.data_dir ?? join6(process.env.HOME ?? "~", ".horus", "data");
2338
+ const dataDir = config?.data_dir ?? DEFAULT_DATA_DIR;
1943
2339
  allResults.push(checkPort(ports.anvil, "Anvil"));
1944
2340
  allResults.push(checkPort(ports.vault_rest, "Vault"));
1945
2341
  allResults.push(checkPort(ports.vault_mcp, "Vault MCP"));
@@ -1995,10 +2391,10 @@ import chalk9 from "chalk";
1995
2391
  import ora7 from "ora";
1996
2392
  import { confirm as confirm4 } from "@inquirer/prompts";
1997
2393
  import { mkdirSync as mkdirSync5, statSync as statSync2, existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
1998
- import { join as join7, basename } from "path";
2394
+ import { join as join6, basename } from "path";
1999
2395
  import { execSync as execSync3 } from "child_process";
2000
2396
  import { stringify as stringifyYaml3 } from "yaml";
2001
- var BACKUPS_DIR = join7(HORUS_DIR, "backups");
2397
+ var BACKUPS_DIR = join6(HORUS_DIR, "backups");
2002
2398
  function ensureBackupsDir() {
2003
2399
  mkdirSync5(BACKUPS_DIR, { recursive: true });
2004
2400
  }
@@ -2045,8 +2441,8 @@ async function createBackup(yes) {
2045
2441
  }
2046
2442
  ensureBackupsDir();
2047
2443
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2048
- const tarFile = join7(BACKUPS_DIR, `${timestamp}.tar.gz`);
2049
- const metaFile = join7(BACKUPS_DIR, `${timestamp}.meta.yaml`);
2444
+ const tarFile = join6(BACKUPS_DIR, `${timestamp}.tar.gz`);
2445
+ const metaFile = join6(BACKUPS_DIR, `${timestamp}.meta.yaml`);
2050
2446
  const backupSpinner = ora7("Creating backup archive...").start();
2051
2447
  try {
2052
2448
  execSync3(`tar -czf "${tarFile}" -C "${HORUS_DIR}" data/`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkhera30/cli",
3
- "version": "0.1.16",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for managing the Horus AI development stack",
5
5
  "type": "module",
6
6
  "bin": {