Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@

## What is agnos?

**agnos is a project-level configuration manager for AI coding agents.** You declare your docs, rules, skills, MCP servers, and hooks once in a single `agnos.json` at the root of your repo. agnos materializes that declaration into whatever each agent expects to find on disk: `CLAUDE.md` + `.mcp.json` + `.claude/settings.json` for **Claude Code**, `AGENTS.md` + `.codex/config.toml` for **OpenAI Codex**, and so on.
**agnos is a project-level configuration manager for AI coding agents.** You declare your docs, rules, skills, MCP servers, and hooks once in a single `agnos.json` at the root of your repo. agnos materializes that declaration into whatever each agent expects to find on disk: `CLAUDE.md` + `.mcp.json` + `.claude/settings.json` for **Claude Code**, `AGENTS.md` + `.codex/config.toml` for **OpenAI Codex**, `GEMINI.md` + `.gemini/settings.json` for **Gemini CLI**, and so on.

agnos ships as a **single package** (`@luxia/agnos`) with a fixed, built-in set of agents (Claude Code, Codex) and domains (docs, rules, skills, mcp, hooks, agents). Point it at your project, run it once, or leave it in watch mode: every agent's files stay in sync with your one source of truth.
agnos ships as a **single package** (`@luxia/agnos`) with a fixed, built-in set of agents (Claude Code, Codex, Gemini CLI) and domains (docs, rules, skills, mcp, hooks, agents). Point it at your project, run it once, or leave it in watch mode: every agent's files stay in sync with your one source of truth.

## What does it solve?

Expand Down Expand Up @@ -60,7 +60,7 @@ agnos collapses all of that into one declarative `agnos.json`. You edit intent;
## Features

- 🎯 **One source of truth**: declare docs, rules, skills, MCP servers, and hooks once in `agnos.json`.
- 🔌 **Multi-agent output**: renders native files for Claude Code and OpenAI Codex from the same config.
- 🔌 **Multi-agent output**: renders native files for Claude Code, OpenAI Codex, and Gemini CLI from the same config.
- 👀 **Watch mode**: a per-domain watcher tree keeps agent files in sync as your sources change; edit a rule fragment and the canonical files re-render.
- 🧩 **Composable rules**: inject titled sections (by frontmatter `title`) from fragment files into your canonical rules file, preserving your hand-written sections.
- 📚 **Docs index**: compile a metadata index from your docs directory and surface it to agents.
Expand Down Expand Up @@ -128,7 +128,7 @@ You can also run a single domain: `agnos rules --once`, `agnos docs`, etc.
{
"$schema": "https://unpkg.com/@luxia/agnos/schema.json",
"schemaVersion": 1,
"agents": ["claude-code", "codex"],
"agents": ["claude-code", "codex", "gemini-cli"],
"docs": { "root": ".docs" },
"rules": {
"files": {
Expand Down Expand Up @@ -168,7 +168,7 @@ You can also run a single domain: `agnos rules --once`, `agnos docs`, etc.
| Field | Type | Description |
| ---------------- | --------------------------- | -------------------------------------------------------------------------------------------- |
| `schemaVersion` | `1` | Required. Config schema version; must be `1`. |
| `agents` | `string[]` | Active agent ids: `"claude-code"`, `"codex"`. |
| `agents` | `string[]` | Active agent ids: `"claude-code"`, `"codex"`, `"gemini-cli"`. |
| `docs.root` | `string` | Directory the docs index is compiled from (default `.docs`). |
| `rules.files` | `{ [canonical]: string[] }` | Maps each canonical rules file → fragment files whose titled sections are injected into it. |
| `skills.route` | `string` | Canonical skills directory (default `.agnos/skills`); agents link their own skills dir here. |
Expand Down Expand Up @@ -270,7 +270,7 @@ The sole config reader: renders every active agent's native files. `add`/`remove

## Active development

⚠️ **agnos is under active development.** The built-in roster is currently **Claude Code** and **OpenAI Codex**, with more agents planned. Config schema, CLI flags, and rendered output may change between releases: pin a version in CI and read the release notes before upgrading. Feedback and bug reports are very welcome.
⚠️ **agnos is under active development.** The built-in roster is currently **Claude Code**, **OpenAI Codex**, and **Gemini CLI**, with more agents planned. Config schema, CLI flags, and rendered output may change between releases: pin a version in CI and read the release notes before upgrading. Feedback and bug reports are very welcome.

## Contributing

Expand Down
26 changes: 25 additions & 1 deletion schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,31 @@
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd"
"SessionEnd",
"Setup",
"UserPromptExpansion",
"PermissionRequest",
"PermissionDenied",
"PostToolUseFailure",
"PostToolBatch",
"MessageDisplay",
"SubagentStart",
"TaskCreated",
"TaskCompleted",
"StopFailure",
"TeammateIdle",
"InstructionsLoaded",
"ConfigChange",
"CwdChanged",
"FileChanged",
"WorktreeCreate",
"WorktreeRemove",
"PostCompact",
"Elicitation",
"ElicitationResult",
"BeforeModel",
"AfterModel",
"BeforeToolSelection"
],
"description": "Normalized hook event name."
},
Expand Down
46 changes: 43 additions & 3 deletions src/agents/adapters/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";
import type {
AgentAdapter,
HookEntry,
HookEventMap,
MaterializeContext,
McpDeclaration,
ResolvedMcp,
Expand All @@ -13,7 +14,7 @@ import {
pickStringArray,
readConfigOrDefault,
} from "../../../core/index.js";
import { flattenHooks, groupHooks } from "../hooks-map.js";
import { identityEventMap, renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js";
import {
linkSkills,
mirrorRules,
Expand All @@ -27,10 +28,48 @@ const CLAUDE_MCP = ".mcp.json";
const CLAUDE_SETTINGS = path.join(".claude", "settings.json");
const CLAUDE_SKILLS_DIR = path.join(".claude", "skills");

/**
* Claude Code exposes the widest native event set and uses the canonical event
* names verbatim (identity mapping). Canonical names are derived from this list.
*/
const CLAUDE_HOOK_EVENTS: HookEventMap = identityEventMap([
"SessionStart",
"SessionEnd",
"Setup",
"InstructionsLoaded",
"ConfigChange",
"CwdChanged",
"UserPromptSubmit",
"UserPromptExpansion",
"Stop",
"StopFailure",
"PreToolUse",
"PermissionRequest",
"PermissionDenied",
"PostToolUse",
"PostToolUseFailure",
"PostToolBatch",
"SubagentStart",
"SubagentStop",
"TaskCreated",
"TaskCompleted",
"TeammateIdle",
"Notification",
"MessageDisplay",
"FileChanged",
"WorktreeCreate",
"WorktreeRemove",
"PreCompact",
"PostCompact",
"Elicitation",
"ElicitationResult",
]);

const claudeCode: AgentAdapter = {
id: "claude-code",
displayName: "Claude Code",
paths: { skillsDir: CLAUDE_SKILLS_DIR, rulesFilename: CLAUDE_RULES, rulesRoot: "." },
hookEvents: CLAUDE_HOOK_EVENTS,

render: {
async rules(state, ctx) {
Expand All @@ -49,7 +88,8 @@ const claudeCode: AgentAdapter = {

scrape: {
mcp: (ctx) => importMcpFile(ctx),
hooks: async (ctx) => flattenHooks((await readSettings(settingsPath(ctx)))?.data["hooks"]),
hooks: async (ctx) =>
scrapeNativeHooks((await readSettings(settingsPath(ctx)))?.data["hooks"], CLAUDE_HOOK_EVENTS),
skills: (ctx) => listSkillDirs(ctx),
},

Expand Down Expand Up @@ -101,7 +141,7 @@ async function writeClaudeHooks(entries: HookEntry[], ctx: MaterializeContext):
ctx.logger.warn(`${CLAUDE_SETTINGS} is not valid JSON; skipping hooks`);
return;
}
const { hooks } = groupHooks(entries, { withMessage: true });
const { hooks } = renderNativeHooks(entries, CLAUDE_HOOK_EVENTS, { withMessage: true });
const hasHooks = Object.keys(hooks).length > 0;
if (hasHooks) {
settings.data["hooks"] = hooks;
Expand Down
27 changes: 14 additions & 13 deletions src/agents/adapters/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import TOML from "@iarna/toml";
import type {
AgentAdapter,
HookEntry,
HookEvent,
HookEventMap,
MaterializeContext,
McpDeclaration,
ResolvedMcp,
} from "../../../core/index.js";
import { importMcpServers, pickEnv, pickStringArray } from "../../../core/index.js";
import { flattenHooks, groupHooks } from "../hooks-map.js";
import { identityEventMap, renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js";
import { linkSkills, mirrorRules, removePaths, writeIfChanged } from "../shared.js";

const CODEX_RULES = "AGENTS.md";
Expand All @@ -19,21 +19,25 @@ const CODEX_CONFIG = path.join(CODEX_DIR, "config.toml");
const CODEX_HOOKS = path.join(CODEX_DIR, "hooks.json");
const CODEX_SKILLS_DIR = path.join(".agents", "skills");

/** New-vocabulary events Codex understands (intersection with the closed set). */
const CODEX_EVENTS: ReadonlySet<HookEvent> = new Set<HookEvent>([
/** Codex uses the canonical event names verbatim, for the subset it understands. */
const CODEX_HOOK_EVENTS: HookEventMap = identityEventMap([
"SessionStart",
"SubagentStart",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"UserPromptSubmit",
"PreCompact",
"PostCompact",
"UserPromptSubmit",
"SubagentStop",
"Stop",
"SessionStart",
]);

const codex: AgentAdapter = {
id: "codex",
displayName: "OpenAI Codex",
paths: { skillsDir: CODEX_SKILLS_DIR, rulesFilename: CODEX_RULES, rulesRoot: "." },
hookEvents: CODEX_HOOK_EVENTS,

render: {
async rules(state, ctx) {
Expand All @@ -54,7 +58,7 @@ const codex: AgentAdapter = {

scrape: {
mcp: (ctx) => importCodexConfig(ctx),
hooks: async (ctx) => flattenHooks(await readCodexHooks(ctx)),
hooks: async (ctx) => scrapeNativeHooks(await readCodexHooks(ctx), CODEX_HOOK_EVENTS),
skills: () => Promise.resolve([]),
},

Expand All @@ -67,12 +71,9 @@ const codex: AgentAdapter = {

async function writeCodexHooks(entries: HookEntry[], ctx: MaterializeContext): Promise<void> {
const file = path.join(ctx.projectRoot, CODEX_HOOKS);
const { hooks, dropped } = groupHooks(entries, { events: CODEX_EVENTS, withMessage: false });
if (dropped > 0) {
ctx.logger.warn(
`codex: skipped ${dropped} hook${dropped === 1 ? "" : "s"} for unsupported events`,
);
}
// Codex supports a handler `statusMessage`; unsupported events are surfaced at
// `hooks add` time, so render just skips them.
const { hooks } = renderNativeHooks(entries, CODEX_HOOK_EVENTS, { withMessage: true });
if (Object.keys(hooks).length === 0) {
if (!ctx.dryRun) await fs.rm(file, { force: true }).catch(() => {});
return;
Expand Down
Loading
Loading