diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md index 318f15a..77010a0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ # hermes-harness -Hermes Agent CLI harness tools — personality management, scheduling, and more \ No newline at end of file +Hermes Agent CLI harness tools — personality management, scheduling, and more. + +## Install + +```bash +bun install +bun link +``` + +## Usage + +```bash +# List all personalities +hermes-har personality list + +# Show personality details +hermes-har personality show 日常团子 + +# Add a personality +hermes-har personality add "测试人格" "你是一个测试用的人格..." + +# Remove a personality +hermes-har personality remove "测试人格" + +# Switch personality (local config only) +hermes-har personality switch 日常团子 + +# Switch personality via Telegram (live session update) +hermes-har personality switch 娇媚团子 --tg +``` + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..6c70845 --- /dev/null +++ b/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "hermes-harness", + "dependencies": { + "yaml": "^2.8.3", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f4aab1 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "hermes-harness", + "version": "0.1.0", + "description": "Hermes Agent CLI harness tools", + "type": "module", + "bin": { + "hermes-har": "./src/cli.ts" + }, + "scripts": { + "dev": "bun run src/cli.ts" + }, + "dependencies": { + "yaml": "^2.8.3" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..97bee0f --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env bun + +import { personality } from "./commands/personality"; + +const COMMANDS: Record Promise> = { + personality, +}; + +async function main() { + const [command, ...args] = process.argv.slice(2); + + if (!command || command === "help" || command === "--help") { + console.log(`hermes-har — Hermes Agent CLI harness tools + +Usage: hermes-har [options] + +Commands: + personality Manage personality presets (list/add/remove/switch) + +Run 'hermes-har --help' for details.`); + process.exit(0); + } + + const handler = COMMANDS[command]; + if (!handler) { + console.error(`Unknown command: ${command}`); + console.error(`Run 'hermes-har help' for available commands.`); + process.exit(1); + } + + await handler(args); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/src/commands/personality.ts b/src/commands/personality.ts new file mode 100644 index 0000000..ad5f02b --- /dev/null +++ b/src/commands/personality.ts @@ -0,0 +1,273 @@ +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { parse, stringify } from "yaml"; + +const CONFIG_PATH = join(homedir(), ".hermes", "config.yaml"); + +interface Config { + agent?: { + personalities?: Record; + system_prompt?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface PersonalityDict { + description?: string; + system_prompt?: string; + tone?: string; + style?: string; +} + +function loadConfig(): Config { + if (!existsSync(CONFIG_PATH)) { + throw new Error(`Config not found: ${CONFIG_PATH}`); + } + return parse(readFileSync(CONFIG_PATH, "utf-8")) || {}; +} + +function saveConfig(config: Config) { + writeFileSync(CONFIG_PATH, stringify(config, { lineWidth: 120 }), "utf-8"); +} + +function getPersonalities(config: Config): Record { + return config?.agent?.personalities || {}; +} + +function resolvePrompt(value: string | PersonalityDict): string { + if (typeof value === "string") return value; + const parts = [value.system_prompt || ""]; + if (value.tone) parts.push(`Tone: ${value.tone}`); + if (value.style) parts.push(`Style: ${value.style}`); + return parts.filter(Boolean).join("\n"); +} + +function getPreview(value: string | PersonalityDict): string { + if (typeof value === "string") { + return value.length > 60 ? value.slice(0, 60) + "..." : value; + } + return value.description || (value.system_prompt || "").slice(0, 60) + "..."; +} + +// --- Actions --- + +function list() { + const config = loadConfig(); + const personalities = getPersonalities(config); + const names = Object.keys(personalities); + + if (names.length === 0) { + console.log("No personalities configured."); + return; + } + + console.log("Available personalities:\n"); + for (const name of names) { + console.log(` ${name} — ${getPreview(personalities[name])}`); + } + console.log(`\nTotal: ${names.length}`); +} + +function show(name: string) { + const config = loadConfig(); + const personalities = getPersonalities(config); + + if (!(name in personalities)) { + console.error(`Personality not found: ${name}`); + console.error(`Available: ${Object.keys(personalities).join(", ")}`); + process.exit(1); + } + + const value = personalities[name]; + console.log(`[${name}]\n`); + if (typeof value === "string") { + console.log(value); + } else { + if (value.description) console.log(`Description: ${value.description}`); + if (value.system_prompt) console.log(`\n${value.system_prompt}`); + if (value.tone) console.log(`\nTone: ${value.tone}`); + if (value.style) console.log(`Style: ${value.style}`); + } +} + +function add(name: string, prompt: string) { + const config = loadConfig(); + if (!config.agent) config.agent = {}; + if (!config.agent.personalities) config.agent.personalities = {}; + + const existed = name in config.agent.personalities; + config.agent.personalities[name] = prompt; + saveConfig(config); + + console.log(`${existed ? "Updated" : "Added"}: ${name}`); +} + +function remove(name: string) { + const config = loadConfig(); + const personalities = getPersonalities(config); + + if (!(name in personalities)) { + console.error(`Personality not found: ${name}`); + process.exit(1); + } + + delete config.agent!.personalities![name]; + saveConfig(config); + console.log(`Removed: ${name}`); +} + +function switchLocal(name: string) { + const config = loadConfig(); + const personalities = getPersonalities(config); + + if (name === "none" || name === "default") { + if (!config.agent) config.agent = {}; + config.agent.system_prompt = ""; + saveConfig(config); + console.log("Personality cleared."); + return; + } + + if (!(name in personalities)) { + console.error(`Personality not found: ${name}`); + console.error(`Available: ${Object.keys(personalities).join(", ")}`); + process.exit(1); + } + + const prompt = resolvePrompt(personalities[name]); + if (!config.agent) config.agent = {}; + config.agent.system_prompt = prompt; + saveConfig(config); + console.log(`Switched to: ${name}`); +} + +async function switchTelegram(name: string) { + const config = loadConfig(); + const personalities = getPersonalities(config); + + if (name !== "none" && name !== "default" && !(name in personalities)) { + console.error(`Personality not found: ${name}`); + console.error(`Available: ${Object.keys(personalities).join(", ")}`); + process.exit(1); + } + + // Find bot token + let token = process.env.TELEGRAM_BOT_TOKEN || ""; + if (!token) { + const envPath = join(homedir(), ".hermes", ".env"); + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf-8").split("\n")) { + if (line.startsWith("TELEGRAM_BOT_TOKEN=")) { + token = line.split("=")[1].trim(); + } + } + } + } + + if (!token) { + console.error("TELEGRAM_BOT_TOKEN not found"); + process.exit(1); + } + + const chatId = + process.env.TELEGRAM_HOME_CHANNEL || + (config as Record).TELEGRAM_HOME_CHANNEL || + ""; + + if (!chatId) { + console.error("TELEGRAM_HOME_CHANNEL not configured"); + process.exit(1); + } + + const displayName = name === "none" || name === "default" ? "none" : name; + const url = `https://api.telegram.org/bot${token}/sendMessage`; + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text: `/personality ${displayName}` }), + }); + + const result = await resp.json() as { ok: boolean; description?: string }; + if (result.ok) { + console.log(`Sent /personality ${displayName} to chat ${chatId}`); + } else { + console.error(`Telegram API error: ${result.description}`); + process.exit(1); + } +} + +// --- CLI --- + +const HELP = `hermes-har personality — Manage personality presets + +Usage: + hermes-har personality list List all personalities + hermes-har personality show Show personality details + hermes-har personality add Add or update a personality + hermes-har personality remove Remove a personality + hermes-har personality switch Switch personality (writes config) + hermes-har personality switch --tg Switch via Telegram /personality command`; + +export async function personality(args: string[]) { + const action = args[0]; + + if (!action || action === "--help" || action === "help") { + console.log(HELP); + return; + } + + switch (action) { + case "list": + case "ls": + list(); + break; + + case "show": + case "get": + if (!args[1]) { + console.error("Usage: hermes-har personality show "); + process.exit(1); + } + show(args[1]); + break; + + case "add": + case "set": + if (!args[1] || !args[2]) { + console.error("Usage: hermes-har personality add "); + process.exit(1); + } + add(args[1], args.slice(2).join(" ")); + break; + + case "remove": + case "rm": + case "delete": + if (!args[1]) { + console.error("Usage: hermes-har personality remove "); + process.exit(1); + } + remove(args[1]); + break; + + case "switch": + case "use": + if (!args[1]) { + console.error("Usage: hermes-har personality switch [--tg]"); + process.exit(1); + } + if (args.includes("--tg") || args.includes("--telegram")) { + await switchTelegram(args[1]); + } else { + switchLocal(args[1]); + } + break; + + default: + console.error(`Unknown action: ${action}`); + console.log(HELP); + process.exit(1); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}