diff --git a/src/commands/ignite.test.ts b/src/commands/ignite.test.ts index b0e3b2cf..38b93dd4 100644 --- a/src/commands/ignite.test.ts +++ b/src/commands/ignite.test.ts @@ -11,6 +11,7 @@ import * as gitUtils from '../utils/git.js' import { MetadataManager } from '../lib/MetadataManager.js' import { TelemetryService } from '../lib/TelemetryService.js' import * as languageDetector from '../utils/language-detector.js' +import * as systemPromptWriter from '../utils/system-prompt-writer.js' // Mock TelemetryService vi.mock('../lib/TelemetryService.js', () => { @@ -1306,6 +1307,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1359,6 +1361,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1398,6 +1401,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1430,6 +1434,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockRejectedValue(new Error('Failed to load agents')), formatForCli: vi.fn(), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1476,6 +1481,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1513,6 +1519,176 @@ describe('IgniteCommand', () => { }) }) + describe('platform-specific agent and system prompt handling', () => { + it('should render agents to disk on Linux instead of passing to launchClaude', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({ + owner: 'testowner', + name: 'testrepo', + }) + + const mockAgentManager = { + loadAgents: vi.fn().mockResolvedValue({ + 'test-agent': { + description: 'Test agent', + prompt: 'Test prompt', + tools: ['Read'], + model: 'sonnet', + }, + }), + formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue(['test-agent.md']), + } + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123__test') + const originalPlatform = process.platform + + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + + const commandWithAgents = new IgniteCommand( + mockTemplateManager, + mockGitWorktreeManager, + mockAgentManager as never, + ) + + try { + await commandWithAgents.execute() + + // Verify renderAgentsToDisk was called instead of formatForCli + expect(mockAgentManager.renderAgentsToDisk).toHaveBeenCalledWith( + expect.objectContaining({ 'test-agent': expect.any(Object) }), + '/path/to/feat/issue-123__test/.claude/agents', + ) + expect(mockAgentManager.formatForCli).not.toHaveBeenCalled() + + // Verify agents is NOT passed to launchClaude + const launchClaudeCall = launchClaudeSpy.mock.calls[0] + expect(launchClaudeCall[1].agents).toBeUndefined() + + // Verify system prompt is still passed inline (Linux uses appendSystemPrompt) + expect(launchClaudeCall[1].appendSystemPrompt).toBeDefined() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + getRepoInfoSpy.mockRestore() + } + }) + + it('should use plugin-dir and /clear prompt on Windows', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({ + owner: 'testowner', + name: 'testrepo', + }) + + // Mock prepareSystemPromptForPlatform to return Windows-style config + const prepareSystemPromptSpy = vi.spyOn(systemPromptWriter, 'prepareSystemPromptForPlatform').mockResolvedValue({ + pluginDir: '/path/to/feat/issue-123__test/.claude/iloom-plugin', + initialPromptOverride: '/clear', + }) + + const mockAgentManager = { + loadAgents: vi.fn().mockResolvedValue({ + 'test-agent': { + description: 'Test agent', + prompt: 'Test prompt', + tools: ['Read'], + model: 'sonnet', + }, + }), + formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue(['test-agent.md']), + } + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123__test') + const originalPlatform = process.platform + + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + + const commandWithAgents = new IgniteCommand( + mockTemplateManager, + mockGitWorktreeManager, + mockAgentManager as never, + ) + + try { + await commandWithAgents.execute() + + // Verify renderAgentsToDisk was called (non-darwin platform) + expect(mockAgentManager.renderAgentsToDisk).toHaveBeenCalled() + expect(mockAgentManager.formatForCli).not.toHaveBeenCalled() + + // Verify launchClaude uses plugin-dir and /clear prompt + const launchClaudeCall = launchClaudeSpy.mock.calls[0] + expect(launchClaudeCall[0]).toBe('/clear') // initial prompt override + expect(launchClaudeCall[1].pluginDir).toBe('/path/to/feat/issue-123__test/.claude/iloom-plugin') + expect(launchClaudeCall[1].appendSystemPrompt).toBeUndefined() + expect(launchClaudeCall[1].agents).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + getRepoInfoSpy.mockRestore() + prepareSystemPromptSpy.mockRestore() + } + }) + + it('should use formatForCli and inline agents on macOS (unchanged behavior)', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + const getRepoInfoSpy = vi.spyOn(githubUtils, 'getRepoInfo').mockResolvedValue({ + owner: 'testowner', + name: 'testrepo', + }) + + const mockAgentManager = { + loadAgents: vi.fn().mockResolvedValue({ + 'test-agent': { + description: 'Test agent', + prompt: 'Test prompt', + tools: ['Read'], + model: 'sonnet', + }, + }), + formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), + } + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-123__test') + + // Ensure we're on darwin (tests run on macOS) + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + + const commandWithAgents = new IgniteCommand( + mockTemplateManager, + mockGitWorktreeManager, + mockAgentManager as never, + ) + + try { + await commandWithAgents.execute() + + // Verify formatForCli was called (darwin path) + expect(mockAgentManager.formatForCli).toHaveBeenCalled() + expect(mockAgentManager.renderAgentsToDisk).not.toHaveBeenCalled() + + // Verify agents ARE passed to launchClaude + const launchClaudeCall = launchClaudeSpy.mock.calls[0] + expect(launchClaudeCall[1].agents).toBeDefined() + expect(launchClaudeCall[1].appendSystemPrompt).toBeDefined() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + getRepoInfoSpy.mockRestore() + } + }) + }) + describe('settings integration', () => { it('should load settings and pass to AgentManager', async () => { const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) @@ -1544,6 +1720,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1600,6 +1777,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1655,6 +1833,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1712,6 +1891,7 @@ describe('IgniteCommand', () => { }, }), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -1773,6 +1953,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const originalCwd = process.cwd @@ -2568,6 +2749,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandWithDraftPr = new IgniteCommand( @@ -2631,6 +2813,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandWithDraftPr = new IgniteCommand( @@ -2694,6 +2877,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandStandard = new IgniteCommand( @@ -2754,6 +2938,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandWithValidRemote = new IgniteCommand( @@ -2814,6 +2999,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandWithInvalidRemote = new IgniteCommand( @@ -2871,6 +3057,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandWithSpaceRemote = new IgniteCommand( @@ -2928,6 +3115,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn((agents) => agents), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const commandWithDefaultRemote = new IgniteCommand( @@ -3174,6 +3362,7 @@ describe('IgniteCommand', () => { { loadAgents: vi.fn().mockResolvedValue([]), formatForCli: vi.fn().mockReturnValue({}), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } as never, { loadSettings: vi.fn().mockResolvedValue({ @@ -3280,6 +3469,7 @@ describe('IgniteCommand', () => { { loadAgents: vi.fn().mockResolvedValue([]), formatForCli: vi.fn().mockReturnValue({}), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } as never, { loadSettings: vi.fn().mockResolvedValue({ @@ -3533,6 +3723,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue([]), formatForCli: vi.fn().mockReturnValue({}), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } return new IgniteCommand( @@ -3977,6 +4168,7 @@ describe('IgniteCommand', () => { { loadAgents: vi.fn().mockResolvedValue([]), formatForCli: vi.fn().mockReturnValue({}), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } as never, { loadSettings: vi.fn().mockResolvedValue({ @@ -4089,6 +4281,7 @@ describe('IgniteCommand', () => { const mockAgentManager = { loadAgents: vi.fn().mockResolvedValue({}), formatForCli: vi.fn().mockReturnValue({}), + renderAgentsToDisk: vi.fn().mockResolvedValue([]), } const mockFirstRunManager = { diff --git a/src/commands/ignite.ts b/src/commands/ignite.ts index f94de3a3..6f1f1e9b 100644 --- a/src/commands/ignite.ts +++ b/src/commands/ignite.ts @@ -24,6 +24,7 @@ import { SwarmSetupService } from '../lib/SwarmSetupService.js' import type { LoomMetadata } from '../lib/MetadataManager.js' import { TelemetryService } from '../lib/TelemetryService.js' import { detectProjectLanguage } from '../utils/language-detector.js' +import { prepareSystemPromptForPlatform } from '../utils/system-prompt-writer.js' /** * Error thrown when the spin command is run from an invalid location @@ -451,11 +452,25 @@ export class IgniteCommand { variables, ['*.md', '!iloom-framework-detector.md'] ) - agents = this.agentManager.formatForCli(loadedAgents) - logger.debug('Loaded agent configurations', { - agentCount: Object.keys(agents).length, - agentNames: Object.keys(agents), - }) + + if (process.platform === 'darwin') { + // macOS: pass agents inline via --agents flag (unchanged behavior) + agents = this.agentManager.formatForCli(loadedAgents) + logger.debug('Loaded agent configurations for CLI', { + agentCount: Object.keys(agents).length, + agentNames: Object.keys(agents), + }) + } else { + // Linux/Windows: render agents to .claude/agents/ for auto-discovery + const agentsDir = path.join(context.workspacePath, '.claude', 'agents') + const rendered = await this.agentManager.renderAgentsToDisk(loadedAgents, agentsDir) + logger.debug('Rendered agent files to disk for auto-discovery', { + agentCount: rendered.length, + agentNames: rendered, + targetDir: agentsDir, + }) + // agents remains undefined - not passed to launchClaude + } } catch (error) { // Log warning but continue without agents logger.warn(`Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`) @@ -471,10 +486,20 @@ export class IgniteCommand { logger.info(isHeadless ? '✨ Launching Claude in headless mode...' : '✨ Launching Claude in current terminal...') + // Prepare system prompt based on platform + const systemPromptConfig = await prepareSystemPromptForPlatform( + systemInstructions, + context.workspacePath, + ) + + // Determine the initial user prompt (Windows overrides with /clear) + const effectiveUserPrompt = systemPromptConfig.initialPromptOverride ?? userPrompt + // Step 5: Launch Claude with system instructions appended and user prompt - const claudeResult = await launchClaude(userPrompt, { + const claudeResult = await launchClaude(effectiveUserPrompt, { ...claudeOptions, - appendSystemPrompt: systemInstructions, + ...(systemPromptConfig.appendSystemPrompt && { appendSystemPrompt: systemPromptConfig.appendSystemPrompt }), + ...(systemPromptConfig.pluginDir && { pluginDir: systemPromptConfig.pluginDir }), ...(mcpConfig && { mcpConfig }), ...(allowedTools && { allowedTools }), ...(disallowedTools && { disallowedTools }), @@ -1055,7 +1080,13 @@ export class IgniteCommand { variables, ['*.md', '!iloom-framework-detector.md'] ) - agents = this.agentManager.formatForCli(loadedAgents) + + if (process.platform === 'darwin') { + agents = this.agentManager.formatForCli(loadedAgents) + } else { + const agentsDir = path.join(epicWorktreePath, '.claude', 'agents') + await this.agentManager.renderAgentsToDisk(loadedAgents, agentsDir) + } } catch (error) { logger.warn(`Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`) } @@ -1071,26 +1102,33 @@ export class IgniteCommand { logger.debug(`Telemetry swarm.started tracking failed: ${error instanceof Error ? error.message : error}`) } - await launchClaude( - `You are the swarm orchestrator for epic #${epicIssueNumber}. Begin by reading your system prompt instructions and executing the workflow.`, - { - model, - permissionMode: 'bypassPermissions', - addDir: epicWorktreePath, - headless: false, - ...(metadata.sessionId && { sessionId: metadata.sessionId }), - appendSystemPrompt: orchestratorPrompt, - mcpConfig: mcpConfigs, - allowedTools, - ...(agents && { agents }), - env: { - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - ILOOM_SWARM: '1', - ENABLE_TOOL_SEARCH: 'auto:30', - }, - }, + // Prepare orchestrator prompt based on platform + const orchestratorPromptConfig = await prepareSystemPromptForPlatform( + orchestratorPrompt, + epicWorktreePath, ) + const effectiveSwarmPrompt = orchestratorPromptConfig.initialPromptOverride + ?? `You are the swarm orchestrator for epic #${epicIssueNumber}. Begin by reading your system prompt instructions and executing the workflow.` + + await launchClaude(effectiveSwarmPrompt, { + model, + permissionMode: 'bypassPermissions', + addDir: epicWorktreePath, + headless: false, + ...(metadata.sessionId && { sessionId: metadata.sessionId }), + ...(orchestratorPromptConfig.appendSystemPrompt && { appendSystemPrompt: orchestratorPromptConfig.appendSystemPrompt }), + ...(orchestratorPromptConfig.pluginDir && { pluginDir: orchestratorPromptConfig.pluginDir }), + mcpConfig: mcpConfigs, + allowedTools, + ...(agents && { agents }), + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + ILOOM_SWARM: '1', + ENABLE_TOOL_SEARCH: 'auto:30', + }, + }) + // Track swarm child completions and overall completion try { const swarmEndTime = Date.now() diff --git a/src/lib/AgentManager.test.ts b/src/lib/AgentManager.test.ts index 2d0d3507..6db33b66 100644 --- a/src/lib/AgentManager.test.ts +++ b/src/lib/AgentManager.test.ts @@ -2,9 +2,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { AgentManager, type AgentConfigs } from './AgentManager.js' import { readFile } from 'fs/promises' import fg from 'fast-glob' +import fs from 'fs-extra' +import path from 'path' vi.mock('fs/promises') vi.mock('fast-glob') +vi.mock('fs-extra') vi.mock('../utils/logger.js', () => ({ logger: { debug: vi.fn(), @@ -982,4 +985,167 @@ Prompt` expect(result['test-agent']).toBeDefined() }) }) + + describe('renderAgentsToDisk', () => { + const targetDir = '/workspace/.claude/agents' + + beforeEach(() => { + vi.mocked(fs.ensureDir).mockResolvedValue(undefined) + vi.mocked(fs.remove).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + // Default: no existing files to clean + vi.mocked(fg).mockResolvedValue([]) + }) + + it('should write agent files with YAML frontmatter to target directory', async () => { + const agents: AgentConfigs = { + 'iloom-issue-analyzer': { + description: 'Analyzer agent', + prompt: 'You are an analyzer', + tools: ['Read', 'Grep'], + model: 'sonnet', + color: 'pink', + }, + } + + await manager.renderAgentsToDisk(agents, targetDir) + + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(targetDir, 'iloom-issue-analyzer.md'), + [ + '---', + 'name: iloom-issue-analyzer', + 'description: Analyzer agent', + 'tools: Read, Grep', + 'model: sonnet', + 'color: pink', + '---', + '', + 'You are an analyzer', + '', + ].join('\n'), + 'utf-8', + ) + }) + + it('should handle agents without tools field (tools omitted from frontmatter)', async () => { + const agents: AgentConfigs = { + 'iloom-no-tools': { + description: 'No tools agent', + prompt: 'Do stuff', + model: 'opus', + }, + } + + await manager.renderAgentsToDisk(agents, targetDir) + + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string + expect(writtenContent).not.toContain('tools:') + expect(writtenContent).toContain('name: iloom-no-tools') + expect(writtenContent).toContain('model: opus') + }) + + it('should handle agents without color field (color omitted from frontmatter)', async () => { + const agents: AgentConfigs = { + 'iloom-no-color': { + description: 'No color agent', + prompt: 'Do stuff', + tools: ['Read'], + model: 'sonnet', + }, + } + + await manager.renderAgentsToDisk(agents, targetDir) + + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string + expect(writtenContent).not.toContain('color:') + }) + + it('should create target directory if it does not exist', async () => { + const agents: AgentConfigs = { + 'iloom-test': { + description: 'Test', + prompt: 'Test prompt', + model: 'sonnet', + }, + } + + await manager.renderAgentsToDisk(agents, targetDir) + + expect(fs.ensureDir).toHaveBeenCalledWith(targetDir) + }) + + it('should clean existing iloom-* agent files before writing', async () => { + // Simulate existing stale files + vi.mocked(fg).mockResolvedValueOnce(['iloom-old-agent.md', 'iloom-stale.md']) + + const agents: AgentConfigs = { + 'iloom-new-agent': { + description: 'New', + prompt: 'New prompt', + model: 'sonnet', + }, + } + + await manager.renderAgentsToDisk(agents, targetDir) + + // Verify old files were removed + expect(fs.remove).toHaveBeenCalledWith(path.join(targetDir, 'iloom-old-agent.md')) + expect(fs.remove).toHaveBeenCalledWith(path.join(targetDir, 'iloom-stale.md')) + + // Verify new file was written + expect(fs.writeFile).toHaveBeenCalledTimes(1) + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(targetDir, 'iloom-new-agent.md'), + expect.stringContaining('name: iloom-new-agent'), + 'utf-8', + ) + }) + + it('should return list of rendered filenames', async () => { + const agents: AgentConfigs = { + 'iloom-issue-analyzer': { + description: 'Analyzer', + prompt: 'Analyze', + tools: ['Read'], + model: 'sonnet', + }, + 'iloom-issue-planner': { + description: 'Planner', + prompt: 'Plan', + tools: ['Write'], + model: 'sonnet', + }, + } + + const result = await manager.renderAgentsToDisk(agents, targetDir) + + expect(result).toEqual(['iloom-issue-analyzer.md', 'iloom-issue-planner.md']) + }) + + it('should reconstruct tools as comma-separated string in frontmatter', async () => { + const agents: AgentConfigs = { + 'iloom-tools-agent': { + description: 'Tools test', + prompt: 'Test', + tools: ['Read', 'Write', 'Edit'], + model: 'sonnet', + }, + } + + await manager.renderAgentsToDisk(agents, targetDir) + + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string + expect(writtenContent).toContain('tools: Read, Write, Edit') + }) + + it('should handle empty agents object', async () => { + const agents: AgentConfigs = {} + + const result = await manager.renderAgentsToDisk(agents, targetDir) + + expect(result).toEqual([]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/lib/AgentManager.ts b/src/lib/AgentManager.ts index bc2f1c72..ba3446a8 100644 --- a/src/lib/AgentManager.ts +++ b/src/lib/AgentManager.ts @@ -3,6 +3,7 @@ import { accessSync } from 'fs' import path from 'path' import { fileURLToPath } from 'url' import fg from 'fast-glob' +import fs from 'fs-extra' import { MarkdownAgentParser } from '../utils/MarkdownAgentParser.js' import { logger } from '../utils/logger.js' import type { IloomSettings } from './SettingsManager.js' @@ -233,4 +234,39 @@ export class AgentManager { // Just return it - launchClaude will JSON.stringify it return agents as Record } + + /** + * Render loaded agents to disk as markdown files with YAML frontmatter. + * Claude Code auto-discovers agents from .claude/agents/ directory. + * + * @param agents - Loaded agent configs (from loadAgents()) + * @param targetDir - Absolute path to target directory (e.g., /.claude/agents/) + * @returns Array of rendered filenames + */ + async renderAgentsToDisk(agents: AgentConfigs, targetDir: string): Promise { + await fs.ensureDir(targetDir) + + // Clean existing iloom agent files to avoid stale agents from previous runs + const existingFiles = await fg('iloom-*.md', { cwd: targetDir, onlyFiles: true }) + for (const file of existingFiles) { + await fs.remove(path.join(targetDir, file)) + } + + const renderedFiles: string[] = [] + for (const [agentName, config] of Object.entries(agents)) { + const safeName = path.basename(agentName) + const filename = `${safeName}.md` + // Build YAML frontmatter + const frontmatterLines = ['---', `name: ${agentName}`, `description: ${config.description}`] + if (config.tools) frontmatterLines.push(`tools: ${config.tools.join(', ')}`) + frontmatterLines.push(`model: ${config.model}`) + if (config.color) frontmatterLines.push(`color: ${config.color}`) + frontmatterLines.push('---') + + const content = frontmatterLines.join('\n') + '\n\n' + config.prompt + '\n' + await fs.writeFile(path.join(targetDir, filename), content, 'utf-8') + renderedFiles.push(filename) + } + return renderedFiles + } } diff --git a/src/utils/claude.test.ts b/src/utils/claude.test.ts index bb4a2576..774c252c 100644 --- a/src/utils/claude.test.ts +++ b/src/utils/claude.test.ts @@ -783,6 +783,105 @@ describe('claude utils', () => { }) }) + + + describe('pluginDir parameter', () => { + it('should use --plugin-dir when provided', async () => { + const prompt = 'Test prompt' + + mockExeca().mockResolvedValueOnce({ + stdout: 'output', + exitCode: 0, + }) + + await launchClaude(prompt, { + headless: true, + pluginDir: '/path/to/plugin', + }) + + expect(execa).toHaveBeenCalledWith( + 'claude', + [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--add-dir', '/tmp', + '--plugin-dir', '/path/to/plugin', + ], + expect.any(Object) + ) + }) + + it('should omit --plugin-dir when not provided', async () => { + const prompt = 'Test prompt' + + mockExeca().mockResolvedValueOnce({ + stdout: 'output', + exitCode: 0, + }) + + await launchClaude(prompt, { headless: true }) + + const execaCall = mockExeca().mock.calls[0] as unknown as [string, string[], Record] + expect(execaCall[1]).not.toContain('--plugin-dir') + }) + + it('should work with pluginDir in interactive mode', async () => { + const prompt = 'Test prompt' + + mockExeca().mockResolvedValueOnce({ + stdout: '', + exitCode: 0, + }) + + await launchClaude(prompt, { + headless: false, + pluginDir: '/path/to/plugin', + }) + + expect(execa).toHaveBeenCalledWith( + 'claude', + ['--add-dir', '/tmp', '--plugin-dir', '/path/to/plugin', '--', prompt], + expect.objectContaining({ + stdio: ['inherit', 'inherit', 'pipe'], + }) + ) + }) + + it('should combine pluginDir with other options in correct order', async () => { + const prompt = 'Test prompt' + const agents = { 'test-agent': { description: 'Test', prompt: 'Test', tools: ['Read'], model: 'sonnet' } } + + mockExeca().mockResolvedValueOnce({ + stdout: 'output', + exitCode: 0, + }) + + await launchClaude(prompt, { + headless: true, + model: 'opus', + agents, + pluginDir: '/path/to/plugin', + }) + + expect(execa).toHaveBeenCalledWith( + 'claude', + [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--model', 'opus', + '--add-dir', '/tmp', + '--agents', JSON.stringify(agents), + '--plugin-dir', '/path/to/plugin', + ], + expect.any(Object) + ) + }) + }) + describe('mcpConfig parameter', () => { it('should add --mcp-config flags for each config in array', async () => { const prompt = 'Test prompt' diff --git a/src/utils/claude.ts b/src/utils/claude.ts index 368fe790..bb1fc13c 100644 --- a/src/utils/claude.ts +++ b/src/utils/claude.ts @@ -66,6 +66,7 @@ export interface ClaudeCliOptions { allowedTools?: string[] // Tools to allow via --allowed-tools flag disallowedTools?: string[] // Tools to disallow via --disallowed-tools flag agents?: Record // Agent configurations for --agents flag + pluginDir?: string // Path to plugin directory for --plugin-dir flag oneShot?: import('../types/index.js').OneShotMode // One-shot automation mode setArguments?: string[] // Raw --set arguments to forward (e.g., ['workflows.issue.startIde=false']) executablePath?: string // Executable path to use for spin command (e.g., 'il', 'il-125', or '/path/to/dist/cli.js') @@ -149,7 +150,7 @@ export async function launchClaude( prompt: string, options: ClaudeCliOptions = {} ): Promise { - const { model, permissionMode, addDir, headless = false, appendSystemPrompt, mcpConfig, allowedTools, disallowedTools, agents, sessionId, noSessionPersistence, outputFormat, verbose, jsonMode, passthroughStdout, env: extraEnv, signal } = options + const { model, permissionMode, addDir, headless = false, appendSystemPrompt, mcpConfig, allowedTools, disallowedTools, agents, pluginDir, sessionId, noSessionPersistence, outputFormat, verbose, jsonMode, passthroughStdout, env: extraEnv, signal } = options const log = getLogger() // Build command arguments @@ -182,7 +183,7 @@ export async function launchClaude( args.push('--add-dir', '/tmp') //TODO: Won't work on Windows - // Add --append-system-prompt flag if provided + // Add system prompt flag if (appendSystemPrompt) { args.push('--append-system-prompt', appendSystemPrompt) } @@ -209,6 +210,11 @@ export async function launchClaude( args.push('--agents', JSON.stringify(agents)) } + // Add --plugin-dir flag if provided + if (pluginDir) { + args.push('--plugin-dir', pluginDir) + } + // Add --session-id flag if provided (enables Claude Code session resume) if (sessionId) { args.push('--session-id', sessionId) diff --git a/src/utils/system-prompt-writer.test.ts b/src/utils/system-prompt-writer.test.ts new file mode 100644 index 00000000..2ad62823 --- /dev/null +++ b/src/utils/system-prompt-writer.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from 'vitest' +import path from 'path' +import fs from 'fs-extra' +import { prepareSystemPromptForPlatform, createSessionStartPlugin } from './system-prompt-writer.js' + +vi.mock('fs-extra') + +const mockFs = vi.mocked(fs) + +describe('system-prompt-writer', () => { + describe('prepareSystemPromptForPlatform', () => { + const systemPrompt = 'You are a helpful assistant.\nFollow these instructions.' + const workspacePath = '/home/user/project' + + it('should return inline appendSystemPrompt on darwin', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'darwin') + + expect(result).toEqual({ appendSystemPrompt: systemPrompt }) + expect(mockFs.ensureDir).not.toHaveBeenCalled() + expect(mockFs.writeFile).not.toHaveBeenCalled() + }) + + it('should return inline appendSystemPrompt on linux', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'linux') + + expect(result).toEqual({ appendSystemPrompt: systemPrompt }) + expect(mockFs.ensureDir).not.toHaveBeenCalled() + expect(mockFs.writeFile).not.toHaveBeenCalled() + }) + + it('should write prompt file and return plugin config on win32', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'win32') + + const claudeDir = path.join(workspacePath, '.claude') + const promptFilePath = path.join(claudeDir, 'iloom-system-prompt.md') + const pluginDir = path.join(claudeDir, 'iloom-plugin') + + // Should create .claude directory and write prompt file + expect(mockFs.ensureDir).toHaveBeenCalledWith(claudeDir) + expect(mockFs.writeFile).toHaveBeenCalledWith(promptFilePath, systemPrompt, 'utf-8') + + // Should create plugin directory with hooks.json + expect(mockFs.ensureDir).toHaveBeenCalledWith(pluginDir) + + // Should return plugin config with /clear override + expect(result).toEqual({ + pluginDir, + initialPromptOverride: '/clear', + }) + }) + + it('should not include appendSystemPrompt in win32 result', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'win32') + + expect(result.appendSystemPrompt).toBeUndefined() + }) + + it('should not include pluginDir or initialPromptOverride for darwin', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'darwin') + + expect(result.pluginDir).toBeUndefined() + expect(result.initialPromptOverride).toBeUndefined() + }) + + it('should not include pluginDir or initialPromptOverride for linux', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'linux') + + expect(result.pluginDir).toBeUndefined() + expect(result.initialPromptOverride).toBeUndefined() + }) + + it('should treat unknown platforms like win32 (non-darwin, non-linux)', async () => { + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath, 'freebsd') + + // Should fall through to Windows-style file-based approach + expect(result.pluginDir).toBeDefined() + expect(result.initialPromptOverride).toBe('/clear') + expect(result.appendSystemPrompt).toBeUndefined() + }) + + it('should default to process.platform when no platform argument given', async () => { + // On macOS (where tests run), this should return inline prompt + const result = await prepareSystemPromptForPlatform(systemPrompt, workspacePath) + + // process.platform is 'darwin' in test environment + expect(result.appendSystemPrompt).toBe(systemPrompt) + }) + }) + + describe('createSessionStartPlugin', () => { + it('should write runner.js and hooks.json with node command for system prompt file', async () => { + const pluginDir = '/workspace/.claude/iloom-plugin' + const promptFilePath = '/workspace/.claude/iloom-system-prompt.md' + + await createSessionStartPlugin(pluginDir, promptFilePath) + + // Should create plugin directory + expect(mockFs.ensureDir).toHaveBeenCalledWith(pluginDir) + + // Should write runner.js with JSON-safe embedded path + const runnerCall = mockFs.writeFile.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].endsWith('runner.js'), + ) + expect(runnerCall).toBeDefined() + const runnerContent = runnerCall![1] as string + expect(runnerContent).toBe( + `process.stdout.write(require('fs').readFileSync(${JSON.stringify(promptFilePath)}, 'utf-8'));` + ) + + // Should write hooks.json that invokes runner.js + const hooksCall = mockFs.writeFile.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].endsWith('hooks.json'), + ) + expect(hooksCall).toBeDefined() + const writtenJson = JSON.parse(hooksCall![1] as string) + + const runnerPath = path.join(pluginDir, 'runner.js').replace(/\\/g, '/') + expect(writtenJson).toEqual({ + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command: `node "${runnerPath}"`, + }, + ], + }, + ], + }, + }) + }) + + it('should normalize Windows backslashes in runner.js path used by hooks.json', async () => { + const pluginDir = 'C:/Users/dev/.claude/iloom-plugin' + const promptFilePath = 'C:\\Users\\dev\\.claude\\iloom-system-prompt.md' + + await createSessionStartPlugin(pluginDir, promptFilePath) + + // runner.js should embed the path via JSON.stringify (handles backslashes safely) + const runnerCall = mockFs.writeFile.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].endsWith('runner.js'), + ) + expect(runnerCall).toBeDefined() + const runnerContent = runnerCall![1] as string + // JSON.stringify preserves the backslashes as escaped sequences + expect(runnerContent).toBe( + `process.stdout.write(require('fs').readFileSync(${JSON.stringify(promptFilePath)}, 'utf-8'));` + ) + + // hooks.json command should reference runner.js with forward slashes + const hooksCall = mockFs.writeFile.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].endsWith('hooks.json'), + ) + expect(hooksCall).toBeDefined() + const writtenJson = JSON.parse(hooksCall![1] as string) + const command = writtenJson.hooks.SessionStart[0].hooks[0].command + expect(command).not.toContain('\\') + expect(command).toContain('runner.js') + }) + + it('should create plugin directory structure with runner.js and hooks.json', async () => { + const pluginDir = '/workspace/.claude/iloom-plugin' + const promptFilePath = '/workspace/.claude/iloom-system-prompt.md' + + await createSessionStartPlugin(pluginDir, promptFilePath) + + expect(mockFs.ensureDir).toHaveBeenCalledWith(pluginDir) + // Should write both runner.js and hooks.json + expect(mockFs.writeFile).toHaveBeenCalledTimes(2) + }) + + it('should use runner.js instead of node -e or cat for cross-platform safety', async () => { + const pluginDir = '/workspace/.claude/iloom-plugin' + const promptFilePath = '/workspace/.claude/iloom-system-prompt.md' + + await createSessionStartPlugin(pluginDir, promptFilePath) + + const hooksCall = mockFs.writeFile.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].endsWith('hooks.json'), + ) + const writtenJson = JSON.parse(hooksCall![1] as string) + const command = writtenJson.hooks.SessionStart[0].hooks[0].command + + // Should use runner.js file, not inline node -e or cat + expect(command).toMatch(/^node ".*runner\.js"$/) + expect(command).not.toContain('-e') + expect(command).not.toMatch(/^cat /) + }) + }) +}) diff --git a/src/utils/system-prompt-writer.ts b/src/utils/system-prompt-writer.ts new file mode 100644 index 00000000..909a9b7d --- /dev/null +++ b/src/utils/system-prompt-writer.ts @@ -0,0 +1,95 @@ +import path from 'path' +import fs from 'fs-extra' + +/** + * Result of preparing the system prompt for a specific platform. + * Exactly one of these strategies will be populated. + */ +export interface SystemPromptConfig { + /** Inline system prompt (macOS + Linux) */ + appendSystemPrompt?: string + /** Plugin directory for --plugin-dir (Windows) */ + pluginDir?: string + /** Override the initial user prompt (Windows: '/clear' to trigger SessionStart) */ + initialPromptOverride?: string +} + +/** + * Prepare the system prompt for the current platform. + * + * - darwin: inline via --append-system-prompt (unchanged) + * - linux: inline via --append-system-prompt (80KB < 128KB limit) + * - win32: write to file, create SessionStart plugin, pass /clear + */ +export async function prepareSystemPromptForPlatform( + systemPrompt: string, + workspacePath: string, + platform: string = process.platform, +): Promise { + if (platform === 'darwin' || platform === 'linux') { + // macOS and Linux: inline system prompt + return { appendSystemPrompt: systemPrompt } + } + + // Windows: write system prompt to file, create SessionStart hook plugin + const claudeDir = path.join(workspacePath, '.claude') + const promptFilePath = path.join(claudeDir, 'iloom-system-prompt.md') + const pluginDir = path.join(claudeDir, 'iloom-plugin') + + await fs.ensureDir(claudeDir) + await fs.writeFile(promptFilePath, systemPrompt, 'utf-8') + + // Create plugin directory with SessionStart hook + await createSessionStartPlugin(pluginDir, promptFilePath) + + return { + pluginDir, + initialPromptOverride: '/clear', + } +} + +/** + * Create a SessionStart hook plugin that injects the system prompt file content. + * + * Writes a small runner.js script that reads and outputs the prompt file, + * since `cat` is not available natively on Windows and Node.js is guaranteed + * to be present (Claude Code requires it). + * + * Uses a runner file instead of `node -e` to avoid command injection when + * the workspace path contains quotes or special characters. + */ +export async function createSessionStartPlugin( + pluginDir: string, + promptFilePath: string, +): Promise { + await fs.ensureDir(pluginDir) + + // Write a small runner script that safely embeds the file path via JSON.stringify + const runnerScript = `process.stdout.write(require('fs').readFileSync(${JSON.stringify(promptFilePath)}, 'utf-8'));` + await fs.writeFile(path.join(pluginDir, 'runner.js'), runnerScript, 'utf-8') + + // Use forward slashes in the hooks command for cross-platform portability + const portableRunnerPath = path.join(pluginDir, 'runner.js').replace(/\\/g, '/') + + const hooksConfig = { + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [ + { + type: 'command' as const, + command: `node "${portableRunnerPath}"`, + }, + ], + }, + ], + }, + } + + await fs.writeFile( + path.join(pluginDir, 'hooks.json'), + JSON.stringify(hooksConfig, null, 2), + 'utf-8', + ) +}