@arkhera30/cli 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command14 } from "commander";
5
- import chalk14 from "chalk";
4
+ import { Command as Command16 } from "commander";
5
+ import chalk15 from "chalk";
6
6
 
7
7
  // src/commands/setup.ts
8
- import { Command as Command2 } from "commander";
9
- import chalk2 from "chalk";
8
+ import { Command as Command3 } from "commander";
9
+ import chalk3 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";
@@ -18,6 +18,7 @@ import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as
18
18
  import { resolve, join as pathJoin, relative } from "path";
19
19
  import { homedir as homedir2 } from "os";
20
20
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
21
+ import { z } from "zod";
21
22
 
22
23
  // src/lib/constants.ts
23
24
  import { homedir } from "os";
@@ -52,7 +53,7 @@ var DEFAULT_PORTS = {
52
53
  vault_router: 8050,
53
54
  // internal routing layer
54
55
  ui: 8400,
55
- // reader — Horus Reader SPA
56
+ // horus-ui — Horus unified UI
56
57
  forge: 8200,
57
58
  typesense: 8108,
58
59
  // Typesense search engine
@@ -64,11 +65,7 @@ var DEFAULT_PORTS = {
64
65
  var DEFAULT_DATA_DIR = join(homedir(), "Horus", "data");
65
66
  var SERVICES = [
66
67
  "anvil",
67
- "vault-router",
68
- // replaces 'vault'
69
- "vault-mcp",
70
- "forge",
71
- "reader",
68
+ "horus-ui",
72
69
  "typesense",
73
70
  "neo4j"
74
71
  ];
@@ -88,8 +85,12 @@ function defaultConfig() {
88
85
  search: {
89
86
  api_key: "horus-local-key"
90
87
  },
88
+ control_plane_url: "",
89
+ token_provider: { kind: "", config: "" },
91
90
  ai: {
92
- key: ""
91
+ key: "",
92
+ anthropic_api_key: "",
93
+ model: "claude-sonnet-4-6"
93
94
  },
94
95
  vaults: {},
95
96
  github_hosts: {},
@@ -101,6 +102,18 @@ function defaultConfig() {
101
102
  function ensureHorusDir() {
102
103
  mkdirSync(HORUS_DIR, { recursive: true });
103
104
  }
105
+ function ensureFsLayout() {
106
+ ensureHorusDir();
107
+ const dirs = {
108
+ providers: pathJoin(HORUS_DIR, "providers"),
109
+ logs: pathJoin(HORUS_DIR, "logs"),
110
+ keys: pathJoin(HORUS_DIR, "keys")
111
+ };
112
+ for (const d of Object.values(dirs)) {
113
+ mkdirSync(d, { recursive: true });
114
+ }
115
+ return dirs;
116
+ }
104
117
  function configExists() {
105
118
  return existsSync2(CONFIG_PATH);
106
119
  }
@@ -155,16 +168,68 @@ function buildConfigFromParsed(parsed) {
155
168
  search: {
156
169
  api_key: parsed.search?.api_key ?? defaults.search.api_key
157
170
  },
171
+ control_plane_url: parsed.control_plane_url ?? defaults.control_plane_url,
172
+ token_provider: {
173
+ kind: parsed.token_provider?.kind ?? defaults.token_provider.kind,
174
+ config: parsed.token_provider?.config ?? defaults.token_provider.config
175
+ },
158
176
  ai: {
159
- key: parsed.ai?.key ?? defaults.ai.key
177
+ key: parsed.ai?.key ?? defaults.ai.key,
178
+ anthropic_api_key: parsed.ai?.anthropic_api_key ?? defaults.ai.anthropic_api_key,
179
+ model: parsed.ai?.model ?? defaults.ai.model
160
180
  },
161
181
  vaults: parsed.vaults ?? defaults.vaults,
162
182
  github_hosts: parsed.github_hosts ?? defaults.github_hosts,
163
183
  host_repos_path: parsed.host_repos_path ?? defaults.host_repos_path,
164
184
  host_repos_extra_scan_dirs: parsed.host_repos_extra_scan_dirs ?? defaults.host_repos_extra_scan_dirs,
165
- enable_ui: parsed.enable_ui ?? defaults.enable_ui
185
+ enable_ui: parsed.enable_ui ?? defaults.enable_ui,
186
+ registry_git_url: parsed.registry_git_url,
187
+ registry_deploy_key: parsed.registry_deploy_key,
188
+ enterprise_registry_url: parsed.enterprise_registry_url
166
189
  };
167
190
  }
191
+ var preprovisionedSchema = z.object({
192
+ version: z.string().optional(),
193
+ data_dir: z.string().optional(),
194
+ runtime: z.enum(["docker", "podman"]).optional(),
195
+ control_plane_url: z.string().optional(),
196
+ token_provider: z.object({ kind: z.string(), config: z.string() }).optional(),
197
+ ports: z.record(z.number()).optional(),
198
+ repos: z.object({ anvil_notes: z.string().optional(), forge_registry: z.string().optional() }).optional(),
199
+ vaults: z.record(z.any()).optional(),
200
+ github_hosts: z.record(z.any()).optional(),
201
+ ai: z.object({
202
+ key: z.string().optional(),
203
+ anthropic_api_key: z.string().optional(),
204
+ model: z.string().optional()
205
+ }).optional()
206
+ }).passthrough();
207
+ function loadPreprovisionedConfig(path2) {
208
+ if (!existsSync2(path2)) {
209
+ throw new Error(`Pre-provisioned config not found at ${path2}`);
210
+ }
211
+ const raw = readFileSync2(path2, "utf-8");
212
+ let parsed;
213
+ try {
214
+ parsed = parseYaml(raw);
215
+ } catch (e) {
216
+ throw new Error(`Pre-provisioned config is not valid YAML: ${e.message}`);
217
+ }
218
+ const result = preprovisionedSchema.safeParse(parsed);
219
+ if (!result.success) {
220
+ const issues = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
221
+ throw new Error(`Pre-provisioned config failed validation:
222
+ ${issues}`);
223
+ }
224
+ return buildConfigFromParsed(result.data);
225
+ }
226
+ function detectPreprovisionedConfig() {
227
+ const candidates = [
228
+ pathJoin(process.cwd(), "config.yaml.preprovisioned"),
229
+ pathJoin(HORUS_DIR, "config.yaml.preprovisioned")
230
+ ];
231
+ return candidates.find((p) => existsSync2(p)) ?? null;
232
+ }
168
233
  function saveConfig(config) {
169
234
  ensureHorusDir();
170
235
  const yaml = stringifyYaml(config, { lineWidth: 0 });
@@ -215,6 +280,7 @@ function discoverRepoDirs(rootDir, maxDepth = 4) {
215
280
  }
216
281
  function generateEnv(config) {
217
282
  const dataDir = resolvePath(config.data_dir);
283
+ const providersPath = pathJoin(HORUS_DIR, "providers");
218
284
  const hostReposPath = config.host_repos_path ? resolvePath(config.host_repos_path) : "";
219
285
  const baseScanPath = "/data/repos";
220
286
  let forgeScanPaths;
@@ -256,12 +322,22 @@ function generateEnv(config) {
256
322
  "# Search",
257
323
  `TYPESENSE_API_KEY=${config.search.api_key}`,
258
324
  "",
259
- "# AI (required for NLP agent search in the Reader)",
325
+ "# Control plane (empty HORUS_CONTROL_PLANE_URL => local-only mode)",
326
+ `HORUS_CONTROL_PLANE_URL=${config.control_plane_url ?? ""}`,
327
+ `TOKEN_PROVIDER_KIND=${config.token_provider?.kind ?? ""}`,
328
+ `TOKEN_PROVIDER_CONFIG=${config.token_provider?.config ?? ""}`,
329
+ "",
330
+ "# Agent chat (Anthropic). Chat surface is disabled when the key is empty.",
331
+ `HORUS_ANTHROPIC_API_KEY=${config.ai.anthropic_api_key}`,
332
+ `HORUS_AGENT_MODEL=${config.ai.model}`,
333
+ "# Legacy Cursor key \u2014 retained for back-compat, unused by the alpha client.",
260
334
  `HORUS_AI_KEY=${config.ai.key}`,
261
335
  "",
262
- "# Repository URLs (must be HTTPS \u2014 container services do not have SSH keys)",
336
+ "# Providers mount (read-only secrets/config dir for horus-ui)",
337
+ `HORUS_PROVIDERS_PATH=${providersPath}`,
338
+ "",
339
+ "# Anvil notes repo (must be HTTPS \u2014 container services do not have SSH keys)",
263
340
  `ANVIL_REPO_URL=${config.repos.anvil_notes}`,
264
- `FORGE_REGISTRY_REPO_URL=${config.repos.forge_registry}`,
265
341
  ""
266
342
  ];
267
343
  return lines.join("\n");
@@ -286,6 +362,11 @@ var CONFIG_KEYS = [
286
362
  "repo.forge-registry",
287
363
  "search.api-key",
288
364
  "ai.key",
365
+ "ai.anthropic-key",
366
+ "ai.model",
367
+ "control-plane-url",
368
+ "token-provider-kind",
369
+ "token-provider-config",
289
370
  "enable-ui"
290
371
  ];
291
372
  function getConfigValue(config, key) {
@@ -318,6 +399,16 @@ function getConfigValue(config, key) {
318
399
  return config.search.api_key;
319
400
  case "ai.key":
320
401
  return config.ai.key;
402
+ case "ai.anthropic-key":
403
+ return config.ai.anthropic_api_key;
404
+ case "ai.model":
405
+ return config.ai.model;
406
+ case "control-plane-url":
407
+ return config.control_plane_url ?? "";
408
+ case "token-provider-kind":
409
+ return config.token_provider?.kind ?? "";
410
+ case "token-provider-config":
411
+ return config.token_provider?.config ?? "";
321
412
  case "enable-ui":
322
413
  return String(config.enable_ui);
323
414
  }
@@ -370,6 +461,21 @@ function setConfigValue(config, key, value) {
370
461
  case "ai.key":
371
462
  updated.ai = { ...updated.ai, key: value };
372
463
  break;
464
+ case "ai.anthropic-key":
465
+ updated.ai = { ...updated.ai, anthropic_api_key: value };
466
+ break;
467
+ case "ai.model":
468
+ updated.ai = { ...updated.ai, model: value };
469
+ break;
470
+ case "control-plane-url":
471
+ updated.control_plane_url = value;
472
+ break;
473
+ case "token-provider-kind":
474
+ updated.token_provider = { kind: value, config: updated.token_provider?.config ?? "" };
475
+ break;
476
+ case "token-provider-config":
477
+ updated.token_provider = { kind: updated.token_provider?.kind ?? "", config: value };
478
+ break;
373
479
  case "enable-ui":
374
480
  if (value !== "true" && value !== "false") {
375
481
  throw new Error(`Invalid value for enable-ui: ${value}. Must be "true" or "false".`);
@@ -596,7 +702,7 @@ Run '${runtime.name} compose logs <service>' from ~/Horus/ to investigate.`
596
702
  Run '${runtime.name} compose logs' from ~/Horus/ to investigate.`
597
703
  );
598
704
  }
599
- await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
705
+ await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
600
706
  }
601
707
  }
602
708
 
@@ -653,67 +759,6 @@ var ANVIL_SERVICE = ` # \u2500\u2500 Anvil \u2500\u2500\u2500\u2500\u2500\u2500
653
759
  timeout: 5s
654
760
  start_period: 60s
655
761
  retries: 3`;
656
- 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
657
- # Workspace manager and package registry MCP server.
658
- forge:
659
- image: ghcr.io/arjunkhera/horus/forge:latest
660
- ports:
661
- - "\${FORGE_PORT:-8200}:8200"
662
- volumes:
663
- - \${HORUS_DATA_PATH}/config:/data/config:rw
664
- - \${HORUS_DATA_PATH}/registry:/data/registry:rw
665
- - \${HORUS_DATA_PATH}/workspaces:/data/workspaces:rw
666
- - \${HORUS_DATA_PATH}/repos:/data/horus-repos:rw
667
- - \${HORUS_DATA_PATH}/sessions:/data/sessions:rw
668
- - \${HOST_REPOS_PATH}:/data/repos:ro
669
- environment:
670
- - HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
671
- - FORGE_PORT=8200
672
- - FORGE_HOST=0.0.0.0
673
- - FORGE_REGISTRY_PATH=/data/registry
674
- - FORGE_WORKSPACES_PATH=/data/workspaces
675
- - FORGE_CONFIG_PATH=/data/config
676
- - FORGE_MANAGED_REPOS_PATH=/data/horus-repos
677
- - FORGE_REGISTRY_REPO_URL=\${FORGE_REGISTRY_REPO_URL:-}
678
- - FORGE_SYNC_INTERVAL=\${FORGE_SYNC_INTERVAL:-300}
679
- - FORGE_ANVIL_URL=http://anvil:8100
680
- - FORGE_VAULT_URL=http://vault-mcp:8300
681
- - FORGE_HOST_WORKSPACES_PATH=\${HORUS_DATA_PATH}/workspaces
682
- - FORGE_HOST_MANAGED_REPOS_PATH=\${HORUS_DATA_PATH}/repos
683
- - FORGE_HOST_REPOS_PATH=\${HOST_REPOS_PATH}
684
- - FORGE_HOST_MANAGED_REPOS_PATH=\${HORUS_DATA_PATH}/repos
685
- - FORGE_HOST_ANVIL_URL=http://localhost:\${ANVIL_PORT:-8100}
686
- - FORGE_HOST_VAULT_URL=http://localhost:\${VAULT_MCP_PORT:-8300}
687
- - FORGE_HOST_FORGE_URL=http://localhost:\${FORGE_PORT:-8200}
688
- - FORGE_SCAN_PATHS=\${FORGE_SCAN_PATHS:-/data/repos}
689
- - FORGE_SESSION_TTL_MS=\${FORGE_SESSION_TTL_MS:-1800000}
690
- - GITHUB_TOKEN=\${GITHUB_TOKEN:-}
691
- - TYPESENSE_HOST=typesense
692
- - TYPESENSE_PORT=8108
693
- - TYPESENSE_API_KEY=\${TYPESENSE_API_KEY:-horus-local-key}
694
- depends_on:
695
- anvil:
696
- condition: service_healthy
697
- typesense:
698
- condition: service_healthy
699
- vault-router:
700
- condition: service_healthy
701
- networks:
702
- - horus-net
703
- restart: unless-stopped
704
- stop_grace_period: 15s
705
- deploy:
706
- resources:
707
- limits:
708
- memory: 512m
709
- reservations:
710
- memory: 128m
711
- healthcheck:
712
- test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
713
- interval: 30s
714
- timeout: 5s
715
- start_period: 60s
716
- retries: 3`;
717
762
  var NEO4J_SERVICE = ` # \u2500\u2500 Neo4j \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
718
763
  # Graph database for relationship-aware knowledge queries.
719
764
  neo4j:
@@ -767,21 +812,33 @@ var TYPESENSE_SERVICE = ` # \u2500\u2500 Typesense \u2500\u2500\u2500\u2500\u25
767
812
  retries: 3
768
813
  start_period: 5s
769
814
  restart: unless-stopped`;
770
- var READER_SERVICE = ` # \u2500\u2500 Reader \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
771
- # Horus Reader \u2014 Express server + NLP agent search at port 8400.
772
- # Serves Reader SPA, proxies /api/* to Anvil, hosts POST /api/ai/ask.
773
- reader:
774
- image: ghcr.io/arjunkhera/horus/reader:latest
815
+ var HORUS_UI_SERVICE = ` # \u2500\u2500 Horus UI \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
816
+ # Unified Horus client. Serves the SPA, proxies /api/anvil/* to the local
817
+ # Anvil and (when connected) vault/forge/admin to the control plane, and hosts
818
+ # the agent chat at POST /api/ai/ask. Boots first \u2014 no depends_on. Connected
819
+ # vs local-only mode is config-driven: an empty HORUS_CONTROL_PLANE_URL is
820
+ # local-only.
821
+ horus-ui:
822
+ image: ghcr.io/arjunkhera/horus/ui:latest
775
823
  ports:
776
824
  - "\${UI_PORT:-8400}:8400"
777
825
  environment:
778
- - CURSOR_API_KEY=\${HORUS_AI_KEY}
779
- - HORUS_AGENT_MODEL=\${HORUS_AGENT_MODEL:-composer-2}
826
+ - HORUS_CONTROL_PLANE_URL=\${HORUS_CONTROL_PLANE_URL:-}
827
+ - TOKEN_PROVIDER_KIND=\${TOKEN_PROVIDER_KIND:-}
828
+ - TOKEN_PROVIDER_CONFIG=\${TOKEN_PROVIDER_CONFIG:-}
829
+ - HORUS_ANTHROPIC_API_KEY=\${HORUS_ANTHROPIC_API_KEY:-}
830
+ - HORUS_AGENT_MODEL=\${HORUS_AGENT_MODEL:-claude-sonnet-4-6}
780
831
  - ANVIL_HOST=anvil
781
832
  - ANVIL_PORT=8100
782
- depends_on:
783
- anvil:
784
- condition: service_healthy
833
+ - FORGE_CONFIG_PATH=/horus/config
834
+ - FORGE_SESSIONS_ROOT=/horus/sessions
835
+ - FORGE_MANAGED_REPOS_PATH=/horus/repos
836
+ - FORGE_WORKSPACES_PATH=/horus/workspaces
837
+ volumes:
838
+ - \${HORUS_PROVIDERS_PATH}:/horus-providers:ro
839
+ - \${HORUS_DATA_PATH}/sessions:/horus/sessions:rw
840
+ - \${HORUS_DATA_PATH}/repos:/horus/repos:rw
841
+ - \${HORUS_DATA_PATH}/config:/horus/config:rw
785
842
  networks:
786
843
  - horus-net
787
844
  restart: unless-stopped
@@ -789,9 +846,9 @@ var READER_SERVICE = ` # \u2500\u2500 Reader \u2500\u2500\u2500\u2500\u2500\u25
789
846
  deploy:
790
847
  resources:
791
848
  limits:
792
- memory: 256m
849
+ memory: 512m
793
850
  reservations:
794
- memory: 128m
851
+ memory: 256m
795
852
  healthcheck:
796
853
  test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:8400/health"]
797
854
  interval: 30s
@@ -854,7 +911,7 @@ services:
854
911
  volumes:
855
912
  - "\${TEST_DATA_PATH:-/tmp/horus-test}/typesense-data:/data"
856
913
 
857
- reader:
914
+ horus-ui:
858
915
  ports:
859
916
  - "\${TEST_PORT_UI:-9260}:8400"
860
917
 
@@ -865,152 +922,37 @@ services:
865
922
  volumes:
866
923
  - "\${TEST_DATA_PATH:-/tmp/horus-test}/neo4j-data:/data"
867
924
  - "\${TEST_DATA_PATH:-/tmp/horus-test}/neo4j-logs:/logs"
868
- `;
869
- }
870
- function generateComposeFile(config, runtime) {
871
- const vaultEntries = Object.entries(config.vaults).sort(([a], [b]) => a.localeCompare(b));
872
- const vaultServices = vaultEntries.map(([name, vault], index) => {
873
- const hostPort = `800${index + 1}`;
874
- const envVarName = `VAULT_REST_PORT_${name.toUpperCase().replace(/-/g, "_")}`;
875
- const githubHost = resolveGitHubHost(vault.repo, config.github_hosts);
876
- const token = githubHost?.token ?? "";
877
- const apiHost = githubHost?.host ?? "github.com";
878
- 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
879
- vault-${name}:
880
- image: ghcr.io/arjunkhera/horus/vault:latest
925
+ ${config.registry_git_url ? `
926
+ forge-registry:
881
927
  ports:
882
- - "\${${envVarName}:-${hostPort}}:8000"
928
+ - "\${TEST_PORT_FORGE_REGISTRY:-9270}:8744"
883
929
  volumes:
884
- - \${HORUS_DATA_PATH}/vaults/${name}:/data/knowledge-repo:rw
885
- - vault-${name}-workspace:/data/workspace
886
- environment:
887
- - HORUS_RUNTIME=\${HORUS_RUNTIME:-docker}
888
- - KNOWLEDGE_REPO_PATH=/data/knowledge-repo
889
- - WORKSPACE_PATH=/data/workspace
890
- - VAULT_KNOWLEDGE_REPO_URL=${vault.repo}
891
- - SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
892
- - VAULT_SYNC_INTERVAL=\${VAULT_SYNC_INTERVAL:-300}
893
- - LOG_LEVEL=\${LOG_LEVEL:-info}
894
- - HOST=0.0.0.0
895
- - PORT=8000
896
- - GITHUB_TOKEN=${token}
897
- - GITHUB_API_HOST=${apiHost}
898
- - TYPESENSE_HOST=typesense
899
- - TYPESENSE_PORT=8108
900
- - TYPESENSE_API_KEY=\${TYPESENSE_API_KEY:-horus-local-key}
901
- - NEO4J_URI=bolt://neo4j:7687
902
- - NEO4J_USER=neo4j
903
- - NEO4J_PASSWORD=horus-neo4j
904
- depends_on:
905
- typesense:
906
- condition: service_healthy
907
- neo4j:
908
- condition: service_healthy
909
- networks:
910
- - horus-net
911
- restart: unless-stopped
912
- deploy:
913
- resources:
914
- limits:
915
- memory: 512m
916
- reservations:
917
- memory: 256m
918
- healthcheck:
919
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
920
- interval: 30s
921
- timeout: 10s
922
- start_period: 60s
923
- retries: 3`;
924
- });
925
- const defaultVaultEntry = vaultEntries.find(([, v]) => v.default);
926
- const defaultVaultName = defaultVaultEntry ? defaultVaultEntry[0] : vaultEntries[0]?.[0] ?? "";
927
- const vaultEndpoints = vaultEntries.map(([name]) => `${name}=http://vault-${name}:8000`).join(",");
928
- const vaultRouterDependsOn = vaultEntries.map(([name]) => ` vault-${name}:
929
- condition: service_healthy`).join("\n");
930
- 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
931
- # Routes requests to the appropriate vault instance by name.
932
- vault-router:
933
- image: ghcr.io/arjunkhera/horus/vault-router:latest
934
- ports:
935
- - "\${VAULT_ROUTER_PORT:-8050}:8400"
936
- environment:
937
- ${vaultEndpoints ? ` - VAULT_ENDPOINTS=${vaultEndpoints}
938
- ` : ""} - VAULT_DEFAULT=${defaultVaultName}
939
- ${vaultRouterDependsOn ? ` depends_on:
940
- ${vaultRouterDependsOn}
941
- ` : ""} networks:
942
- - horus-net
943
- restart: unless-stopped
944
- deploy:
945
- resources:
946
- limits:
947
- memory: 256m
948
- reservations:
949
- memory: 64m
950
- healthcheck:
951
- test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8400/health')"]
952
- interval: 30s
953
- timeout: 10s
954
- start_period: 30s
955
- retries: 3`;
956
- 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
957
- # Thin MCP adapter that translates MCP tool calls to Vault REST API calls.
958
- vault-mcp:
959
- image: ghcr.io/arjunkhera/horus/vault-mcp:latest
960
- ports:
961
- - "\${VAULT_MCP_PORT:-8300}:8300"
962
- environment:
963
- - VAULT_MCP_HTTP=true
964
- - VAULT_MCP_PORT=8300
965
- - VAULT_MCP_HOST=0.0.0.0
966
- - KNOWLEDGE_SERVICE_URL=http://vault-router:8400
967
- depends_on:
968
- vault-router:
969
- condition: service_healthy
970
- networks:
971
- - horus-net
972
- restart: unless-stopped
973
- stop_grace_period: 15s
974
- deploy:
975
- resources:
976
- limits:
977
- memory: 256m
978
- reservations:
979
- memory: 64m
980
- healthcheck:
981
- test: ["CMD", "curl", "-f", "http://localhost:8300/health"]
982
- interval: 30s
983
- timeout: 5s
984
- start_period: 30s
985
- retries: 3`;
986
- const vaultVolumeEntries = [
987
- ...vaultEntries.map(([name]) => ` vault-${name}-workspace:`),
988
- " neo4j-data:",
989
- " neo4j-logs:"
990
- ].join("\n");
930
+ - "\${TEST_DATA_PATH:-/tmp/horus-test}/registry:/data/registry:rw"
931
+ ` : ""}`;
932
+ }
933
+ function generateComposeFile(_config, runtime) {
991
934
  const sections = [
992
935
  "# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
993
936
  "# Horus \u2014 Generated Docker Compose",
994
937
  "# Managed by @arkhera30/cli. Do not edit manually.",
995
938
  "# Generated dynamically from ~/Horus/config.yaml by `horus setup`.",
939
+ "#",
940
+ "# Alpha client topology (\xA7C): horus-ui, anvil, typesense, neo4j only.",
941
+ "# Vault and Forge are remote behind the control plane. Connected vs",
942
+ "# local-only mode is config-driven (empty HORUS_CONTROL_PLANE_URL =",
943
+ "# local-only), NOT compose-driven \u2014 the service set is identical in both.",
996
944
  "# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
997
945
  "",
998
946
  "services:",
999
947
  "",
1000
- ANVIL_SERVICE,
948
+ HORUS_UI_SERVICE,
1001
949
  "",
1002
- ...vaultServices.map((s) => s + "\n"),
1003
- vaultRouterService,
1004
- "",
1005
- vaultMcpService,
950
+ ANVIL_SERVICE,
1006
951
  "",
1007
- FORGE_SERVICE,
952
+ TYPESENSE_SERVICE,
1008
953
  "",
1009
954
  NEO4J_SERVICE,
1010
955
  "",
1011
- TYPESENSE_SERVICE,
1012
- "",
1013
- ...config.enable_ui !== false ? [READER_SERVICE, ""] : [],
1014
956
  "# \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",
1015
957
  "networks:",
1016
958
  " horus-net:",
@@ -1018,7 +960,8 @@ ${vaultRouterDependsOn}
1018
960
  "",
1019
961
  "# \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",
1020
962
  "volumes:",
1021
- vaultVolumeEntries
963
+ " neo4j-data:",
964
+ " neo4j-logs:"
1022
965
  ];
1023
966
  let content = sections.join("\n");
1024
967
  if (runtime === "podman") {
@@ -1432,10 +1375,13 @@ function buildClaudeDesktopServers(config, host) {
1432
1375
  const npxPath = detectNpxPath();
1433
1376
  const npxDir = npxPath === "npx" ? "/usr/local/bin" : npxPath.substring(0, npxPath.lastIndexOf("/"));
1434
1377
  const envPath = `${npxDir}:/usr/local/bin:/usr/bin:/bin`;
1378
+ const connectedMode = !!(config.control_plane_url && config.control_plane_url.trim());
1379
+ const vaultUrl = connectedMode ? `http://${host}:${config.ports.ui}/vault/mcp` : `http://${host}:${config.ports.vault_mcp}/mcp`;
1380
+ const forgeUrl = connectedMode ? `http://${host}:${config.ports.ui}/forge/mcp` : `http://${host}:${config.ports.forge}/mcp`;
1435
1381
  return {
1436
1382
  anvil: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.anvil}/mcp`], env: { PATH: envPath } },
1437
- vault: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.vault_mcp}/mcp`], env: { PATH: envPath } },
1438
- forge: { command: npxPath, args: ["mcp-remote", `http://${host}:${config.ports.forge}/mcp`], env: { PATH: envPath } }
1383
+ vault: { command: npxPath, args: ["mcp-remote", vaultUrl], env: { PATH: envPath } },
1384
+ forge: { command: npxPath, args: ["mcp-remote", forgeUrl], env: { PATH: envPath } }
1439
1385
  };
1440
1386
  }
1441
1387
  async function isClaudeCliAvailable() {
@@ -1529,7 +1475,12 @@ function printNextSteps(targets) {
1529
1475
  console.log("");
1530
1476
  }
1531
1477
  async function runConnect(config, runtime, targets, host = "localhost") {
1532
- const httpServers = {
1478
+ const connectedMode = !!(config.control_plane_url && config.control_plane_url.trim());
1479
+ const httpServers = connectedMode ? {
1480
+ anvil: { url: `http://${host}:${config.ports.anvil}/mcp` },
1481
+ vault: { url: `http://${host}:${config.ports.ui}/vault/mcp` },
1482
+ forge: { url: `http://${host}:${config.ports.ui}/forge/mcp` }
1483
+ } : {
1533
1484
  anvil: { url: `http://${host}:${config.ports.anvil}/mcp` },
1534
1485
  vault: { url: `http://${host}:${config.ports.vault_mcp}/mcp` },
1535
1486
  forge: { url: `http://${host}:${config.ports.forge}/mcp` }
@@ -1589,23 +1540,32 @@ async function runConnect(config, runtime, targets, host = "localhost") {
1589
1540
  }
1590
1541
  }
1591
1542
  if (targets.includes("claude-code")) {
1592
- const skillsSpinner = ora("Syncing horus-core skills...").start();
1593
- try {
1594
- await syncSkills(runtime);
1595
- skillsSpinner.succeed("horus-core skills synced to ~/.claude/skills/");
1596
- } catch (error) {
1597
- skillsSpinner.warn("Could not sync skills (Forge container may not be running)");
1598
- console.log(chalk.dim(error.message));
1543
+ if (connectedMode) {
1544
+ console.warn(chalk.yellow("[connect] Skipping skills sync \u2014 connected mode has no local forge container."));
1545
+ console.log(chalk.dim(" TODO: fetch skills via the control plane when the skills API is available."));
1546
+ } else {
1547
+ const skillsSpinner = ora("Syncing horus-core skills...").start();
1548
+ try {
1549
+ await syncSkills(runtime);
1550
+ skillsSpinner.succeed("horus-core skills synced to ~/.claude/skills/");
1551
+ } catch (error) {
1552
+ skillsSpinner.warn("Could not sync skills (Forge container may not be running)");
1553
+ console.log(chalk.dim(error.message));
1554
+ }
1599
1555
  }
1600
1556
  }
1601
1557
  if (targets.includes("cursor")) {
1602
- const cursorRulesSpinner = ora("Syncing horus-core rules for Cursor...").start();
1603
- try {
1604
- await syncSkillsForCursor(runtime);
1605
- cursorRulesSpinner.succeed("horus-core rules synced to ~/.cursor/rules/ and skills to ~/.cursor/skills-cursor/");
1606
- } catch (error) {
1607
- cursorRulesSpinner.warn("Could not sync Cursor rules (Forge container may not be running)");
1608
- console.log(chalk.dim(error.message));
1558
+ if (connectedMode) {
1559
+ console.warn(chalk.yellow("[connect] Skipping Cursor rules sync \u2014 connected mode has no local forge container."));
1560
+ } else {
1561
+ const cursorRulesSpinner = ora("Syncing horus-core rules for Cursor...").start();
1562
+ try {
1563
+ await syncSkillsForCursor(runtime);
1564
+ cursorRulesSpinner.succeed("horus-core rules synced to ~/.cursor/rules/ and skills to ~/.cursor/skills-cursor/");
1565
+ } catch (error) {
1566
+ cursorRulesSpinner.warn("Could not sync Cursor rules (Forge container may not be running)");
1567
+ console.log(chalk.dim(error.message));
1568
+ }
1609
1569
  }
1610
1570
  }
1611
1571
  if (configured.length > 0) {
@@ -1674,321 +1634,220 @@ var connectCommand = new Command("connect").description("Configure Claude/Cursor
1674
1634
  await runConnect(config, runtime, targets, opts.host);
1675
1635
  });
1676
1636
 
1677
- // src/commands/setup.ts
1678
- function injectToken(url, token) {
1679
- if (!token) return url;
1680
- try {
1681
- const parsed = new URL(url);
1682
- parsed.username = "oauth2";
1683
- parsed.password = token;
1684
- return parsed.toString();
1685
- } catch {
1686
- return url;
1637
+ // src/commands/login.ts
1638
+ import { Command as Command2 } from "commander";
1639
+ import chalk2 from "chalk";
1640
+ async function runLogin(config) {
1641
+ const cp = (config.control_plane_url ?? "").trim();
1642
+ if (!cp) {
1643
+ return { ok: true, deferred: false, message: "Local-only mode \u2014 no control plane to log into." };
1687
1644
  }
1688
- }
1689
- function extractHostname(url) {
1645
+ let reachable = false;
1690
1646
  try {
1691
- return new URL(url).hostname;
1647
+ const res = await fetch(cp.replace(/\/$/, "") + "/health", { signal: AbortSignal.timeout(8e3) });
1648
+ reachable = res.ok;
1692
1649
  } catch {
1693
- return "github.com";
1650
+ reachable = false;
1651
+ }
1652
+ if (!reachable) {
1653
+ return {
1654
+ ok: false,
1655
+ deferred: true,
1656
+ message: `Control plane at ${cp} is not reachable yet. Run \`horus login\` once it is available.`
1657
+ };
1658
+ }
1659
+ const kind = config.token_provider?.kind ?? "";
1660
+ if (kind === "static") {
1661
+ const hasToken = !!(config.token_provider?.config ?? "").trim();
1662
+ return hasToken ? { ok: true, deferred: false, message: "Static principal token configured \u2014 client is authenticated." } : {
1663
+ ok: false,
1664
+ deferred: true,
1665
+ message: "Static token provider selected but no token is set. Run `horus config set token-provider-config <token>`."
1666
+ };
1694
1667
  }
1668
+ return {
1669
+ ok: false,
1670
+ deferred: true,
1671
+ message: `Interactive login (${kind || "unconfigured"}) completes against the control plane. Run \`horus login\` once it is available.`
1672
+ };
1695
1673
  }
1696
- var setupCommand = new Command2("setup").description("Interactive first-run setup for Horus").option("-y, --yes", "Non-interactive mode (use defaults + env vars)").option("--runtime <runtime>", "Container runtime to use: docker or podman (non-interactive only)").option("--data-dir <path>", "Data directory path").option("--repos-path <path>", "Host repos path for Forge scanning").option("--anvil-repo <url>", "Anvil notes repository URL").option("--vault-name <name>", "Vault name (can be specified multiple times)").option("--vault-repo <url>", "Vault knowledge-base repository URL (matches positionally with --vault-name)").option("--forge-repo <url>", "Forge registry repository URL").option("--github-token <token>", "GitHub personal access token for private repos (primary host)").option("--claude-desktop", "Configure Claude Desktop MCP servers during setup (non-interactive opt-in)").action(async (opts) => {
1674
+ var loginCommand = new Command2("login").description("Authenticate the client against the configured control plane").action(async () => {
1675
+ if (!configExists()) {
1676
+ console.log(chalk2.red("Horus is not configured yet. Run `horus setup` first."));
1677
+ process.exit(1);
1678
+ }
1679
+ const result = await runLogin(loadConfig());
1680
+ if (result.ok) {
1681
+ console.log(chalk2.green(result.message));
1682
+ } else {
1683
+ console.log(chalk2.yellow(result.message));
1684
+ }
1685
+ });
1686
+
1687
+ // src/commands/setup.ts
1688
+ var setupCommand = new Command3("setup").description("First-run setup for the Horus client").option("-y, --yes", "Non-interactive mode (use defaults + env vars)").option("--local-only", "Local-only mode: skip the control-plane / login prompts").option("--config <path>", "Provision from a pre-provisioned, zod-validated config.yaml bundle").option("--no-pull", "Skip pulling container images").option("--runtime <runtime>", "Container runtime to use: docker or podman").option("--data-dir <path>", "Data directory path").option("--anvil-repo <url>", "Anvil notes repository URL (HTTPS)").option("--control-plane <url>", "Control-plane base URL (connected mode)").option("--claude-desktop", "Configure Claude Desktop MCP servers during setup").action(async (opts) => {
1697
1689
  console.log("");
1698
- console.log(chalk2.bold("Horus Setup"));
1699
- 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"));
1690
+ console.log(chalk3.bold("Horus Setup"));
1691
+ console.log(chalk3.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"));
1700
1692
  console.log("");
1701
- if (configExists()) {
1702
- if (opts.yes) {
1703
- console.log(chalk2.yellow("Existing configuration found. Merging with existing values in non-interactive mode."));
1704
- } else {
1705
- const proceed = await confirm({
1706
- message: "Horus is already configured. Reconfigure?",
1707
- default: false
1708
- });
1709
- if (!proceed) {
1710
- console.log(chalk2.dim("Setup cancelled."));
1711
- return;
1712
- }
1693
+ const preprovisionedPath = opts.config ?? detectPreprovisionedConfig();
1694
+ const isPreprovisioned = !!preprovisionedPath;
1695
+ const isYes = !!opts.yes;
1696
+ const isLocalOnly = !!opts.localOnly;
1697
+ const interactive = !isPreprovisioned && !isYes;
1698
+ if (configExists() && interactive) {
1699
+ const proceed = await confirm({
1700
+ message: "Horus is already configured. Reconfigure? (existing data is preserved)",
1701
+ default: false
1702
+ });
1703
+ if (!proceed) {
1704
+ console.log(chalk3.dim("Setup cancelled."));
1705
+ return;
1713
1706
  }
1714
1707
  }
1715
1708
  const checkSpinner = ora2("Checking for container runtimes...").start();
1716
- const [hasDocker, hasPodman] = await Promise.all([
1717
- checkRuntime("docker"),
1718
- checkRuntime("podman")
1719
- ]);
1709
+ const [hasDocker, hasPodman] = await Promise.all([checkRuntime("docker"), checkRuntime("podman")]);
1720
1710
  checkSpinner.stop();
1721
1711
  const available = [
1722
1712
  ...hasDocker ? ["docker"] : [],
1723
1713
  ...hasPodman ? ["podman"] : []
1724
1714
  ];
1725
1715
  if (available.length === 0) {
1726
- console.log(chalk2.red("No container runtime found."));
1716
+ console.log(chalk3.red("No container runtime found."));
1727
1717
  console.log("");
1728
1718
  console.log("Horus requires Docker or Podman with the Compose plugin.");
1729
- console.log("");
1730
- console.log("Install one of:");
1731
1719
  console.log(" Docker Desktop: https://www.docker.com/products/docker-desktop/");
1732
1720
  console.log(" Podman Desktop: https://podman-desktop.io/");
1733
1721
  process.exit(1);
1734
1722
  }
1723
+ let config;
1735
1724
  let selectedRuntime;
1736
- if (opts.yes) {
1737
- const requested = opts.runtime;
1738
- if (requested && !available.includes(requested)) {
1739
- console.log(chalk2.red(`Requested runtime "${requested}" is not installed.`));
1740
- console.log(chalk2.dim(`Available: ${available.join(", ")}`));
1725
+ if (isPreprovisioned) {
1726
+ const loadSpinner = ora2(`Loading pre-provisioned config: ${preprovisionedPath}`).start();
1727
+ try {
1728
+ config = loadPreprovisionedConfig(preprovisionedPath);
1729
+ loadSpinner.succeed("Pre-provisioned config validated");
1730
+ } catch (err) {
1731
+ loadSpinner.fail("Pre-provisioned config is invalid");
1732
+ console.log(chalk3.red(err.message));
1741
1733
  process.exit(1);
1742
1734
  }
1743
- selectedRuntime = requested ?? available[0];
1744
- console.log(`Using ${chalk2.cyan(selectedRuntime)}`);
1745
- } else {
1746
- selectedRuntime = await select({
1747
- message: "Which container runtime would you like to use?",
1748
- choices: available.map((r) => ({
1749
- value: r,
1750
- name: r === "docker" ? "Docker" : "Podman"
1751
- }))
1752
- });
1753
- }
1754
- const runtime = await detectRuntime(selectedRuntime);
1755
- let config;
1756
- if (opts.yes) {
1735
+ const requested = opts.runtime ?? config.runtime;
1736
+ selectedRuntime = available.includes(requested) ? requested : available[0];
1737
+ } else if (isYes) {
1757
1738
  const existing = configExists() ? loadConfig() : null;
1758
1739
  const defaults = defaultConfig();
1759
- let vaults;
1760
- if (opts.vaultName || opts.vaultRepo) {
1761
- const vaultNames = opts.vaultName ? Array.isArray(opts.vaultName) ? opts.vaultName : [opts.vaultName] : ["default"];
1762
- const vaultRepos = opts.vaultRepo ? Array.isArray(opts.vaultRepo) ? opts.vaultRepo : [opts.vaultRepo] : [process.env.VAULT_KNOWLEDGE_REPO_URL ?? ""];
1763
- vaults = {};
1764
- vaultNames.forEach((name, i) => {
1765
- vaults[name] = {
1766
- repo: vaultRepos[i] ?? vaultRepos[0] ?? "",
1767
- default: i === 0
1768
- };
1769
- });
1770
- } else {
1771
- vaults = existing?.vaults ?? (process.env.VAULT_KNOWLEDGE_REPO_URL ? { default: { repo: process.env.VAULT_KNOWLEDGE_REPO_URL, default: true } } : defaults.vaults);
1772
- }
1773
- const primaryToken = opts.githubToken || process.env.GITHUB_TOKEN || "";
1774
- const anvilRepo = opts.anvilRepo || process.env.ANVIL_REPO_URL || existing?.repos.anvil_notes || defaults.repos.anvil_notes;
1775
- let github_hosts;
1776
- if (opts.githubToken || !existing || Object.keys(existing.github_hosts).length === 0) {
1777
- const allRepoUrls = [anvilRepo, ...Object.values(vaults).map((v) => v.repo)].filter(Boolean);
1778
- const seenHosts = /* @__PURE__ */ new Set();
1779
- github_hosts = {};
1780
- let hostIndex = 0;
1781
- for (const url of allRepoUrls) {
1782
- const hostname = extractHostname(url);
1783
- if (!seenHosts.has(hostname)) {
1784
- seenHosts.add(hostname);
1785
- const hostKey = hostIndex === 0 ? "default" : hostname;
1786
- github_hosts[hostKey] = {
1787
- host: hostname,
1788
- token: primaryToken
1789
- };
1790
- hostIndex++;
1791
- }
1792
- }
1793
- if (Object.keys(github_hosts).length === 0) {
1794
- github_hosts["default"] = { host: "github.com", token: primaryToken };
1795
- }
1796
- } else {
1797
- github_hosts = existing.github_hosts;
1740
+ const requested = opts.runtime;
1741
+ if (requested && !available.includes(requested)) {
1742
+ console.log(chalk3.red(`Requested runtime "${requested}" is not installed.`));
1743
+ process.exit(1);
1798
1744
  }
1745
+ selectedRuntime = requested ?? existing?.runtime ?? available[0];
1746
+ const controlPlane = opts.controlPlane || process.env.HORUS_CONTROL_PLANE_URL || existing?.control_plane_url || "";
1799
1747
  config = {
1800
1748
  ...defaults,
1801
- // Preserve all existing top-level fields first
1802
1749
  ...existing ?? {},
1803
- // Then apply runtime (always re-detected)
1804
- runtime: runtime.name,
1805
- // Apply field-level overrides: explicit flag > existing value > default
1750
+ runtime: selectedRuntime,
1806
1751
  data_dir: opts.dataDir || existing?.data_dir || DEFAULT_DATA_DIR,
1807
- host_repos_path: opts.reposPath || existing?.host_repos_path || "",
1752
+ control_plane_url: controlPlane,
1753
+ token_provider: {
1754
+ kind: process.env.TOKEN_PROVIDER_KIND || existing?.token_provider?.kind || (controlPlane ? "static" : ""),
1755
+ config: process.env.TOKEN_PROVIDER_CONFIG || existing?.token_provider?.config || ""
1756
+ },
1808
1757
  repos: {
1809
- anvil_notes: anvilRepo,
1810
- forge_registry: opts.forgeRepo || process.env.FORGE_REGISTRY_REPO_URL || existing?.repos.forge_registry || defaults.repos.forge_registry
1758
+ anvil_notes: opts.anvilRepo || process.env.ANVIL_REPO_URL || existing?.repos.anvil_notes || "",
1759
+ forge_registry: existing?.repos.forge_registry || ""
1811
1760
  },
1812
- vaults,
1813
- github_hosts,
1814
1761
  ai: {
1815
- key: process.env.HORUS_AI_KEY || existing?.ai.key || ""
1762
+ key: process.env.HORUS_AI_KEY || existing?.ai.key || "",
1763
+ anthropic_api_key: process.env.HORUS_ANTHROPIC_API_KEY || existing?.ai.anthropic_api_key || "",
1764
+ model: process.env.HORUS_AGENT_MODEL || existing?.ai.model || defaults.ai.model
1816
1765
  }
1817
1766
  };
1818
1767
  } else {
1819
- const data_dir = await input({
1820
- message: "Data directory:",
1821
- default: DEFAULT_DATA_DIR
1822
- });
1823
- const host_repos_path = await input({
1824
- message: "Host repos path (for Forge repo scanning, leave empty to skip):",
1825
- default: ""
1826
- });
1827
- const host_repos_extra_scan_dirs = [];
1828
- const customize_ports = await confirm({
1829
- message: "Customize port assignments?",
1830
- default: false
1831
- });
1832
- let ports = { ...DEFAULT_PORTS };
1833
- if (customize_ports) {
1834
- const anvil = await number({
1835
- message: "Anvil port:",
1836
- default: DEFAULT_PORTS.anvil
1837
- });
1838
- const vault_rest = await number({
1839
- message: "Vault REST port (per-vault instances):",
1840
- default: DEFAULT_PORTS.vault_rest
1841
- });
1842
- const vault_mcp = await number({
1843
- message: "Vault MCP port:",
1844
- default: DEFAULT_PORTS.vault_mcp
1845
- });
1846
- const vault_router = await number({
1847
- message: "Vault Router port:",
1848
- default: DEFAULT_PORTS.vault_router
1849
- });
1850
- const forge = await number({
1851
- message: "Forge port:",
1852
- default: DEFAULT_PORTS.forge
1853
- });
1854
- ports = {
1855
- anvil: anvil ?? DEFAULT_PORTS.anvil,
1856
- vault_rest: vault_rest ?? DEFAULT_PORTS.vault_rest,
1857
- vault_mcp: vault_mcp ?? DEFAULT_PORTS.vault_mcp,
1858
- vault_router: vault_router ?? DEFAULT_PORTS.vault_router,
1859
- ui: DEFAULT_PORTS.ui,
1860
- forge: forge ?? DEFAULT_PORTS.forge,
1861
- typesense: DEFAULT_PORTS.typesense,
1862
- neo4j_http: DEFAULT_PORTS.neo4j_http,
1863
- neo4j_bolt: DEFAULT_PORTS.neo4j_bolt
1864
- };
1865
- }
1866
- console.log("");
1867
- console.log(chalk2.bold("Repository Configuration"));
1868
- console.log(chalk2.dim("Horus stores notes and knowledge in Git repos you own."));
1869
- console.log(chalk2.dim("Create empty repos on your Git server, then paste the URLs below."));
1870
- console.log("");
1871
- console.log(chalk2.yellow(" Use HTTPS URLs \u2014 container services do not have SSH keys."));
1872
- console.log(chalk2.dim(" SSH URLs (git@github.com:...) will fail at runtime inside Docker/Podman."));
1873
- console.log("");
1874
- const primaryHost = await input({
1875
- message: "Primary Git server hostname:",
1876
- default: "github.com"
1877
- });
1878
- const example = (repo) => chalk2.dim(` e.g., https://${primaryHost}/<owner>/${repo}`);
1879
- console.log("");
1880
- const anvil_notes = await input({
1881
- message: `Anvil notes repo URL:
1882
- ${example("horus-notes")}
1883
- `,
1884
- validate: (v) => v.trim().length > 0 || "Anvil needs a notes repo to store your data."
1885
- });
1886
- const forge_registry = await input({
1887
- message: `Forge registry repo URL:
1888
- ${example("forge-registry")}
1889
- `,
1890
- validate: (v) => v.trim().length > 0 || "Forge needs a registry repo."
1768
+ selectedRuntime = available.length === 1 ? available[0] : await select({
1769
+ message: "Which container runtime would you like to use?",
1770
+ choices: available.map((r) => ({ value: r, name: r === "docker" ? "Docker" : "Podman" }))
1891
1771
  });
1892
- console.log("");
1893
- console.log(chalk2.bold("Vault Configuration"));
1894
- console.log(chalk2.dim("Add one or more knowledge-base vaults. Each vault is a separate Git repo."));
1895
- console.log("");
1896
- const vaults = {};
1897
- let addingVaults = true;
1898
- let isFirstVault = true;
1899
- while (addingVaults) {
1900
- const vaultName = await input({
1901
- message: "Add vault name (e.g., personal):",
1902
- validate: (v) => {
1903
- const trimmed = v.trim();
1904
- if (!trimmed) return "Vault name cannot be empty.";
1905
- if (!/^[a-z0-9-]+$/.test(trimmed)) return "Vault name must be lowercase alphanumeric with hyphens only.";
1906
- if (trimmed in vaults) return `Vault "${trimmed}" already added.`;
1907
- return true;
1772
+ const defaults = defaultConfig();
1773
+ let control_plane_url = "";
1774
+ let token_provider = { kind: "", config: "" };
1775
+ if (!isLocalOnly) {
1776
+ control_plane_url = (await input({
1777
+ message: "Control-plane URL (leave empty for local-only):",
1778
+ default: opts.controlPlane ?? ""
1779
+ })).trim();
1780
+ if (control_plane_url) {
1781
+ const cpSpinner = ora2(`Checking control plane at ${control_plane_url}...`).start();
1782
+ try {
1783
+ const res = await fetch(control_plane_url.replace(/\/$/, "") + "/health", {
1784
+ signal: AbortSignal.timeout(8e3)
1785
+ });
1786
+ if (res.ok) cpSpinner.succeed("Control plane reachable");
1787
+ else cpSpinner.warn(`Control plane responded HTTP ${res.status} \u2014 continuing (login can be deferred)`);
1788
+ } catch {
1789
+ cpSpinner.warn("Control plane unreachable \u2014 continuing; run `horus login` later");
1908
1790
  }
1909
- });
1910
- const vaultRepo = await input({
1911
- message: `Vault repo URL:
1912
- ${example(`${vaultName.trim()}-knowledge`)}
1913
- `,
1914
- validate: (v) => v.trim().length > 0 || "Vault repo URL cannot be empty."
1915
- });
1916
- let isDefault = isFirstVault;
1917
- if (!isFirstVault) {
1918
- isDefault = await confirm({
1919
- message: `Is "${vaultName.trim()}" the default vault?`,
1920
- default: false
1791
+ const kind = await select({
1792
+ message: "Principal token provider:",
1793
+ choices: [
1794
+ { value: "static", name: "Static token (paste a token)" },
1795
+ { value: "oidc", name: "OIDC (issuer URL; login completes against the control plane)" },
1796
+ { value: "none", name: "None (anonymous / configure later)" }
1797
+ ]
1921
1798
  });
1922
- }
1923
- if (isDefault) {
1924
- for (const v of Object.values(vaults)) {
1925
- v.default = false;
1799
+ let providerConfig = "";
1800
+ if (kind === "static") {
1801
+ providerConfig = (await password({ message: "Principal token:", mask: "*" })).trim();
1802
+ } else if (kind === "oidc") {
1803
+ providerConfig = (await input({ message: "OIDC issuer URL:" })).trim();
1926
1804
  }
1805
+ token_provider = { kind, config: providerConfig };
1927
1806
  }
1928
- vaults[vaultName.trim()] = {
1929
- repo: vaultRepo.trim(),
1930
- default: isDefault || isFirstVault
1931
- };
1932
- isFirstVault = false;
1933
- addingVaults = await confirm({
1934
- message: "Add another vault?",
1935
- default: false
1936
- });
1937
- }
1938
- const defaultCount = Object.values(vaults).filter((v) => v.default).length;
1939
- if (defaultCount === 0 && Object.keys(vaults).length > 0) {
1940
- const firstKey = Object.keys(vaults)[0];
1941
- vaults[firstKey].default = true;
1807
+ } else {
1808
+ console.log(chalk3.dim("Local-only mode \u2014 skipping control-plane and login prompts."));
1942
1809
  }
1943
- console.log("");
1944
- console.log(chalk2.bold("Authentication"));
1945
- console.log(chalk2.dim("A personal access token is required per Git server for private repositories."));
1946
- console.log("");
1947
- const allRepoUrls = [anvil_notes.trim(), ...Object.values(vaults).map((v) => v.repo)].filter(Boolean);
1948
- const uniqueHostnames = [...new Set(allRepoUrls.map(extractHostname))];
1949
- const github_hosts = {};
1950
- for (let i = 0; i < uniqueHostnames.length; i++) {
1951
- const hostname = uniqueHostnames[i];
1952
- const token = await password({
1953
- message: `GitHub token for ${chalk2.cyan(hostname)} (leave empty to skip):`,
1954
- mask: "*"
1955
- });
1956
- const hostKey = i === 0 ? "default" : hostname;
1957
- github_hosts[hostKey] = {
1958
- host: hostname,
1959
- token: token.trim()
1810
+ const anvil_notes = (await input({
1811
+ message: "Anvil notes repo URL (HTTPS, optional \u2014 leave empty to start with an empty local notes dir):",
1812
+ default: opts.anvilRepo ?? ""
1813
+ })).trim();
1814
+ const data_dir = await input({ message: "Data directory:", default: opts.dataDir ?? DEFAULT_DATA_DIR });
1815
+ const anthropicKey = (await password({ message: "Anthropic API key for agent chat (leave empty to skip):", mask: "*" })).trim();
1816
+ let ports = { ...DEFAULT_PORTS };
1817
+ const customizePorts = await confirm({ message: "Customize port assignments?", default: false });
1818
+ if (customizePorts) {
1819
+ const anvil = await number({ message: "Anvil port:", default: DEFAULT_PORTS.anvil });
1820
+ const ui = await number({ message: "Horus UI port:", default: DEFAULT_PORTS.ui });
1821
+ const typesense = await number({ message: "Typesense port:", default: DEFAULT_PORTS.typesense });
1822
+ const neo4j_http = await number({ message: "Neo4j HTTP port:", default: DEFAULT_PORTS.neo4j_http });
1823
+ const neo4j_bolt = await number({ message: "Neo4j Bolt port:", default: DEFAULT_PORTS.neo4j_bolt });
1824
+ ports = {
1825
+ ...ports,
1826
+ anvil: anvil ?? DEFAULT_PORTS.anvil,
1827
+ ui: ui ?? DEFAULT_PORTS.ui,
1828
+ typesense: typesense ?? DEFAULT_PORTS.typesense,
1829
+ neo4j_http: neo4j_http ?? DEFAULT_PORTS.neo4j_http,
1830
+ neo4j_bolt: neo4j_bolt ?? DEFAULT_PORTS.neo4j_bolt
1960
1831
  };
1961
1832
  }
1962
- console.log("");
1963
- console.log(chalk2.bold("NLP Agent Search"));
1964
- console.log(chalk2.dim("Required for the \u2726 Ask bar in the Horus Reader."));
1965
- console.log("");
1966
- const aiKey = await password({
1967
- message: "Horus AI key (leave empty to configure later):",
1968
- mask: "*"
1969
- });
1970
1833
  config = {
1971
- ...defaultConfig(),
1834
+ ...defaults,
1835
+ runtime: selectedRuntime,
1972
1836
  data_dir,
1973
- host_repos_path,
1974
- host_repos_extra_scan_dirs,
1975
- runtime: runtime.name,
1976
1837
  ports,
1977
- repos: {
1978
- anvil_notes: anvil_notes.trim(),
1979
- forge_registry: forge_registry.trim()
1980
- },
1981
- vaults,
1982
- github_hosts,
1983
- ai: {
1984
- key: aiKey.trim()
1985
- }
1838
+ control_plane_url,
1839
+ token_provider,
1840
+ repos: { anvil_notes, forge_registry: "" },
1841
+ ai: { key: "", anthropic_api_key: anthropicKey, model: defaults.ai.model }
1986
1842
  };
1987
1843
  }
1844
+ const runtime = await detectRuntime(selectedRuntime);
1845
+ config.runtime = runtime.name;
1988
1846
  const configSpinner = ora2("Saving configuration...").start();
1989
1847
  try {
1990
1848
  saveConfig(config);
1991
- configSpinner.succeed("Configuration saved to ~/Horus/config.yaml");
1849
+ ensureFsLayout();
1850
+ configSpinner.succeed("Configuration saved to ~/Horus/config.yaml (providers/, logs/, keys/ ready)");
1992
1851
  } catch (error) {
1993
1852
  configSpinner.fail("Failed to save configuration");
1994
1853
  console.error(error.message);
@@ -2013,108 +1872,57 @@ ${example(`${vaultName.trim()}-knowledge`)}
2013
1872
  process.exit(1);
2014
1873
  }
2015
1874
  const dataDir = resolvePath(config.data_dir);
2016
- const anvilToken = resolveGitHubHost(config.repos.anvil_notes, config.github_hosts)?.token ?? "";
2017
- const forgeToken = resolveGitHubHost(config.repos.forge_registry, config.github_hosts)?.token ?? "";
2018
- const reposToClone = [
2019
- {
2020
- url: config.repos.anvil_notes,
2021
- dest: join3(dataDir, "notes"),
2022
- label: "Anvil notes",
2023
- token: anvilToken
2024
- },
2025
- {
2026
- url: config.repos.forge_registry,
2027
- dest: join3(dataDir, "registry"),
2028
- label: "Forge registry",
2029
- token: forgeToken
2030
- }
2031
- ].filter((r) => r.url);
2032
- for (const [name, vault] of Object.entries(config.vaults)) {
2033
- if (vault.repo) {
2034
- const vaultToken = resolveGitHubHost(vault.repo, config.github_hosts)?.token ?? "";
2035
- reposToClone.push({
2036
- url: vault.repo,
2037
- dest: join3(dataDir, "vaults", name),
2038
- label: `Vault: ${name}`,
2039
- token: vaultToken
2040
- });
2041
- }
2042
- }
2043
- if (reposToClone.length > 0) {
2044
- console.log("");
2045
- console.log(chalk2.bold("Cloning repositories..."));
2046
- mkdirSync3(dataDir, { recursive: true });
2047
- for (const repo of reposToClone) {
2048
- const spinner = ora2(`Cloning ${repo.label}...`).start();
2049
- if (existsSync5(join3(repo.dest, ".git"))) {
2050
- spinner.succeed(`${repo.label} already cloned`);
2051
- continue;
2052
- }
1875
+ mkdirSync3(dataDir, { recursive: true });
1876
+ const notesDest = join3(dataDir, "notes");
1877
+ if (config.repos.anvil_notes) {
1878
+ const spinner = ora2("Cloning Anvil notes...").start();
1879
+ if (existsSync5(join3(notesDest, ".git"))) {
1880
+ spinner.succeed("Anvil notes already cloned");
1881
+ } else {
2053
1882
  try {
2054
- mkdirSync3(repo.dest, { recursive: true });
2055
- const cloneUrl = injectToken(repo.url, repo.token);
2056
- execSync(`git clone "${cloneUrl}" "${repo.dest}"`, {
2057
- stdio: "pipe",
2058
- timeout: 6e4
2059
- });
2060
- spinner.succeed(`${repo.label} cloned`);
1883
+ mkdirSync3(notesDest, { recursive: true });
1884
+ execSync(`git clone "${config.repos.anvil_notes}" "${notesDest}"`, { stdio: "pipe", timeout: 6e4 });
1885
+ spinner.succeed("Anvil notes cloned");
2061
1886
  } catch (error) {
2062
- spinner.fail(`Failed to clone ${repo.label}`);
2063
- const msg = error.message || "";
2064
- if (msg.includes("already exists and is not an empty directory")) {
2065
- console.log(chalk2.dim(" Directory exists but has no .git \u2014 check the path."));
2066
- } else {
2067
- console.log(chalk2.dim(` ${msg.split("\n")[0]}`));
2068
- }
2069
- console.log(chalk2.dim(` URL: ${repo.url}`));
2070
- if (!repo.token) {
2071
- console.log(chalk2.dim(" Tip: Re-run setup and provide a GitHub token if the repo is private."));
2072
- }
2073
- process.exit(1);
1887
+ spinner.fail("Failed to clone Anvil notes (continuing with an empty notes dir)");
1888
+ console.log(chalk3.dim(` ${(error.message || "").split("\n")[0]}`));
1889
+ console.log(chalk3.dim(` URL: ${config.repos.anvil_notes}`));
1890
+ mkdirSync3(notesDest, { recursive: true });
2074
1891
  }
2075
1892
  }
1893
+ } else {
1894
+ mkdirSync3(notesDest, { recursive: true });
2076
1895
  }
2077
- if (process.platform === "linux") {
2078
- const forgeDirs = ["config", "registry", "workspaces", "sessions"].map((d) => join3(dataDir, d));
2079
- for (const dir of forgeDirs) {
2080
- mkdirSync3(dir, { recursive: true });
2081
- }
2082
- const dirList = forgeDirs.map((d) => `"${d}"`).join(" ");
1896
+ if (opts.pull !== false) {
1897
+ console.log("");
1898
+ console.log(chalk3.bold("Pulling container images..."));
2083
1899
  try {
2084
- execSync(`chown -R 1001:1001 ${dirList}`, { stdio: "pipe" });
2085
- } catch (error) {
2086
- console.log(chalk2.yellow("Warning: could not chown Forge data dirs to UID 1001."));
2087
- console.log(chalk2.dim(" Forge may fail to start if directory ownership is incorrect."));
2088
- console.log(chalk2.dim(` Run manually: sudo chown -R 1001:1001 ${forgeDirs.join(" ")}`));
1900
+ await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
1901
+ } catch {
1902
+ console.log(chalk3.yellow("Some images could not be pulled \u2014 continuing."));
2089
1903
  }
1904
+ } else {
1905
+ console.log(chalk3.dim("Skipping image pull (--no-pull)."));
2090
1906
  }
2091
1907
  console.log("");
2092
- console.log(chalk2.bold("Pulling container images..."));
2093
- try {
2094
- await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
2095
- } catch {
2096
- console.log(chalk2.yellow("Some images could not be pulled."));
2097
- console.log(chalk2.dim("Continuing \u2014 services will be built from source if build contexts are available."));
2098
- }
2099
- console.log("");
2100
- console.log(chalk2.bold("Starting Horus services..."));
1908
+ console.log(chalk3.bold("Starting Horus services..."));
2101
1909
  try {
2102
1910
  await composeStreaming(runtime, ["up", "-d", "--remove-orphans"]);
2103
1911
  } catch (error) {
2104
- console.log(chalk2.red("Failed to start services."));
2105
- console.log(chalk2.dim(error.message));
1912
+ console.log(chalk3.red("Failed to start services."));
1913
+ console.log(chalk3.dim(error.message));
2106
1914
  process.exit(1);
2107
1915
  }
2108
1916
  console.log("");
2109
1917
  const healthSpinner = ora2("Waiting for services to become healthy...").start();
2110
1918
  let lastStates = [];
2111
1919
  try {
2112
- const states = await pollUntilHealthy(
1920
+ lastStates = await pollUntilHealthy(
2113
1921
  runtime,
2114
1922
  (current) => {
2115
1923
  lastStates = current;
2116
1924
  const summary = current.map((s) => {
2117
- const icon = s.status === "healthy" ? chalk2.green("*") : s.status === "starting" ? chalk2.yellow("~") : chalk2.red("x");
1925
+ const icon = s.status === "healthy" ? chalk3.green("*") : s.status === "starting" ? chalk3.yellow("~") : chalk3.red("x");
2118
1926
  return `${icon} ${s.name}`;
2119
1927
  }).join(" ");
2120
1928
  healthSpinner.text = `Waiting for services... ${summary}`;
@@ -2123,76 +1931,75 @@ ${example(`${vaultName.trim()}-knowledge`)}
2123
1931
  5e3
2124
1932
  );
2125
1933
  healthSpinner.succeed("All services are healthy");
2126
- lastStates = states;
2127
1934
  } catch (error) {
2128
1935
  healthSpinner.fail("Some services did not become healthy");
2129
- console.log(chalk2.dim(error.message));
1936
+ console.log(chalk3.dim(error.message));
1937
+ console.log(chalk3.dim("Tip: check logs with `horus status` or `docker compose logs` from ~/Horus/"));
1938
+ }
1939
+ if (config.control_plane_url) {
2130
1940
  console.log("");
2131
- console.log(chalk2.dim("Tip: Check logs with `docker compose logs` from ~/Horus/"));
2132
- process.exit(1);
1941
+ const loginSpinner = ora2("Logging in to the control plane...").start();
1942
+ const result = await runLogin(config);
1943
+ if (result.ok) loginSpinner.succeed(result.message);
1944
+ else loginSpinner.warn(result.message);
2133
1945
  }
2134
1946
  console.log("");
2135
1947
  const detectedClients = detectInstalledClients();
2136
1948
  if (detectedClients.length > 0) {
2137
- console.log(chalk2.bold("Configuring AI clients..."));
1949
+ console.log(chalk3.bold("Configuring AI clients..."));
2138
1950
  let clientsToConnect = [...detectedClients];
2139
1951
  if (clientsToConnect.includes("claude-desktop")) {
2140
1952
  let configureDesktop;
2141
- if (opts.yes) {
1953
+ if (!interactive) {
2142
1954
  configureDesktop = opts.claudeDesktop === true;
2143
- if (!configureDesktop) {
2144
- console.log(chalk2.dim("Skipping Claude Desktop (pass --claude-desktop to configure it)."));
2145
- }
2146
1955
  } else {
2147
1956
  configureDesktop = opts.claudeDesktop === false ? false : await confirm({ message: "Setup for Claude Desktop?", default: true });
2148
1957
  }
2149
- if (!configureDesktop) {
2150
- clientsToConnect = clientsToConnect.filter((c) => c !== "claude-desktop");
2151
- }
1958
+ if (!configureDesktop) clientsToConnect = clientsToConnect.filter((c) => c !== "claude-desktop");
2152
1959
  }
2153
1960
  if (clientsToConnect.length > 0) {
2154
1961
  try {
2155
1962
  await runConnect(config, runtime, clientsToConnect, "localhost");
2156
- } catch (error) {
2157
- console.log(chalk2.yellow("Could not configure AI clients automatically."));
2158
- console.log(chalk2.dim(`Run ${chalk2.cyan("horus connect")} to configure them manually.`));
1963
+ } catch {
1964
+ console.log(chalk3.yellow("Could not configure AI clients automatically."));
1965
+ console.log(chalk3.dim(`Run ${chalk3.cyan("horus connect")} to configure them manually.`));
2159
1966
  }
2160
1967
  }
2161
1968
  } else {
2162
- console.log(chalk2.dim(`No AI clients detected. Run ${chalk2.cyan("horus connect")} after installing Claude Desktop, Claude Code, or Cursor.`));
1969
+ console.log(chalk3.dim(`No AI clients detected. Run ${chalk3.cyan("horus connect")} after installing one.`));
2163
1970
  }
1971
+ const mode = config.control_plane_url ? "connected" : "local-only";
2164
1972
  console.log("");
2165
- console.log(chalk2.bold.green("Setup complete!"));
2166
- 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"));
1973
+ console.log(chalk3.bold.green("Setup complete!"));
1974
+ console.log(chalk3.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"));
2167
1975
  console.log("");
2168
- console.log(` ${chalk2.bold("Runtime:")} ${runtime.name}`);
2169
- console.log(` ${chalk2.bold("Config:")} ~/Horus/config.yaml`);
2170
- console.log(` ${chalk2.bold("Data:")} ${config.data_dir}`);
2171
- console.log("");
2172
- console.log(chalk2.bold(" Service URLs:"));
2173
- console.log(` Anvil: http://localhost:${config.ports.anvil}`);
2174
- console.log(` Vault Router: http://localhost:${config.ports.vault_router}`);
2175
- console.log(` Vault MCP: http://localhost:${config.ports.vault_mcp}`);
2176
- console.log(` Forge: http://localhost:${config.ports.forge}`);
1976
+ console.log(` ${chalk3.bold("Mode:")} ${mode}`);
1977
+ console.log(` ${chalk3.bold("Runtime:")} ${runtime.name}`);
1978
+ console.log(` ${chalk3.bold("Config:")} ~/Horus/config.yaml`);
1979
+ console.log(` ${chalk3.bold("Data:")} ${config.data_dir}`);
1980
+ if (config.control_plane_url) {
1981
+ console.log(` ${chalk3.bold("Control plane:")} ${config.control_plane_url}`);
1982
+ }
2177
1983
  console.log("");
2178
- console.log(chalk2.bold(" Vault instances:"));
2179
- Object.entries(config.vaults).sort(([a], [b]) => a.localeCompare(b)).forEach(([name, vault], index) => {
2180
- const port = `800${index + 1}`;
2181
- const defaultLabel = vault.default ? chalk2.dim(" (default)") : "";
2182
- console.log(` ${name}${defaultLabel}: http://localhost:${port}`);
2183
- });
1984
+ console.log(chalk3.bold(" Service URLs:"));
1985
+ console.log(` Horus UI: http://localhost:${config.ports.ui}`);
1986
+ console.log(` Anvil: http://localhost:${config.ports.anvil}`);
2184
1987
  console.log("");
1988
+ if (mode === "local-only") {
1989
+ console.log(chalk3.dim(" Vault, Forge, and Admin require a control plane \u2014 not available in local-only mode."));
1990
+ console.log("");
1991
+ }
2185
1992
  void lastStates;
2186
1993
  });
2187
1994
 
2188
1995
  // src/commands/up.ts
2189
- import { Command as Command3 } from "commander";
2190
- import chalk3 from "chalk";
1996
+ import { Command as Command4 } from "commander";
1997
+ import chalk4 from "chalk";
2191
1998
  import ora3 from "ora";
2192
- var upCommand = new Command3("up").description("Start the Horus stack").option("--no-pull", "Skip pulling latest images before starting").action(async (opts) => {
1999
+ var upCommand = new Command4("up").description("Start the Horus stack").option("--no-pull", "Skip pulling latest images before starting").action(async (opts) => {
2193
2000
  if (!configExists() || !composeFileExists()) {
2194
- console.log(chalk3.red("Horus is not set up yet."));
2195
- console.log(chalk3.dim("Run `horus setup` first."));
2001
+ console.log(chalk4.red("Horus is not set up yet."));
2002
+ console.log(chalk4.dim("Run `horus setup` first."));
2196
2003
  process.exit(1);
2197
2004
  }
2198
2005
  const config = loadConfig();
@@ -2200,7 +2007,7 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
2200
2007
  let runtime;
2201
2008
  try {
2202
2009
  runtime = await detectRuntime(config.runtime);
2203
- spinner.succeed(`Using ${chalk3.cyan(runtime.name)}`);
2010
+ spinner.succeed(`Using ${chalk4.cyan(runtime.name)}`);
2204
2011
  } catch (error) {
2205
2012
  spinner.fail("No container runtime found");
2206
2013
  console.log(error.message);
@@ -2208,25 +2015,25 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
2208
2015
  }
2209
2016
  if (opts.pull) {
2210
2017
  console.log("");
2211
- console.log(chalk3.bold("Pulling latest images..."));
2018
+ console.log(chalk4.bold("Pulling latest images..."));
2212
2019
  try {
2213
2020
  await composeStreaming(runtime, ["pull"]);
2214
2021
  console.log("");
2215
- console.log(chalk3.green("\u2713 Pull complete"));
2022
+ console.log(chalk4.green("\u2713 Pull complete"));
2216
2023
  } catch {
2217
2024
  console.log("");
2218
- console.log(chalk3.yellow("\u26A0 Warning: failed to pull one or more images \u2014 using cached versions."));
2219
- console.log(chalk3.dim(" Run `docker compose pull` to see which services failed."));
2025
+ console.log(chalk4.yellow("\u26A0 Warning: failed to pull one or more images \u2014 using cached versions."));
2026
+ console.log(chalk4.dim(" Run `docker compose pull` to see which services failed."));
2220
2027
  }
2221
2028
  }
2222
2029
  console.log("");
2223
- console.log(chalk3.bold("Starting Horus services..."));
2030
+ console.log(chalk4.bold("Starting Horus services..."));
2224
2031
  try {
2225
2032
  const upArgs = opts.pull ? ["up", "-d", "--force-recreate", "--remove-orphans"] : ["up", "-d", "--remove-orphans"];
2226
2033
  await composeStreaming(runtime, upArgs);
2227
2034
  } catch (error) {
2228
- console.log(chalk3.red("Failed to start services."));
2229
- console.log(chalk3.dim(error.message));
2035
+ console.log(chalk4.red("Failed to start services."));
2036
+ console.log(chalk4.dim(error.message));
2230
2037
  process.exit(1);
2231
2038
  }
2232
2039
  console.log("");
@@ -2234,16 +2041,16 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
2234
2041
  try {
2235
2042
  const states = await checkAllHealth(runtime);
2236
2043
  statusSpinner.stop();
2237
- console.log(chalk3.bold("Service Status:"));
2044
+ console.log(chalk4.bold("Service Status:"));
2238
2045
  for (const s of states) {
2239
- const color = s.status === "healthy" ? chalk3.green : s.status === "starting" ? chalk3.yellow : chalk3.red;
2046
+ const color = s.status === "healthy" ? chalk4.green : s.status === "starting" ? chalk4.yellow : chalk4.red;
2240
2047
  console.log(` ${color(s.status.padEnd(10))} ${s.name}`);
2241
2048
  }
2242
2049
  const allHealthy = states.every((s) => s.status === "healthy");
2243
2050
  if (!allHealthy) {
2244
2051
  console.log("");
2245
2052
  console.log(
2246
- chalk3.yellow("Some services are still starting. Run `horus status` to check progress.")
2053
+ chalk4.yellow("Some services are still starting. Run `horus status` to check progress.")
2247
2054
  );
2248
2055
  }
2249
2056
  } catch {
@@ -2253,13 +2060,13 @@ var upCommand = new Command3("up").description("Start the Horus stack").option("
2253
2060
  });
2254
2061
 
2255
2062
  // src/commands/down.ts
2256
- import { Command as Command4 } from "commander";
2257
- import chalk4 from "chalk";
2063
+ import { Command as Command5 } from "commander";
2064
+ import chalk5 from "chalk";
2258
2065
  import ora4 from "ora";
2259
- var downCommand = new Command4("down").description("Stop the Horus stack").action(async () => {
2066
+ var downCommand = new Command5("down").description("Stop the Horus stack").action(async () => {
2260
2067
  if (!configExists() || !composeFileExists()) {
2261
- console.log(chalk4.red("Horus is not set up yet."));
2262
- console.log(chalk4.dim("Run `horus setup` first."));
2068
+ console.log(chalk5.red("Horus is not set up yet."));
2069
+ console.log(chalk5.dim("Run `horus setup` first."));
2263
2070
  process.exit(1);
2264
2071
  }
2265
2072
  const config = loadConfig();
@@ -2267,35 +2074,35 @@ var downCommand = new Command4("down").description("Stop the Horus stack").actio
2267
2074
  let runtime;
2268
2075
  try {
2269
2076
  runtime = await detectRuntime(config.runtime);
2270
- spinner.succeed(`Using ${chalk4.cyan(runtime.name)}`);
2077
+ spinner.succeed(`Using ${chalk5.cyan(runtime.name)}`);
2271
2078
  } catch (error) {
2272
2079
  spinner.fail("No container runtime found");
2273
2080
  console.log(error.message);
2274
2081
  process.exit(1);
2275
2082
  }
2276
2083
  console.log("");
2277
- console.log(chalk4.bold("Stopping Horus services..."));
2084
+ console.log(chalk5.bold("Stopping Horus services..."));
2278
2085
  try {
2279
2086
  await composeStreaming(runtime, ["down"]);
2280
2087
  } catch (error) {
2281
- console.log(chalk4.red("Failed to stop services."));
2282
- console.log(chalk4.dim(error.message));
2088
+ console.log(chalk5.red("Failed to stop services."));
2089
+ console.log(chalk5.dim(error.message));
2283
2090
  process.exit(1);
2284
2091
  }
2285
2092
  console.log("");
2286
- console.log(chalk4.green("All services stopped."));
2287
- console.log(chalk4.dim("Data volumes have been preserved. Run `horus up` to restart."));
2093
+ console.log(chalk5.green("All services stopped."));
2094
+ console.log(chalk5.dim("Data volumes have been preserved. Run `horus up` to restart."));
2288
2095
  console.log("");
2289
2096
  });
2290
2097
 
2291
2098
  // src/commands/status.ts
2292
- import { Command as Command5 } from "commander";
2293
- import chalk5 from "chalk";
2099
+ import { Command as Command6 } from "commander";
2100
+ import chalk6 from "chalk";
2294
2101
  import ora5 from "ora";
2295
- var statusCommand = new Command5("status").description("Show status of Horus services").action(async () => {
2102
+ var statusCommand = new Command6("status").description("Show status of Horus services").action(async () => {
2296
2103
  if (!configExists() || !composeFileExists()) {
2297
- console.log(chalk5.red("Horus is not set up yet."));
2298
- console.log(chalk5.dim("Run `horus setup` first."));
2104
+ console.log(chalk6.red("Horus is not set up yet."));
2105
+ console.log(chalk6.dim("Run `horus setup` first."));
2299
2106
  process.exit(1);
2300
2107
  }
2301
2108
  const config = loadConfig();
@@ -2317,28 +2124,28 @@ var statusCommand = new Command5("status").description("Show status of Horus ser
2317
2124
  }
2318
2125
  spinner.stop();
2319
2126
  console.log("");
2320
- console.log(chalk5.bold("Horus Status"));
2321
- 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"));
2322
- console.log(` ${chalk5.bold("Version:")} ${CLI_VERSION}`);
2323
- console.log(` ${chalk5.bold("Runtime:")} ${runtime.name}`);
2324
- console.log(` ${chalk5.bold("Config:")} ~/Horus/config.yaml`);
2127
+ console.log(chalk6.bold("Horus Status"));
2128
+ console.log(chalk6.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"));
2129
+ console.log(` ${chalk6.bold("Version:")} ${CLI_VERSION}`);
2130
+ console.log(` ${chalk6.bold("Runtime:")} ${runtime.name}`);
2131
+ console.log(` ${chalk6.bold("Config:")} ~/Horus/config.yaml`);
2325
2132
  console.log("");
2326
2133
  if (containers.length === 0) {
2327
- console.log(chalk5.yellow(" No services are running."));
2328
- console.log(chalk5.dim(" Run `horus up` to start the stack."));
2134
+ console.log(chalk6.yellow(" No services are running."));
2135
+ console.log(chalk6.dim(" Run `horus up` to start the stack."));
2329
2136
  console.log("");
2330
2137
  return;
2331
2138
  }
2332
2139
  const header = ` ${pad("SERVICE", 14)} ${pad("STATUS", 12)} ${pad("PORTS", 20)} ${pad("UPTIME", 20)}`;
2333
- console.log(chalk5.bold(header));
2334
- console.log(chalk5.dim(" " + "\u2500".repeat(66)));
2140
+ console.log(chalk6.bold(header));
2141
+ console.log(chalk6.dim(" " + "\u2500".repeat(66)));
2335
2142
  for (const service of SERVICES) {
2336
2143
  const container = containers.find(
2337
2144
  (c) => c.Service === service || c.Name?.includes(service)
2338
2145
  );
2339
2146
  if (!container) {
2340
2147
  console.log(
2341
- ` ${pad(service, 14)} ${chalk5.red(pad("stopped", 12))} ${pad("-", 20)} ${pad("-", 20)}`
2148
+ ` ${pad(service, 14)} ${chalk6.red(pad("stopped", 12))} ${pad("-", 20)} ${pad("-", 20)}`
2342
2149
  );
2343
2150
  continue;
2344
2151
  }
@@ -2356,9 +2163,9 @@ function pad(str, width) {
2356
2163
  }
2357
2164
  function getStatusColor(status) {
2358
2165
  const lower = status.toLowerCase();
2359
- if (lower === "healthy" || lower === "running") return chalk5.green;
2360
- if (lower === "starting") return chalk5.yellow;
2361
- return chalk5.red;
2166
+ if (lower === "healthy" || lower === "running") return chalk6.green;
2167
+ if (lower === "starting") return chalk6.yellow;
2168
+ return chalk6.red;
2362
2169
  }
2363
2170
  function formatPorts(publishers) {
2364
2171
  if (!publishers || publishers.length === 0) return "-";
@@ -2373,69 +2180,69 @@ function extractUptime(status) {
2373
2180
  }
2374
2181
 
2375
2182
  // src/commands/config.ts
2376
- import { Command as Command6 } from "commander";
2377
- import chalk6 from "chalk";
2183
+ import { Command as Command7 } from "commander";
2184
+ import chalk7 from "chalk";
2378
2185
  import { confirm as confirm2 } from "@inquirer/prompts";
2379
- var configCommand = new Command6("config").description("View or modify Horus configuration").action(async () => {
2186
+ var configCommand = new Command7("config").description("View or modify Horus configuration").action(async () => {
2380
2187
  if (!configExists()) {
2381
- console.log(chalk6.red("Horus is not configured yet."));
2382
- console.log(chalk6.dim("Run `horus setup` first."));
2188
+ console.log(chalk7.red("Horus is not configured yet."));
2189
+ console.log(chalk7.dim("Run `horus setup` first."));
2383
2190
  process.exit(1);
2384
2191
  }
2385
2192
  const config = loadConfig();
2386
2193
  console.log("");
2387
- console.log(chalk6.bold("Horus Configuration"));
2388
- console.log(chalk6.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"));
2389
- console.log(` ${chalk6.bold("version:")} ${config.version}`);
2390
- console.log(` ${chalk6.bold("data-dir:")} ${config.data_dir}`);
2391
- console.log(` ${chalk6.bold("runtime:")} ${config.runtime}`);
2392
- console.log(` ${chalk6.bold("host-repos-path:")} ${config.host_repos_path || chalk6.dim("(not set)")}`);
2194
+ console.log(chalk7.bold("Horus Configuration"));
2195
+ console.log(chalk7.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"));
2196
+ console.log(` ${chalk7.bold("version:")} ${config.version}`);
2197
+ console.log(` ${chalk7.bold("data-dir:")} ${config.data_dir}`);
2198
+ console.log(` ${chalk7.bold("runtime:")} ${config.runtime}`);
2199
+ console.log(` ${chalk7.bold("host-repos-path:")} ${config.host_repos_path || chalk7.dim("(not set)")}`);
2393
2200
  const extraDirs = (config.host_repos_extra_scan_dirs ?? []).join(", ");
2394
- console.log(` ${chalk6.bold("host-repos-extra-scan-dirs:")} ${extraDirs || chalk6.dim("(not set)")}`);
2201
+ console.log(` ${chalk7.bold("host-repos-extra-scan-dirs:")} ${extraDirs || chalk7.dim("(not set)")}`);
2395
2202
  console.log("");
2396
- console.log(chalk6.bold(" Ports:"));
2397
- console.log(` ${chalk6.bold("anvil:")} ${config.ports.anvil}`);
2398
- console.log(` ${chalk6.bold("vault-rest:")} ${config.ports.vault_rest}`);
2399
- console.log(` ${chalk6.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
2400
- console.log(` ${chalk6.bold("vault-router:")} ${config.ports.vault_router}`);
2401
- console.log(` ${chalk6.bold("forge:")} ${config.ports.forge}`);
2203
+ console.log(chalk7.bold(" Ports:"));
2204
+ console.log(` ${chalk7.bold("anvil:")} ${config.ports.anvil}`);
2205
+ console.log(` ${chalk7.bold("vault-rest:")} ${config.ports.vault_rest}`);
2206
+ console.log(` ${chalk7.bold("vault-mcp:")} ${config.ports.vault_mcp}`);
2207
+ console.log(` ${chalk7.bold("vault-router:")} ${config.ports.vault_router}`);
2208
+ console.log(` ${chalk7.bold("forge:")} ${config.ports.forge}`);
2402
2209
  console.log("");
2403
- console.log(chalk6.bold(" Repos:"));
2404
- console.log(` ${chalk6.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk6.dim("(not set)")}`);
2405
- console.log(` ${chalk6.bold("forge-registry:")} ${config.repos.forge_registry || chalk6.dim("(not set)")}`);
2210
+ console.log(chalk7.bold(" Repos:"));
2211
+ console.log(` ${chalk7.bold("anvil-notes:")} ${config.repos.anvil_notes || chalk7.dim("(not set)")}`);
2212
+ console.log(` ${chalk7.bold("forge-registry:")} ${config.repos.forge_registry || chalk7.dim("(not set)")}`);
2406
2213
  console.log("");
2407
- console.log(chalk6.bold(" Vaults:"));
2214
+ console.log(chalk7.bold(" Vaults:"));
2408
2215
  if (Object.keys(config.vaults ?? {}).length === 0) {
2409
- console.log(chalk6.dim(" (none configured)"));
2216
+ console.log(chalk7.dim(" (none configured)"));
2410
2217
  } else {
2411
2218
  for (const [name, vault] of Object.entries(config.vaults)) {
2412
- const defaultLabel = vault.default ? chalk6.dim(" (default)") : "";
2413
- console.log(` ${chalk6.bold(name)}${defaultLabel}: ${vault.repo || chalk6.dim("(no repo)")}`);
2219
+ const defaultLabel = vault.default ? chalk7.dim(" (default)") : "";
2220
+ console.log(` ${chalk7.bold(name)}${defaultLabel}: ${vault.repo || chalk7.dim("(no repo)")}`);
2414
2221
  }
2415
2222
  }
2416
2223
  console.log("");
2417
- console.log(chalk6.bold(" GitHub Hosts:"));
2224
+ console.log(chalk7.bold(" GitHub Hosts:"));
2418
2225
  if (Object.keys(config.github_hosts ?? {}).length === 0) {
2419
- console.log(chalk6.dim(" (none configured)"));
2226
+ console.log(chalk7.dim(" (none configured)"));
2420
2227
  } else {
2421
2228
  for (const [key, gh] of Object.entries(config.github_hosts)) {
2422
- console.log(` ${chalk6.bold(key)}: ${gh.host} token: ${gh.token ? maskApiKey(gh.token) : chalk6.dim("(not set)")}`);
2229
+ console.log(` ${chalk7.bold(key)}: ${gh.host} token: ${gh.token ? maskApiKey(gh.token) : chalk7.dim("(not set)")}`);
2423
2230
  }
2424
2231
  }
2425
2232
  console.log("");
2426
- console.log(chalk6.dim(` Config file: ~/Horus/config.yaml`));
2427
- console.log(chalk6.dim(` Use 'horus config get <key>' or 'horus config set <key> <value>'`));
2233
+ console.log(chalk7.dim(` Config file: ~/Horus/config.yaml`));
2234
+ console.log(chalk7.dim(` Use 'horus config get <key>' or 'horus config set <key> <value>'`));
2428
2235
  console.log("");
2429
2236
  });
2430
2237
  configCommand.command("get <key>").description("Get a configuration value").action(async (key) => {
2431
2238
  if (!configExists()) {
2432
- console.log(chalk6.red("Horus is not configured yet."));
2433
- console.log(chalk6.dim("Run `horus setup` first."));
2239
+ console.log(chalk7.red("Horus is not configured yet."));
2240
+ console.log(chalk7.dim("Run `horus setup` first."));
2434
2241
  process.exit(1);
2435
2242
  }
2436
2243
  if (!isValidKey(key)) {
2437
- console.log(chalk6.red(`Unknown config key: ${key}`));
2438
- console.log(chalk6.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
2244
+ console.log(chalk7.red(`Unknown config key: ${key}`));
2245
+ console.log(chalk7.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
2439
2246
  process.exit(1);
2440
2247
  }
2441
2248
  const config = loadConfig();
@@ -2444,25 +2251,26 @@ configCommand.command("get <key>").description("Get a configuration value").acti
2444
2251
  });
2445
2252
  configCommand.command("set <key> <value>").description("Set a configuration value").action(async (key, value) => {
2446
2253
  if (!configExists()) {
2447
- console.log(chalk6.red("Horus is not configured yet."));
2448
- console.log(chalk6.dim("Run `horus setup` first."));
2254
+ console.log(chalk7.red("Horus is not configured yet."));
2255
+ console.log(chalk7.dim("Run `horus setup` first."));
2449
2256
  process.exit(1);
2450
2257
  }
2451
2258
  if (!isValidKey(key)) {
2452
- console.log(chalk6.red(`Unknown config key: ${key}`));
2453
- console.log(chalk6.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
2259
+ console.log(chalk7.red(`Unknown config key: ${key}`));
2260
+ console.log(chalk7.dim(`Valid keys: ${CONFIG_KEYS.join(", ")}`));
2454
2261
  process.exit(1);
2455
2262
  }
2456
2263
  let config = loadConfig();
2457
2264
  try {
2458
2265
  config = setConfigValue(config, key, value);
2459
2266
  } catch (error) {
2460
- console.log(chalk6.red(error.message));
2267
+ console.log(chalk7.red(error.message));
2461
2268
  process.exit(1);
2462
2269
  }
2463
2270
  saveConfig(config);
2464
2271
  writeEnvFile(config);
2465
- console.log(chalk6.green(`Set ${key} and regenerated .env file.`));
2272
+ installComposeFile(config, config.runtime === "podman" ? "podman" : "docker");
2273
+ console.log(chalk7.green(`Set ${key} and regenerated .env + docker-compose.yml.`));
2466
2274
  const needsRestart = [
2467
2275
  "data-dir",
2468
2276
  "host-repos-path",
@@ -2475,17 +2283,17 @@ configCommand.command("set <key> <value>").description("Set a configuration valu
2475
2283
  "port.forge"
2476
2284
  ];
2477
2285
  if (needsRestart.includes(key)) {
2478
- console.log(chalk6.yellow("Restart required for changes to take effect."));
2286
+ console.log(chalk7.yellow("Restart required for changes to take effect."));
2479
2287
  if (process.stdin.isTTY) {
2480
2288
  const restart = await confirm2({
2481
2289
  message: "Restart Horus now?",
2482
2290
  default: false
2483
2291
  });
2484
2292
  if (restart) {
2485
- console.log(chalk6.dim("Run `horus down && horus up` to restart."));
2293
+ console.log(chalk7.dim("Run `horus down && horus up` to restart."));
2486
2294
  }
2487
2295
  } else {
2488
- console.log(chalk6.dim("Run `horus down && horus up` to restart."));
2296
+ console.log(chalk7.dim("Run `horus down && horus up` to restart."));
2489
2297
  }
2490
2298
  }
2491
2299
  });
@@ -2494,8 +2302,8 @@ function isValidKey(key) {
2494
2302
  }
2495
2303
 
2496
2304
  // src/commands/update.ts
2497
- import { Command as Command7 } from "commander";
2498
- import chalk7 from "chalk";
2305
+ import { Command as Command8 } from "commander";
2306
+ import chalk8 from "chalk";
2499
2307
  import ora6 from "ora";
2500
2308
  import { select as select2, confirm as confirm3 } from "@inquirer/prompts";
2501
2309
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync2, existsSync as existsSync6 } from "fs";
@@ -2562,17 +2370,17 @@ async function fetchLatestVersion() {
2562
2370
  return null;
2563
2371
  }
2564
2372
  }
2565
- var updateCommand = new Command7("update").description("Update Horus to the latest version").option("--rollback", "Roll back to the previous version").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
2373
+ var updateCommand = new Command8("update").description("Update Horus to the latest version").option("--rollback", "Roll back to the previous version").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
2566
2374
  console.log("");
2567
- console.log(chalk7.bold(opts.rollback ? "Horus Rollback" : "Horus Update"));
2568
- console.log(chalk7.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"));
2375
+ console.log(chalk8.bold(opts.rollback ? "Horus Rollback" : "Horus Update"));
2376
+ console.log(chalk8.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"));
2569
2377
  console.log("");
2570
2378
  const config = loadConfig();
2571
2379
  const runtimeSpinner = ora6("Detecting runtime...").start();
2572
2380
  let runtime;
2573
2381
  try {
2574
2382
  runtime = await detectRuntime(config.runtime);
2575
- runtimeSpinner.succeed(`Using ${chalk7.cyan(runtime.name)}`);
2383
+ runtimeSpinner.succeed(`Using ${chalk8.cyan(runtime.name)}`);
2576
2384
  } catch (error) {
2577
2385
  runtimeSpinner.fail("No container runtime found");
2578
2386
  console.log(error.message);
@@ -2581,14 +2389,14 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2581
2389
  if (opts.rollback) {
2582
2390
  const snapshots = listSnapshots();
2583
2391
  if (snapshots.length === 0) {
2584
- console.log(chalk7.red("No snapshots found. Cannot roll back."));
2585
- console.log(chalk7.dim(`Snapshots are stored in ${SNAPSHOTS_DIR}`));
2392
+ console.log(chalk8.red("No snapshots found. Cannot roll back."));
2393
+ console.log(chalk8.dim(`Snapshots are stored in ${SNAPSHOTS_DIR}`));
2586
2394
  process.exit(1);
2587
2395
  }
2588
2396
  let snapshotToRestore;
2589
2397
  if (opts.yes) {
2590
2398
  snapshotToRestore = snapshots[0].snapshot;
2591
- console.log(`Using most recent snapshot: ${chalk7.cyan(snapshotToRestore.timestamp)}`);
2399
+ console.log(`Using most recent snapshot: ${chalk8.cyan(snapshotToRestore.timestamp)}`);
2592
2400
  } else {
2593
2401
  const choices = snapshots.map(({ snapshot }, i) => ({
2594
2402
  name: `${snapshot.timestamp} (images: ${Object.keys(snapshot.images).length})`,
@@ -2606,7 +2414,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2606
2414
  default: false
2607
2415
  });
2608
2416
  if (!confirmed) {
2609
- console.log(chalk7.dim("Rollback cancelled."));
2417
+ console.log(chalk8.dim("Rollback cancelled."));
2610
2418
  return;
2611
2419
  }
2612
2420
  }
@@ -2616,16 +2424,16 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2616
2424
  stopSpinner.succeed("Services stopped");
2617
2425
  } catch (error) {
2618
2426
  stopSpinner.fail("Failed to stop services");
2619
- console.log(chalk7.dim(error.message));
2427
+ console.log(chalk8.dim(error.message));
2620
2428
  process.exit(1);
2621
2429
  }
2622
2430
  console.log("");
2623
- console.log(chalk7.bold("Restarting from snapshot (using cached images)..."));
2431
+ console.log(chalk8.bold("Restarting from snapshot (using cached images)..."));
2624
2432
  try {
2625
2433
  await composeStreaming(runtime, ["up", "-d", "--remove-orphans"]);
2626
2434
  } catch (error) {
2627
- console.log(chalk7.red("Failed to restart services."));
2628
- console.log(chalk7.dim(error.message));
2435
+ console.log(chalk8.red("Failed to restart services."));
2436
+ console.log(chalk8.dim(error.message));
2629
2437
  process.exit(1);
2630
2438
  }
2631
2439
  console.log("");
@@ -2635,7 +2443,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2635
2443
  runtime,
2636
2444
  (current) => {
2637
2445
  const summary = current.map((s) => {
2638
- const icon = s.status === "healthy" ? chalk7.green("*") : s.status === "starting" ? chalk7.yellow("~") : chalk7.red("x");
2446
+ const icon = s.status === "healthy" ? chalk8.green("*") : s.status === "starting" ? chalk8.yellow("~") : chalk8.red("x");
2639
2447
  return `${icon} ${s.name}`;
2640
2448
  }).join(" ");
2641
2449
  healthSpinner2.text = `Waiting... ${summary}`;
@@ -2646,11 +2454,11 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2646
2454
  healthSpinner2.succeed("All services healthy after rollback");
2647
2455
  } catch (error) {
2648
2456
  healthSpinner2.fail("Some services did not become healthy");
2649
- console.log(chalk7.dim(error.message));
2457
+ console.log(chalk8.dim(error.message));
2650
2458
  process.exit(1);
2651
2459
  }
2652
2460
  console.log("");
2653
- console.log(chalk7.bold.green("Rollback complete!"));
2461
+ console.log(chalk8.bold.green("Rollback complete!"));
2654
2462
  console.log("");
2655
2463
  return;
2656
2464
  }
@@ -2661,14 +2469,14 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2661
2469
  ]);
2662
2470
  versionSpinner.stop();
2663
2471
  if (latestVersion) {
2664
- console.log(` Latest release: ${chalk7.cyan(latestVersion)}`);
2472
+ console.log(` Latest release: ${chalk8.cyan(latestVersion)}`);
2665
2473
  } else {
2666
- console.log(chalk7.dim(" Could not reach GitHub to check latest version."));
2474
+ console.log(chalk8.dim(" Could not reach GitHub to check latest version."));
2667
2475
  }
2668
2476
  console.log("");
2669
- console.log(chalk7.dim(" Note: this updates the Horus container services only."));
2670
- console.log(chalk7.dim(" To update the Horus CLI itself, run:"));
2671
- console.log(` ${chalk7.cyan("npm install -g @arkhera30/cli@latest")}`);
2477
+ console.log(chalk8.dim(" Note: this updates the Horus container services only."));
2478
+ console.log(chalk8.dim(" To update the Horus CLI itself, run:"));
2479
+ console.log(` ${chalk8.cyan("npm install -g @arkhera30/cli@latest")}`);
2672
2480
  console.log("");
2673
2481
  if (!opts.yes) {
2674
2482
  const confirmed = await confirm3({
@@ -2676,7 +2484,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2676
2484
  default: true
2677
2485
  });
2678
2486
  if (!confirmed) {
2679
- console.log(chalk7.dim("Update cancelled."));
2487
+ console.log(chalk8.dim("Update cancelled."));
2680
2488
  return;
2681
2489
  }
2682
2490
  }
@@ -2684,10 +2492,10 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2684
2492
  let snapshotPath = "";
2685
2493
  try {
2686
2494
  snapshotPath = saveSnapshot(currentImages);
2687
- snapshotSpinner.succeed(`Snapshot saved: ${chalk7.dim(snapshotPath)}`);
2495
+ snapshotSpinner.succeed(`Snapshot saved: ${chalk8.dim(snapshotPath)}`);
2688
2496
  } catch (error) {
2689
2497
  snapshotSpinner.warn("Could not save snapshot (update will proceed)");
2690
- console.log(chalk7.dim(error.message));
2498
+ console.log(chalk8.dim(error.message));
2691
2499
  }
2692
2500
  const composeSpinner = ora6("Updating compose configuration...").start();
2693
2501
  try {
@@ -2696,23 +2504,23 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2696
2504
  composeSpinner.succeed("Compose configuration updated");
2697
2505
  } catch (error) {
2698
2506
  composeSpinner.warn("Could not update compose configuration \u2014 using existing file");
2699
- console.log(chalk7.dim(error.message));
2507
+ console.log(chalk8.dim(error.message));
2700
2508
  }
2701
2509
  console.log("");
2702
- console.log(chalk7.bold("Pulling latest images..."));
2510
+ console.log(chalk8.bold("Pulling latest images..."));
2703
2511
  try {
2704
2512
  await composeStreaming(runtime, runtime.name === "podman" ? ["pull"] : ["pull", "--ignore-pull-failures"]);
2705
2513
  } catch {
2706
- console.log(chalk7.yellow("Some images could not be pulled."));
2707
- console.log(chalk7.dim("Continuing \u2014 services will be built from source if build contexts are available."));
2514
+ console.log(chalk8.yellow("Some images could not be pulled."));
2515
+ console.log(chalk8.dim("Continuing \u2014 services will be built from source if build contexts are available."));
2708
2516
  }
2709
2517
  console.log("");
2710
- console.log(chalk7.bold("Restarting services..."));
2518
+ console.log(chalk8.bold("Restarting services..."));
2711
2519
  try {
2712
2520
  await composeStreaming(runtime, ["up", "-d", "--force-recreate", "--remove-orphans"]);
2713
2521
  } catch (error) {
2714
- console.log(chalk7.red("Failed to restart services."));
2715
- console.log(chalk7.dim(error.message));
2522
+ console.log(chalk8.red("Failed to restart services."));
2523
+ console.log(chalk8.dim(error.message));
2716
2524
  process.exit(1);
2717
2525
  }
2718
2526
  console.log("");
@@ -2723,7 +2531,7 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2723
2531
  runtime,
2724
2532
  (current) => {
2725
2533
  const summary = current.map((s) => {
2726
- const icon = s.status === "healthy" ? chalk7.green("*") : s.status === "starting" ? chalk7.yellow("~") : chalk7.red("x");
2534
+ const icon = s.status === "healthy" ? chalk8.green("*") : s.status === "starting" ? chalk8.yellow("~") : chalk8.red("x");
2727
2535
  return `${icon} ${s.name}`;
2728
2536
  }).join(" ");
2729
2537
  healthSpinner.text = `Waiting for services... ${summary}`;
@@ -2734,55 +2542,55 @@ var updateCommand = new Command7("update").description("Update Horus to the late
2734
2542
  healthSpinner.succeed("All services healthy");
2735
2543
  } catch (error) {
2736
2544
  healthSpinner.fail("Some services did not become healthy");
2737
- console.log(chalk7.dim(error.message));
2545
+ console.log(chalk8.dim(error.message));
2738
2546
  console.log("");
2739
- console.log(chalk7.dim(`Tip: Roll back with \`horus update --rollback\``));
2547
+ console.log(chalk8.dim(`Tip: Roll back with \`horus update --rollback\``));
2740
2548
  process.exit(1);
2741
2549
  }
2742
2550
  console.log("");
2743
- console.log(chalk7.bold.green("Update complete!"));
2744
- console.log(chalk7.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"));
2551
+ console.log(chalk8.bold.green("Update complete!"));
2552
+ console.log(chalk8.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"));
2745
2553
  if (latestVersion) {
2746
- console.log(` ${chalk7.bold("Version:")} ${latestVersion}`);
2554
+ console.log(` ${chalk8.bold("Version:")} ${latestVersion}`);
2747
2555
  }
2748
2556
  console.log("");
2749
- console.log(chalk7.bold(" Service Status:"));
2557
+ console.log(chalk8.bold(" Service Status:"));
2750
2558
  for (const s of finalStates) {
2751
- const color = s.status === "healthy" ? chalk7.green : s.status === "starting" ? chalk7.yellow : chalk7.red;
2559
+ const color = s.status === "healthy" ? chalk8.green : s.status === "starting" ? chalk8.yellow : chalk8.red;
2752
2560
  console.log(` ${color(s.status.padEnd(10))} ${s.name}`);
2753
2561
  }
2754
2562
  if (snapshotPath) {
2755
2563
  console.log("");
2756
- console.log(chalk7.dim(` Snapshot saved for rollback: ${snapshotPath}`));
2757
- console.log(chalk7.dim(" Run `horus update --rollback` to revert if needed."));
2564
+ console.log(chalk8.dim(` Snapshot saved for rollback: ${snapshotPath}`));
2565
+ console.log(chalk8.dim(" Run `horus update --rollback` to revert if needed."));
2758
2566
  }
2759
2567
  console.log("");
2760
2568
  });
2761
2569
 
2762
2570
  // src/commands/doctor.ts
2763
- import { Command as Command8 } from "commander";
2764
- import chalk8 from "chalk";
2571
+ import { Command as Command9 } from "commander";
2572
+ import chalk9 from "chalk";
2765
2573
  import { execSync as execSync2 } from "child_process";
2766
2574
  import { existsSync as existsSync7, accessSync, statfsSync, constants } from "fs";
2767
2575
  import { join as join5 } from "path";
2768
2576
  function symbol(status) {
2769
2577
  switch (status) {
2770
2578
  case "pass":
2771
- return chalk8.green(" \u2713 ");
2579
+ return chalk9.green(" \u2713 ");
2772
2580
  case "warn":
2773
- return chalk8.yellow(" \u26A0 ");
2581
+ return chalk9.yellow(" \u26A0 ");
2774
2582
  case "fail":
2775
- return chalk8.red(" \u2717 ");
2583
+ return chalk9.red(" \u2717 ");
2776
2584
  }
2777
2585
  }
2778
2586
  function colorMessage(status, msg) {
2779
2587
  switch (status) {
2780
2588
  case "pass":
2781
- return chalk8.white(msg);
2589
+ return chalk9.white(msg);
2782
2590
  case "warn":
2783
- return chalk8.yellow(msg);
2591
+ return chalk9.yellow(msg);
2784
2592
  case "fail":
2785
- return chalk8.red(msg);
2593
+ return chalk9.red(msg);
2786
2594
  }
2787
2595
  }
2788
2596
  async function checkRuntimeAvailability(preferred) {
@@ -3007,10 +2815,10 @@ async function checkSyncHealth(serviceName, ports) {
3007
2815
  };
3008
2816
  }
3009
2817
  }
3010
- var doctorCommand = new Command8("doctor").description("Diagnose common Horus issues").action(async () => {
2818
+ var doctorCommand = new Command9("doctor").description("Diagnose common Horus issues").action(async () => {
3011
2819
  console.log("");
3012
- console.log(chalk8.bold("Horus Doctor"));
3013
- console.log(chalk8.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"));
2820
+ console.log(chalk9.bold("Horus Doctor"));
2821
+ console.log(chalk9.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"));
3014
2822
  const allResults = [];
3015
2823
  const config = configExists() ? loadConfig() : null;
3016
2824
  allResults.push(await checkRuntimeAvailability(config?.runtime));
@@ -3052,20 +2860,20 @@ var doctorCommand = new Command8("doctor").description("Diagnose common Horus is
3052
2860
  }
3053
2861
  const errors = allResults.filter((r) => r.status === "fail");
3054
2862
  const warnings = allResults.filter((r) => r.status === "warn");
3055
- console.log(chalk8.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"));
2863
+ console.log(chalk9.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"));
3056
2864
  if (errors.length === 0 && warnings.length === 0) {
3057
- console.log(chalk8.green(" All checks passed."));
2865
+ console.log(chalk9.green(" All checks passed."));
3058
2866
  } else {
3059
2867
  const parts = [];
3060
- if (errors.length > 0) parts.push(chalk8.red(`${errors.length} error${errors.length > 1 ? "s" : ""}`));
3061
- if (warnings.length > 0) parts.push(chalk8.yellow(`${warnings.length} warning${warnings.length > 1 ? "s" : ""}`));
2868
+ if (errors.length > 0) parts.push(chalk9.red(`${errors.length} error${errors.length > 1 ? "s" : ""}`));
2869
+ if (warnings.length > 0) parts.push(chalk9.yellow(`${warnings.length} warning${warnings.length > 1 ? "s" : ""}`));
3062
2870
  console.log(` ${parts.join(", ")}`);
3063
2871
  const withHints = [...errors, ...warnings].filter((r) => r.hint);
3064
2872
  if (withHints.length > 0) {
3065
2873
  console.log("");
3066
2874
  for (const r of withHints) {
3067
- const icon = r.status === "fail" ? chalk8.red("\u2717") : chalk8.yellow("\u26A0");
3068
- console.log(` ${icon} ${chalk8.dim(r.hint)}`);
2875
+ const icon = r.status === "fail" ? chalk9.red("\u2717") : chalk9.yellow("\u26A0");
2876
+ console.log(` ${icon} ${chalk9.dim(r.hint)}`);
3069
2877
  }
3070
2878
  }
3071
2879
  }
@@ -3076,8 +2884,8 @@ var doctorCommand = new Command8("doctor").description("Diagnose common Horus is
3076
2884
  });
3077
2885
 
3078
2886
  // src/commands/backup.ts
3079
- import { Command as Command9 } from "commander";
3080
- import chalk9 from "chalk";
2887
+ import { Command as Command10 } from "commander";
2888
+ import chalk10 from "chalk";
3081
2889
  import ora7 from "ora";
3082
2890
  import { confirm as confirm4 } from "@inquirer/prompts";
3083
2891
  import { mkdirSync as mkdirSync5, statSync as statSync2, existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
@@ -3096,15 +2904,15 @@ function formatBytes(bytes) {
3096
2904
  }
3097
2905
  async function createBackup(yes) {
3098
2906
  console.log("");
3099
- console.log(chalk9.bold("Horus Backup"));
3100
- console.log(chalk9.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"));
2907
+ console.log(chalk10.bold("Horus Backup"));
2908
+ console.log(chalk10.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"));
3101
2909
  console.log("");
3102
2910
  const config = loadConfig();
3103
2911
  const runtimeSpinner = ora7("Detecting runtime...").start();
3104
2912
  let runtime;
3105
2913
  try {
3106
2914
  runtime = await detectRuntime(config.runtime);
3107
- runtimeSpinner.succeed(`Using ${chalk9.cyan(runtime.name)}`);
2915
+ runtimeSpinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
3108
2916
  } catch (error) {
3109
2917
  runtimeSpinner.fail("No container runtime found");
3110
2918
  console.log(error.message);
@@ -3116,7 +2924,7 @@ async function createBackup(yes) {
3116
2924
  default: true
3117
2925
  });
3118
2926
  if (!confirmed) {
3119
- console.log(chalk9.dim("Backup cancelled."));
2927
+ console.log(chalk10.dim("Backup cancelled."));
3120
2928
  return;
3121
2929
  }
3122
2930
  }
@@ -3126,7 +2934,7 @@ async function createBackup(yes) {
3126
2934
  stopSpinner.succeed("Services stopped");
3127
2935
  } catch (error) {
3128
2936
  stopSpinner.fail("Failed to stop services");
3129
- console.log(chalk9.dim(error.message));
2937
+ console.log(chalk10.dim(error.message));
3130
2938
  process.exit(1);
3131
2939
  }
3132
2940
  ensureBackupsDir();
@@ -3138,10 +2946,10 @@ async function createBackup(yes) {
3138
2946
  execSync3(`tar -czf "${tarFile}" -C "${HORUS_DIR}" data/`, {
3139
2947
  stdio: "pipe"
3140
2948
  });
3141
- backupSpinner.succeed(`Archive created: ${chalk9.dim(tarFile)}`);
2949
+ backupSpinner.succeed(`Archive created: ${chalk10.dim(tarFile)}`);
3142
2950
  } catch (error) {
3143
2951
  backupSpinner.fail("Failed to create backup archive");
3144
- console.log(chalk9.dim(error.message));
2952
+ console.log(chalk10.dim(error.message));
3145
2953
  await composeStreaming(runtime, ["start"]).catch(() => {
3146
2954
  });
3147
2955
  process.exit(1);
@@ -3164,25 +2972,25 @@ async function createBackup(yes) {
3164
2972
  startSpinner.succeed("Services restarted");
3165
2973
  } catch (error) {
3166
2974
  startSpinner.fail("Failed to restart services");
3167
- console.log(chalk9.dim(error.message));
3168
- console.log(chalk9.yellow("Run `horus up` to restart services manually."));
2975
+ console.log(chalk10.dim(error.message));
2976
+ console.log(chalk10.yellow("Run `horus up` to restart services manually."));
3169
2977
  }
3170
2978
  console.log("");
3171
- console.log(chalk9.bold.green("Backup complete!"));
3172
- console.log(chalk9.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"));
3173
- console.log(` ${chalk9.bold("File:")} ${tarFile}`);
3174
- console.log(` ${chalk9.bold("Size:")} ${formatBytes(sizeBytes)}`);
2979
+ console.log(chalk10.bold.green("Backup complete!"));
2980
+ console.log(chalk10.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"));
2981
+ console.log(` ${chalk10.bold("File:")} ${tarFile}`);
2982
+ console.log(` ${chalk10.bold("Size:")} ${formatBytes(sizeBytes)}`);
3175
2983
  console.log("");
3176
- console.log(chalk9.dim(" Restore with: horus backup restore <file>"));
2984
+ console.log(chalk10.dim(" Restore with: horus backup restore <file>"));
3177
2985
  console.log("");
3178
2986
  }
3179
2987
  async function restoreBackup(file, yes) {
3180
2988
  console.log("");
3181
- console.log(chalk9.bold("Horus Restore"));
3182
- console.log(chalk9.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"));
2989
+ console.log(chalk10.bold("Horus Restore"));
2990
+ console.log(chalk10.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"));
3183
2991
  console.log("");
3184
2992
  if (!existsSync8(file)) {
3185
- console.log(chalk9.red(`Backup file not found: ${file}`));
2993
+ console.log(chalk10.red(`Backup file not found: ${file}`));
3186
2994
  process.exit(1);
3187
2995
  }
3188
2996
  const config = loadConfig();
@@ -3190,21 +2998,21 @@ async function restoreBackup(file, yes) {
3190
2998
  let runtime;
3191
2999
  try {
3192
3000
  runtime = await detectRuntime(config.runtime);
3193
- runtimeSpinner.succeed(`Using ${chalk9.cyan(runtime.name)}`);
3001
+ runtimeSpinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
3194
3002
  } catch (error) {
3195
3003
  runtimeSpinner.fail("No container runtime found");
3196
3004
  console.log(error.message);
3197
3005
  process.exit(1);
3198
3006
  }
3199
3007
  if (!yes) {
3200
- console.log(chalk9.yellow(` Warning: This will overwrite current data in ${config.data_dir}`));
3008
+ console.log(chalk10.yellow(` Warning: This will overwrite current data in ${config.data_dir}`));
3201
3009
  console.log("");
3202
3010
  const confirmed = await confirm4({
3203
3011
  message: `Restore from ${basename(file)}? Current data will be overwritten.`,
3204
3012
  default: false
3205
3013
  });
3206
3014
  if (!confirmed) {
3207
- console.log(chalk9.dim("Restore cancelled."));
3015
+ console.log(chalk10.dim("Restore cancelled."));
3208
3016
  return;
3209
3017
  }
3210
3018
  }
@@ -3214,7 +3022,7 @@ async function restoreBackup(file, yes) {
3214
3022
  stopSpinner.succeed("Services stopped");
3215
3023
  } catch (error) {
3216
3024
  stopSpinner.fail("Failed to stop services");
3217
- console.log(chalk9.dim(error.message));
3025
+ console.log(chalk10.dim(error.message));
3218
3026
  process.exit(1);
3219
3027
  }
3220
3028
  const extractSpinner = ora7("Extracting backup...").start();
@@ -3223,18 +3031,18 @@ async function restoreBackup(file, yes) {
3223
3031
  extractSpinner.succeed("Backup extracted");
3224
3032
  } catch (error) {
3225
3033
  extractSpinner.fail("Failed to extract backup");
3226
- console.log(chalk9.dim(error.message));
3034
+ console.log(chalk10.dim(error.message));
3227
3035
  await composeStreaming(runtime, ["start"]).catch(() => {
3228
3036
  });
3229
3037
  process.exit(1);
3230
3038
  }
3231
3039
  console.log("");
3232
- console.log(chalk9.bold("Starting services..."));
3040
+ console.log(chalk10.bold("Starting services..."));
3233
3041
  try {
3234
3042
  await composeStreaming(runtime, ["start"]);
3235
3043
  } catch (error) {
3236
- console.log(chalk9.red("Failed to start services."));
3237
- console.log(chalk9.dim(error.message));
3044
+ console.log(chalk10.red("Failed to start services."));
3045
+ console.log(chalk10.dim(error.message));
3238
3046
  process.exit(1);
3239
3047
  }
3240
3048
  console.log("");
@@ -3244,7 +3052,7 @@ async function restoreBackup(file, yes) {
3244
3052
  runtime,
3245
3053
  (current) => {
3246
3054
  const summary = current.map((s) => {
3247
- const icon = s.status === "healthy" ? chalk9.green("*") : s.status === "starting" ? chalk9.yellow("~") : chalk9.red("x");
3055
+ const icon = s.status === "healthy" ? chalk10.green("*") : s.status === "starting" ? chalk10.yellow("~") : chalk10.red("x");
3248
3056
  return `${icon} ${s.name}`;
3249
3057
  }).join(" ");
3250
3058
  healthSpinner.text = `Waiting for services... ${summary}`;
@@ -3255,14 +3063,14 @@ async function restoreBackup(file, yes) {
3255
3063
  healthSpinner.succeed("All services healthy");
3256
3064
  } catch (error) {
3257
3065
  healthSpinner.fail("Some services did not become healthy");
3258
- console.log(chalk9.dim(error.message));
3066
+ console.log(chalk10.dim(error.message));
3259
3067
  process.exit(1);
3260
3068
  }
3261
3069
  console.log("");
3262
- console.log(chalk9.bold.green("Restore complete!"));
3070
+ console.log(chalk10.bold.green("Restore complete!"));
3263
3071
  console.log("");
3264
3072
  }
3265
- var backupCommand = new Command9("backup").description("Backup or restore Horus data").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
3073
+ var backupCommand = new Command10("backup").description("Backup or restore Horus data").option("-y, --yes", "Skip confirmation prompts").action(async (opts) => {
3266
3074
  await createBackup(opts.yes);
3267
3075
  });
3268
3076
  backupCommand.command("restore <file>").description("Restore Horus data from a backup file").option("-y, --yes", "Skip confirmation prompts").action(async (file, opts) => {
@@ -3270,8 +3078,8 @@ backupCommand.command("restore <file>").description("Restore Horus data from a b
3270
3078
  });
3271
3079
 
3272
3080
  // src/commands/test-env.ts
3273
- import { Command as Command10 } from "commander";
3274
- import chalk10 from "chalk";
3081
+ import { Command as Command11 } from "commander";
3082
+ import chalk11 from "chalk";
3275
3083
  import ora8 from "ora";
3276
3084
  import { join as join8 } from "path";
3277
3085
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -3724,7 +3532,7 @@ function projectName(slot) {
3724
3532
  }
3725
3533
 
3726
3534
  // src/commands/test-env.ts
3727
- var testEnvCommand = new Command10("test-env").description("Manage isolated shadow stacks for integration testing");
3535
+ var testEnvCommand = new Command11("test-env").description("Manage isolated shadow stacks for integration testing");
3728
3536
  testEnvCommand.command("acquire").description("Start a shadow stack on alternate ports with isolated data").option("--timeout <seconds>", "Max wait for health checks (default: 120)", "120").option("--image <overrides...>", "Override service images (format: service=image:tag)").option(
3729
3537
  "--standalone",
3730
3538
  "Generate a fully-projected compose file (no overlay-merge). Eliminates port-collision with a live stack. Required on fresh VMs."
@@ -3736,6 +3544,11 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3736
3544
  const mkSpinner = (text) => ora8({ text, stream: jsonMode ? process.stderr : process.stdout });
3737
3545
  const config = loadConfig();
3738
3546
  const dataDir = config.data_dir;
3547
+ if (!config.vaults || Object.keys(config.vaults).length === 0) {
3548
+ config.vaults = {
3549
+ default: { repo: "", default: true }
3550
+ };
3551
+ }
3739
3552
  const testCfg = loadTestEnvConfig(dataDir);
3740
3553
  const vaultNames = Object.keys(config.vaults).sort();
3741
3554
  const defaultVaultEntry = Object.entries(config.vaults).find(([, v]) => v.default);
@@ -3744,7 +3557,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3744
3557
  let runtime;
3745
3558
  try {
3746
3559
  runtime = await detectRuntime(config.runtime);
3747
- spinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
3560
+ spinner.succeed(`Using ${chalk11.cyan(runtime.name)}`);
3748
3561
  } catch (error) {
3749
3562
  spinner.fail("No container runtime found");
3750
3563
  console.error(error.message);
@@ -3752,8 +3565,8 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3752
3565
  }
3753
3566
  const slot = findFreeSlot(dataDir, testCfg);
3754
3567
  if (slot === null) {
3755
- console.error(chalk10.red(
3756
- `All ${testCfg.max_slots} slot(s) are in use. Run ${chalk10.bold("horus test-env status")} to see active slots, or ${chalk10.bold("horus test-env release")} to free one.`
3568
+ console.error(chalk11.red(
3569
+ `All ${testCfg.max_slots} slot(s) are in use. Run ${chalk11.bold("horus test-env status")} to see active slots, or ${chalk11.bold("horus test-env release")} to free one.`
3757
3570
  ));
3758
3571
  process.exit(1);
3759
3572
  }
@@ -3762,7 +3575,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3762
3575
  const project = projectName(slot);
3763
3576
  const dirSpinner = mkSpinner(`Creating slot-${slot} data directories...`).start();
3764
3577
  createSlotDirs(slotDataPath, vaultNames);
3765
- dirSpinner.succeed(`Data directory: ${chalk10.dim(slotDataPath)}`);
3578
+ dirSpinner.succeed(`Data directory: ${chalk11.dim(slotDataPath)}`);
3766
3579
  const seedSpinner = mkSpinner("Pre-seeding git repos...").start();
3767
3580
  try {
3768
3581
  await preSeedNotesDir(dataDir, slotDataPath);
@@ -3787,7 +3600,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3787
3600
  for (const entry of opts.image) {
3788
3601
  const eqIdx = entry.indexOf("=");
3789
3602
  if (eqIdx < 1) {
3790
- console.error(chalk10.red(`Invalid --image format: "${entry}". Expected: service=image:tag`));
3603
+ console.error(chalk11.red(`Invalid --image format: "${entry}". Expected: service=image:tag`));
3791
3604
  process.exit(1);
3792
3605
  }
3793
3606
  imageOverrides[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
@@ -3796,7 +3609,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3796
3609
  const standaloneMode = Boolean(opts.standalone);
3797
3610
  const modeLabel = standaloneMode ? "standalone" : "overlay";
3798
3611
  const upSpinner = mkSpinner(
3799
- `Starting shadow stack (project ${chalk10.cyan(project)}, mode: ${chalk10.dim(modeLabel)})...`
3612
+ `Starting shadow stack (project ${chalk11.cyan(project)}, mode: ${chalk11.dim(modeLabel)})...`
3800
3613
  ).start();
3801
3614
  try {
3802
3615
  if (standaloneMode) {
@@ -3816,7 +3629,7 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3816
3629
  const timeoutMs = parseInt(opts.timeout, 10) * 1e3;
3817
3630
  try {
3818
3631
  await waitForShadowStackHealthy(runtime, project, timeoutMs, 3e3, (statuses) => {
3819
- const parts = Object.entries(statuses).map(([svc, s]) => `${svc}:${s === "healthy" ? chalk10.green(s) : chalk10.yellow(s)}`).join(" ");
3632
+ const parts = Object.entries(statuses).map(([svc, s]) => `${svc}:${s === "healthy" ? chalk11.green(s) : chalk11.yellow(s)}`).join(" ");
3820
3633
  healthSpinner.text = `Waiting for services... ${parts}`;
3821
3634
  });
3822
3635
  healthSpinner.succeed("All services healthy");
@@ -3844,22 +3657,22 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3844
3657
  return;
3845
3658
  }
3846
3659
  console.log("");
3847
- console.log(chalk10.bold.green(`\u2713 Slot ${slot} acquired`));
3660
+ console.log(chalk11.bold.green(`\u2713 Slot ${slot} acquired`));
3848
3661
  console.log("");
3849
- console.log(chalk10.bold("Connection info:"));
3850
- console.log(` Slot: ${chalk10.cyan(slot)}`);
3851
- console.log(` Project: ${chalk10.cyan(project)}`);
3852
- console.log(` Data: ${chalk10.dim(slotDataPath)}`);
3662
+ console.log(chalk11.bold("Connection info:"));
3663
+ console.log(` Slot: ${chalk11.cyan(slot)}`);
3664
+ console.log(` Project: ${chalk11.cyan(project)}`);
3665
+ console.log(` Data: ${chalk11.dim(slotDataPath)}`);
3853
3666
  console.log("");
3854
- console.log(chalk10.bold("Ports:"));
3855
- console.log(` Anvil: http://localhost:${chalk10.cyan(ports.anvil)}`);
3856
- console.log(` Forge: http://localhost:${chalk10.cyan(ports.forge)}`);
3857
- console.log(` Vault MCP: http://localhost:${chalk10.cyan(ports.vault_mcp)}`);
3858
- console.log(` Vault Router: http://localhost:${chalk10.cyan(ports.vault_router)}`);
3859
- console.log(` Typesense: http://localhost:${chalk10.cyan(ports.typesense)}`);
3860
- console.log(` UI: http://localhost:${chalk10.cyan(ports.ui)}`);
3667
+ console.log(chalk11.bold("Ports:"));
3668
+ console.log(` Anvil: http://localhost:${chalk11.cyan(ports.anvil)}`);
3669
+ console.log(` Forge: http://localhost:${chalk11.cyan(ports.forge)}`);
3670
+ console.log(` Vault MCP: http://localhost:${chalk11.cyan(ports.vault_mcp)}`);
3671
+ console.log(` Vault Router: http://localhost:${chalk11.cyan(ports.vault_router)}`);
3672
+ console.log(` Typesense: http://localhost:${chalk11.cyan(ports.typesense)}`);
3673
+ console.log(` UI: http://localhost:${chalk11.cyan(ports.ui)}`);
3861
3674
  console.log("");
3862
- console.log(chalk10.bold("Environment:"));
3675
+ console.log(chalk11.bold("Environment:"));
3863
3676
  console.log(` export TEST_SLOT=${slot}`);
3864
3677
  console.log(` export TEST_ANVIL_URL=http://localhost:${ports.anvil}`);
3865
3678
  console.log(` export TEST_FORGE_URL=http://localhost:${ports.forge}`);
@@ -3867,16 +3680,21 @@ testEnvCommand.command("acquire").description("Start a shadow stack on alternate
3867
3680
  console.log(` export TEST_DATA_PATH=${slotDataPath}`);
3868
3681
  console.log("");
3869
3682
  const settingsPath = join8(slotDataPath, "claude-settings.json");
3870
- console.log(chalk10.bold("Agent dev mode:"));
3871
- console.log(` MCP config: ${chalk10.dim(settingsPath)}`);
3872
- console.log(` Launch: ${chalk10.cyan(`claude --mcp-config ${settingsPath}`)}`);
3683
+ console.log(chalk11.bold("Agent dev mode:"));
3684
+ console.log(` MCP config: ${chalk11.dim(settingsPath)}`);
3685
+ console.log(` Launch: ${chalk11.cyan(`claude --mcp-config ${settingsPath}`)}`);
3873
3686
  console.log("");
3874
- console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env seed --slot ${slot}`)} to populate with fixtures.`));
3875
- console.log(chalk10.dim(`Run ${chalk10.bold(`horus test-env release --slot ${slot}`)} when done.`));
3687
+ console.log(chalk11.dim(`Run ${chalk11.bold(`horus test-env seed --slot ${slot}`)} to populate with fixtures.`));
3688
+ console.log(chalk11.dim(`Run ${chalk11.bold(`horus test-env release --slot ${slot}`)} when done.`));
3876
3689
  });
3877
3690
  testEnvCommand.command("release").description("Tear down a shadow stack and remove its data").option("--slot <n>", "Slot number to release (default: auto-detect acquired slot)").action(async (opts) => {
3878
3691
  const config = loadConfig();
3879
3692
  const dataDir = config.data_dir;
3693
+ if (!config.vaults || Object.keys(config.vaults).length === 0) {
3694
+ config.vaults = {
3695
+ default: { repo: "", default: true }
3696
+ };
3697
+ }
3880
3698
  const testCfg = loadTestEnvConfig(dataDir);
3881
3699
  const vaultNames = Object.keys(config.vaults).sort();
3882
3700
  const defaultVaultEntry = Object.entries(config.vaults).find(([, v]) => v.default);
@@ -3888,7 +3706,7 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
3888
3706
  const statuses = getAllSlotStatuses(dataDir, testCfg);
3889
3707
  const acquired = statuses.find((s) => s.state === "acquired" || s.state === "expired");
3890
3708
  if (!acquired) {
3891
- console.log(chalk10.yellow("No active slots found."));
3709
+ console.log(chalk11.yellow("No active slots found."));
3892
3710
  return;
3893
3711
  }
3894
3712
  slot = acquired.slot;
@@ -3901,13 +3719,13 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
3901
3719
  let runtime;
3902
3720
  try {
3903
3721
  runtime = await detectRuntime(config.runtime);
3904
- spinner.succeed(`Using ${chalk10.cyan(runtime.name)}`);
3722
+ spinner.succeed(`Using ${chalk11.cyan(runtime.name)}`);
3905
3723
  } catch (error) {
3906
3724
  spinner.fail("No container runtime found");
3907
3725
  console.error(error.message);
3908
3726
  process.exit(1);
3909
3727
  }
3910
- const downSpinner = ora8(`Stopping ${chalk10.cyan(project)}...`).start();
3728
+ const downSpinner = ora8(`Stopping ${chalk11.cyan(project)}...`).start();
3911
3729
  try {
3912
3730
  const { existsSync: existsSync12 } = await import("fs");
3913
3731
  const { join: join11 } = await import("path");
@@ -3926,7 +3744,7 @@ testEnvCommand.command("release").description("Tear down a shadow stack and remo
3926
3744
  removeLock(dataDir, slot);
3927
3745
  cleanSpinner.succeed("Test data removed");
3928
3746
  console.log("");
3929
- console.log(chalk10.bold.green(`\u2713 Slot ${slot} released`));
3747
+ console.log(chalk11.bold.green(`\u2713 Slot ${slot} released`));
3930
3748
  });
3931
3749
  testEnvCommand.command("status").description("Show active shadow stack slots").action(() => {
3932
3750
  const config = loadConfig();
@@ -3935,20 +3753,20 @@ testEnvCommand.command("status").description("Show active shadow stack slots").a
3935
3753
  const statuses = getAllSlotStatuses(dataDir, testCfg);
3936
3754
  const acquiredCount = statuses.filter((s) => s.state === "acquired").length;
3937
3755
  console.log("");
3938
- console.log(chalk10.bold("Test Environment Status"));
3756
+ console.log(chalk11.bold("Test Environment Status"));
3939
3757
  console.log(` Max slots: ${testCfg.max_slots}`);
3940
3758
  console.log(` In use: ${acquiredCount} / ${testCfg.max_slots}`);
3941
3759
  console.log(` Base port: ${testCfg.base_port}`);
3942
3760
  console.log("");
3943
3761
  if (statuses.every((s) => s.state === "free")) {
3944
- console.log(chalk10.dim(" No active slots."));
3762
+ console.log(chalk11.dim(" No active slots."));
3945
3763
  console.log("");
3946
3764
  return;
3947
3765
  }
3948
3766
  for (const s of statuses) {
3949
3767
  if (s.state === "free") continue;
3950
- const stateLabel = s.state === "expired" ? chalk10.yellow("EXPIRED") : chalk10.green("ACTIVE");
3951
- console.log(` ${chalk10.bold(`Slot ${s.slot}`)} ${stateLabel}`);
3768
+ const stateLabel = s.state === "expired" ? chalk11.yellow("EXPIRED") : chalk11.green("ACTIVE");
3769
+ console.log(` ${chalk11.bold(`Slot ${s.slot}`)} ${stateLabel}`);
3952
3770
  if (s.acquiredAt) {
3953
3771
  console.log(` Acquired: ${s.acquiredAt} (${s.elapsedMinutes}m ago)`);
3954
3772
  }
@@ -3956,7 +3774,7 @@ testEnvCommand.command("status").description("Show active shadow stack slots").a
3956
3774
  console.log(` Ports: anvil=${s.ports.anvil} forge=${s.ports.forge} vault-mcp=${s.ports.vault_mcp} typesense=${s.ports.typesense}`);
3957
3775
  }
3958
3776
  if (s.dataPath) {
3959
- console.log(` Data: ${chalk10.dim(s.dataPath)}`);
3777
+ console.log(` Data: ${chalk11.dim(s.dataPath)}`);
3960
3778
  }
3961
3779
  console.log("");
3962
3780
  }
@@ -3972,7 +3790,7 @@ testEnvCommand.command("seed").description("Populate a slot with test fixtures (
3972
3790
  const statuses = getAllSlotStatuses(dataDir, testCfg);
3973
3791
  const acquired = statuses.find((s) => s.state === "acquired");
3974
3792
  if (!acquired) {
3975
- console.error(chalk10.red("No active slot found. Run `horus test-env acquire` first."));
3793
+ console.error(chalk11.red("No active slot found. Run `horus test-env acquire` first."));
3976
3794
  process.exit(1);
3977
3795
  }
3978
3796
  slot = acquired.slot;
@@ -4003,12 +3821,12 @@ testEnvCommand.command("seed").description("Populate a slot with test fixtures (
4003
3821
  }
4004
3822
  }
4005
3823
  console.log("");
4006
- console.log(chalk10.dim("Services will re-index automatically. Allow ~10s before running tests."));
3824
+ console.log(chalk11.dim("Services will re-index automatically. Allow ~10s before running tests."));
4007
3825
  });
4008
3826
 
4009
3827
  // src/commands/help.ts
4010
- import { Command as Command11 } from "commander";
4011
- import chalk11 from "chalk";
3828
+ import { Command as Command12 } from "commander";
3829
+ import chalk12 from "chalk";
4012
3830
  import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
4013
3831
  import { join as join9, dirname as dirname3 } from "path";
4014
3832
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -4185,47 +4003,47 @@ function stripFrontMatter(content) {
4185
4003
  }
4186
4004
  function printTopicIndex(index) {
4187
4005
  console.log("");
4188
- console.log(chalk11.bold("Horus Help \u2014 Available Guides"));
4189
- console.log(chalk11.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
4006
+ console.log(chalk12.bold("Horus Help \u2014 Available Guides"));
4007
+ console.log(chalk12.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
4190
4008
  console.log("");
4191
4009
  for (const g of index.guides) {
4192
- console.log(` ${chalk11.cyan(g.slug.padEnd(20))} ${g.title}`);
4193
- console.log(` ${" ".repeat(20)} ${chalk11.dim(g.description)}`);
4010
+ console.log(` ${chalk12.cyan(g.slug.padEnd(20))} ${g.title}`);
4011
+ console.log(` ${" ".repeat(20)} ${chalk12.dim(g.description)}`);
4194
4012
  console.log("");
4195
4013
  }
4196
- console.log(chalk11.dim("Example queries:"));
4197
- console.log(chalk11.dim(" horus help how do I start"));
4198
- console.log(chalk11.dim(" horus help what is a forge workspace"));
4199
- console.log(chalk11.dim(" horus help create my first anvil note"));
4014
+ console.log(chalk12.dim("Example queries:"));
4015
+ console.log(chalk12.dim(" horus help how do I start"));
4016
+ console.log(chalk12.dim(" horus help what is a forge workspace"));
4017
+ console.log(chalk12.dim(" horus help create my first anvil note"));
4200
4018
  console.log("");
4201
- console.log(chalk11.dim("To print a specific guide directly:"));
4202
- console.log(chalk11.dim(" horus guide <slug>"));
4019
+ console.log(chalk12.dim("To print a specific guide directly:"));
4020
+ console.log(chalk12.dim(" horus guide <slug>"));
4203
4021
  console.log("");
4204
4022
  }
4205
4023
  function printGuideBody(guidesDir, file) {
4206
- const path = join9(guidesDir, file);
4207
- const content = readFileSync7(path, "utf8");
4024
+ const path2 = join9(guidesDir, file);
4025
+ const content = readFileSync7(path2, "utf8");
4208
4026
  console.log(stripFrontMatter(content));
4209
4027
  }
4210
4028
  function printSeeAlso(alternates, guidesDir) {
4211
4029
  if (alternates.length === 0) return;
4212
- console.log(chalk11.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
4213
- console.log(chalk11.bold("See also:"));
4030
+ console.log(chalk12.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
4031
+ console.log(chalk12.bold("See also:"));
4214
4032
  for (const a of alternates) {
4215
- console.log(` ${chalk11.cyan(a.slug.padEnd(20))} ${a.title}`);
4216
- console.log(` ${" ".repeat(20)} ${chalk11.dim(join9(guidesDir, a.file))}`);
4033
+ console.log(` ${chalk12.cyan(a.slug.padEnd(20))} ${a.title}`);
4034
+ console.log(` ${" ".repeat(20)} ${chalk12.dim(join9(guidesDir, a.file))}`);
4217
4035
  }
4218
4036
  console.log("");
4219
4037
  }
4220
4038
  function printFooter() {
4221
4039
  console.log(
4222
- chalk11.dim(
4040
+ chalk12.dim(
4223
4041
  "Run `horus guide <slug>` to print a specific guide without retrieval, or `horus guide` to list all."
4224
4042
  )
4225
4043
  );
4226
4044
  console.log("");
4227
4045
  }
4228
- var helpCommand = new Command11("help").description("Search and print bundled Horus getting-started guides").argument("[query...]", "Natural-language query. Omit to see the topic index.").action((query) => {
4046
+ var helpCommand = new Command12("help").description("Search and print bundled Horus getting-started guides").argument("[query...]", "Natural-language query. Omit to see the topic index.").action((query) => {
4229
4047
  const guidesDir = findGuidesDir();
4230
4048
  const index = loadIndex(guidesDir);
4231
4049
  if (!query || query.length === 0) {
@@ -4236,15 +4054,15 @@ var helpCommand = new Command11("help").description("Search and print bundled Ho
4236
4054
  const result = retrieve(queryStr, index, 3);
4237
4055
  if (!result.primary) {
4238
4056
  console.log("");
4239
- console.log(chalk11.yellow(`No guide matched "${queryStr}".`));
4057
+ console.log(chalk12.yellow(`No guide matched "${queryStr}".`));
4240
4058
  console.log("");
4241
- console.log(chalk11.dim("Try `horus help` with no arguments to see the full topic index,"));
4242
- console.log(chalk11.dim("or pick a slug directly with `horus guide <slug>`."));
4059
+ console.log(chalk12.dim("Try `horus help` with no arguments to see the full topic index,"));
4060
+ console.log(chalk12.dim("or pick a slug directly with `horus guide <slug>`."));
4243
4061
  console.log("");
4244
4062
  return;
4245
4063
  }
4246
4064
  console.log("");
4247
- console.log(chalk11.dim(`# ${result.primary.title} (${result.primary.slug})`));
4065
+ console.log(chalk12.dim(`# ${result.primary.title} (${result.primary.slug})`));
4248
4066
  console.log("");
4249
4067
  printGuideBody(guidesDir, result.primary.file);
4250
4068
  console.log("");
@@ -4253,8 +4071,8 @@ var helpCommand = new Command11("help").description("Search and print bundled Ho
4253
4071
  });
4254
4072
 
4255
4073
  // src/commands/guide.ts
4256
- import { Command as Command12 } from "commander";
4257
- import chalk12 from "chalk";
4074
+ import { Command as Command13 } from "commander";
4075
+ import chalk13 from "chalk";
4258
4076
  import { readFileSync as readFileSync8, existsSync as existsSync11 } from "fs";
4259
4077
  import { join as join10, dirname as dirname4 } from "path";
4260
4078
  import { fileURLToPath as fileURLToPath4 } from "url";
@@ -4293,17 +4111,17 @@ function lookupTopic(topic, index) {
4293
4111
  }
4294
4112
  function printGuideList(index) {
4295
4113
  console.log("");
4296
- console.log(chalk12.bold("Bundled Horus Guides"));
4297
- console.log(chalk12.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
4114
+ console.log(chalk13.bold("Bundled Horus Guides"));
4115
+ console.log(chalk13.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"));
4298
4116
  console.log("");
4299
4117
  for (const g of index.guides) {
4300
- console.log(` ${chalk12.cyan(g.slug.padEnd(20))} ${g.title}`);
4301
- console.log(` ${" ".repeat(20)} ${chalk12.dim(g.description)}`);
4118
+ console.log(` ${chalk13.cyan(g.slug.padEnd(20))} ${g.title}`);
4119
+ console.log(` ${" ".repeat(20)} ${chalk13.dim(g.description)}`);
4302
4120
  console.log("");
4303
4121
  }
4304
- console.log(chalk12.dim("Print a guide: horus guide <slug>"));
4305
- console.log(chalk12.dim("Print a guide file path: horus guide <slug> --path"));
4306
- console.log(chalk12.dim("Print the guides root: horus guide --path"));
4122
+ console.log(chalk13.dim("Print a guide: horus guide <slug>"));
4123
+ console.log(chalk13.dim("Print a guide file path: horus guide <slug> --path"));
4124
+ console.log(chalk13.dim("Print the guides root: horus guide --path"));
4307
4125
  console.log("");
4308
4126
  }
4309
4127
  function printGuideBody2(guidesDir, file) {
@@ -4312,16 +4130,16 @@ function printGuideBody2(guidesDir, file) {
4312
4130
  }
4313
4131
  function printDisambiguation(tier, matches) {
4314
4132
  console.log("");
4315
- console.log(chalk12.yellow(`Multiple guides matched (tier: ${tier}):`));
4133
+ console.log(chalk13.yellow(`Multiple guides matched (tier: ${tier}):`));
4316
4134
  console.log("");
4317
4135
  for (const m of matches) {
4318
- console.log(` ${chalk12.cyan(m.slug.padEnd(20))} ${m.title}`);
4136
+ console.log(` ${chalk13.cyan(m.slug.padEnd(20))} ${m.title}`);
4319
4137
  }
4320
4138
  console.log("");
4321
- console.log(chalk12.dim("Pick one: horus guide <slug>"));
4139
+ console.log(chalk13.dim("Pick one: horus guide <slug>"));
4322
4140
  console.log("");
4323
4141
  }
4324
- var guideCommand = new Command12("guide").description("Print a bundled Horus guide, or list all guides").argument("[topic]", "Slug, slug prefix, or search term. Omit to list all guides.").option("--path", "Print the file path instead of the body (or the guides dir root if no topic)").action((topic, opts) => {
4142
+ var guideCommand = new Command13("guide").description("Print a bundled Horus guide, or list all guides").argument("[topic]", "Slug, slug prefix, or search term. Omit to list all guides.").option("--path", "Print the file path instead of the body (or the guides dir root if no topic)").action((topic, opts) => {
4325
4143
  const guidesDir = findGuidesDir2();
4326
4144
  const index = loadIndex2(guidesDir);
4327
4145
  if (!topic) {
@@ -4335,9 +4153,9 @@ var guideCommand = new Command12("guide").description("Print a bundled Horus gui
4335
4153
  const result = lookupTopic(topic, index);
4336
4154
  if (result.matches.length === 0) {
4337
4155
  console.log("");
4338
- console.log(chalk12.yellow(`No guide matched "${topic}".`));
4156
+ console.log(chalk13.yellow(`No guide matched "${topic}".`));
4339
4157
  console.log("");
4340
- console.log(chalk12.dim("Run `horus guide` to see all available guides."));
4158
+ console.log(chalk13.dim("Run `horus guide` to see all available guides."));
4341
4159
  console.log("");
4342
4160
  process.exitCode = 1;
4343
4161
  return;
@@ -4356,14 +4174,88 @@ var guideCommand = new Command12("guide").description("Print a bundled Horus gui
4356
4174
  });
4357
4175
 
4358
4176
  // src/commands/repo.ts
4359
- import { Command as Command13 } from "commander";
4360
- import chalk13 from "chalk";
4177
+ import { Command as Command14 } from "commander";
4178
+ import chalk14 from "chalk";
4361
4179
  import ora9 from "ora";
4362
- var repoCommand = new Command13("repo").description("Manage the Forge repository index");
4180
+ import { RepoRegistryClient } from "@forge/core";
4181
+
4182
+ // src/lib/repo-migrate.ts
4183
+ import { promises as fs } from "fs";
4184
+ import os from "os";
4185
+ import path from "path";
4186
+ import { RepoExistsError } from "@forge/core";
4187
+ function inferOrg(remoteUrl) {
4188
+ const sshMatch = remoteUrl.match(/git@[^:]+:([^/]+)\//);
4189
+ if (sshMatch) return sshMatch[1];
4190
+ const httpsMatch = remoteUrl.match(/https?:\/\/[^/]+\/([^/]+)\//);
4191
+ if (httpsMatch) return httpsMatch[1];
4192
+ return null;
4193
+ }
4194
+ function mapWorkflow(entry) {
4195
+ if (!entry.workflow) return void 0;
4196
+ const wf = entry.workflow;
4197
+ return {
4198
+ type: entry.workflow.type,
4199
+ pushTo: entry.workflow.pushTo,
4200
+ prTarget: entry.workflow.prTarget,
4201
+ branchPattern: entry.workflow.branchPattern,
4202
+ commitFormat: entry.workflow.commitFormat,
4203
+ mergeStrategy: wf["mergeStrategy"]
4204
+ };
4205
+ }
4206
+ async function migrateRepos(opts) {
4207
+ const fromPath = opts.from ? opts.from : path.join(os.homedir(), "Horus", "data", "config", "repos.json");
4208
+ const raw = await fs.readFile(fromPath, "utf8");
4209
+ const index = JSON.parse(raw);
4210
+ const entries = index.repos ?? [];
4211
+ const result = { migrated: [], skipped: [], failed: [] };
4212
+ for (const entry of entries) {
4213
+ if (!entry.remoteUrl) {
4214
+ result.skipped.push({ repo: entry.name, reason: "no remoteUrl" });
4215
+ continue;
4216
+ }
4217
+ const org = inferOrg(entry.remoteUrl);
4218
+ if (!org) {
4219
+ result.skipped.push({ repo: entry.name, reason: "could not infer org from remoteUrl" });
4220
+ continue;
4221
+ }
4222
+ const input2 = {
4223
+ org,
4224
+ name: entry.name,
4225
+ canonicalUrl: entry.remoteUrl,
4226
+ defaultBranch: entry.defaultBranch,
4227
+ language: entry.language ?? void 0,
4228
+ workflow: mapWorkflow(entry)
4229
+ };
4230
+ if (opts.dryRun) {
4231
+ result.migrated.push(`${org}/${entry.name} (dry-run)`);
4232
+ continue;
4233
+ }
4234
+ try {
4235
+ await opts.client.register(input2);
4236
+ result.migrated.push(`${org}/${entry.name}`);
4237
+ } catch (err) {
4238
+ if (err instanceof RepoExistsError) {
4239
+ result.skipped.push({ repo: `${org}/${entry.name}`, reason: "already registered" });
4240
+ } else {
4241
+ const msg = err.message ?? String(err);
4242
+ if (msg.includes("REPO_EXISTS") || err.code === "REPO_EXISTS") {
4243
+ result.skipped.push({ repo: `${org}/${entry.name}`, reason: "already registered" });
4244
+ } else {
4245
+ result.failed.push({ repo: `${org}/${entry.name}`, error: msg });
4246
+ }
4247
+ }
4248
+ }
4249
+ }
4250
+ return result;
4251
+ }
4252
+
4253
+ // src/commands/repo.ts
4254
+ var repoCommand = new Command14("repo").description("Manage the Forge repository index");
4363
4255
  repoCommand.command("rindex").alias("scan").description("Trigger a full repository index rescan via Forge").action(async () => {
4364
4256
  if (!configExists()) {
4365
- console.log(chalk13.red("Horus is not set up yet."));
4366
- console.log(chalk13.dim("Run `horus setup` first."));
4257
+ console.log(chalk14.red("Horus is not set up yet."));
4258
+ console.log(chalk14.dim("Run `horus setup` first."));
4367
4259
  process.exit(1);
4368
4260
  }
4369
4261
  const config = loadConfig();
@@ -4385,13 +4277,13 @@ repoCommand.command("rindex").alias("scan").description("Trigger a full reposito
4385
4277
  body = await res.text();
4386
4278
  if (!res.ok) {
4387
4279
  spinner.fail(`Forge returned HTTP ${res.status}`);
4388
- console.error(chalk13.red(body));
4280
+ console.error(chalk14.red(body));
4389
4281
  process.exit(1);
4390
4282
  }
4391
4283
  } catch (err) {
4392
4284
  spinner.fail("Could not reach Forge");
4393
- console.error(chalk13.red(`Is Horus running? (horus up)`));
4394
- console.error(chalk13.dim(err.message));
4285
+ console.error(chalk14.red(`Is Horus running? (horus up)`));
4286
+ console.error(chalk14.dim(err.message));
4395
4287
  process.exit(1);
4396
4288
  }
4397
4289
  let parsed;
@@ -4404,7 +4296,7 @@ repoCommand.command("rindex").alias("scan").description("Trigger a full reposito
4404
4296
  }
4405
4297
  if (parsed.error) {
4406
4298
  spinner.fail("Scan failed");
4407
- console.error(chalk13.red(parsed.error.message ?? JSON.stringify(parsed.error)));
4299
+ console.error(chalk14.red(parsed.error.message ?? JSON.stringify(parsed.error)));
4408
4300
  process.exit(1);
4409
4301
  }
4410
4302
  let result = {};
@@ -4415,19 +4307,318 @@ repoCommand.command("rindex").alias("scan").description("Trigger a full reposito
4415
4307
  }
4416
4308
  spinner.succeed("Repository scan complete");
4417
4309
  console.log("");
4418
- console.log(` ${chalk13.bold("Scan paths:")} ${(result.scanPaths ?? []).length}`);
4419
- console.log(` ${chalk13.bold("Repos found:")} ${result.reposFound ?? 0}`);
4310
+ console.log(` ${chalk14.bold("Scan paths:")} ${(result.scanPaths ?? []).length}`);
4311
+ console.log(` ${chalk14.bold("Repos found:")} ${result.reposFound ?? 0}`);
4420
4312
  if (result.repos && result.repos.length > 0) {
4421
4313
  console.log("");
4422
4314
  for (const repo of result.repos) {
4423
- console.log(` ${chalk13.green("\u2713")} ${chalk13.bold(repo.name)} ${chalk13.dim(repo.localPath)}`);
4315
+ console.log(` ${chalk14.green("\u2713")} ${chalk14.bold(repo.name)} ${chalk14.dim(repo.localPath)}`);
4424
4316
  }
4425
4317
  }
4426
4318
  console.log("");
4427
4319
  });
4320
+ repoCommand.command("migrate").description("Import existing repos.json entries into the shared repo registry").option("--from <path>", "Path to repos.json (default: ~/Horus/data/config/repos.json)").option("--registry <url>", "Shared registry base URL (overrides enterprise_registry_url)").option("--dry-run", "Preview what would be migrated without writing to the registry").action(async (opts) => {
4321
+ console.log("");
4322
+ console.log(chalk14.bold("Horus Repo Migrate"));
4323
+ console.log(chalk14.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"));
4324
+ console.log("");
4325
+ if (!configExists()) {
4326
+ console.log(chalk14.red("Horus is not set up yet."));
4327
+ console.log(chalk14.dim("Run `horus setup` first."));
4328
+ process.exit(1);
4329
+ }
4330
+ const config = loadConfig();
4331
+ const registryUrl = opts.registry ?? config.enterprise_registry_url ?? "http://localhost:8744";
4332
+ const client = new RepoRegistryClient({ baseUrl: registryUrl });
4333
+ if (opts.dryRun) {
4334
+ console.log(chalk14.yellow(" Dry-run mode \u2014 no changes will be written to the registry."));
4335
+ console.log("");
4336
+ }
4337
+ const spinner = ora9("Reading repos.json...").start();
4338
+ let migrateResult;
4339
+ try {
4340
+ migrateResult = await migrateRepos({
4341
+ from: opts.from,
4342
+ dryRun: opts.dryRun,
4343
+ client
4344
+ });
4345
+ } catch (err) {
4346
+ spinner.fail("Migration failed");
4347
+ console.error(chalk14.red(err.message));
4348
+ process.exit(1);
4349
+ }
4350
+ spinner.stop();
4351
+ const total = migrateResult.migrated.length + migrateResult.skipped.length + migrateResult.failed.length;
4352
+ console.log(
4353
+ `Migrated ${chalk14.bold(String(migrateResult.migrated.length))}/${total} repos:`
4354
+ );
4355
+ console.log("");
4356
+ for (const repo of migrateResult.migrated) {
4357
+ console.log(` ${chalk14.green("\u2705")} ${repo}`);
4358
+ }
4359
+ for (const { repo, reason } of migrateResult.skipped) {
4360
+ console.log(` ${chalk14.yellow("\u26A0\uFE0F ")} ${repo} ${chalk14.dim(`(skipped \u2014 ${reason})`)}`);
4361
+ }
4362
+ for (const { repo, error } of migrateResult.failed) {
4363
+ console.log(` ${chalk14.red("\u274C")} ${repo} ${chalk14.dim(`(failed \u2014 ${error})`)}`);
4364
+ }
4365
+ console.log("");
4366
+ if (migrateResult.failed.length > 0) {
4367
+ process.exit(1);
4368
+ }
4369
+ });
4370
+
4371
+ // src/commands/operator.ts
4372
+ import { writeFileSync as writeFileSync8 } from "fs";
4373
+ import { resolve as resolve2 } from "path";
4374
+ import { Command as Command15 } from "commander";
4375
+ import { execa as execa4 } from "execa";
4376
+
4377
+ // src/lib/bundle.ts
4378
+ import { stringify as stringifyYaml4 } from "yaml";
4379
+ var BUNDLE_VERSION = "1";
4380
+ function buildBundle(input2) {
4381
+ const vaults = {};
4382
+ for (const v of input2.vaults ?? []) {
4383
+ vaults[v.namespace] = v.default ? { endpoint: v.endpoint, default: true } : { endpoint: v.endpoint };
4384
+ }
4385
+ const bundle = {
4386
+ version: input2.version ?? BUNDLE_VERSION,
4387
+ control_plane_url: input2.controlPlaneUrl,
4388
+ token_provider: { kind: input2.tokenProviderKind ?? "static", config: input2.initialToken },
4389
+ vaults
4390
+ };
4391
+ if (input2.dataDir) bundle.data_dir = input2.dataDir;
4392
+ if (input2.runtime) bundle.runtime = input2.runtime;
4393
+ return bundle;
4394
+ }
4395
+ function serializeBundle(bundle) {
4396
+ return stringifyYaml4(bundle);
4397
+ }
4398
+
4399
+ // src/commands/operator.ts
4400
+ async function connect(opts) {
4401
+ const explicit = opts.operatorUrl ?? process.env.HORUS_OPERATOR_URL;
4402
+ if (explicit) {
4403
+ return { baseUrl: explicit.replace(/\/$/, ""), close: async () => {
4404
+ } };
4405
+ }
4406
+ const localPort = Number(opts.port ?? "8090");
4407
+ const ns = opts.namespace ?? "horus-system";
4408
+ const proc = execa4(
4409
+ "kubectl",
4410
+ ["port-forward", "svc/operator-service", `${localPort}:8090`, "-n", ns],
4411
+ { stdio: "ignore" }
4412
+ );
4413
+ await new Promise((r) => setTimeout(r, 1500));
4414
+ return {
4415
+ baseUrl: `http://127.0.0.1:${localPort}`,
4416
+ close: async () => {
4417
+ proc.kill();
4418
+ }
4419
+ };
4420
+ }
4421
+ async function api(baseUrl, method, path2, body) {
4422
+ const res = await fetch(`${baseUrl}${path2}`, {
4423
+ method,
4424
+ headers: { "content-type": "application/json", "x-operator-role": "admin", "x-operator-user": "admin" },
4425
+ body: body === void 0 ? void 0 : JSON.stringify(body)
4426
+ });
4427
+ const text = await res.text();
4428
+ if (!res.ok) {
4429
+ throw new Error(`operator-service ${method} ${path2} \u2192 ${res.status}: ${text}`);
4430
+ }
4431
+ return text ? JSON.parse(text) : {};
4432
+ }
4433
+ function collectVault(value, prev) {
4434
+ const [namespace, endpoint] = value.split("=");
4435
+ if (!namespace || !endpoint) {
4436
+ throw new Error(`--vault must be "<owner/ns>=<endpoint>", got "${value}"`);
4437
+ }
4438
+ return [...prev, { namespace, endpoint }];
4439
+ }
4440
+ var operatorCommand = new Command15("operator").description(
4441
+ "Control Plane admin: users, vaults, provisioning requests"
4442
+ );
4443
+ var userCmd = operatorCommand.command("user").description("User management");
4444
+ userCmd.command("add <userId>").description("Onboard a user and emit a pre-provisioned config bundle").requiredOption("--tenant <tenant>", "tenant the user belongs to").requiredOption("--cp-url <url>", "public Control Plane URL the client connects to").option("--role <role>", "user role", "user").option("--vault <ns=endpoint>", "assign a vault (repeatable)", collectVault, []).option("--ttl <seconds>", "initial token lifetime", "86400").option("--out <path>", "bundle output path").option("--operator-url <url>", "operator-service base URL (skip port-forward)").option("--namespace <ns>", "k8s namespace for port-forward", "horus-system").action(async (userId, opts) => {
4445
+ const vaults = opts.vault;
4446
+ const client = await connect(opts);
4447
+ try {
4448
+ await api(client.baseUrl, "POST", "/requests", {
4449
+ kind: "onboard",
4450
+ tenant: opts.tenant,
4451
+ payload: { userId, role: opts.role, assignedVaults: vaults.map((v) => v.namespace) }
4452
+ });
4453
+ const minted = await api(client.baseUrl, "POST", "/tokens", {
4454
+ userId,
4455
+ ttlSeconds: Number(opts.ttl)
4456
+ });
4457
+ const bundle = buildBundle({
4458
+ controlPlaneUrl: opts.cpUrl,
4459
+ initialToken: minted.token,
4460
+ vaults
4461
+ });
4462
+ const outPath = resolve2(opts.out ?? `./${userId}.bundle.yaml`);
4463
+ writeFileSync8(outPath, serializeBundle(bundle), "utf8");
4464
+ console.log(`Bundle written to ${outPath}`);
4465
+ console.log(`Run: horus setup --config ${outPath}`);
4466
+ } finally {
4467
+ await client.close();
4468
+ }
4469
+ });
4470
+ userCmd.command("list").description("List users").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
4471
+ const client = await connect(opts);
4472
+ try {
4473
+ console.log(JSON.stringify(await api(client.baseUrl, "GET", "/users"), null, 2));
4474
+ } finally {
4475
+ await client.close();
4476
+ }
4477
+ });
4478
+ var vaultCmd = operatorCommand.command("vault").description("Vault provisioning");
4479
+ function vaultRequest(kind) {
4480
+ return async (opts) => {
4481
+ const client = await connect(opts);
4482
+ try {
4483
+ const out = await api(client.baseUrl, "POST", "/requests", {
4484
+ kind,
4485
+ tenant: opts.tenant,
4486
+ payload: {
4487
+ namespace: opts.namespace,
4488
+ target_router: opts.router,
4489
+ backing_store_adapter: opts.adapter,
4490
+ endpoint: opts.endpoint
4491
+ }
4492
+ });
4493
+ console.log(JSON.stringify(out, null, 2));
4494
+ } finally {
4495
+ await client.close();
4496
+ }
4497
+ };
4498
+ }
4499
+ for (const [sub, kind] of [
4500
+ ["create", "vault_create"],
4501
+ ["attach", "vault_attach"],
4502
+ ["delete", "vault_delete"]
4503
+ ]) {
4504
+ vaultCmd.command(`${sub}`).requiredOption("--namespace <owner/ns>", "vault namespace").requiredOption("--tenant <tenant>", "tenant").option("--router <name>", "target router", "vault-router").option("--adapter <adapter>", "backing-store adapter", "git-subdir").option("--endpoint <url>", "explicit upstream endpoint").option("--operator-url <url>", "operator-service base URL").action(vaultRequest(kind));
4505
+ }
4506
+ var reqCmd = operatorCommand.command("request").description("Provisioning requests");
4507
+ reqCmd.command("list").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
4508
+ const client = await connect(opts);
4509
+ try {
4510
+ console.log(JSON.stringify(await api(client.baseUrl, "GET", "/requests"), null, 2));
4511
+ } finally {
4512
+ await client.close();
4513
+ }
4514
+ });
4515
+ reqCmd.command("show <id>").option("--operator-url <url>", "operator-service base URL").action(async (id, opts) => {
4516
+ const client = await connect(opts);
4517
+ try {
4518
+ console.log(JSON.stringify(await api(client.baseUrl, "GET", `/requests/${id}`), null, 2));
4519
+ } finally {
4520
+ await client.close();
4521
+ }
4522
+ });
4523
+ for (const decision of ["approve", "reject"]) {
4524
+ reqCmd.command(`${decision} <id>`).option("--operator-url <url>", "operator-service base URL").action(async (id, opts) => {
4525
+ const client = await connect(opts);
4526
+ try {
4527
+ console.log(
4528
+ JSON.stringify(await api(client.baseUrl, "POST", `/requests/${id}/${decision}`), null, 2)
4529
+ );
4530
+ } finally {
4531
+ await client.close();
4532
+ }
4533
+ });
4534
+ }
4535
+ operatorCommand.command("login").description("Authenticate; forces bootstrap-admin credential rotation on first login (\xA7H)").option("--admin <id>", "admin user id", "admin").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
4536
+ const client = await connect(opts);
4537
+ try {
4538
+ const adminId = opts.admin ?? "admin";
4539
+ const users = await api(
4540
+ client.baseUrl,
4541
+ "GET",
4542
+ "/users"
4543
+ );
4544
+ const admin = users.find((u) => u.userId === adminId);
4545
+ if (admin?.mustRotate) {
4546
+ await api(client.baseUrl, "POST", "/admin/rotate", { adminId });
4547
+ console.log("Bootstrap admin credential rotated (forced on first login).");
4548
+ } else {
4549
+ console.log("Logged in. No rotation required.");
4550
+ }
4551
+ } finally {
4552
+ await client.close();
4553
+ }
4554
+ });
4555
+ function renderPrincipalSecrets(namespace, b) {
4556
+ const clientJwks = JSON.stringify(b.clientJwks);
4557
+ const internalSigningKey = JSON.stringify(b.internalSigningKey);
4558
+ const pubJwk = JSON.stringify(b.internalPublicJwk);
4559
+ return `apiVersion: v1
4560
+ kind: Secret
4561
+ metadata:
4562
+ name: horus-service-secrets
4563
+ namespace: ${namespace}
4564
+ type: Opaque
4565
+ stringData:
4566
+ HORUS_CLIENT_JWKS_JSON: '${clientJwks}'
4567
+ HORUS_INTERNAL_SIGNING_KEY_JSON: '${internalSigningKey}'
4568
+ ---
4569
+ apiVersion: v1
4570
+ kind: Secret
4571
+ metadata:
4572
+ name: horus-principal-pub
4573
+ namespace: ${namespace}
4574
+ type: Opaque
4575
+ stringData:
4576
+ pub.jwk: '${pubJwk}'
4577
+ `;
4578
+ }
4579
+ operatorCommand.command("init").description(
4580
+ "Derive horus-service-secrets + horus-principal-pub from operator-service keys and apply them"
4581
+ ).option("--namespace <ns>", "k8s namespace", "horus-system").option("--operator-url <url>", "operator-service base URL (skip port-forward)").option("--port <port>", "local port for port-forward", "8090").option("--dry-run", "print the Secret manifests instead of applying", false).option("--out <path>", "write the Secret manifests to a file instead of applying").action(async (opts) => {
4582
+ const client = await connect(opts);
4583
+ try {
4584
+ const bundle = await api(client.baseUrl, "GET", "/admin/principal-bundle");
4585
+ const ns = opts.namespace ?? "horus-system";
4586
+ const manifests = renderPrincipalSecrets(ns, bundle);
4587
+ if (opts.out) {
4588
+ writeFileSync8(resolve2(opts.out), manifests, "utf8");
4589
+ console.log(`Principal Secrets written to ${resolve2(opts.out)}`);
4590
+ return;
4591
+ }
4592
+ if (opts.dryRun) {
4593
+ process.stdout.write(manifests);
4594
+ return;
4595
+ }
4596
+ await execa4("kubectl", ["apply", "-n", ns, "-f", "-"], {
4597
+ input: manifests,
4598
+ stdio: ["pipe", "inherit", "inherit"]
4599
+ });
4600
+ console.log(`Applied horus-service-secrets + horus-principal-pub to namespace ${ns}.`);
4601
+ console.log("horus-service can now mint X-Horus-Principal; Vault and forge-registry can verify it.");
4602
+ } finally {
4603
+ await client.close();
4604
+ }
4605
+ });
4606
+ operatorCommand.command("status").option("--operator-url <url>", "operator-service base URL").action(async (opts) => {
4607
+ const client = await connect(opts);
4608
+ try {
4609
+ const health = await api(client.baseUrl, "GET", "/health");
4610
+ const requests = await api(client.baseUrl, "GET", "/requests");
4611
+ const users = await api(client.baseUrl, "GET", "/users");
4612
+ console.log(
4613
+ JSON.stringify({ health, requests: requests.length, users: users.length }, null, 2)
4614
+ );
4615
+ } finally {
4616
+ await client.close();
4617
+ }
4618
+ });
4428
4619
 
4429
4620
  // src/index.ts
4430
- var program = new Command14();
4621
+ var program = new Command16();
4431
4622
  program.name("horus").description("CLI for managing the Horus Docker Compose stack").version(CLI_VERSION);
4432
4623
  program.addCommand(setupCommand);
4433
4624
  program.addCommand(upCommand);
@@ -4435,6 +4626,7 @@ program.addCommand(downCommand);
4435
4626
  program.addCommand(statusCommand);
4436
4627
  program.addCommand(configCommand);
4437
4628
  program.addCommand(connectCommand);
4629
+ program.addCommand(loginCommand);
4438
4630
  program.addCommand(updateCommand);
4439
4631
  program.addCommand(doctorCommand);
4440
4632
  program.addCommand(backupCommand);
@@ -4442,6 +4634,7 @@ program.addCommand(testEnvCommand);
4442
4634
  program.addCommand(helpCommand);
4443
4635
  program.addCommand(guideCommand);
4444
4636
  program.addCommand(repoCommand);
4637
+ program.addCommand(operatorCommand);
4445
4638
  program.exitOverride();
4446
4639
  try {
4447
4640
  await program.parseAsync(process.argv);
@@ -4450,9 +4643,9 @@ try {
4450
4643
  process.exit(0);
4451
4644
  }
4452
4645
  if (error instanceof Error) {
4453
- console.error(chalk14.red(`Error: ${error.message}`));
4646
+ console.error(chalk15.red(`Error: ${error.message}`));
4454
4647
  } else {
4455
- console.error(chalk14.red("An unexpected error occurred."));
4648
+ console.error(chalk15.red("An unexpected error occurred."));
4456
4649
  }
4457
4650
  process.exit(1);
4458
4651
  }