diff --git a/docs/plans/agent-mappings-and-link-command.md b/docs/plans/agent-mappings-and-link-command.md new file mode 100644 index 0000000..bc4663c --- /dev/null +++ b/docs/plans/agent-mappings-and-link-command.md @@ -0,0 +1,164 @@ +# Agent Mapping Gap Analysis + Zero-Config Link Command + +## Part 1: Missing Agent Mappings + +### Current Support Summary + +**Supported Agents:** claude, cursor, codex, roocode, opencode, generic + +| Feature | Claude | Cursor | Codex | RooCode | OpenCode | +|---------|--------|--------|-------|---------|----------| +| Rules | ✅ `CLAUDE.md` | ✅ `.cursor/rules/` | ✅ `AGENTS.md` | ✅ `.roo/rules/` | ✅ `AGENTS.md` | +| Commands | ✅ `.claude/commands/` | ✅ `.cursor/commands/` | ❌ | ❌ | ✅ `.opencode/command/` | +| Skills | ✅ `.claude/skills/` | ✅ `.cursor/skills/` | ❌ | ❌ | ✅ (Claude path) | +| Agents | ✅ `.claude/agents/` | ✅ `.cursor/agents/` | ❌ | ❌ | ✅ `.opencode/agent/` | +| Hooks | ✅ settings.json | ✅ hooks.json | ❌ | ❌ | ❌ | + +--- + +### Feature 1: Add Factory Agent (New) + +Based on [Factory AI docs](https://docs.factory.ai/cli/configuration/settings): + +**Project-relative paths:** +| Feature | Path | +|---------|------| +| Rules | `.factory/AGENTS.md` | +| Skills | `.factory/skills//SKILL.md` | +| Droids | `.factory/droids/` | +| Hooks | `.factory/settings.json` (hooks key) | + +**Files to modify:** + +1. `src/types/index.ts:3` - Add `'factory'` to AgentName +2. `src/types/index.ts:157-158` - Add to `isValidAgentName` +3. `src/agents/index.ts:9-25` - Add to AGENT_DIRECTORY_MAPPINGS: + ```typescript + skills: { factory: '.factory/skills' }, + agents: { factory: '.factory/droids' }, + ``` +4. `src/agents/index.ts:38-71` - Add to AGENT_MAPPINGS: + ```typescript + factory: { + path: 'AGENTS.md', + format: 'markdown', + directory: '.factory', + mcpPath: '.factory/settings.json' + } + ``` +5. `src/agents/hooks-distributor.ts` - Add Factory hook distribution (same format as Claude) +6. `src/core/config-loader.ts:152` - Add `'factory'` to validAgentNames + +--- + +### Feature 2: Expand Codex Support + +Based on [Codex Custom Prompts docs](https://developers.openai.com/codex/custom-prompts/) and [Skills docs](https://developers.openai.com/codex/skills/): + +**Project-relative paths:** +| Feature | Path | Notes | +|---------|------|-------| +| Rules | `.codex/AGENTS.md` | | +| Commands | `.codex/prompts/` | Equivalent to Claude's commands | +| Skills | `.codex/skills/` | Uses SKILL.md format | + +**Files to modify:** + +1. `src/agents/index.ts:9-25` - Add Codex to AGENT_DIRECTORY_MAPPINGS: + ```typescript + commands: { codex: '.codex/prompts' }, // Note: "prompts" not "commands" + skills: { codex: '.codex/skills' }, + ``` +2. `src/agents/index.ts:50-54` - Update Codex in AGENT_MAPPINGS: + ```typescript + codex: { + path: 'AGENTS.md', + format: 'markdown', + directory: '.codex', // Add this + mcpPath: '.codex/config.toml' // Update path + } + ``` + +--- + +## Part 2: Zero-Config Link Command + +### Usage +```bash +bunx glooit link # Symlink .agents/ to all supported agents +bunx glooit link .glooit # Symlink from .glooit/ directory +bunx glooit link -t claude,cursor # Symlink to specific agents only +bunx glooit link .glooit -t claude # Combine source dir and targets +``` + +### How it works + +1. Accept optional positional argument for source directory (defaults to `.agents/`, falls back to `.glooit/`) +2. Scan for known patterns: + - `CLAUDE.md` → sync to Claude + - `AGENTS.md` → sync to Codex, OpenCode, Factory + - `commands/` → sync commands directory + - `skills/` → sync skills directory + - `agents/` → sync agents directory +3. Build virtual config with `mode: 'symlink'` and run distributor +4. No `glooit.config.ts` required + +### Implementation + +Add to `src/cli/index.ts`: + +```typescript +program + .command('link') + .description('Zero-config symlink: auto-sync .agents/ to all supported agents') + .argument('[source]', 'source directory (default: .agents, fallback: .glooit)') + .option('-t, --targets ', 'comma-separated list of agents (default: all)') + .action(async (source, options) => { + await linkCommand(source, options.targets); + }); +``` + +New function `linkCommand(source?: string, targets?: string)` (~80 lines): +1. Resolve source dir: use provided arg, or default to `.agents/`, or fall back to `.glooit/` +2. Scan for files/directories +3. Build config object with discovered rules and `mode: 'symlink'` +4. Create `AIRulesCore` and call `sync()` + +Note: `--copy` option is intentionally omitted - use `glooit sync` for copy mode (requires config file). + +The existing `unlink` command already works without config - it reads from the manifest to find symlinks to replace. + +--- + +## Files to Modify (Summary) + +| File | Changes | +|------|---------| +| `src/types/index.ts` | Add `factory` to AgentName, update validation | +| `src/agents/index.ts` | Add Factory config, add Codex/Factory directory mappings | +| `src/agents/hooks-distributor.ts` | Add Factory hook support | +| `src/core/config-loader.ts` | Add `factory` to validAgentNames | +| `src/cli/index.ts` | Add `link` command | + +--- + +## Verification + +1. Run existing tests: `bun test` +2. Test Factory agent: + ```bash + mkdir -p .agents/skills/test-skill + echo "---\nname: test\n---\nTest" > .agents/skills/test-skill/SKILL.md + bunx glooit link + # Verify .factory/skills/test-skill/SKILL.md exists as symlink + ``` +3. Test Codex support: + ```bash + # Verify .codex/skills/test-skill/SKILL.md exists as symlink + # Verify .codex/prompts/ has commands symlinked + ``` +4. Test zero-config link: + ```bash + rm glooit.config.ts # Remove config + bunx glooit link # Should still work + ``` diff --git a/src/agents/hooks-distributor.ts b/src/agents/hooks-distributor.ts index 620dcef..ca176e7 100644 --- a/src/agents/hooks-distributor.ts +++ b/src/agents/hooks-distributor.ts @@ -66,6 +66,7 @@ export class AgentHooksDistributor { // Group hooks by target agent const claudeHooks: AgentHook[] = []; const cursorHooks: AgentHook[] = []; + const factoryHooks: AgentHook[] = []; for (const hook of this.config.hooks) { for (const target of hook.targets) { @@ -73,8 +74,10 @@ export class AgentHooksDistributor { claudeHooks.push(hook); } else if (target === 'cursor') { cursorHooks.push(hook); + } else if (target === 'factory') { + factoryHooks.push(hook); } - // codex and roocode don't support hooks + // codex, roocode, opencode don't support hooks } } @@ -85,6 +88,10 @@ export class AgentHooksDistributor { if (cursorHooks.length > 0) { await this.distributeCursorHooks(cursorHooks); } + + if (factoryHooks.length > 0) { + await this.distributeFactoryHooks(factoryHooks); + } } private async distributeClaudeHooks(hooks: AgentHook[]): Promise { @@ -189,6 +196,71 @@ export class AgentHooksDistributor { writeFileSync(hooksPath, JSON.stringify(config, null, 2), 'utf-8'); } + private async distributeFactoryHooks(hooks: AgentHook[]): Promise { + // Factory uses the same hook format as Claude, stored in .factory/settings.json + const settingsPath = '.factory/settings.json'; + + // Load existing settings or create new + let settings: ClaudeSettings = {}; + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } catch { + // Invalid JSON, start fresh + } + } + + if (!settings.hooks) { + settings.hooks = {}; + } + + // Group hooks by event (Factory uses same events as Claude) + const hooksByEvent = new Map(); + + for (const hook of hooks) { + const factoryEvent = CLAUDE_EVENT_MAP[hook.event]; // Factory uses same event names + if (!factoryEvent) { + console.warn(`Event '${hook.event}' is not supported by Factory, skipping...`); + continue; + } + + if (!hooksByEvent.has(factoryEvent)) { + hooksByEvent.set(factoryEvent, []); + } + hooksByEvent.get(factoryEvent)?.push(hook); + } + + // Build Factory hooks config (same format as Claude) + for (const [event, eventHooks] of hooksByEvent) { + if (!settings.hooks[event]) { + settings.hooks[event] = []; + } + + for (const hook of eventHooks) { + const command = this.buildCommand(hook); + const matcher = hook.matcher || CLAUDE_DEFAULT_MATCHERS[hook.event] || '*'; + + // Check if we already have a hook with this matcher + const existingEntry = settings.hooks[event].find(h => h.matcher === matcher); + + if (existingEntry) { + // Add to existing matcher's hooks + existingEntry.hooks.push({ type: 'command', command }); + } else { + // Create new entry + settings.hooks[event].push({ + matcher, + hooks: [{ type: 'command', command }] + }); + } + } + } + + // Write settings + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + } + private buildCommand(hook: AgentHook): string { if (hook.command) { return hook.command; @@ -223,6 +295,7 @@ export class AgentHooksDistributor { const hasClaudeHooks = this.config.hooks.some(h => h.targets.includes('claude')); const hasCursorHooks = this.config.hooks.some(h => h.targets.includes('cursor')); + const hasFactoryHooks = this.config.hooks.some(h => h.targets.includes('factory')); if (hasClaudeHooks) { paths.push('.claude/settings.json'); @@ -230,6 +303,9 @@ export class AgentHooksDistributor { if (hasCursorHooks) { paths.push('.cursor/hooks.json'); } + if (hasFactoryHooks) { + paths.push('.factory/settings.json'); + } return paths; } diff --git a/src/agents/index.ts b/src/agents/index.ts index 4af6965..13e3ff2 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -11,16 +11,20 @@ export const AGENT_DIRECTORY_MAPPINGS: Record = { codex: { path: 'AGENTS.md', format: 'markdown', - mcpPath: 'codex_mcp.json' + directory: '.codex', + mcpPath: '.codex/config.toml' }, roocode: { path: '.roo/rules/{name}.md', @@ -63,6 +68,12 @@ export const AGENT_MAPPINGS: Record = { format: 'markdown', mcpPath: 'opencode.jsonc' }, + factory: { + path: 'AGENTS.md', + format: 'markdown', + directory: '.factory', + mcpPath: '.factory/settings.json' + }, generic: { path: '{name}.md', format: 'markdown', diff --git a/src/cli/index.ts b/src/cli/index.ts index 06cdd20..44888d8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,8 +4,9 @@ import { Command } from 'commander'; import { AIRulesCore } from '../core'; import { ConfigLoader } from '../core/config-loader'; import { ConfigValidator } from '../core/validation'; -import { existsSync, writeFileSync, rmSync, readdirSync, readFileSync } from 'fs'; -import { dirname } from 'path'; +import { existsSync, writeFileSync, rmSync, readdirSync, readFileSync, statSync } from 'fs'; +import { dirname, join } from 'path'; +import type { AgentName, Config, Rule } from '../types'; import { GitIgnoreManager } from '../core/gitignore'; import { ManifestManager } from '../core/manifest'; import { detect } from 'package-manager-detector/detect'; @@ -136,6 +137,20 @@ program } }); +program + .command('link') + .description('Zero-config symlink: auto-sync .agents/ to all supported agents') + .argument('[source]', 'source directory (default: .agents, fallback: .glooit)') + .option('-t, --targets ', 'comma-separated list of agents') + .action(async (source, options) => { + try { + await linkCommand(source, options.targets); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } + }); + program .command('upgrade') .description('Upgrade glooit to the latest version') @@ -477,6 +492,109 @@ async function replaceSymlinkWithFile(linkPath: string): Promise { } } +async function linkCommand(source?: string, targetsArg?: string): Promise { + // Resolve source directory + let sourceDir: string; + if (source) { + if (!existsSync(source)) { + throw new Error(`Source directory not found: ${source}`); + } + sourceDir = source; + } else if (existsSync('.agents')) { + sourceDir = '.agents'; + } else if (existsSync('.glooit')) { + sourceDir = '.glooit'; + } else { + throw new Error('No source directory found. Create .agents/ or specify a source directory.'); + } + + console.log(`🔗 Linking from ${sourceDir}/...`); + + // Parse targets + const validAgents: AgentName[] = ['claude', 'cursor', 'codex', 'roocode', 'opencode', 'factory']; + let targets: AgentName[]; + + if (targetsArg) { + targets = targetsArg.split(',').map(t => t.trim()) as AgentName[]; + for (const t of targets) { + if (!validAgents.includes(t)) { + throw new Error(`Invalid agent: ${t}. Valid agents: ${validAgents.join(', ')}`); + } + } + } else { + targets = validAgents; + } + + // Build rules by scanning the source directory + const rules: Rule[] = []; + + // Check for rule files (CLAUDE.md, AGENTS.md) + const claudeMdPath = join(sourceDir, 'CLAUDE.md'); + const agentsMdPath = join(sourceDir, 'AGENTS.md'); + + if (existsSync(claudeMdPath)) { + const claudeTargets = targets.filter(t => t === 'claude'); + if (claudeTargets.length > 0) { + rules.push({ + name: 'claude-rules', + file: claudeMdPath, + to: './', + mode: 'symlink', + targets: claudeTargets + }); + } + } + + if (existsSync(agentsMdPath)) { + // AGENTS.md goes to codex, opencode, factory (agents that use AGENTS.md) + const agentsTargets = targets.filter(t => ['codex', 'opencode', 'factory'].includes(t)); + if (agentsTargets.length > 0) { + rules.push({ + name: 'agents-rules', + file: agentsMdPath, + to: './', + mode: 'symlink', + targets: agentsTargets + }); + } + } + + // Check for directories (commands, skills, agents) + const dirsToCheck = ['commands', 'skills', 'agents']; + for (const dirName of dirsToCheck) { + const dirPath = join(sourceDir, dirName); + if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { + rules.push({ + name: dirName, + file: dirPath, + to: './', + mode: 'symlink', + targets: targets + }); + } + } + + if (rules.length === 0) { + console.log(`⚠️ No files or directories found in ${sourceDir}/ to link.`); + console.log('Expected: CLAUDE.md, AGENTS.md, commands/, skills/, or agents/'); + return; + } + + // Build config and sync + const config: Config = { + configDir: sourceDir, + mode: 'symlink', + rules, + gitignore: true, + mergeMcps: true + }; + + const core = new AIRulesCore(config); + await core.sync(); + + console.log(`✅ Linked ${rules.length} item(s) to ${targets.length} agent(s)`); +} + async function upgradeCommand(forceGlobal: boolean, forceLocal: boolean): Promise { const pm = await detect(); const agent = pm?.agent || 'npm'; diff --git a/src/core/config-loader.ts b/src/core/config-loader.ts index 1019f3c..9a97d13 100644 --- a/src/core/config-loader.ts +++ b/src/core/config-loader.ts @@ -149,7 +149,7 @@ export class ConfigLoader { throw new Error(`Rule at index ${index}: Rule.targets must be a non-empty array`); } - const validAgentNames = ['claude', 'cursor', 'codex', 'roocode', 'opencode', 'generic']; + const validAgentNames = ['claude', 'cursor', 'codex', 'roocode', 'opencode', 'factory', 'generic']; // Check if all targets are valid agents if (!r.targets.every((agent: unknown) => { diff --git a/src/types/index.ts b/src/types/index.ts index 63a8ff8..c703ef3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,6 @@ import { resolveConfigDir } from '../core/utils'; -export type AgentName = 'claude' | 'cursor' | 'codex' | 'roocode' | 'opencode' | 'generic'; +export type AgentName = 'claude' | 'cursor' | 'codex' | 'roocode' | 'opencode' | 'factory' | 'generic'; export interface AgentTarget { name: AgentName; @@ -155,7 +155,7 @@ export interface BackupEntry { // Simple validation functions function isValidAgentName(agentName: unknown): agentName is AgentName { - return ['claude', 'cursor', 'codex', 'roocode', 'opencode', 'generic'].includes(agentName as string); + return ['claude', 'cursor', 'codex', 'roocode', 'opencode', 'factory', 'generic'].includes(agentName as string); } function isValidAgent(agent: unknown): agent is Agent { diff --git a/tests/integration/cli-directory-sync.test.ts b/tests/integration/cli-directory-sync.test.ts index 2907715..f766641 100644 --- a/tests/integration/cli-directory-sync.test.ts +++ b/tests/integration/cli-directory-sync.test.ts @@ -169,6 +169,7 @@ export default { mkdirSync('.agents/commands', { recursive: true }); writeFileSync('.agents/commands/test.md', '# Test'); + // roocode doesn't support commands directory const config = ` export default { configDir: '.agents', @@ -176,7 +177,7 @@ export default { { name: 'commands', file: '.agents/commands', - targets: ['codex'] + targets: ['roocode'] } ] }; diff --git a/tests/unit/agents.test.ts b/tests/unit/agents.test.ts index 2cfe5a3..f6a030d 100644 --- a/tests/unit/agents.test.ts +++ b/tests/unit/agents.test.ts @@ -50,7 +50,12 @@ describe('Agent Mappings', () => { it('should return undefined for agents without directories', () => { expect(getAgentDirectory('claude')).toBeUndefined(); - expect(getAgentDirectory('codex')).toBeUndefined(); + expect(getAgentDirectory('opencode')).toBeUndefined(); + }); + + it('should return directories for codex and factory', () => { + expect(getAgentDirectory('codex')).toBe('.codex'); + expect(getAgentDirectory('factory')).toBe('.factory'); }); }); diff --git a/tests/unit/hooks-distributor.test.ts b/tests/unit/hooks-distributor.test.ts index a79eb24..e7c9446 100644 --- a/tests/unit/hooks-distributor.test.ts +++ b/tests/unit/hooks-distributor.test.ts @@ -129,4 +129,73 @@ describe('AgentHooksDistributor', () => { expect(paths).toContain('.claude/settings.json'); expect(paths).toContain('.cursor/hooks.json'); }); + + it('writes Factory hooks with same format as Claude', async () => { + const config: Config = { + rules: [], + hooks: [ + { event: 'PreToolUse', command: 'echo pre', matcher: 'Bash', targets: ['factory'] }, + { event: 'PostToolUse', script: 'scripts/format.ts', targets: ['factory'] }, + { event: 'Stop', command: 'echo done', targets: ['factory'] } + ] + }; + + const distributor = new AgentHooksDistributor(config); + await distributor.distributeHooks(); + + expect(existsSync('.factory/settings.json')).toBe(true); + const factory = JSON.parse(readFileSync('.factory/settings.json', 'utf-8')); + expect(factory.hooks.PreToolUse[0].matcher).toBe('Bash'); + expect(factory.hooks.PreToolUse[0].hooks[0].command).toBe('echo pre'); + expect(factory.hooks.PostToolUse[0].hooks[0].command).toBe('bun run scripts/format.ts'); + expect(factory.hooks.Stop[0].hooks[0].command).toBe('echo done'); + }); + + it('skips unsupported events for Factory and handles invalid JSON', async () => { + mkdirSync('.factory', { recursive: true }); + writeFileSync('.factory/settings.json', '{ invalid json'); + + const config: Config = { + rules: [], + hooks: [ + { event: 'beforeReadFile', command: 'echo skip', targets: ['factory'] }, // Not supported + { event: 'UserPromptSubmit', command: 'echo ok', targets: ['factory'] } + ] + }; + + const distributor = new AgentHooksDistributor(config); + await distributor.distributeHooks(); + + const factory = JSON.parse(readFileSync('.factory/settings.json', 'utf-8')); + expect(factory.hooks.UserPromptSubmit[0].hooks[0].command).toBe('echo ok'); + }); + + it('appends hooks with same matcher for Factory', async () => { + const config: Config = { + rules: [], + hooks: [ + { event: 'PreToolUse', command: 'echo one', matcher: 'Edit', targets: ['factory'] }, + { event: 'PreToolUse', command: 'echo two', matcher: 'Edit', targets: ['factory'] } + ] + }; + + const distributor = new AgentHooksDistributor(config); + await distributor.distributeHooks(); + + const factory = JSON.parse(readFileSync('.factory/settings.json', 'utf-8')); + expect(factory.hooks.PreToolUse[0].hooks.length).toBe(2); + }); + + it('getGeneratedPaths includes Factory when targeted', () => { + const config: Config = { + rules: [], + hooks: [ + { event: 'Stop', command: 'echo ok', targets: ['factory'] } + ] + }; + + const distributor = new AgentHooksDistributor(config); + const paths = distributor.getGeneratedPaths(); + expect(paths).toContain('.factory/settings.json'); + }); });