@arkhera30/cli 0.1.17 → 0.2.1

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
@@ -46,19 +46,18 @@ var COMPOSE_PATH = join(HORUS_DIR, "docker-compose.yml");
46
46
  var DEFAULT_PORTS = {
47
47
  anvil: 8100,
48
48
  vault_rest: 8e3,
49
+ // keep for individual vault instances
49
50
  vault_mcp: 8300,
51
+ vault_router: 8400,
52
+ // new
50
53
  forge: 8200
51
54
  };
52
- var DEFAULT_REPOS = {
53
- anvil_notes: "",
54
- vault_knowledge: "",
55
- forge_registry: ""
56
- };
57
55
  var DEFAULT_DATA_DIR = join(homedir(), "Horus", "data");
58
56
  var SERVICES = [
59
57
  "qmd-daemon",
60
58
  "anvil",
61
- "vault",
59
+ "vault-router",
60
+ // replaces 'vault'
62
61
  "vault-mcp",
63
62
  "forge"
64
63
  ];
@@ -71,11 +70,14 @@ function defaultConfig() {
71
70
  data_dir: DEFAULT_DATA_DIR,
72
71
  runtime: "docker",
73
72
  ports: { ...DEFAULT_PORTS },
74
- git_host: "github.com",
75
- repos: { ...DEFAULT_REPOS },
73
+ repos: {
74
+ anvil_notes: "",
75
+ forge_registry: ""
76
+ },
77
+ vaults: {},
78
+ github_hosts: {},
76
79
  host_repos_path: "",
77
- host_repos_extra_scan_dirs: [],
78
- github_token: ""
80
+ host_repos_extra_scan_dirs: []
79
81
  };
80
82
  }
81
83
  function ensureHorusDir() {
@@ -96,52 +98,42 @@ The new default is ~/Horus. Run \`horus setup\` to migrate.
96
98
  );
97
99
  const raw2 = readFileSync2(legacyConfigPath, "utf-8");
98
100
  const parsed2 = parseYaml(raw2);
99
- const defaults2 = defaultConfig();
100
- return {
101
- version: parsed2.version ?? defaults2.version,
102
- data_dir: parsed2.data_dir ?? defaults2.data_dir,
103
- runtime: parsed2.runtime ?? defaults2.runtime,
104
- ports: {
105
- anvil: parsed2.ports?.anvil ?? defaults2.ports.anvil,
106
- vault_rest: parsed2.ports?.vault_rest ?? defaults2.ports.vault_rest,
107
- vault_mcp: parsed2.ports?.vault_mcp ?? defaults2.ports.vault_mcp,
108
- forge: parsed2.ports?.forge ?? defaults2.ports.forge
109
- },
110
- git_host: parsed2.git_host ?? defaults2.git_host,
111
- repos: {
112
- anvil_notes: parsed2.repos?.anvil_notes ?? defaults2.repos.anvil_notes,
113
- vault_knowledge: parsed2.repos?.vault_knowledge ?? defaults2.repos.vault_knowledge,
114
- forge_registry: parsed2.repos?.forge_registry ?? defaults2.repos.forge_registry
115
- },
116
- host_repos_path: parsed2.host_repos_path ?? defaults2.host_repos_path,
117
- host_repos_extra_scan_dirs: parsed2.host_repos_extra_scan_dirs ?? defaults2.host_repos_extra_scan_dirs,
118
- github_token: parsed2.github_token ?? defaults2.github_token
119
- };
101
+ return buildConfigFromParsed(parsed2);
120
102
  }
121
103
  return defaultConfig();
122
104
  }
123
105
  const raw = readFileSync2(CONFIG_PATH, "utf-8");
124
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
+ }
125
116
  const defaults = defaultConfig();
117
+ const parsedPorts = parsed.ports;
126
118
  return {
127
119
  version: parsed.version ?? defaults.version,
128
120
  data_dir: parsed.data_dir ?? defaults.data_dir,
129
121
  runtime: parsed.runtime ?? defaults.runtime,
130
122
  ports: {
131
- anvil: parsed.ports?.anvil ?? defaults.ports.anvil,
132
- vault_rest: parsed.ports?.vault_rest ?? defaults.ports.vault_rest,
133
- vault_mcp: parsed.ports?.vault_mcp ?? defaults.ports.vault_mcp,
134
- 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
135
128
  },
136
- git_host: parsed.git_host ?? defaults.git_host,
137
129
  repos: {
138
- anvil_notes: parsed.repos?.anvil_notes ?? defaults.repos.anvil_notes,
139
- vault_knowledge: parsed.repos?.vault_knowledge ?? defaults.repos.vault_knowledge,
140
- 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
141
132
  },
133
+ vaults: parsed.vaults ?? defaults.vaults,
134
+ github_hosts: parsed.github_hosts ?? defaults.github_hosts,
142
135
  host_repos_path: parsed.host_repos_path ?? defaults.host_repos_path,
143
- host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs,
144
- 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
145
137
  };
146
138
  }
147
139
  function saveConfig(config) {
@@ -149,6 +141,14 @@ function saveConfig(config) {
149
141
  const yaml = stringifyYaml(config, { lineWidth: 0 });
150
142
  writeFileSync(CONFIG_PATH, yaml, "utf-8");
151
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
+ }
152
152
  function resolvePath(p) {
153
153
  if (p.startsWith("~")) {
154
154
  return resolve(homedir2(), p.slice(2));
@@ -218,15 +218,12 @@ function generateEnv(config) {
218
218
  `ANVIL_PORT=${config.ports.anvil}`,
219
219
  `VAULT_PORT=${config.ports.vault_rest}`,
220
220
  `VAULT_MCP_PORT=${config.ports.vault_mcp}`,
221
+ `VAULT_ROUTER_PORT=${config.ports.vault_router}`,
221
222
  `FORGE_PORT=${config.ports.forge}`,
222
223
  "",
223
224
  "# Repository URLs (must be HTTPS \u2014 container services do not have SSH keys)",
224
225
  `ANVIL_REPO_URL=${config.repos.anvil_notes}`,
225
- `VAULT_KNOWLEDGE_REPO_URL=${config.repos.vault_knowledge}`,
226
226
  `FORGE_REGISTRY_REPO_URL=${config.repos.forge_registry}`,
227
- "",
228
- "# Authentication",
229
- `GITHUB_TOKEN=${config.github_token}`,
230
227
  ""
231
228
  ];
232
229
  return lines.join("\n");
@@ -244,11 +241,9 @@ var CONFIG_KEYS = [
244
241
  "port.anvil",
245
242
  "port.vault-rest",
246
243
  "port.vault-mcp",
244
+ "port.vault-router",
247
245
  "port.forge",
248
- "github-token",
249
- "git-host",
250
246
  "repo.anvil-notes",
251
- "repo.vault-knowledge",
252
247
  "repo.forge-registry"
253
248
  ];
254
249
  function getConfigValue(config, key) {
@@ -267,16 +262,12 @@ function getConfigValue(config, key) {
267
262
  return String(config.ports.vault_rest);
268
263
  case "port.vault-mcp":
269
264
  return String(config.ports.vault_mcp);
265
+ case "port.vault-router":
266
+ return String(config.ports.vault_router);
270
267
  case "port.forge":
271
268
  return String(config.ports.forge);
272
- case "github-token":
273
- return config.github_token;
274
- case "git-host":
275
- return config.git_host;
276
269
  case "repo.anvil-notes":
277
270
  return config.repos.anvil_notes;
278
- case "repo.vault-knowledge":
279
- return config.repos.vault_knowledge;
280
271
  case "repo.forge-registry":
281
272
  return config.repos.forge_registry;
282
273
  }
@@ -308,21 +299,15 @@ function setConfigValue(config, key, value) {
308
299
  case "port.vault-mcp":
309
300
  updated.ports = { ...updated.ports, vault_mcp: parseInt(value, 10) };
310
301
  break;
302
+ case "port.vault-router":
303
+ updated.ports = { ...updated.ports, vault_router: parseInt(value, 10) };
304
+ break;
311
305
  case "port.forge":
312
306
  updated.ports = { ...updated.ports, forge: parseInt(value, 10) };
313
307
  break;
314
- case "github-token":
315
- updated.github_token = value;
316
- break;
317
- case "git-host":
318
- updated.git_host = value;
319
- break;
320
308
  case "repo.anvil-notes":
321
309
  updated.repos = { ...updated.repos, anvil_notes: value };
322
310
  break;
323
- case "repo.vault-knowledge":
324
- updated.repos = { ...updated.repos, vault_knowledge: value };
325
- break;
326
311
  case "repo.forge-registry":
327
312
  updated.repos = { ...updated.repos, forge_registry: value };
328
313
  break;
@@ -552,41 +537,280 @@ Run '${runtime.name} compose logs' from ~/Horus/ to investigate.`
552
537
 
553
538
  // src/lib/compose.ts
554
539
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
555
- import { join as join2, dirname as dirname2 } from "path";
556
- import { fileURLToPath as fileURLToPath2 } from "url";
557
- var __filename = fileURLToPath2(import.meta.url);
558
- var __dirname = dirname2(__filename);
559
- function getBundledComposePath() {
560
- const candidates = [
561
- join2(__dirname, "..", "..", "compose", "docker-compose.yml"),
562
- join2(__dirname, "..", "compose", "docker-compose.yml")
563
- ];
564
- for (const candidate of candidates) {
565
- if (existsSync3(candidate)) {
566
- return candidate;
567
- }
568
- }
569
- throw new Error(
570
- `Bundled docker-compose.yml not found. The CLI package may be corrupted.
571
- Searched: ${candidates.join(", ")}`
572
- );
573
- }
574
540
  function applyPodmanUserOverride(compose) {
575
541
  return compose.replace(
576
542
  /^( image: .+)$/gm,
577
543
  '$1\n user: "0:0"'
578
544
  );
579
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
+ }
580
808
  function composeFileExists() {
581
809
  return existsSync3(COMPOSE_PATH);
582
810
  }
583
- function installComposeFile(runtime) {
811
+ function installComposeFile(config, runtime) {
584
812
  ensureHorusDir();
585
- const bundledPath = getBundledComposePath();
586
- let content = readFileSync3(bundledPath, "utf-8");
587
- if (runtime === "podman") {
588
- content = applyPodmanUserOverride(content);
589
- }
813
+ const content = generateComposeFile(config, runtime);
590
814
  writeFileSync2(COMPOSE_PATH, content, "utf-8");
591
815
  }
592
816
 
@@ -596,22 +820,22 @@ import chalk from "chalk";
596
820
  import ora from "ora";
597
821
  import { checkbox } from "@inquirer/prompts";
598
822
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
599
- import { join as join3 } from "path";
823
+ import { join as join2 } from "path";
600
824
  import { homedir as homedir3 } from "os";
601
825
  import { execa as execa2 } from "execa";
602
826
  function detectInstalledClients() {
603
827
  const detected = [];
604
828
  const home = homedir3();
605
- const claudeDesktopDir = join3(home, "Library", "Application Support", "Claude");
829
+ const claudeDesktopDir = join2(home, "Library", "Application Support", "Claude");
606
830
  if (existsSync4(claudeDesktopDir)) {
607
831
  detected.push("claude-desktop");
608
832
  }
609
- const claudeCodeDir = join3(home, ".claude");
833
+ const claudeCodeDir = join2(home, ".claude");
610
834
  if (existsSync4(claudeCodeDir)) {
611
835
  detected.push("claude-code");
612
836
  }
613
- const cursorDir = join3(home, ".cursor");
614
- const cursorAppDir = join3(home, "Library", "Application Support", "Cursor");
837
+ const cursorDir = join2(home, ".cursor");
838
+ const cursorAppDir = join2(home, "Library", "Application Support", "Cursor");
615
839
  if (existsSync4(cursorDir) || existsSync4(cursorAppDir)) {
616
840
  detected.push("cursor");
617
841
  }
@@ -621,11 +845,11 @@ function getConfigPath(target) {
621
845
  const home = homedir3();
622
846
  switch (target) {
623
847
  case "claude-desktop":
624
- return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
848
+ return join2(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
625
849
  case "claude-code":
626
- return join3(home, ".claude", "settings.json");
850
+ return join2(home, ".claude", "settings.json");
627
851
  case "cursor":
628
- return join3(home, ".cursor", "mcp.json");
852
+ return join2(home, ".cursor", "mcp.json");
629
853
  }
630
854
  }
631
855
  function mergeAndWriteConfig(configPath, mcpServers) {
@@ -645,7 +869,7 @@ function mergeAndWriteConfig(configPath, mcpServers) {
645
869
  writeFileSync3(configPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
646
870
  }
647
871
  function getMcpRemoteWrapperPath() {
648
- return join3(homedir3(), ".forge", "bin", "mcp-remote-wrapper");
872
+ return join2(homedir3(), ".forge", "bin", "mcp-remote-wrapper");
649
873
  }
650
874
  function buildStdioServers(config, wrapperPath, host) {
651
875
  return {
@@ -683,14 +907,14 @@ async function registerWithClaudeCode(mcpServers) {
683
907
  }
684
908
  async function syncSkills(runtime) {
685
909
  const home = homedir3();
686
- const skillsBase = join3(home, ".claude", "skills");
910
+ const skillsBase = join2(home, ".claude", "skills");
687
911
  const skills = ["horus-anvil", "horus-vault", "horus-forge"];
688
912
  const forgeContainer = "horus-forge-1";
689
913
  for (const skill of skills) {
690
- const destDir = join3(skillsBase, skill);
914
+ const destDir = join2(skillsBase, skill);
691
915
  mkdirSync2(destDir, { recursive: true });
692
916
  const src = `/home/forge/.claude/skills/${skill}/SKILL.md`;
693
- const dest = join3(destDir, "SKILL.md");
917
+ const dest = join2(destDir, "SKILL.md");
694
918
  const result = await runtime.exec(forgeContainer, "cat", src);
695
919
  if (result.exitCode === 0 && result.stdout.trim()) {
696
920
  writeFileSync3(dest, result.stdout, "utf-8");
@@ -699,8 +923,8 @@ async function syncSkills(runtime) {
699
923
  }
700
924
  async function syncSkillsForCursor(runtime) {
701
925
  const home = homedir3();
702
- const rulesDir = join3(home, ".cursor", "rules");
703
- const skillsBase = join3(home, ".cursor", "skills-cursor");
926
+ const rulesDir = join2(home, ".cursor", "rules");
927
+ const skillsBase = join2(home, ".cursor", "skills-cursor");
704
928
  const skills = ["horus-anvil", "horus-vault", "horus-forge"];
705
929
  const forgeContainer = "horus-forge-1";
706
930
  mkdirSync2(rulesDir, { recursive: true });
@@ -708,7 +932,7 @@ async function syncSkillsForCursor(runtime) {
708
932
  const src = `/home/forge/.claude/skills/${skill}/SKILL.md`;
709
933
  const result = await runtime.exec(forgeContainer, "cat", src);
710
934
  if (result.exitCode === 0 && result.stdout.trim()) {
711
- const ruleDest = join3(rulesDir, `${skill}.mdc`);
935
+ const ruleDest = join2(rulesDir, `${skill}.mdc`);
712
936
  const frontmatter = `---
713
937
  description: Horus ${skill} reference
714
938
  alwaysApply: true
@@ -716,9 +940,9 @@ alwaysApply: true
716
940
 
717
941
  `;
718
942
  writeFileSync3(ruleDest, frontmatter + result.stdout, "utf-8");
719
- const skillDir = join3(skillsBase, skill);
943
+ const skillDir = join2(skillsBase, skill);
720
944
  mkdirSync2(skillDir, { recursive: true });
721
- writeFileSync3(join3(skillDir, "SKILL.md"), result.stdout, "utf-8");
945
+ writeFileSync3(join2(skillDir, "SKILL.md"), result.stdout, "utf-8");
722
946
  }
723
947
  }
724
948
  }
@@ -906,7 +1130,14 @@ function injectToken(url, token) {
906
1130
  return url;
907
1131
  }
908
1132
  }
909
- 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) => {
910
1141
  console.log("");
911
1142
  console.log(chalk2.bold("Horus Setup"));
912
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"));
@@ -968,18 +1199,47 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
968
1199
  let config;
969
1200
  if (opts.yes) {
970
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
+ }
971
1232
  config = {
972
1233
  ...defaults,
973
1234
  runtime: runtime.name,
974
1235
  data_dir: opts.dataDir || DEFAULT_DATA_DIR,
975
1236
  host_repos_path: opts.reposPath || "",
976
- git_host: opts.gitHost || defaults.git_host,
977
1237
  repos: {
978
- anvil_notes: opts.anvilRepo || process.env.ANVIL_REPO_URL || defaults.repos.anvil_notes,
979
- vault_knowledge: opts.vaultRepo || process.env.VAULT_KNOWLEDGE_REPO_URL || defaults.repos.vault_knowledge,
1238
+ anvil_notes: anvilRepo,
980
1239
  forge_registry: opts.forgeRepo || process.env.FORGE_REGISTRY_REPO_URL || defaults.repos.forge_registry
981
1240
  },
982
- github_token: opts.githubToken || process.env.GITHUB_TOKEN || ""
1241
+ vaults,
1242
+ github_hosts
983
1243
  };
984
1244
  } else {
985
1245
  const data_dir = await input({
@@ -1002,13 +1262,17 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
1002
1262
  default: DEFAULT_PORTS.anvil
1003
1263
  });
1004
1264
  const vault_rest = await number({
1005
- message: "Vault REST port:",
1265
+ message: "Vault REST port (per-vault instances):",
1006
1266
  default: DEFAULT_PORTS.vault_rest
1007
1267
  });
1008
1268
  const vault_mcp = await number({
1009
1269
  message: "Vault MCP port:",
1010
1270
  default: DEFAULT_PORTS.vault_mcp
1011
1271
  });
1272
+ const vault_router = await number({
1273
+ message: "Vault Router port:",
1274
+ default: DEFAULT_PORTS.vault_router
1275
+ });
1012
1276
  const forge = await number({
1013
1277
  message: "Forge port:",
1014
1278
  default: DEFAULT_PORTS.forge
@@ -1017,6 +1281,7 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
1017
1281
  anvil: anvil ?? DEFAULT_PORTS.anvil,
1018
1282
  vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
1019
1283
  vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
1284
+ vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
1020
1285
  forge: forge ?? DEFAULT_PORTS.forge
1021
1286
  };
1022
1287
  }
@@ -1028,12 +1293,11 @@ var setupCommand = new Command2("setup").description("Interactive first-run setu
1028
1293
  console.log(chalk2.yellow(" Use HTTPS URLs \u2014 container services do not have SSH keys."));
1029
1294
  console.log(chalk2.dim(" SSH URLs (git@github.com:...) will fail at runtime inside Docker/Podman."));
1030
1295
  console.log("");
1031
- const git_host = await input({
1032
- message: "Git server hostname:",
1296
+ const primaryHost = await input({
1297
+ message: "Primary Git server hostname:",
1033
1298
  default: "github.com"
1034
1299
  });
1035
- const host = git_host.trim();
1036
- const example = (repo) => chalk2.dim(` e.g., https://${host}/<owner>/${repo}`);
1300
+ const example = (repo) => chalk2.dim(` e.g., https://${primaryHost}/<owner>/${repo}`);
1037
1301
  console.log("");
1038
1302
  const anvil_notes = await input({
1039
1303
  message: `Anvil notes repo URL:
@@ -1041,12 +1305,6 @@ ${example("horus-notes")}
1041
1305
  `,
1042
1306
  validate: (v) => v.trim().length > 0 || "Anvil needs a notes repo to store your data."
1043
1307
  });
1044
- const vault_knowledge = await input({
1045
- message: `Vault knowledge-base repo URL:
1046
- ${example("knowledge-base")}
1047
- `,
1048
- validate: (v) => v.trim().length > 0 || "Vault needs a knowledge-base repo."
1049
- });
1050
1308
  const forge_registry = await input({
1051
1309
  message: `Forge registry repo URL:
1052
1310
  ${example("forge-registry")}
@@ -1054,13 +1312,75 @@ ${example("forge-registry")}
1054
1312
  validate: (v) => v.trim().length > 0 || "Forge needs a registry repo."
1055
1313
  });
1056
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("");
1057
1366
  console.log(chalk2.bold("Authentication"));
1058
- 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."));
1059
1368
  console.log("");
1060
- const github_token = await password({
1061
- message: "GitHub personal access token (leave empty to skip):",
1062
- mask: "*"
1063
- });
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
+ }
1064
1384
  config = {
1065
1385
  ...defaultConfig(),
1066
1386
  data_dir,
@@ -1068,13 +1388,12 @@ ${example("forge-registry")}
1068
1388
  host_repos_extra_scan_dirs,
1069
1389
  runtime: runtime.name,
1070
1390
  ports,
1071
- git_host: git_host.trim(),
1072
1391
  repos: {
1073
1392
  anvil_notes: anvil_notes.trim(),
1074
- vault_knowledge: vault_knowledge.trim(),
1075
1393
  forge_registry: forge_registry.trim()
1076
1394
  },
1077
- github_token: github_token.trim()
1395
+ vaults,
1396
+ github_hosts
1078
1397
  };
1079
1398
  }
1080
1399
  const configSpinner = ora2("Saving configuration...").start();
@@ -1097,7 +1416,7 @@ ${example("forge-registry")}
1097
1416
  }
1098
1417
  const composeSpinner = ora2("Installing docker-compose.yml...").start();
1099
1418
  try {
1100
- installComposeFile(runtime.name);
1419
+ installComposeFile(config, runtime.name);
1101
1420
  composeSpinner.succeed("Compose file installed to ~/Horus/docker-compose.yml");
1102
1421
  } catch (error) {
1103
1422
  composeSpinner.fail("Failed to install compose file");
@@ -1105,24 +1424,46 @@ ${example("forge-registry")}
1105
1424
  process.exit(1);
1106
1425
  }
1107
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 ?? "";
1108
1429
  const reposToClone = [
1109
- { url: config.repos.anvil_notes, dest: join4(dataDir, "notes"), label: "Anvil notes" },
1110
- { url: config.repos.vault_knowledge, dest: join4(dataDir, "knowledge-base"), label: "Vault knowledge-base" },
1111
- { 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
+ }
1112
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
+ }
1113
1454
  if (reposToClone.length > 0) {
1114
1455
  console.log("");
1115
1456
  console.log(chalk2.bold("Cloning repositories..."));
1116
1457
  mkdirSync3(dataDir, { recursive: true });
1117
1458
  for (const repo of reposToClone) {
1118
1459
  const spinner = ora2(`Cloning ${repo.label}...`).start();
1119
- if (existsSync5(join4(repo.dest, ".git"))) {
1460
+ if (existsSync5(join3(repo.dest, ".git"))) {
1120
1461
  spinner.succeed(`${repo.label} already cloned`);
1121
1462
  continue;
1122
1463
  }
1123
1464
  try {
1124
1465
  mkdirSync3(repo.dest, { recursive: true });
1125
- const cloneUrl = injectToken(repo.url, config.github_token);
1466
+ const cloneUrl = injectToken(repo.url, repo.token);
1126
1467
  execSync(`git clone "${cloneUrl}" "${repo.dest}"`, {
1127
1468
  stdio: "pipe",
1128
1469
  timeout: 6e4
@@ -1137,7 +1478,7 @@ ${example("forge-registry")}
1137
1478
  console.log(chalk2.dim(` ${msg.split("\n")[0]}`));
1138
1479
  }
1139
1480
  console.log(chalk2.dim(` URL: ${repo.url}`));
1140
- if (!config.github_token) {
1481
+ if (!repo.token) {
1141
1482
  console.log(chalk2.dim(" Tip: Re-run setup and provide a GitHub token if the repo is private."));
1142
1483
  }
1143
1484
  process.exit(1);
@@ -1147,7 +1488,7 @@ ${example("forge-registry")}
1147
1488
  console.log("");
1148
1489
  console.log(chalk2.bold("Pulling container images..."));
1149
1490
  try {
1150
- await composeStreaming(runtime, ["pull", "--ignore-pull-failures"]);
1491
+ await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
1151
1492
  } catch {
1152
1493
  console.log(chalk2.yellow("Some images could not be pulled."));
1153
1494
  console.log(chalk2.dim("Continuing \u2014 services will be built from source if build contexts are available."));
@@ -1209,11 +1550,19 @@ ${example("forge-registry")}
1209
1550
  console.log(` ${chalk2.bold("Data:")} ${config.data_dir}`);
1210
1551
  console.log("");
1211
1552
  console.log(chalk2.bold(" Service URLs:"));
1212
- console.log(` Anvil: http://localhost:${config.ports.anvil}`);
1213
- console.log(` Vault REST: http://localhost:${config.ports.vault_rest}`);
1214
- console.log(` Vault MCP: http://localhost:${config.ports.vault_mcp}`);
1215
- 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}`);
1216
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;
1217
1566
  });
1218
1567
 
1219
1568
  // src/commands/up.ts
@@ -1240,7 +1589,7 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
1240
1589
  if (opts.pull) {
1241
1590
  const pullSpinner = ora3("Pulling latest images...").start();
1242
1591
  try {
1243
- await composeStreaming(runtime, ["pull", "--ignore-pull-failures"]);
1592
+ await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
1244
1593
  pullSpinner.succeed("Images up to date");
1245
1594
  } catch {
1246
1595
  pullSpinner.warn("Could not pull images, using cached");
@@ -1418,19 +1767,36 @@ var configCommand = new Command6("config").description("View or modify Horus con
1418
1767
  console.log(` ${chalk6.bold("host-repos-path:")} ${config.host_repos_path || chalk6.dim("(not set)")}`);
1419
1768
  const extraDirs = (config.host_repos_extra_scan_dirs ?? []).join(", ");
1420
1769
  console.log(` ${chalk6.bold("host-repos-extra-scan-dirs:")} ${extraDirs || chalk6.dim("(not set)")}`);
1421
- console.log(` ${chalk6.bold("git-host:")} ${config.git_host || chalk6.dim("(not set)")}`);
1422
- console.log(` ${chalk6.bold("github-token:")} ${config.github_token ? maskApiKey(config.github_token) : chalk6.dim("(not set)")}`);
1423
1770
  console.log("");
1424
1771
  console.log(chalk6.bold(" Ports:"));
1425
- console.log(` ${chalk6.bold("anvil:")} ${config.ports.anvil}`);
1426
- console.log(` ${chalk6.bold("vault-rest:")} ${config.ports.vault_rest}`);
1427
- console.log(` ${chalk6.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
1428
- 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}`);
1429
1777
  console.log("");
1430
1778
  console.log(chalk6.bold(" Repos:"));
1431
- console.log(` ${chalk6.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk6.dim("(not set)")}`);
1432
- console.log(` ${chalk6.bold("vault-knowledge:")} ${config.repos.vault_knowledge || chalk6.dim("(not set)")}`);
1433
- 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
+ }
1434
1800
  console.log("");
1435
1801
  console.log(chalk6.dim(` Config file: ~/Horus/config.yaml`));
1436
1802
  console.log(chalk6.dim(` Use 'horus config get <key>' or 'horus config set <key> <value>'`));
@@ -1449,11 +1815,7 @@ configCommand.command("get <key>").description("Get a configuration value").acti
1449
1815
  }
1450
1816
  const config = loadConfig();
1451
1817
  const value = getConfigValue(config, key);
1452
- if (key === "github-token") {
1453
- console.log(maskApiKey(value));
1454
- } else {
1455
- console.log(value || "");
1456
- }
1818
+ console.log(value || "");
1457
1819
  });
1458
1820
  configCommand.command("set <key> <value>").description("Set a configuration value").action(async (key, value) => {
1459
1821
  if (!configExists()) {
@@ -1484,6 +1846,7 @@ configCommand.command("set <key> <value>").description("Set a configuration valu
1484
1846
  "port.anvil",
1485
1847
  "port.vault-rest",
1486
1848
  "port.vault-mcp",
1849
+ "port.vault-router",
1487
1850
  "port.forge"
1488
1851
  ];
1489
1852
  if (needsRestart.includes(key)) {
@@ -1511,10 +1874,10 @@ import chalk7 from "chalk";
1511
1874
  import ora6 from "ora";
1512
1875
  import { select as select2, confirm as confirm3 } from "@inquirer/prompts";
1513
1876
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync2, existsSync as existsSync6 } from "fs";
1514
- import { join as join5 } from "path";
1877
+ import { join as join4 } from "path";
1515
1878
  import { createHash } from "crypto";
1516
1879
  import { stringify as stringifyYaml2, parse as parseYaml2 } from "yaml";
1517
- var SNAPSHOTS_DIR = join5(HORUS_DIR, "snapshots");
1880
+ var SNAPSHOTS_DIR = join4(HORUS_DIR, "snapshots");
1518
1881
  function ensureSnapshotsDir() {
1519
1882
  mkdirSync4(SNAPSHOTS_DIR, { recursive: true });
1520
1883
  }
@@ -1549,14 +1912,14 @@ function saveSnapshot(images) {
1549
1912
  images,
1550
1913
  compose_hash: composeFileHash()
1551
1914
  };
1552
- const filePath = join5(SNAPSHOTS_DIR, `${timestamp}.yaml`);
1915
+ const filePath = join4(SNAPSHOTS_DIR, `${timestamp}.yaml`);
1553
1916
  writeFileSync4(filePath, stringifyYaml2(snapshot, { lineWidth: 0 }), "utf-8");
1554
1917
  return filePath;
1555
1918
  }
1556
1919
  function listSnapshots() {
1557
1920
  if (!existsSync6(SNAPSHOTS_DIR)) return [];
1558
1921
  return readdirSync2(SNAPSHOTS_DIR).filter((f) => f.endsWith(".yaml")).sort().reverse().map((f) => {
1559
- const file = join5(SNAPSHOTS_DIR, f);
1922
+ const file = join4(SNAPSHOTS_DIR, f);
1560
1923
  const snapshot = parseYaml2(readFileSync5(file, "utf-8"));
1561
1924
  return { file, snapshot };
1562
1925
  });
@@ -1704,7 +2067,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
1704
2067
  console.log("");
1705
2068
  console.log(chalk7.bold("Pulling latest images..."));
1706
2069
  try {
1707
- await composeStreaming(runtime, ["pull", "--ignore-pull-failures"]);
2070
+ await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
1708
2071
  } catch {
1709
2072
  console.log(chalk7.yellow("Some images could not be pulled."));
1710
2073
  console.log(chalk7.dim("Continuing \u2014 services will be built from source if build contexts are available."));
@@ -1767,7 +2130,7 @@ import { Command as Command8 } from "commander";
1767
2130
  import chalk8 from "chalk";
1768
2131
  import { execSync as execSync2 } from "child_process";
1769
2132
  import { existsSync as existsSync7, accessSync, statfsSync, constants } from "fs";
1770
- import { join as join6 } from "path";
2133
+ import { join as join5 } from "path";
1771
2134
  function symbol(status) {
1772
2135
  switch (status) {
1773
2136
  case "pass":
@@ -1895,7 +2258,7 @@ function checkDataDir(dataDir) {
1895
2258
  }
1896
2259
  }
1897
2260
  function checkDiskSpace(dataDir) {
1898
- const checkDir = existsSync7(dataDir) ? dataDir : join6(dataDir, "..");
2261
+ const checkDir = existsSync7(dataDir) ? dataDir : join5(dataDir, "..");
1899
2262
  try {
1900
2263
  const stats = statfsSync(checkDir);
1901
2264
  const freeBytes = stats.bfree * stats.bsize;
@@ -2028,10 +2391,10 @@ import chalk9 from "chalk";
2028
2391
  import ora7 from "ora";
2029
2392
  import { confirm as confirm4 } from "@inquirer/prompts";
2030
2393
  import { mkdirSync as mkdirSync5, statSync as statSync2, existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
2031
- import { join as join7, basename } from "path";
2394
+ import { join as join6, basename } from "path";
2032
2395
  import { execSync as execSync3 } from "child_process";
2033
2396
  import { stringify as stringifyYaml3 } from "yaml";
2034
- var BACKUPS_DIR = join7(HORUS_DIR, "backups");
2397
+ var BACKUPS_DIR = join6(HORUS_DIR, "backups");
2035
2398
  function ensureBackupsDir() {
2036
2399
  mkdirSync5(BACKUPS_DIR, { recursive: true });
2037
2400
  }
@@ -2078,8 +2441,8 @@ async function createBackup(yes) {
2078
2441
  }
2079
2442
  ensureBackupsDir();
2080
2443
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2081
- const tarFile = join7(BACKUPS_DIR, `${timestamp}.tar.gz`);
2082
- 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`);
2083
2446
  const backupSpinner = ora7("Creating backup archive...").start();
2084
2447
  try {
2085
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.17",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for managing the Horus AI development stack",
5
5
  "type": "module",
6
6
  "bin": {