diff --git a/README.md b/README.md index 0d527d1..cdb81c6 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,7 @@ Adapters are used by both `export` and `run`. Available adapters: | `github` | GitHub Actions agent | | `git` | Git-native execution (run only) | | `opencode` | OpenCode instructions + config | +| `gemini` | Google Gemini CLI (GEMINI.md + settings.json) | | `openclaw` | OpenClaw format | | `nanobot` | Nanobot format | | `cursor` | Cursor `.cursor/rules/*.mdc` files | diff --git a/docs/adapters/gemini.md b/docs/adapters/gemini.md new file mode 100644 index 0000000..474060e --- /dev/null +++ b/docs/adapters/gemini.md @@ -0,0 +1,407 @@ +# Google Gemini CLI Adapter + +Complete mapping guide for converting between gitagent and Google Gemini CLI formats. + +## Overview + +Google Gemini CLI is Google's open-source AI agent for the terminal. It uses: + +- **GEMINI.md** at the project root (or `~/.gemini/GEMINI.md` globally) for custom instructions +- **.gemini/settings.json** for model configuration, tool permissions, and approval modes +- Supports Gemini models via Google AI Studio or Vertex AI +- Has approval modes, policy engine, MCP servers, skills, hooks, and extensions + +The gitagent Gemini adapter enables: +1. **Export**: Convert gitagent → Gemini CLI format +2. **Run**: Execute gitagent agents using `gemini` CLI +3. **Import**: Convert Gemini CLI projects → gitagent format + +## Installation + +```bash +# Install Gemini CLI +npm install -g @google/generative-ai-cli + +# Or via Homebrew (macOS) +brew install google/tap/gemini + +# Verify installation +gemini --version +``` + +## Field Mapping + +### Export: gitagent → Gemini CLI + +| gitagent | Gemini CLI | Notes | +|----------|-----------|-------| +| `SOUL.md` | `GEMINI.md` (identity section) | Core personality and communication style | +| `RULES.md` | `GEMINI.md` (constraints section) | Hard constraints and safety boundaries | +| `DUTIES.md` | `GEMINI.md` (SOD section) | Segregation of duties policy | +| `skills/*/SKILL.md` | `GEMINI.md` (skills section) | Progressive disclosure with full instructions | +| `tools/*.yaml` | `.gemini/settings.json` → `allowedTools` | Tool names extracted from YAML | +| `knowledge/` (always_load) | `GEMINI.md` (knowledge section) | Reference documents embedded | +| `manifest.model.preferred` | `.gemini/settings.json` → `model` | Model object with `id` and `provider` (e.g., `{"id": "gemini-2.0-flash-exp", "provider": "google"}`) | +| `manifest.compliance.supervision.human_in_the_loop` | CLI flag `--approval-mode` | Approval mode mapping (see below) | +| `hooks/hooks.yaml` | `.gemini/settings.json` → `hooks` | Lifecycle event handlers | +| `agents/` (sub-agents) | `GEMINI.md` (delegation section) | Documented as pattern (no native support) | +| `compliance/` (policy files) | `.gemini/settings.json` → `policy` | Policy file paths | + +### Import: Gemini CLI → gitagent + +| Gemini CLI | gitagent | Notes | +|-----------|----------|-------| +| `GEMINI.md` | `SOUL.md` + `RULES.md` + `DUTIES.md` | Split by section keywords | +| `.gemini/settings.json` → `model` | `agent.yaml` → `model.preferred` | Direct mapping | +| `.gemini/settings.json` → `approvalMode` | `compliance.supervision.human_in_the_loop` | Reverse approval mode mapping | +| `.gemini/settings.json` → `allowedTools` | `tools/*.yaml` | Creates tool YAML files | +| `.gemini/settings.json` → `hooks` | `hooks/hooks.yaml` | Event mapping | + +## Approval Mode Mapping + +### Export (gitagent → Gemini CLI) + +| gitagent `human_in_the_loop` | Gemini CLI `approvalMode` | Behavior | +|------------------------------|---------------------------|----------| +| `always` | `plan` | Read-only mode, no actions executed | +| `conditional` | `default` | Prompt for approval on tool use | +| `none` | `yolo` | Auto-approve all actions | +| `advisory` | `auto_edit` | Auto-approve edit tools only | + +### Import (Gemini CLI → gitagent) + +| Gemini CLI `approvalMode` | gitagent `human_in_the_loop` | +|---------------------------|------------------------------| +| `plan` | `always` | +| `default` | `conditional` | +| `yolo` | `none` | +| `auto_edit` | `advisory` | + +## Usage Examples + +### Export to Gemini CLI + +```bash +# Export to stdout +gitagent export --format gemini -d ./my-agent + +# Save to file +gitagent export --format gemini -d ./my-agent -o gemini-export.txt + +# The export includes both GEMINI.md and .gemini/settings.json content +``` + +**Output Structure:** +``` +# === GEMINI.md === +# agent-name +Agent description + +## Soul +[SOUL.md content] + +## Rules +[RULES.md content] + +## Skills +[Skills with progressive disclosure] + +## Tools +[Tool schemas] + +# === .gemini/settings.json === +{ + "model": { + "id": "gemini-2.0-flash-exp", + "provider": "google" + }, + "allowedTools": ["bash", "edit", "read"], + "approvalMode": "default", + "hooks": {...} +} +``` + +### Run with Gemini CLI + +```bash +# Interactive mode +gitagent run ./my-agent --adapter gemini + +# Single-shot mode with prompt +gitagent run ./my-agent --adapter gemini -p "Explain quantum computing" + +# From git repository +gitagent run --repo https://github.com/user/agent.git --adapter gemini +``` + +**What Happens:** +1. Creates temporary workspace +2. Writes `GEMINI.md` at project root +3. Creates `.gemini/settings.json` with config +4. Launches `gemini` CLI in that workspace +5. Cleans up temporary files on exit + +### Import from Gemini CLI + +```bash +# Import from existing Gemini CLI project +gitagent import --from gemini /path/to/gemini-project -d ./imported-agent + +# Verify the imported agent +cd ./imported-agent +gitagent validate +``` + +**What Gets Created:** +- `agent.yaml` - Manifest with model from settings.json +- `SOUL.md` - Identity sections from GEMINI.md +- `RULES.md` - Constraint sections from GEMINI.md +- `DUTIES.md` - SOD/delegation sections (if present) +- `tools/*.yaml` - Tool definitions from allowedTools +- `hooks/hooks.yaml` - Hooks from settings.json + +## Section Detection (Import) + +When importing `GEMINI.md`, sections are split based on keywords: + +**→ SOUL.md:** +- Sections with: identity, personality, style, about, soul +- Default destination for unmatched sections + +**→ RULES.md:** +- Sections with: rule, constraint, never, always, must, compliance + +**→ DUTIES.md:** +- Sections with: duties, segregation, delegation + +## What Maps Cleanly + +✅ **Fully Supported:** +- Agent identity and personality (SOUL.md ↔ GEMINI.md) +- Rules and constraints (RULES.md ↔ GEMINI.md) +- Model preferences +- Tool permissions +- Approval modes +- Basic hooks +- Knowledge documents + +## What Requires Manual Setup + +⚠️ **Not Automatically Mapped:** + +### 1. MCP Servers +**Issue:** Gemini CLI's MCP server config doesn't have a direct gitagent equivalent. + +**Workaround:** +- Document MCP servers in GEMINI.md during export +- Manually configure `.gemini/settings.json` → `mcpServers` after export +- On import, MCP config is ignored (not portable) + +**Example Manual Setup:** +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"] + } + } +} +``` + +### 2. Extensions +**Issue:** Gemini CLI extensions are runtime-specific and not portable. + +**Workaround:** +- Extensions are not exported or imported +- Document extension requirements in README +- Users must install extensions separately + +### 3. Policy Files +**Issue:** Gemini's policy engine uses separate policy files that need manual creation. + +**Workaround:** +- Export references policy files in settings.json if they exist in `compliance/` +- Import does not create policy files (only references them) +- Users must manually create policy files based on RULES.md + +### 4. Sub-agents +**Issue:** Gemini CLI doesn't have native sub-agent support like gitagent. + +**Workaround:** +- Export documents sub-agents as a "Delegation Pattern" section in GEMINI.md +- Import does not create sub-agent directories +- Users must manually implement delegation logic + +### 5. Workflows +**Issue:** gitagent's SkillsFlow YAML doesn't map to Gemini CLI. + +**Workaround:** +- Convert workflows to skills or document in instructions +- Not preserved during import/export cycle + +### 6. API Keys +**Issue:** Gemini CLI requires Google AI Studio or Vertex AI credentials. + +**Workaround:** +- Set `GOOGLE_API_KEY` environment variable +- Or configure Vertex AI credentials +- Document in agent README + +## Hooks Mapping + +### Event Name Mapping + +| gitagent Event | Gemini CLI Event | Notes | +|---------------|------------------|-------| +| `on_session_start` | `SessionStart` | Runs at session initialization | +| `pre_tool_use` | `BeforeTool` | Runs before tool execution | +| `post_tool_use` | `AfterTool` | Runs after tool execution | +| `pre_response` | `AfterModel` | Runs after model generates response | +| `post_response` | `AfterAgent` | Runs after agent loop completes | +| `on_error` | `Notification` | Error notifications | +| `on_session_end` | `SessionEnd` | Runs at session cleanup | + +### Hook Format + +**gitagent (hooks/hooks.yaml):** +```yaml +hooks: + on_session_start: + - script: scripts/init.sh + description: Initialize session +``` + +**Gemini CLI (.gemini/settings.json):** +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "name": "hook-0", + "type": "command", + "command": "bash hooks/scripts/init.sh", + "description": "Initialize session" + } + ] + } + ] + } +} +``` + +**Note:** On Windows, commands are prefixed with `bash` to enable execution through PowerShell. On Linux/macOS, the `bash` prefix is omitted. + +## Best Practices + +### When Exporting + +1. **Use Gemini-compatible models** in `agent.yaml`: + - `gemini-2.0-flash-exp` + - `gemini-1.5-pro` + - `gemini-1.5-flash` + +2. **Set appropriate approval mode** via compliance config: + ```yaml + compliance: + supervision: + human_in_the_loop: critical # → approvalMode: default + ``` + +3. **Document MCP requirements** in README if your agent needs external tools + +4. **Keep skills self-contained** - full instructions in SKILL.md + +### When Importing + +1. **Review split sections** - verify SOUL.md/RULES.md split is correct + +2. **Add missing metadata** to agent.yaml: + - Author, license, tags + - Compliance frameworks + - Dependencies + +3. **Create proper tool schemas** - imported tools have minimal schemas + +4. **Test the agent** with `gitagent validate` + +### When Running + +1. **Set API key** before running: + ```bash + export GOOGLE_API_KEY=your-api-key + ``` + +2. **Use appropriate approval mode** for your use case: + - Development: `--approval-mode default` + - Production: `--approval-mode plan` + - Testing: `--approval-mode yolo` (use with caution) + +3. **Monitor temporary workspace** - cleaned up automatically on exit + +## Troubleshooting + +### "gemini: command not found" + +**Solution:** +```bash +npm install -g @google/generative-ai-cli +# Or +brew install google/tap/gemini +``` + +### "API key not configured" + +**Solution:** +```bash +export GOOGLE_API_KEY=your-api-key-here +# Or configure Vertex AI credentials +``` + +### "GEMINI.md not found" (import) + +**Solution:** +- Ensure you're pointing to the project root directory +- Gemini CLI projects must have GEMINI.md at the root + +### Tools not working after import + +**Solution:** +- Imported tool schemas are minimal placeholders +- Manually update `tools/*.yaml` with proper input schemas +- Or use Gemini CLI's native tool configuration + +## Resources + +- [Gemini CLI GitHub](https://github.com/google/generative-ai-cli) +- [Gemini CLI Documentation](https://geminicli.com/docs) +- [Google AI Studio](https://aistudio.google.com/) +- [gitagent Specification](../../spec/SPECIFICATION.md) +- [Example Gemini Agent](../../examples/gemini-example/) + +## Limitations Summary + +| Feature | Export | Import | Run | Notes | +|---------|--------|--------|-----|-------| +| Identity (SOUL.md) | ✅ | ✅ | ✅ | Full support | +| Rules (RULES.md) | ✅ | ✅ | ✅ | Full support | +| Duties (DUTIES.md) | ✅ | ✅ | ✅ | Full support | +| Skills | ✅ | ⚠️ | ✅ | Import creates basic structure | +| Tools | ✅ | ⚠️ | ✅ | Import creates minimal schemas | +| Model preference | ✅ | ✅ | ✅ | Full support | +| Approval modes | ✅ | ✅ | ✅ | Full support | +| Hooks | ✅ | ✅ | ✅ | Full support | +| Knowledge | ✅ | ❌ | ✅ | Not preserved on import | +| Sub-agents | ⚠️ | ❌ | ⚠️ | Documented only, not executable | +| Workflows | ❌ | ❌ | ❌ | Not supported | +| MCP servers | ⚠️ | ❌ | ⚠️ | Manual setup required | +| Extensions | ❌ | ❌ | ❌ | Not portable | +| Policy files | ⚠️ | ⚠️ | ⚠️ | References only | + +**Legend:** +- ✅ Fully supported +- ⚠️ Partial support or manual setup required +- ❌ Not supported diff --git a/src/adapters/gemini.ts b/src/adapters/gemini.ts new file mode 100644 index 0000000..438805a --- /dev/null +++ b/src/adapters/gemini.ts @@ -0,0 +1,378 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import yaml from 'js-yaml'; +import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; +import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildComplianceSection } from './shared.js'; + +/** + * Export a gitagent to Google Gemini CLI format. + * + * Gemini CLI uses: + * - GEMINI.md (custom agent instructions, project root or ~/.gemini/GEMINI.md) + * - .gemini/settings.json (model configuration, tool permissions, approval mode) + * + * Returns structured output with all files that should be written. + */ +export interface GeminiExport { + instructions: string; + settings: Record; +} + +export function exportToGemini(dir: string): GeminiExport { + const agentDir = resolve(dir); + const manifest = loadAgentManifest(agentDir); + + const instructions = buildInstructions(agentDir, manifest); + const settings = buildSettings(agentDir, manifest); + + return { instructions, settings }; +} + +/** + * Export as a single string (for `gitagent export -f gemini`). + */ +export function exportToGeminiString(dir: string): string { + const exp = exportToGemini(dir); + const parts: string[] = []; + + parts.push('# === GEMINI.md ==='); + parts.push(exp.instructions); + parts.push('\n# === .gemini/settings.json ==='); + parts.push(JSON.stringify(exp.settings, null, 2)); + + return parts.join('\n'); +} + +function buildInstructions( + agentDir: string, + manifest: ReturnType, +): string { + const parts: string[] = []; + + // Agent identity + parts.push(`# ${manifest.name}`); + parts.push(`${manifest.description}`); + parts.push(''); + + // SOUL.md → identity section + const soul = loadFileIfExists(join(agentDir, 'SOUL.md')); + if (soul) { + parts.push(soul); + parts.push(''); + } + + // RULES.md → constraints section + const rules = loadFileIfExists(join(agentDir, 'RULES.md')); + if (rules) { + parts.push(rules); + parts.push(''); + } + + // DUTIES.md → segregation of duties policy + const duty = loadFileIfExists(join(agentDir, 'DUTIES.md')); + if (duty) { + parts.push(duty); + parts.push(''); + } + + // Skills — loaded via skill-loader (progressive disclosure) + const skillsDir = join(agentDir, 'skills'); + const skills = loadAllSkills(skillsDir); + if (skills.length > 0) { + parts.push('## Skills'); + parts.push(''); + for (const skill of skills) { + const skillDirName = skill.directory.split(/[/\\]/).pop()!; + const toolsList = getAllowedTools(skill.frontmatter); + const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : ''; + parts.push(`### ${skill.frontmatter.name}`); + parts.push(`${skill.frontmatter.description}${toolsNote}`); + parts.push(''); + parts.push(skill.instructions); + parts.push(''); + } + } + + // Tools + const toolsDir = join(agentDir, 'tools'); + if (existsSync(toolsDir)) { + const toolFiles = readdirSync(toolsDir).filter(f => f.endsWith('.yaml')); + if (toolFiles.length > 0) { + parts.push('## Tools'); + parts.push(''); + for (const file of toolFiles) { + try { + const content = readFileSync(join(toolsDir, file), 'utf-8'); + const toolConfig = yaml.load(content) as { + name?: string; + description?: string; + input_schema?: Record; + }; + if (toolConfig?.name) { + parts.push(`### ${toolConfig.name}`); + if (toolConfig.description) { + parts.push(toolConfig.description); + } + if (toolConfig.input_schema) { + parts.push(''); + parts.push('```yaml'); + parts.push(yaml.dump(toolConfig.input_schema).trimEnd()); + parts.push('```'); + } + parts.push(''); + } + } catch { /* skip malformed tools */ } + } + } + } + + // Knowledge (always_load documents) + const knowledgeDir = join(agentDir, 'knowledge'); + const indexPath = join(knowledgeDir, 'index.yaml'); + if (existsSync(indexPath)) { + const index = yaml.load(readFileSync(indexPath, 'utf-8')) as { + documents?: Array<{ path: string; always_load?: boolean }>; + }; + + if (index.documents) { + const alwaysLoad = index.documents.filter(d => d.always_load); + if (alwaysLoad.length > 0) { + parts.push('## Knowledge'); + parts.push(''); + for (const doc of alwaysLoad) { + const content = loadFileIfExists(join(knowledgeDir, doc.path)); + if (content) { + parts.push(`### ${doc.path}`); + parts.push(content); + parts.push(''); + } + } + } + } + } + + // Compliance constraints + if (manifest.compliance) { + const constraints = buildComplianceSection(manifest.compliance); + if (constraints) { + parts.push(constraints); + parts.push(''); + } + } + + // Sub-agents (document as delegation pattern since Gemini CLI doesn't have native support) + if (manifest.agents && Object.keys(manifest.agents).length > 0) { + parts.push('## Delegation Pattern'); + parts.push(''); + parts.push('This agent uses sub-agents for specialized tasks:'); + parts.push(''); + for (const [name, config] of Object.entries(manifest.agents)) { + parts.push(`### ${name}`); + if (config.description) { + parts.push(config.description); + } + if (config.delegation?.triggers) { + parts.push(`Triggers: ${config.delegation.triggers.join(', ')}`); + } + parts.push(''); + } + } + + // Memory + const memory = loadFileIfExists(join(agentDir, 'memory', 'MEMORY.md')); + if (memory && memory.trim().split('\n').length > 2) { + parts.push('## Memory'); + parts.push(memory); + parts.push(''); + } + + return parts.join('\n').trimEnd() + '\n'; +} + +function buildSettings( + agentDir: string, + manifest: ReturnType, +): Record { + const settings: Record = {}; + + // Model preference - Gemini CLI expects object format + if (manifest.model?.preferred) { + // Extract provider from model name or default to google + const modelName = manifest.model.preferred; + const provider = modelName.includes('claude') ? 'anthropic' : + modelName.includes('gpt') ? 'openai' : 'google'; + + settings.model = { + id: modelName, + provider: provider + }; + } + + // Collect allowed tools from skills and tool definitions + const allowedTools = collectAllowedTools(agentDir); + if (allowedTools.length > 0) { + settings.allowedTools = allowedTools; + } + + // Approval mode from compliance supervision + if (manifest.compliance?.supervision?.human_in_the_loop) { + const hitl = manifest.compliance.supervision.human_in_the_loop; + if (hitl === 'always') { + settings.approvalMode = 'plan'; // read-only mode + } else if (hitl === 'conditional') { + settings.approvalMode = 'default'; // prompt for approval + } else if (hitl === 'none') { + settings.approvalMode = 'yolo'; // auto-approve all + } else if (hitl === 'advisory') { + settings.approvalMode = 'auto_edit'; // auto-approve edits only + } + } + + // Policy files (if they exist in compliance/) + const policyDir = join(agentDir, 'compliance'); + if (existsSync(policyDir)) { + const policyFiles = readdirSync(policyDir).filter(f => f.endsWith('.md') || f.endsWith('.txt')); + if (policyFiles.length > 0) { + settings.policy = policyFiles.map(f => `compliance/${f}`); + } + } + + // Hooks mapping + const hooksConfig = buildHooksConfig(agentDir); + if (hooksConfig && Object.keys(hooksConfig).length > 0) { + settings.hooks = hooksConfig; + } + + // MCP servers (placeholder - requires manual configuration) + settings.mcpServers = {}; + + return settings; +} + +/** + * Collect allowed tools from skills (allowed-tools frontmatter) + * and tool definitions (tools/*.yaml names). + */ +function collectAllowedTools(agentDir: string): string[] { + const tools: Set = new Set(); + + // From skills' allowed-tools + const skillsDir = join(agentDir, 'skills'); + const skills = loadAllSkills(skillsDir); + for (const skill of skills) { + for (const tool of getAllowedTools(skill.frontmatter)) { + tools.add(tool); + } + } + + // From tools/*.yaml definitions + const toolsDir = join(agentDir, 'tools'); + if (existsSync(toolsDir)) { + const files = readdirSync(toolsDir).filter(f => f.endsWith('.yaml')); + for (const file of files) { + try { + const content = readFileSync(join(toolsDir, file), 'utf-8'); + const toolConfig = yaml.load(content) as { name?: string }; + if (toolConfig?.name) { + tools.add(toolConfig.name); + } + } catch { /* skip malformed tools */ } + } + } + + return Array.from(tools); +} + +/** + * Map hooks/hooks.yaml to Gemini CLI hooks format. + * + * Gemini CLI expects hooks in settings.json. + */ +function buildHooksConfig(agentDir: string): Record | null { + try { + const hooksPath = join(agentDir, 'hooks', 'hooks.yaml'); + if (!existsSync(hooksPath)) { + return null; + } + + const hooksYaml = readFileSync(hooksPath, 'utf-8'); + const hooksConfig = yaml.load(hooksYaml) as { hooks: Record> }; + + if (!hooksConfig.hooks || Object.keys(hooksConfig.hooks).length === 0) { + return null; + } + + // Map gitagent hook events to Gemini CLI hook events + // Gemini CLI uses PascalCase event names + const eventMap: Record = { + 'on_session_start': 'SessionStart', + 'on_session_end': 'SessionEnd', + 'pre_tool_use': 'BeforeTool', + 'post_tool_use': 'AfterTool', + 'pre_agent': 'BeforeAgent', + 'post_agent': 'AfterAgent', + 'pre_model': 'BeforeModel', + 'post_model': 'AfterModel', + 'pre_response': 'AfterModel', // Runs after model generates response + 'post_response': 'AfterAgent', // Runs after agent loop completes + 'on_error': 'Notification', // Map errors to notification system + }; + + // Gemini CLI uses a matcher-based structure for hooks + const geminiHooks: Record; + }>> = {}; + + for (const [event, hooks] of Object.entries(hooksConfig.hooks)) { + const geminiEvent = eventMap[event] || event; + + // Filter out hooks whose script files don't exist + // Scripts are relative to the hooks directory + const validHooks = hooks.filter(hook => { + const scriptPath = join(agentDir, 'hooks', hook.script); + return existsSync(scriptPath); + }); + + // Skip this event if no valid hooks remain + if (validHooks.length === 0) continue; + + if (!geminiHooks[geminiEvent]) { + geminiHooks[geminiEvent] = []; + } + + // Convert each hook to Gemini CLI format with matcher + const geminiHookDefs = validHooks.map((hook, index) => { + // On Windows, Gemini CLI uses PowerShell which can't run .sh files directly + // Prefix with 'bash' and include hooks/ directory path + let command = `hooks/${hook.script}`; + if (process.platform === 'win32' && hook.script.endsWith('.sh')) { + command = `bash ${command}`; + } + + return { + name: `hook-${index}`, + type: 'command', + command: command, + description: hook.description, + }; + }); + + // Wrap hooks in a matcher object (use '*' to match all) + geminiHooks[geminiEvent].push({ + matcher: '*', + hooks: geminiHookDefs, + }); + } + + return Object.keys(geminiHooks).length > 0 ? geminiHooks : null; + } catch { + return null; + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e5532e9..ab2e1a4 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -7,3 +7,4 @@ export { exportToNanobotString, exportToNanobot } from './nanobot.js'; export { exportToCopilotString, exportToCopilot } from './copilot.js'; export { exportToOpenCodeString, exportToOpenCode } from './opencode.js'; export { exportToCursorString, exportToCursor } from './cursor.js'; +export { exportToGeminiString, exportToGemini } from './gemini.js'; diff --git a/src/commands/export.ts b/src/commands/export.ts index 22d6637..2bcb94e 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -11,6 +11,7 @@ import { exportToCopilotString, exportToOpenCodeString, exportToCursorString, + exportToGeminiString, } from '../adapters/index.js'; import { exportToLyzrString } from '../adapters/lyzr.js'; import { exportToGitHubString } from '../adapters/github.js'; @@ -23,7 +24,7 @@ interface ExportOptions { export const exportCommand = new Command('export') .description('Export agent to other formats') - .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor)') + .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini)') .option('-d, --dir ', 'Agent directory', '.') .option('-o, --output ', 'Output file path') .action(async (options: ExportOptions) => { @@ -69,9 +70,12 @@ export const exportCommand = new Command('export') case 'cursor': result = exportToCursorString(dir); break; + case 'gemini': + result = exportToGeminiString(dir); + break; default: error(`Unknown format: ${options.format}`); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor'); + info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini'); process.exit(1); } diff --git a/src/commands/import.ts b/src/commands/import.ts index db5cdbb..b824bd6 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -323,6 +323,94 @@ function importFromOpenCode(sourcePath: string, targetDir: string): void { } } +function importFromGemini(sourcePath: string, targetDir: string): void { + const sourceDir = resolve(sourcePath); + + // Look for GEMINI.md + const geminiMdPath = join(sourceDir, 'GEMINI.md'); + if (!existsSync(geminiMdPath)) { + throw new Error('GEMINI.md not found in source directory'); + } + + const geminiMd = readFileSync(geminiMdPath, 'utf-8'); + + // Look for .gemini/settings.json (optional) + let settings: Record = {}; + const settingsPath = join(sourceDir, '.gemini', 'settings.json'); + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + info('Found .gemini/settings.json'); + } catch { /* ignore malformed config */ } + } + + const dirName = basename(sourceDir); + + // Determine model from settings.json + const model = settings.model as string | undefined; + const agentYaml: Record = { + spec_version: '0.1.0', + name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), + version: '0.1.0', + description: `Imported from Gemini CLI project: ${dirName}`, + }; + if (model) { + agentYaml.model = { preferred: model }; + } + + // Ensure target directory exists + mkdirSync(targetDir, { recursive: true }); + + // Map approval mode to compliance + if (settings.approvalMode) { + const approvalMode = settings.approvalMode as string; + let hitl: string | undefined; + if (approvalMode === 'plan') hitl = 'always'; + else if (approvalMode === 'default') hitl = 'conditional'; + else if (approvalMode === 'yolo') hitl = 'none'; + else if (approvalMode === 'auto_edit') hitl = 'advisory'; + + if (hitl) { + agentYaml.compliance = { + supervision: { + human_in_the_loop: hitl, + }, + }; + } + } + + writeFileSync(join(targetDir, 'agent.yaml'), yaml.dump(agentYaml), 'utf-8'); + success('Created agent.yaml'); + + // Convert GEMINI.md to SOUL.md + RULES.md + const sections = parseSections(geminiMd); + let soulContent = '# Soul\n\n'; + let rulesContent = '# Rules\n\n'; + let hasRules = false; + + for (const [title, content] of sections) { + const lower = title.toLowerCase(); + if (lower.includes('rule') || lower.includes('constraint') || lower.includes('never') || lower.includes('always') || lower.includes('must') || lower.includes('compliance')) { + rulesContent += `## ${title}\n${content}\n\n`; + hasRules = true; + } else { + soulContent += `## ${title}\n${content}\n\n`; + } + } + + if (sections.length === 0) { + soulContent += geminiMd; + } + + writeFileSync(join(targetDir, 'SOUL.md'), soulContent, 'utf-8'); + success('Created SOUL.md'); + + if (hasRules) { + writeFileSync(join(targetDir, 'RULES.md'), rulesContent, 'utf-8'); + success('Created RULES.md'); + } +} + function parseSections(markdown: string): [string, string][] { const sections: [string, string][] = []; const lines = markdown.split('\n'); @@ -351,7 +439,7 @@ function parseSections(markdown: string): [string, string][] { export const importCommand = new Command('import') .description('Import from other agent formats') - .requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode)') + .requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode, gemini)') .argument('', 'Source file or directory path') .option('-d, --dir ', 'Target directory', '.') .action((sourcePath: string, options: ImportOptions) => { @@ -375,9 +463,12 @@ export const importCommand = new Command('import') case 'opencode': importFromOpenCode(sourcePath, targetDir); break; + case 'gemini': + importFromGemini(sourcePath, targetDir); + break; default: error(`Unknown format: ${options.from}`); - info('Supported formats: claude, cursor, crewai, opencode'); + info('Supported formats: claude, cursor, crewai, opencode, gemini'); process.exit(1); } diff --git a/src/commands/run.ts b/src/commands/run.ts index cc8afb9..657e0e4 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -13,6 +13,7 @@ import { runWithLyzr } from '../runners/lyzr.js'; import { runWithGitHub } from '../runners/github.js'; import { runWithGit } from '../runners/git.js'; import { runWithOpenCode } from '../runners/opencode.js'; +import { runWithGemini } from '../runners/gemini.js'; interface RunOptions { repo?: string; @@ -27,7 +28,7 @@ interface RunOptions { export const runCommand = new Command('run') .description('Run an agent from a git repository or local directory') .option('-r, --repo ', 'Git repository URL') - .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, git, prompt', 'claude') + .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, gemini, git, prompt', 'claude') .option('-b, --branch ', 'Git branch/tag to clone', 'main') .option('--refresh', 'Force re-clone (pull latest)', false) .option('--no-cache', 'Clone to temp dir, delete on exit') @@ -116,6 +117,9 @@ export const runCommand = new Command('run') case 'opencode': runWithOpenCode(agentDir, manifest, { prompt: options.prompt }); break; + case 'gemini': + runWithGemini(agentDir, manifest, { prompt: options.prompt }); + break; case 'git': if (!options.repo) { error('The git adapter requires --repo (-r)'); @@ -134,7 +138,7 @@ export const runCommand = new Command('run') break; default: error(`Unknown adapter: ${options.adapter}`); - info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, git, prompt'); + info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, gemini, git, prompt'); process.exit(1); } } catch (e) { diff --git a/src/runners/gemini.ts b/src/runners/gemini.ts new file mode 100644 index 0000000..645a0f9 --- /dev/null +++ b/src/runners/gemini.ts @@ -0,0 +1,125 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync, existsSync, cpSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { exportToGemini } from '../adapters/gemini.js'; +import { AgentManifest } from '../utils/loader.js'; +import { error, info } from '../utils/format.js'; + +export interface GeminiRunOptions { + prompt?: string; +} + +/** + * Run a gitagent agent using Google Gemini CLI. + * + * Creates a temporary workspace with: + * - GEMINI.md (agent instructions) + * - .gemini/settings.json (model config, tool permissions, approval mode) + * + * Then launches `gemini` in that workspace. Gemini CLI reads both files + * automatically on startup. + * + * Supports both interactive mode (no prompt) and single-shot mode (`gemini -p`). + */ +export function runWithGemini(agentDir: string, manifest: AgentManifest, options: GeminiRunOptions = {}): void { + const exp = exportToGemini(agentDir); + + // Create a temporary workspace + const workspaceDir = join(tmpdir(), `gitagent-gemini-${randomBytes(4).toString('hex')}`); + mkdirSync(workspaceDir, { recursive: true }); + + // Write GEMINI.md at project root + writeFileSync(join(workspaceDir, 'GEMINI.md'), exp.instructions, 'utf-8'); + + // Create .gemini directory and write settings.json + const geminiDir = join(workspaceDir, '.gemini'); + mkdirSync(geminiDir, { recursive: true }); + writeFileSync(join(geminiDir, 'settings.json'), JSON.stringify(exp.settings, null, 2), 'utf-8'); + + // Copy hooks directory if it exists (needed for hook script execution) + const hooksDir = join(agentDir, 'hooks'); + if (existsSync(hooksDir)) { + const targetHooksDir = join(workspaceDir, 'hooks'); + cpSync(hooksDir, targetHooksDir, { recursive: true }); + } + + info(`Workspace prepared at ${workspaceDir}`); + info(` GEMINI.md, .gemini/settings.json`); + if (manifest.model?.preferred) { + info(` Model: ${manifest.model.preferred}`); + } + + // Build gemini CLI args + const args: string[] = []; + + // Model override (if specified in manifest and not in settings) + if (manifest.model?.preferred && !exp.settings.model) { + args.push('--model', manifest.model.preferred); + } + + // Approval mode from compliance (if not already in settings) + if (manifest.compliance?.supervision?.human_in_the_loop && !exp.settings.approvalMode) { + const hitl = manifest.compliance.supervision.human_in_the_loop; + if (hitl === 'always') { + args.push('--approval-mode', 'plan'); + } else if (hitl === 'conditional') { + args.push('--approval-mode', 'default'); + } else if (hitl === 'none') { + args.push('--approval-mode', 'yolo'); + } else if (hitl === 'advisory') { + args.push('--approval-mode', 'auto_edit'); + } + } + + // Single-shot mode uses `gemini -p "..."`, interactive is just `gemini` + if (options.prompt) { + args.push('-p', options.prompt); + } + + info(`Launching Gemini CLI agent "${manifest.name}"...`); + if (!options.prompt) { + info('Starting interactive mode. Type your messages to chat.'); + } + + // On Windows with shell: true, we need to build a properly quoted command string + // On Unix, we can pass args array directly + let result; + if (process.platform === 'win32') { + // Build command string with proper quoting for Windows shell + const quotedArgs = args.map(arg => { + // Quote arguments that contain spaces or special characters + if (arg.includes(' ') || arg.includes('"')) { + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + }); + const commandString = `gemini ${quotedArgs.join(' ')}`; + + result = spawnSync(commandString, [], { + stdio: 'inherit', + cwd: workspaceDir, + env: { ...process.env }, + shell: true, + }); + } else { + result = spawnSync('gemini', args, { + stdio: 'inherit', + cwd: workspaceDir, + env: { ...process.env }, + }); + } + + // Cleanup temp workspace before exiting + try { rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ } + + if (result.error) { + error(`Failed to launch Gemini CLI: ${result.error.message}`); + info('Make sure Gemini CLI is installed: npm install -g @google/gemini-cli'); + info('Or visit: https://github.com/google-gemini/gemini-cli'); + process.exit(1); + } + + process.exit(result.status ?? 0); +}