@calvin.magezi/agent-hq 0.1.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calvin.magezi/agent-hq",
3
- "version": "0.1.0",
3
+ "version": "0.5.0",
4
4
  "description": "HQ CLI — local-first AI agent hub for Claude, Gemini & Discord",
5
5
  "homepage": "https://github.com/CalvinMagezi/agent-hq",
6
6
  "repository": {
@@ -9,12 +9,21 @@
9
9
  "directory": "packages/hq-cli"
10
10
  },
11
11
  "license": "MIT",
12
- "keywords": ["ai", "agent", "claude", "gemini", "discord", "local-first", "obsidian"],
12
+ "keywords": [
13
+ "ai",
14
+ "agent",
15
+ "claude",
16
+ "gemini",
17
+ "discord",
18
+ "local-first",
19
+ "obsidian"
20
+ ],
13
21
  "bin": {
14
22
  "hq": "./bin/hq"
15
23
  },
16
24
  "files": [
17
25
  "bin/hq",
26
+ "src/",
18
27
  "README.md"
19
28
  ],
20
29
  "engines": {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Idempotent init state tracker.
3
+ * Reads/writes .hq-init-state.json at the repo root so re-running
4
+ * `hq init` skips steps already completed.
5
+ */
6
+
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+
10
+ export type InitStep =
11
+ | "preflight"
12
+ | "clone"
13
+ | "install"
14
+ | "tools"
15
+ | "vault"
16
+ | "models"
17
+ | "env"
18
+ | "services"
19
+ | "cli"
20
+ | "mcp";
21
+
22
+ export interface InitState {
23
+ version: string;
24
+ platform: string;
25
+ completedSteps: InitStep[];
26
+ lastRun: string;
27
+ warnings: string[];
28
+ }
29
+
30
+ const STATE_VERSION = "1.0.0";
31
+
32
+ export class InitStateManager {
33
+ private filePath: string;
34
+ private state: InitState;
35
+
36
+ constructor(repoRoot: string) {
37
+ this.filePath = path.join(repoRoot, ".hq-init-state.json");
38
+ this.state = this.load();
39
+ }
40
+
41
+ private load(): InitState {
42
+ if (fs.existsSync(this.filePath)) {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(this.filePath, "utf-8")) as InitState;
45
+ } catch { /* fall through */ }
46
+ }
47
+ return {
48
+ version: STATE_VERSION,
49
+ platform: process.platform,
50
+ completedSteps: [],
51
+ lastRun: new Date().toISOString(),
52
+ warnings: [],
53
+ };
54
+ }
55
+
56
+ isDone(step: InitStep): boolean {
57
+ return this.state.completedSteps.includes(step);
58
+ }
59
+
60
+ markDone(step: InitStep): void {
61
+ if (!this.isDone(step)) {
62
+ this.state.completedSteps.push(step);
63
+ }
64
+ this.state.lastRun = new Date().toISOString();
65
+ this.save();
66
+ }
67
+
68
+ addWarning(msg: string): void {
69
+ if (!this.state.warnings.includes(msg)) {
70
+ this.state.warnings.push(msg);
71
+ }
72
+ this.save();
73
+ }
74
+
75
+ reset(): void {
76
+ this.state = {
77
+ version: STATE_VERSION,
78
+ platform: process.platform,
79
+ completedSteps: [],
80
+ lastRun: new Date().toISOString(),
81
+ warnings: [],
82
+ };
83
+ this.save();
84
+ }
85
+
86
+ get warnings(): string[] {
87
+ return this.state.warnings;
88
+ }
89
+
90
+ private save(): void {
91
+ try {
92
+ fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), "utf-8");
93
+ } catch { /* non-fatal */ }
94
+ }
95
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Platform detection for hq CLI.
3
+ * All OS/arch/service-manager branching goes through this module.
4
+ */
5
+
6
+ import * as os from "os";
7
+ import * as path from "path";
8
+ import { execSync } from "child_process";
9
+
10
+ export type OSName = "macos" | "linux" | "windows";
11
+ export type Arch = "arm64" | "x64" | "unknown";
12
+ export type ServiceManager = "launchd" | "systemd" | "taskscheduler" | "none";
13
+ export type Shell = "zsh" | "bash" | "pwsh" | "cmd" | "unknown";
14
+
15
+ export interface PlatformInfo {
16
+ os: OSName;
17
+ arch: Arch;
18
+ serviceManager: ServiceManager;
19
+ /** Directory for user-level config files */
20
+ configDir: string;
21
+ /** Directory where CLIs should be symlinked */
22
+ binDir: string;
23
+ /** User's preferred shell */
24
+ shell: Shell;
25
+ /** Shell rc file path (for PATH modifications) */
26
+ shellRc: string;
27
+ /** Whether the OS supports background services natively */
28
+ hasDaemonSupport: boolean;
29
+ }
30
+
31
+ function detectOS(): OSName {
32
+ switch (process.platform) {
33
+ case "darwin": return "macos";
34
+ case "linux": return "linux";
35
+ case "win32": return "windows";
36
+ default: return "linux";
37
+ }
38
+ }
39
+
40
+ function detectArch(): Arch {
41
+ switch (process.arch) {
42
+ case "arm64": return "arm64";
43
+ case "x64": return "x64";
44
+ default: return "unknown";
45
+ }
46
+ }
47
+
48
+ function detectServiceManager(os: OSName): ServiceManager {
49
+ if (os === "macos") return "launchd";
50
+ if (os === "windows") return "taskscheduler";
51
+ // Linux: check for systemd
52
+ try {
53
+ execSync("systemctl --version", { stdio: "pipe" });
54
+ return "systemd";
55
+ } catch {
56
+ return "none";
57
+ }
58
+ }
59
+
60
+ function detectShell(os: OSName): Shell {
61
+ if (os === "windows") {
62
+ return process.env.PSModulePath ? "pwsh" : "cmd";
63
+ }
64
+ const shell = process.env.SHELL ?? "";
65
+ if (shell.includes("zsh")) return "zsh";
66
+ if (shell.includes("bash")) return "bash";
67
+ return "bash";
68
+ }
69
+
70
+ function shellRcPath(shell: Shell, home: string): string {
71
+ switch (shell) {
72
+ case "zsh": return path.join(home, ".zshrc");
73
+ case "bash": return path.join(home, ".bashrc");
74
+ case "pwsh": return path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
75
+ default: return path.join(home, ".bashrc");
76
+ }
77
+ }
78
+
79
+ let _cached: PlatformInfo | null = null;
80
+
81
+ export function getPlatform(): PlatformInfo {
82
+ if (_cached) return _cached;
83
+
84
+ const home = os.homedir();
85
+ const osName = detectOS();
86
+ const arch = detectArch();
87
+ const sm = detectServiceManager(osName);
88
+ const shell = detectShell(osName);
89
+
90
+ let configDir: string;
91
+ let binDir: string;
92
+
93
+ if (osName === "macos") {
94
+ configDir = path.join(home, "Library", "Application Support", "agent-hq");
95
+ binDir = path.join(home, ".local", "bin");
96
+ } else if (osName === "windows") {
97
+ configDir = path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "agent-hq");
98
+ binDir = path.join(home, "AppData", "Local", "Microsoft", "WindowsApps");
99
+ } else {
100
+ configDir = path.join(home, ".config", "agent-hq");
101
+ binDir = path.join(home, ".local", "bin");
102
+ }
103
+
104
+ _cached = {
105
+ os: osName,
106
+ arch,
107
+ serviceManager: sm,
108
+ configDir,
109
+ binDir,
110
+ shell,
111
+ shellRc: shellRcPath(shell, home),
112
+ hasDaemonSupport: sm !== "none",
113
+ };
114
+
115
+ return _cached;
116
+ }
117
+
118
+ /** Returns true if running on Windows Subsystem for Linux */
119
+ export function isWSL(): boolean {
120
+ try {
121
+ const release = execSync("uname -r", { stdio: "pipe", encoding: "utf-8" });
122
+ return release.toLowerCase().includes("microsoft");
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Dependency preflight checker + auto-installer for hq init.
3
+ *
4
+ * Checks for required and optional tools, attempts silent installs
5
+ * where possible, and prints a clean summary table.
6
+ */
7
+
8
+ import { execSync, spawnSync } from "child_process";
9
+ import type { OSName } from "./platform.ts";
10
+
11
+ // ─── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ export interface DepResult {
14
+ name: string;
15
+ status: "ok" | "installed" | "missing" | "skipped" | "failed";
16
+ version?: string;
17
+ note?: string;
18
+ }
19
+
20
+ interface DepSpec {
21
+ name: string;
22
+ /** Shell command to check — returns version string or empty */
23
+ check: string;
24
+ /** Minimum version string (semver prefix match) */
25
+ minVersion?: string;
26
+ required: boolean;
27
+ autoInstall: boolean;
28
+ install: Record<OSName, string[]>;
29
+ skipNote?: string;
30
+ }
31
+
32
+ // ─── ANSI ─────────────────────────────────────────────────────────────────────
33
+
34
+ const c = {
35
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
36
+ green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m",
37
+ cyan: "\x1b[36m", gray: "\x1b[90m",
38
+ };
39
+
40
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
41
+
42
+ function sh(cmd: string): string {
43
+ try {
44
+ return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
45
+ } catch { return ""; }
46
+ }
47
+
48
+ function run(cmd: string, args: string[], cwd?: string): boolean {
49
+ const r = spawnSync(cmd, args, { stdio: "pipe", cwd });
50
+ return r.status === 0;
51
+ }
52
+
53
+ function semverOk(actual: string, min: string): boolean {
54
+ const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
55
+ const [aa, ab, ac] = parse(actual);
56
+ const [ma, mb, mc] = parse(min);
57
+ if (aa !== ma) return aa > ma;
58
+ if (ab !== mb) return ab > mb;
59
+ return (ac ?? 0) >= (mc ?? 0);
60
+ }
61
+
62
+ // ─── Dependency specs ─────────────────────────────────────────────────────────
63
+
64
+ const DEPS: DepSpec[] = [
65
+ {
66
+ name: "Bun",
67
+ check: "bun --version 2>/dev/null",
68
+ minVersion: "1.1.0",
69
+ required: true,
70
+ autoInstall: true,
71
+ install: {
72
+ macos: ["curl -fsSL https://bun.sh/install | bash"],
73
+ linux: ["curl -fsSL https://bun.sh/install | bash"],
74
+ windows: ["powershell -c \"irm bun.sh/install.ps1 | iex\""],
75
+ },
76
+ },
77
+ {
78
+ name: "Git",
79
+ check: "git --version 2>/dev/null",
80
+ minVersion: "2.30.0",
81
+ required: true,
82
+ autoInstall: true,
83
+ install: {
84
+ macos: ["brew install git"],
85
+ linux: ["sudo apt-get install -y git || sudo dnf install -y git || sudo pacman -S git"],
86
+ windows: ["winget install --id Git.Git -e --source winget"],
87
+ },
88
+ },
89
+ {
90
+ name: "Ollama",
91
+ check: "ollama --version 2>/dev/null",
92
+ required: true,
93
+ autoInstall: true,
94
+ install: {
95
+ macos: ["brew install ollama"],
96
+ linux: ["curl -fsSL https://ollama.com/install.sh | sh"],
97
+ windows: ["winget install --id Ollama.Ollama -e || powershell -c \"irm https://ollama.com/install.ps1 | iex\""],
98
+ },
99
+ },
100
+ {
101
+ name: "Claude CLI",
102
+ check: "claude --version 2>/dev/null",
103
+ required: false,
104
+ autoInstall: true,
105
+ install: {
106
+ macos: ["npm install -g @anthropic-ai/claude-code"],
107
+ linux: ["npm install -g @anthropic-ai/claude-code"],
108
+ windows: ["npm install -g @anthropic-ai/claude-code"],
109
+ },
110
+ },
111
+ {
112
+ name: "Gemini CLI",
113
+ check: "gemini --version 2>/dev/null",
114
+ required: false,
115
+ autoInstall: true,
116
+ install: {
117
+ macos: ["npm install -g @google/gemini-cli"],
118
+ linux: ["npm install -g @google/gemini-cli"],
119
+ windows: ["npm install -g @google/gemini-cli"],
120
+ },
121
+ },
122
+ {
123
+ name: "OpenCode",
124
+ check: "opencode --version 2>/dev/null",
125
+ required: false,
126
+ autoInstall: true,
127
+ install: {
128
+ macos: ["npm install -g opencode-ai"],
129
+ linux: ["npm install -g opencode-ai"],
130
+ windows: ["npm install -g opencode-ai"],
131
+ },
132
+ },
133
+ {
134
+ name: "gws CLI",
135
+ check: "gws --version 2>/dev/null",
136
+ required: false,
137
+ autoInstall: false,
138
+ install: { macos: [], linux: [], windows: [] },
139
+ skipNote: "requires manual auth setup (Google Workspace)",
140
+ },
141
+ ];
142
+
143
+ // ─── Main export ──────────────────────────────────────────────────────────────
144
+
145
+ export async function runPreflight(
146
+ osName: OSName,
147
+ opts: { nonInteractive?: boolean; skipOptional?: boolean } = {}
148
+ ): Promise<{ results: DepResult[]; allRequiredOk: boolean }> {
149
+ const results: DepResult[] = [];
150
+ let allRequiredOk = true;
151
+
152
+ console.log(`\n${c.bold}── Dependency Preflight ──${c.reset}`);
153
+
154
+ for (const dep of DEPS) {
155
+ if (opts.skipOptional && !dep.required) continue;
156
+
157
+ // Check if installed
158
+ const raw = sh(dep.check);
159
+ const version = raw.split(/\s+/).find(s => /\d+\.\d+/.test(s)) ?? raw.split("\n")[0];
160
+
161
+ if (raw && version) {
162
+ // Version check
163
+ if (dep.minVersion && !semverOk(version, dep.minVersion)) {
164
+ printRow(dep.name, "failed", version, `need ≥ ${dep.minVersion}`);
165
+ results.push({ name: dep.name, status: "failed", version, note: `need ≥ ${dep.minVersion}` });
166
+ if (dep.required) allRequiredOk = false;
167
+ continue;
168
+ }
169
+ printRow(dep.name, "ok", version);
170
+ results.push({ name: dep.name, status: "ok", version });
171
+ continue;
172
+ }
173
+
174
+ // Not installed
175
+ if (dep.skipNote) {
176
+ printRow(dep.name, "skipped", undefined, dep.skipNote);
177
+ results.push({ name: dep.name, status: "skipped", note: dep.skipNote });
178
+ continue;
179
+ }
180
+
181
+ if (!dep.autoInstall) {
182
+ const status = dep.required ? "missing" : "skipped";
183
+ printRow(dep.name, status);
184
+ results.push({ name: dep.name, status });
185
+ if (dep.required) allRequiredOk = false;
186
+ continue;
187
+ }
188
+
189
+ // Auto-install
190
+ const cmds = dep.install[osName];
191
+ if (!cmds?.length) {
192
+ printRow(dep.name, "missing", undefined, "no auto-install for this platform");
193
+ results.push({ name: dep.name, status: "missing", note: "no auto-install for this platform" });
194
+ if (dep.required) allRequiredOk = false;
195
+ continue;
196
+ }
197
+
198
+ process.stdout.write(` ${c.yellow}⟳${c.reset} ${c.bold}${dep.name}${c.reset} — installing...`);
199
+ let installed = false;
200
+
201
+ for (const cmd of cmds) {
202
+ const result = spawnSync(cmd, { shell: true, stdio: "pipe" });
203
+ if (result.status === 0) {
204
+ installed = true;
205
+ break;
206
+ }
207
+ }
208
+
209
+ if (installed) {
210
+ const newVersion = sh(dep.check).split(/\s+/).find(s => /\d+\.\d+/.test(s)) ?? "installed";
211
+ process.stdout.write(` ${c.green}done${c.reset} (${newVersion})\n`);
212
+ results.push({ name: dep.name, status: "installed", version: newVersion });
213
+ } else {
214
+ process.stdout.write(` ${c.red}failed${c.reset}\n`);
215
+ results.push({ name: dep.name, status: "failed", note: "auto-install failed — see manual install instructions" });
216
+ if (dep.required) allRequiredOk = false;
217
+ }
218
+ }
219
+
220
+ // Summary line
221
+ const failed = results.filter(r => r.status === "failed" || (r.status === "missing" && DEPS.find(d => d.name === r.name)?.required));
222
+ console.log();
223
+ if (allRequiredOk) {
224
+ console.log(` ${c.green}✓${c.reset} All required dependencies satisfied.\n`);
225
+ } else {
226
+ console.log(` ${c.red}✗${c.reset} ${failed.length} required dependency/dependencies failed. Fix above before continuing.\n`);
227
+ }
228
+
229
+ return { results, allRequiredOk };
230
+ }
231
+
232
+ /** Pull required Ollama models if not already present */
233
+ export async function ensureOllamaModels(models: string[]): Promise<void> {
234
+ const existing = sh("ollama list 2>/dev/null");
235
+ const toFetch = models.filter(m => !existing.includes(m.split(":")[0]));
236
+
237
+ if (!toFetch.length) {
238
+ console.log(` ${c.green}✓${c.reset} Ollama models already present.`);
239
+ return;
240
+ }
241
+
242
+ for (const model of toFetch) {
243
+ console.log(` ${c.cyan}⟳${c.reset} Pulling ${c.bold}${model}${c.reset} (background)...`);
244
+ // Run pull in background — large models shouldn't block init
245
+ const child = spawnSync("ollama", ["pull", model], { stdio: "pipe" });
246
+ if (child.status === 0) {
247
+ console.log(` ${c.green}✓${c.reset} ${model} ready`);
248
+ } else {
249
+ console.log(` ${c.yellow}⚠${c.reset} ${model} pull failed — memory features may be limited`);
250
+ }
251
+ }
252
+ }
253
+
254
+ function printRow(name: string, status: DepResult["status"], version?: string, note?: string) {
255
+ const icon = status === "ok" || status === "installed"
256
+ ? `${c.green}✓${c.reset}`
257
+ : status === "skipped"
258
+ ? `${c.gray}○${c.reset}`
259
+ : status === "failed" || status === "missing"
260
+ ? `${c.red}✗${c.reset}`
261
+ : `${c.yellow}⟳${c.reset}`;
262
+
263
+ const detail = version ? `${c.dim}${version}${c.reset}` : note ? `${c.gray}${note}${c.reset}` : "";
264
+ console.log(` ${icon} ${c.bold}${name}${c.reset}${detail ? " " + detail : ""}`);
265
+ }