diff --git a/cli/src/commands/hatch.ts b/cli/src/commands/hatch.ts index 4bf11dc6e2b..9d54c85f269 100644 --- a/cli/src/commands/hatch.ts +++ b/cli/src/commands/hatch.ts @@ -1,5 +1,5 @@ import { randomBytes } from "crypto"; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "fs"; import { homedir, tmpdir, userInfo } from "os"; import { join } from "path"; @@ -535,6 +535,43 @@ async function hatchCustom( } } +function installCLISymlink(): void { + const cliBinary = process.execPath; + if (!cliBinary || !existsSync(cliBinary)) return; + + const symlinkPath = "/usr/local/bin/vellum"; + + try { + // Use lstatSync (not existsSync) to detect dangling symlinks — + // existsSync follows symlinks and returns false for broken links. + try { + const stats = lstatSync(symlinkPath); + if (!stats.isSymbolicLink()) { + // Real file — don't overwrite (developer's local install) + return; + } + // Already a symlink — skip if it already points to our binary + const dest = readlinkSync(symlinkPath); + if (dest === cliBinary) return; + // Stale or dangling symlink — remove before creating new one + unlinkSync(symlinkPath); + } catch (e: any) { + if (e?.code !== "ENOENT") throw e; + // Path doesn't exist — proceed to create symlink + } + + const dir = "/usr/local/bin"; + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + symlinkSync(cliBinary, symlinkPath); + console.log(` Symlinked ${symlinkPath} → ${cliBinary}`); + } catch { + // Permission denied or other error — not critical + console.log(` ⚠ Could not create symlink at ${symlinkPath} (run with sudo or create manually)`); + } +} + async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise { const instanceName = name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`; @@ -602,6 +639,10 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo if (!daemonOnly) { saveAssistantEntry(localEntry); + if (process.env.VELLUM_DESKTOP_APP) { + installCLISymlink(); + } + console.log(""); console.log(`✅ Local assistant hatched!`); console.log(""); diff --git a/cli/src/index.ts b/cli/src/index.ts index 6918e57b4bf..a2d5538189f 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,6 +5,8 @@ import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; + +import cliPkg from "../package.json"; import { autonomy } from "./commands/autonomy"; import { client } from "./commands/client"; import { config } from "./commands/config"; @@ -71,6 +73,11 @@ async function main() { const args = process.argv.slice(2); const commandName = args[0]; + if (commandName === "--version" || commandName === "-v") { + console.log(`@vellumai/cli v${cliPkg.version}`); + process.exit(0); + } + if (!commandName || commandName === "--help" || commandName === "-h") { console.log("Usage: vellum [options]"); console.log("");