diff --git a/docs/resources/(resources)/hermes-agent.mdx b/docs/resources/(resources)/hermes-agent.mdx new file mode 100644 index 00000000..8a3da219 --- /dev/null +++ b/docs/resources/(resources)/hermes-agent.mdx @@ -0,0 +1,77 @@ +--- +title: hermes-agent +description: A reference page for the hermes-agent resource +--- + +The hermes-agent resource installs [Hermes Agent](https://hermes-agent.nousresearch.com) — Nous Research's self-improving, multi-platform personal AI agent — and manages its core configuration. It handles installation via the official installer script and gives you declarative control over the default model, timezone, tool approval policy, and MCP servers. + +## Parameters + +- **model**: *(object, optional)* Default LLM provider and model, written to `~/.hermes/config.yaml` under `model`. + - `provider` — provider id, e.g. `"anthropic"`, `"openai"`, `"deepseek"` + - `default` — default model id, e.g. `"anthropic/claude-opus-4"` + +- **timezone**: *(string, optional)* IANA timezone used for scheduling and reports, e.g. `"America/New_York"`. Written to `~/.hermes/config.yaml` under `timezone`. + +- **approvalsMode**: *(string, optional)* Tool approval policy. One of: + - `"manual"` — ask before every tool call (default) + - `"smart"` — auto-approve low-risk actions + - `"off"` — disable approval prompts entirely + +- **mcpServers**: *(array, optional)* MCP servers registered in `~/.hermes/config.yaml` under `mcp_servers`. Each entry requires a unique `name`, plus either stdio or HTTP transport fields: + - **stdio**: `{ name, command, args?, env? }` — local process server + - **http**: `{ name, url, headers? }` — remote MCP endpoint + - `enabled` — optional boolean to disable a server without removing it (default: `true`) + +## Example usage + +### Hermes Agent with a default model and MCP server + +```json title="codify.jsonc" +[ + { + "type": "hermes-agent", + "model": { + "provider": "anthropic", + "default": "anthropic/claude-opus-4" + }, + "mcpServers": [ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + ] + } +] +``` + +### Hermes Agent with timezone, approvals, and a remote MCP server + +```json title="codify.jsonc" +[ + { + "type": "hermes-agent", + "timezone": "America/New_York", + "approvalsMode": "smart", + "mcpServers": [ + { + "name": "remote-tools", + "url": "https://mcp.example.com/sse", + "headers": { + "Authorization": "Bearer " + } + } + ] + } +] +``` + +## Notes + +- Hermes Agent is installed via the official installer (`curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash`) on both macOS and Linux. The installer requires `git` and automatically installs all other dependencies (uv, Python 3.11, Node.js, ripgrep, ffmpeg). This resource declares a dependency on the `git` resource. +- The installer places the `hermes` command at `~/.local/bin/hermes` and adds it to your PATH via your shell RC file. This PATH entry remains after destroy — remove it manually if you no longer want it. +- `model`, `timezone`, and `approvalsMode` are merged into `~/.hermes/config.yaml`. Other keys in that file are left untouched. +- MCP servers are stored in `~/.hermes/config.yaml` under `mcp_servers`, keyed by each server's `name`. Removing an MCP server from your config removes it from the file; other servers are untouched. +- Secrets such as provider API keys and messaging tokens (Telegram, Discord, Slack, etc.) are stored in `~/.hermes/.env` and are not managed by this resource — configure them with `hermes setup` or `hermes config set`. +- To see all available configuration options, run `hermes doctor` or visit the [configuration reference](https://hermes-agent.nousresearch.com/docs/user-guide/configuration). diff --git a/package-lock.json b/package-lock.json index 64af52e7..75819205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "default", - "version": "1.6.0-beta.7", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "default", - "version": "1.6.0-beta.7", + "version": "1.6.0", "license": "ISC", "dependencies": { "@codifycli/plugin-core": "1.2.2", @@ -15,6 +15,7 @@ "ajv-formats": "^2.1.1", "chalk": "^5.3.0", "debug": "^4.3.4", + "js-yaml": "^4.1.1", "lodash.isequal": "^4.5.0", "nanoid": "^5.0.9", "plist": "^3.1.0", @@ -39,6 +40,7 @@ "@types/chalk": "^2.2.0", "@types/commander": "^2.12.2", "@types/debug": "4.1.12", + "@types/js-yaml": "^4.0.9", "@types/lodash.isequal": "^4.5.8", "@types/mock-fs": "^4.13.4", "@types/node": "^18", @@ -2929,6 +2931,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3868,7 +3877,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -8182,7 +8190,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index deb16045..0fcdf58f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "ajv-formats": "^2.1.1", "chalk": "^5.3.0", "debug": "^4.3.4", + "js-yaml": "^4.1.1", "lodash.isequal": "^4.5.0", "nanoid": "^5.0.9", "plist": "^3.1.0", @@ -71,6 +72,7 @@ "@types/chalk": "^2.2.0", "@types/commander": "^2.12.2", "@types/debug": "4.1.12", + "@types/js-yaml": "^4.0.9", "@types/lodash.isequal": "^4.5.8", "@types/mock-fs": "^4.13.4", "@types/node": "^18", diff --git a/src/index.ts b/src/index.ts index b0145bc8..1940c3e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { GitLfsResource } from './resources/git/lfs/git-lfs.js'; import { GitRepositoriesResource } from './resources/git/repositories/git-repositories.js'; import { GitRepositoryResource } from './resources/git/repository/git-repository.js'; import { WaitGithubSshKey } from './resources/git/wait-github-ssh-key/wait-github-ssh-key.js'; +import { HermesAgentResource } from './resources/hermes-agent/hermes-agent.js'; import { HomebrewResource } from './resources/homebrew/homebrew.js'; import { JenvResource } from './resources/java/jenv/jenv.js'; import { Npm } from './resources/javascript/npm/npm.js'; @@ -73,6 +74,7 @@ runPlugin(Plugin.create( new AliasesResource(), new EnvVarResource(), new EnvVarsResource(), + new HermesAgentResource(), new HomebrewResource(), new PyenvResource(), new UvResource(), diff --git a/src/resources/hermes-agent/hermes-agent-config.ts b/src/resources/hermes-agent/hermes-agent-config.ts new file mode 100644 index 00000000..c28ff844 --- /dev/null +++ b/src/resources/hermes-agent/hermes-agent-config.ts @@ -0,0 +1,30 @@ +import yaml from 'js-yaml'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export const HERMES_DIR = path.join(os.homedir(), '.hermes'); +export const HERMES_CONFIG_PATH = path.join(HERMES_DIR, 'config.yaml'); + +export type HermesYamlConfig = Record; + +export async function readHermesConfig(): Promise { + try { + const content = await fs.readFile(HERMES_CONFIG_PATH, 'utf8'); + const parsed = yaml.load(content); + return (parsed && typeof parsed === 'object') ? parsed as HermesYamlConfig : {}; + } catch { + return {}; + } +} + +export async function writeHermesConfig(config: HermesYamlConfig): Promise { + await fs.mkdir(HERMES_DIR, { recursive: true }); + await fs.writeFile(HERMES_CONFIG_PATH, yaml.dump(config), 'utf8'); +} + +export async function mutateHermesConfig(mutate: (config: HermesYamlConfig) => void): Promise { + const config = await readHermesConfig(); + mutate(config); + await writeHermesConfig(config); +} diff --git a/src/resources/hermes-agent/hermes-agent.ts b/src/resources/hermes-agent/hermes-agent.ts new file mode 100644 index 00000000..a3c2e045 --- /dev/null +++ b/src/resources/hermes-agent/hermes-agent.ts @@ -0,0 +1,223 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { HERMES_DIR, mutateHermesConfig, readHermesConfig } from './hermes-agent-config.js'; +import { McpServersParameter } from './mcp-servers-parameter.js'; + +const mcpServerSchema = z.object({ + name: z.string().describe('Unique name for this MCP server. Used as its key under mcp_servers in config.yaml'), + command: z.string().optional().describe('Executable to launch for a stdio MCP server'), + args: z.array(z.string()).optional().describe('Arguments passed to the command for a stdio MCP server'), + env: z.record(z.string(), z.string()).optional().describe('Environment variables passed to the server process'), + url: z.string().optional().describe('Remote endpoint for an HTTP MCP server'), + headers: z.record(z.string(), z.string()).optional().describe('HTTP headers sent with every request to an HTTP MCP server'), + enabled: z.boolean().optional().describe('Whether this MCP server is active (default: true)'), +}).refine((data) => (data.command != null) !== (data.url != null), { + message: 'MCP server must specify either "command" (stdio) or "url" (http), but not both', +}); + +export type McpServer = z.infer; + +const schema = z + .object({ + model: z + .object({ + provider: z.string().optional().describe('LLM provider id, e.g. "anthropic", "openai", "deepseek"'), + default: z.string().optional().describe('Default model id, e.g. "anthropic/claude-opus-4"'), + }) + .optional() + .describe('Default LLM provider and model, written to ~/.hermes/config.yaml under model'), + timezone: z + .string() + .optional() + .describe('IANA timezone used for scheduling and reports, e.g. "America/New_York"'), + approvalsMode: z + .enum(['manual', 'smart', 'off']) + .optional() + .describe('Tool approval policy: "manual" asks every time, "smart" auto-approves low-risk actions, "off" disables approvals'), + mcpServers: z + .array(mcpServerSchema) + .optional() + .describe('MCP servers registered in ~/.hermes/config.yaml under mcp_servers'), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/hermes-agent' }) + .describe('Hermes Agent installation and configuration management'); + +export type HermesAgentConfig = z.infer; + +const defaultConfig: Partial = { + approvalsMode: 'manual', + mcpServers: [], +}; + +const exampleBasic: ExampleConfig = { + title: 'Hermes Agent with a default model and MCP server', + description: 'Install Hermes Agent, set the default LLM provider/model, and register a filesystem MCP server.', + configs: [ + { + type: 'hermes-agent', + model: { + provider: 'anthropic', + default: 'anthropic/claude-opus-4', + }, + mcpServers: [ + { + name: 'filesystem', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + ], + }, + ], +}; + +const exampleAdvanced: ExampleConfig = { + title: 'Hermes Agent with timezone, approvals, and a remote MCP server', + description: 'Configure scheduling timezone, set tool approvals to smart mode, and connect a remote HTTP MCP server.', + configs: [ + { + type: 'hermes-agent', + timezone: 'America/New_York', + approvalsMode: 'smart', + mcpServers: [ + { + name: 'remote-tools', + url: 'https://mcp.example.com/sse', + headers: { + Authorization: 'Bearer ', + }, + }, + ], + }, + ], +}; + +export class HermesAgentResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'hermes-agent', + defaultConfig, + exampleConfigs: { + example1: exampleBasic, + example2: exampleAdvanced, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + dependencies: ['git'], + parameterSettings: { + model: { canModify: true }, + timezone: { canModify: true }, + approvalsMode: { canModify: true }, + mcpServers: { type: 'stateful', definition: new McpServersParameter(), order: 1 }, + }, + }; + } + + async refresh(parameters: Partial): Promise | null> { + const $ = getPty(); + const { status } = await $.spawnSafe('hermes version'); + if (status === SpawnStatus.ERROR) { + return null; + } + + const result: Partial = {}; + const config = await readHermesConfig(); + + if (parameters.model != null) { + const model = config['model']; + if (model && typeof model === 'object') { + result.model = model as HermesAgentConfig['model']; + } + } + + if (parameters.timezone != null) { + const timezone = config['timezone']; + if (typeof timezone === 'string') { + result.timezone = timezone; + } + } + + if (parameters.approvalsMode != null) { + const approvals = config['approvals']; + if (approvals && typeof approvals === 'object') { + const mode = (approvals as Record)['mode']; + if (typeof mode === 'string') { + result.approvalsMode = mode as HermesAgentConfig['approvalsMode']; + } + } + } + + return result; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + + await $.spawn( + 'bash -c "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"', + { interactive: true }, + ); + + // Ensure PATH is updated so subsequent lifecycle methods can call `hermes` + const localBin = path.join(os.homedir(), '.local', 'bin'); + process.env['PATH'] = `${localBin}:${process.env['PATH'] ?? ''}`; + + const { model, timezone, approvalsMode } = plan.desiredConfig; + if (model != null || timezone != null || approvalsMode != null) { + await this.writeSettings(model, timezone, approvalsMode); + } + } + + async modify( + pc: ParameterChange, + plan: ModifyPlan, + ): Promise { + if (pc.name === 'model' || pc.name === 'timezone' || pc.name === 'approvalsMode') { + const { model, timezone, approvalsMode } = plan.desiredConfig; + await this.writeSettings(model, timezone, approvalsMode); + } + } + + async destroy(_plan: DestroyPlan): Promise { + const $ = getPty(); + + await $.spawnSafe('hermes uninstall --full', { interactive: true }); + + await fs.rm(HERMES_DIR, { recursive: true, force: true }); + await fs.rm(path.join(os.homedir(), '.local', 'bin', 'hermes'), { force: true }); + } + + private async writeSettings( + model: HermesAgentConfig['model'], + timezone: HermesAgentConfig['timezone'], + approvalsMode: HermesAgentConfig['approvalsMode'], + ): Promise { + await mutateHermesConfig((config) => { + if (model != null) { + config['model'] = { ...(config['model'] as object | undefined), ...model }; + } + + if (timezone != null) { + config['timezone'] = timezone; + } + + if (approvalsMode != null) { + config['approvals'] = { ...(config['approvals'] as object | undefined), mode: approvalsMode }; + } + }); + } +} diff --git a/src/resources/hermes-agent/mcp-servers-parameter.ts b/src/resources/hermes-agent/mcp-servers-parameter.ts new file mode 100644 index 00000000..90fd2c9b --- /dev/null +++ b/src/resources/hermes-agent/mcp-servers-parameter.ts @@ -0,0 +1,45 @@ +import { ArrayStatefulParameter, Plan } from '@codifycli/plugin-core'; +import { StringIndexedObject } from '@codifycli/schemas'; + +import { mutateHermesConfig, readHermesConfig } from './hermes-agent-config.js'; +import { McpServer } from './hermes-agent.js'; + +export class McpServersParameter extends ArrayStatefulParameter { + override getSettings() { + return { + type: 'array' as const, + isElementEqual: (a: McpServer, b: McpServer) => a.name === b.name, + }; + } + + async refresh(_desired: McpServer[] | null): Promise { + const config = await readHermesConfig(); + const mcpServers = config['mcp_servers']; + + if (!mcpServers || typeof mcpServers !== 'object') { + return []; + } + + return Object.entries(mcpServers as Record).map(([name, serverConfig]) => ({ + name, + ...(serverConfig as object), + })) as McpServer[]; + } + + async addItem(item: McpServer, _plan: Plan): Promise { + const { name, ...serverConfig } = item; + await mutateHermesConfig((config) => { + const mcpServers = (config['mcp_servers'] ??= {}) as Record; + mcpServers[name] = serverConfig; + }); + } + + async removeItem(item: McpServer, _plan: Plan): Promise { + await mutateHermesConfig((config) => { + const mcpServers = config['mcp_servers']; + if (mcpServers && typeof mcpServers === 'object') { + delete (mcpServers as Record)[item.name]; + } + }); + } +} diff --git a/test/hermes-agent/hermes-agent.test.ts b/test/hermes-agent/hermes-agent.test.ts new file mode 100644 index 00000000..45191435 --- /dev/null +++ b/test/hermes-agent/hermes-agent.test.ts @@ -0,0 +1,106 @@ +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import yaml from 'js-yaml'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml'); + +async function readHermesConfig(): Promise> { + const content = await fs.readFile(HERMES_CONFIG_PATH, 'utf8'); + return yaml.load(content) as Record; +} + +describe('hermes-agent resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install hermes-agent', { timeout: 600_000 }, async () => { + const hermesBin = path.join(os.homedir(), '.local', 'bin', 'hermes'); + await PluginTester.fullTest( + pluginPath, + [{ type: 'hermes-agent' }], + { + skipUninstall: true, + validateApply: async () => { + const exists = await fs.access(hermesBin).then(() => true).catch(() => false); + expect(exists).toBe(true); + }, + }, + ); + }); + + it('Can manage model, timezone, and approvalsMode', { timeout: 600_000 }, async () => { + const initialConfig = { + model: { provider: 'anthropic', default: 'anthropic/claude-opus-4' }, + timezone: 'America/New_York', + approvalsMode: 'manual' as const, + }; + + const modifiedConfig = { + model: { provider: 'anthropic', default: 'anthropic/claude-sonnet-4-6' }, + timezone: 'America/Los_Angeles', + approvalsMode: 'smart' as const, + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'hermes-agent', ...initialConfig }], + { + skipUninstall: true, + validateApply: async () => { + const config = await readHermesConfig(); + expect(config.model.provider).toBe('anthropic'); + expect(config.model.default).toBe('anthropic/claude-opus-4'); + expect(config.timezone).toBe('America/New_York'); + expect(config.approvals.mode).toBe('manual'); + }, + testModify: { + modifiedConfigs: [{ type: 'hermes-agent', ...modifiedConfig }], + validateModify: async () => { + const config = await readHermesConfig(); + expect(config.model.default).toBe('anthropic/claude-sonnet-4-6'); + expect(config.timezone).toBe('America/Los_Angeles'); + expect(config.approvals.mode).toBe('smart'); + }, + }, + }, + ); + }); + + it('Can manage MCP servers', { timeout: 600_000 }, async () => { + const mcpServer = { + name: 'test-filesystem', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'hermes-agent', mcpServers: [mcpServer] }], + { + validateApply: async () => { + const config = await readHermesConfig(); + expect(config.mcp_servers).toBeDefined(); + expect(config.mcp_servers['test-filesystem']).toBeDefined(); + expect(config.mcp_servers['test-filesystem'].command).toBe('npx'); + }, + validateDestroy: async () => { + try { + const config = await readHermesConfig(); + expect(config.mcp_servers?.['test-filesystem']).toBeUndefined(); + } catch { + // file removed entirely is also acceptable + } + }, + }, + ); + }); + + afterAll(async () => { + // Best-effort cleanup in case tests left hermes installed + await testSpawn('hermes uninstall --full'); + await testSpawn('rm -f ~/.local/bin/hermes'); + await testSpawn('rm -rf ~/.hermes'); + }, 60_000); +});