mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: OpenCode integration Phase 5 — API routes, frontend UI
API changes: - GET /api/opencode/status — check if OpenCode CLI is available - POST /api/sessions — support mode: 'opencode' with openCodeConfig - POST /api/quick-start — support mode: 'opencode', guard writeHooksConfig - Fix hardcoded mode: 'claude' in lifecycle logs (use session.mode) Schema changes: - Add 'opencode' to mode enums in CreateSessionSchema, QuickStartSchema - Add OpenCodeConfigSchema (model, autoAllowTools, continueSession, etc.) - Add 'OPENCODE_' to ALLOWED_ENV_PREFIXES - Block OPENCODE_SERVER_PASSWORD in BLOCKED_ENV_KEYS (atomic with prefix addition) Frontend UI: - "Run OC" button in toolbar + "Run OpenCode" on welcome screen - Green "oc" tab badge for OpenCode sessions (matching green brand) - runOpenCode() method — checks availability, quick-starts with autoAllowTools - Feature gating: hide Respawn/Context/Ralph tabs for OpenCode sessions - Session options defaults to Summary tab for OpenCode - CSS: OpenCode button styles, tab badge styles Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+50
-3
@@ -5440,7 +5440,7 @@ class ClaudemanApp {
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : ''}
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${this.escapeHtml(folderName)}</span>` : ''}
|
||||
@@ -9507,6 +9507,48 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
async runOpenCode() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = await statusRes.json();
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default)
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
|
||||
|
||||
// Switch to the new session
|
||||
if (data.sessionId) {
|
||||
this.activeSessionId = data.sessionId;
|
||||
await this.selectSession(data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Directory Input ==========
|
||||
|
||||
toggleDirInput() {
|
||||
@@ -10263,8 +10305,8 @@ class ClaudemanApp {
|
||||
|
||||
this.editingSessionId = sessionId;
|
||||
|
||||
// Reset to Respawn tab
|
||||
this.switchOptionsTab('respawn');
|
||||
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
|
||||
this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||
@@ -10292,6 +10334,11 @@ class ClaudemanApp {
|
||||
respawnSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for OpenCode sessions
|
||||
const isOpenCode = session.mode === 'opencode';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
|
||||
|
||||
// Reset duration presets to default (unlimited)
|
||||
this.selectDurationPreset('');
|
||||
|
||||
|
||||
@@ -238,6 +238,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-opencode" onclick="app.runOpenCode()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-ralph" onclick="app.showRalphWizard()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
Start Ralph Loop Wizard
|
||||
@@ -317,6 +321,9 @@
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-opencode" onclick="app.runOpenCode()" title="Run OpenCode (multi-model)">
|
||||
Run OC
|
||||
</button>
|
||||
<div class="case-select-group">
|
||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||
<option value="testcase">testcase</option>
|
||||
@@ -496,9 +503,9 @@
|
||||
</div>
|
||||
<!-- Modal Tabs -->
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="respawn" onclick="app.switchOptionsTab('respawn')">Respawn</button>
|
||||
<button class="modal-tab-btn" data-tab="context" onclick="app.switchOptionsTab('context')">Context</button>
|
||||
<button class="modal-tab-btn" data-tab="ralph" onclick="app.switchOptionsTab('ralph')">Ralph / Todo</button>
|
||||
<button class="modal-tab-btn active" data-tab="respawn" data-claude-only onclick="app.switchOptionsTab('respawn')">Respawn</button>
|
||||
<button class="modal-tab-btn" data-tab="context" data-claude-only onclick="app.switchOptionsTab('context')">Context</button>
|
||||
<button class="modal-tab-btn" data-tab="ralph" data-claude-only onclick="app.switchOptionsTab('ralph')">Ralph / Todo</button>
|
||||
<button class="modal-tab-btn" data-tab="summary" onclick="app.switchOptionsTab('summary')">Summary</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -616,6 +616,11 @@ body {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.opencode {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
@@ -1727,6 +1732,19 @@ body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.welcome-btn-opencode {
|
||||
background: linear-gradient(135deg, #0a2e2a 0%, #0d4a40 100%);
|
||||
border-color: #10b981;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.welcome-btn-opencode:hover {
|
||||
background: linear-gradient(135deg, #0d4a40 0%, #116a58 100%);
|
||||
border-color: #34d399;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.welcome-btn-ralph {
|
||||
background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%);
|
||||
border-color: #6b5a1e;
|
||||
@@ -1876,6 +1894,20 @@ body {
|
||||
color: #a8d8a8;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-opencode {
|
||||
background: linear-gradient(135deg, #0a2e2a 0%, #0d4a40 100%);
|
||||
border-color: #10b981;
|
||||
color: #6ee7b7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-opencode:hover {
|
||||
background: linear-gradient(135deg, #0d4a40 0%, #116a58 100%);
|
||||
border-color: #34d399;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
/* Stop button - hidden on desktop, shown on mobile */
|
||||
.btn-toolbar.btn-stop {
|
||||
display: none;
|
||||
|
||||
+16
-4
@@ -34,12 +34,13 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_'];
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
|
||||
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
|
||||
'CLAUDEMAN_MUX_NAME', 'CLAUDEMAN_TMUX',
|
||||
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
|
||||
]);
|
||||
|
||||
/** Validate that an env var key is allowed */
|
||||
@@ -54,7 +55,7 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin
|
||||
if (!val) return true;
|
||||
return Object.keys(val).every(isAllowedEnvKey);
|
||||
},
|
||||
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* keys are allowed.' },
|
||||
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.' },
|
||||
);
|
||||
|
||||
// ========== Session Routes ==========
|
||||
@@ -63,11 +64,21 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin
|
||||
* Schema for POST /api/sessions
|
||||
* Creates a new session with optional working directory, mode, and name.
|
||||
*/
|
||||
/** Schema for OpenCode-specific configuration */
|
||||
const OpenCodeConfigSchema = z.object({
|
||||
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(),
|
||||
autoAllowTools: z.boolean().optional(),
|
||||
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
|
||||
forkSession: z.boolean().optional(),
|
||||
configContent: z.string().max(10000).optional(),
|
||||
}).optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -106,7 +117,8 @@ export const CreateCaseSchema = z.object({
|
||||
*/
|
||||
export const QuickStartSchema = z.object({
|
||||
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.').optional(),
|
||||
mode: z.enum(['claude', 'shell']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
|
||||
+45
-9
@@ -667,6 +667,15 @@ export class WebServer extends EventEmitter {
|
||||
// API Routes
|
||||
this.app.get('/api/status', async () => this.getLightState());
|
||||
|
||||
// OpenCode CLI availability check
|
||||
this.app.get('/api/opencode/status', async () => {
|
||||
const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js');
|
||||
return {
|
||||
available: isOpenCodeAvailable(),
|
||||
path: resolveOpenCodeDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// Cleanup stale sessions from state file
|
||||
this.app.post('/api/cleanup-state', async () => {
|
||||
const cleaned = this.cleanupStaleSessions();
|
||||
@@ -837,13 +846,24 @@ export class WebServer extends EventEmitter {
|
||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
}
|
||||
}
|
||||
|
||||
const globalNice = await this.getGlobalNiceConfig();
|
||||
const modelConfig = await this.getModelConfig();
|
||||
const model = (body.mode !== 'shell') ? modelConfig?.defaultModel : undefined;
|
||||
const mode = body.mode || 'claude';
|
||||
const model = mode === 'opencode'
|
||||
? body.openCodeConfig?.model
|
||||
: (mode !== 'shell' ? modelConfig?.defaultModel : undefined);
|
||||
const claudeModeConfig = await this.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: body.mode || 'claude',
|
||||
mode,
|
||||
name: body.name || '',
|
||||
mux: this.mux,
|
||||
useMux: true,
|
||||
@@ -851,6 +871,7 @@ export class WebServer extends EventEmitter {
|
||||
model,
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -1654,7 +1675,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude' });
|
||||
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode });
|
||||
this.broadcast('session:interactive', { id });
|
||||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||||
|
||||
@@ -2004,7 +2025,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Start interactive session
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude', reason: 'interactive_respawn' });
|
||||
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode, reason: 'interactive_respawn' });
|
||||
this.broadcast('session:interactive', { id });
|
||||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||||
|
||||
@@ -2598,7 +2619,15 @@ export class WebServer extends EventEmitter {
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { caseName = 'testcase', mode = 'claude' } = result.data;
|
||||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
}
|
||||
}
|
||||
|
||||
const casePath = join(casesDir, caseName);
|
||||
|
||||
@@ -2622,7 +2651,10 @@ export class WebServer extends EventEmitter {
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
await writeHooksConfig(casePath);
|
||||
// (Claude-specific — OpenCode uses its own plugin system)
|
||||
if (mode !== 'opencode') {
|
||||
await writeHooksConfig(casePath);
|
||||
}
|
||||
|
||||
this.broadcast('case:created', { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
@@ -2634,7 +2666,9 @@ export class WebServer extends EventEmitter {
|
||||
// Apply global Nice priority config and model config from settings
|
||||
const niceConfig = await this.getGlobalNiceConfig();
|
||||
const qsModelConfig = await this.getModelConfig();
|
||||
const qsModel = (mode !== 'shell') ? qsModelConfig?.defaultModel : undefined;
|
||||
const qsModel = mode === 'opencode'
|
||||
? openCodeConfig?.model
|
||||
: (mode !== 'shell' ? qsModelConfig?.defaultModel : undefined);
|
||||
const qsClaudeModeConfig = await this.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
@@ -2645,6 +2679,7 @@ export class WebServer extends EventEmitter {
|
||||
model: qsModel,
|
||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
@@ -2671,9 +2706,10 @@ export class WebServer extends EventEmitter {
|
||||
getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'shell' });
|
||||
this.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
||||
} else {
|
||||
// Both 'claude' and 'opencode' modes use startInteractive()
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude' });
|
||||
this.broadcast('session:interactive', { id: session.id });
|
||||
getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode });
|
||||
this.broadcast('session:interactive', { id: session.id, mode });
|
||||
}
|
||||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user