From 14898e18272acec019c511510a49da409d7110d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=A2=E5=AD=90?= Date: Thu, 30 Apr 2026 13:46:55 +0000 Subject: [PATCH] feat(cli): add nerve agent inject/update/remove/status commands Phase 2 of #289: - nerve agent inject hermes [--profile ] - nerve agent update (updates all injected skills) - nerve agent remove hermes [--profile ] - nerve agent status (version check across profiles) - Include skills/ in npm package files Ref: #289, #293 --- packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/agent.ts | 225 +++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/agent.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 190c1d7..60edfe2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,7 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", - "files": ["dist"], + "files": ["dist", "skills"], "publishConfig": { "access": "public" }, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 603c03a..82043fa 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import "@uncaged/nerve-daemon/experimental-warning-suppression.js"; import { defineCommand, runMain } from "citty"; import { consumeGlobalDaemonCliFlags } from "./cli-global.js"; +import { agentCommand } from "./commands/agent.js"; import { createCommand } from "./commands/create.js"; import { daemonCommand } from "./commands/daemon.js"; import { devCommand } from "./commands/dev.js"; @@ -42,6 +43,7 @@ const main = defineCommand({ "Nerve — an AI agent kernel. Global options: --host (remote HTTP), --api-token (Bearer auth).", }, subCommands: { + agent: agentCommand, init: initCommand, create: createCommand, daemon: daemonCommand, diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts new file mode 100644 index 0000000..4c68855 --- /dev/null +++ b/packages/cli/src/commands/agent.ts @@ -0,0 +1,225 @@ +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineCommand } from "citty"; + +const CLI_VERSION = "0.5.0"; + +function getSkillSourceDir(): string { + const thisFile = fileURLToPath(import.meta.url); + let dir = dirname(thisFile); + for (let i = 0; i < 5; i++) { + if (existsSync(join(dir, "skills"))) return join(dir, "skills"); + dir = dirname(dir); + } + throw new Error("Cannot locate skills directory. Is the CLI package intact?"); +} + +function getHermesSkillDir(profile: string | null): string { + const hermesHome = join(homedir(), ".hermes"); + if (profile !== null) { + return join(hermesHome, "profiles", profile, "skills", "nerve"); + } + return join(hermesHome, "skills", "nerve"); +} + +function readVersionFile(skillDir: string): string | null { + const versionPath = join(skillDir, ".nerve-version"); + if (!existsSync(versionPath)) return null; + return readFileSync(versionPath, "utf8").trim(); +} + +function writeVersionFile(skillDir: string, version: string): void { + writeFileSync(join(skillDir, ".nerve-version"), `${version}\n`, "utf8"); +} + +function injectHermes(profile: string | null): void { + const sourceDir = join(getSkillSourceDir(), "hermes"); + const targetDir = getHermesSkillDir(profile); + const existing = readVersionFile(targetDir); + + if (existing === CLI_VERSION) { + const loc = profile !== null ? ` (profile: ${profile})` : ""; + process.stdout.write(`✅ Hermes nerve skill is already up to date (v${CLI_VERSION})${loc}\n`); + return; + } + + mkdirSync(targetDir, { recursive: true }); + cpSync(sourceDir, targetDir, { recursive: true }); + writeVersionFile(targetDir, CLI_VERSION); + + const action = existing !== null ? "Updated" : "Installed"; + const loc = profile !== null ? ` (profile: ${profile})` : ""; + process.stdout.write(`✅ ${action} Hermes nerve skill v${CLI_VERSION}${loc}\n`); + process.stdout.write(` → ${targetDir}/SKILL.md\n`); +} + +function removeHermes(profile: string | null): void { + const targetDir = getHermesSkillDir(profile); + if (!existsSync(targetDir)) { + process.stdout.write("ℹ️ Hermes nerve skill is not installed.\n"); + return; + } + rmSync(targetDir, { recursive: true, force: true }); + const loc = profile !== null ? ` (profile: ${profile})` : ""; + process.stdout.write(`✅ Removed Hermes nerve skill${loc}\n`); +} + +function printStatus(): void { + process.stdout.write(`nerve agent skills (CLI v${CLI_VERSION})\n\n`); + + // Default profile + const defaultDir = getHermesSkillDir(null); + const defaultVer = readVersionFile(defaultDir); + printAgentLine("Hermes (default)", defaultVer); + + // Named profiles + const profilesDir = join(homedir(), ".hermes", "profiles"); + if (existsSync(profilesDir)) { + const profiles = readdirSync(profilesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const profile of profiles) { + const dir = getHermesSkillDir(profile); + const ver = readVersionFile(dir); + if (ver !== null) { + printAgentLine(`Hermes (${profile})`, ver); + } + } + } + + process.stdout.write("\n"); +} + +function printAgentLine(label: string, version: string | null): void { + if (version === null) { + process.stdout.write(` ${label}: ❌ not installed\n`); + } else if (version === CLI_VERSION) { + process.stdout.write(` ${label}: ✅ v${version}\n`); + } else { + process.stdout.write( + ` ${label}: ⚠️ v${version} → v${CLI_VERSION} available (run \`nerve agent update\`)\n`, + ); + } +} + +const injectCommand = defineCommand({ + meta: { + name: "inject", + description: "Inject nerve skill into an AI agent", + }, + args: { + target: { + type: "positional", + description: "Agent target: hermes", + }, + profile: { + type: "string", + description: "Hermes profile name (default: main profile)", + }, + }, + run({ args }) { + if (args.target !== "hermes") { + process.stderr.write(`❌ Unknown agent target: ${args.target}\n`); + process.stderr.write(" Supported targets: hermes\n"); + process.exit(1); + } + injectHermes(args.profile ?? null); + }, +}); + +const updateCommand = defineCommand({ + meta: { + name: "update", + description: "Update all injected nerve skills to current CLI version", + }, + run() { + let updated = 0; + + // Default profile + const defaultDir = getHermesSkillDir(null); + if (existsSync(defaultDir)) { + injectHermes(null); + updated++; + } + + // Named profiles + const profilesDir = join(homedir(), ".hermes", "profiles"); + if (existsSync(profilesDir)) { + const profiles = readdirSync(profilesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const profile of profiles) { + const dir = getHermesSkillDir(profile); + if (existsSync(dir)) { + injectHermes(profile); + updated++; + } + } + } + + if (updated === 0) { + process.stdout.write("ℹ️ No injected skills found. Run `nerve agent inject hermes` first.\n"); + } + }, +}); + +const removeCommand = defineCommand({ + meta: { + name: "remove", + description: "Remove injected nerve skill from an AI agent", + }, + args: { + target: { + type: "positional", + description: "Agent target: hermes", + }, + profile: { + type: "string", + description: "Hermes profile name (default: main profile)", + }, + }, + run({ args }) { + if (args.target !== "hermes") { + process.stderr.write(`❌ Unknown agent target: ${args.target}\n`); + process.stderr.write(" Supported targets: hermes\n"); + process.exit(1); + } + removeHermes(args.profile ?? null); + }, +}); + +const statusCommand = defineCommand({ + meta: { + name: "status", + description: "Show injection status of nerve skills across agents", + }, + run() { + printStatus(); + }, +}); + +export const agentCommand = defineCommand({ + meta: { + name: "agent", + description: "Manage nerve skill injection for AI agents", + }, + subCommands: { + inject: injectCommand, + update: updateCommand, + remove: removeCommand, + status: statusCommand, + }, +});