feat: add opencode support (#762)

* feat: add opencode support

* fix: stabilize opencode session startup

* fix: /models

* fix: improveUI for commands

* fix: format commands.js

* feat: load models through provider adapters

Provider model selection had outgrown a single hardcoded service.

The old service mixed shared caching with provider catalogs and CLI lookup details.

That made stale model lists more likely as providers changed on separate schedules.

Move model discovery behind each provider so lookup lives next to the integration.

The shared service now focuses on provider resolution, caching, persistence, and dedupe.

Return cache metadata and add bypassCache because model availability changes outside the app.

The UI and /models command can show freshness and let users force a provider refresh.

Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.

* feat(models): resolve active session models through provider adapters

The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved

* feat: support session-scoped model overrides

Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.

* feat: make command modal more compact

* fix: preserve opencode session creation events

OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.

Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.

Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.

* fix: clarify model refresh and onboarding providers

OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.

The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.

Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.

* fix: format opencode model catalog labels

OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.

Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.

This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.

* feat: add more fallback models for cursor

* docs: move model catalog out of shared

The model catalog is no longer a frontend/backend runtime contract.

Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.

Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.

Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.

Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.

* fix: simplify empty-state model selector

Keep the provider empty state focused on the setup action users need there:

choosing a model.

The refresh control, cache timestamp, and refresh explanation made the dialog feel

like a cache-management surface.

That extra action is out of place in the empty state, where the goal is to start

a chat with the selected provider and model.

Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the

now-unused refresh/cache props from the ChatMessagesPane pass-through.

Refresh behavior remains available in the dedicated command result flow.
This commit is contained in:
Haile
2026-05-28 11:50:41 +03:00
committed by GitHub
parent 10f721cf14
commit 374e9de719
87 changed files with 7024 additions and 577 deletions

View File

@@ -9,8 +9,9 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js';
@@ -608,7 +609,7 @@ class ResponseCollector {
/**
* POST /api/agent
*
* Trigger an AI agent (Claude or Cursor) to work on a project.
* Trigger an AI agent to work on a project.
* Supports automatic GitHub branch and pull request creation after successful completion.
*
* ================================================================================================
@@ -633,7 +634,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -751,7 +752,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
* - provider must be 'claude', 'cursor', 'codex', or 'gemini'
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
@@ -859,8 +860,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
}
// Validate GitHub branch/PR creation requirements
@@ -938,6 +939,10 @@ router.post('/', validateExternalApiKey, async (req, res) => {
});
}
const codexModels = (await providerModelsService.getProviderModels('codex')).models;
const geminiModels = (await providerModelsService.getProviderModels('gemini')).models;
const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
// Start the appropriate session
if (provider === 'claude') {
console.log('🤖 Starting Claude SDK session');
@@ -967,7 +972,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || CODEX_MODELS.DEFAULT,
model: model || codexModels.DEFAULT,
permissionMode: 'bypassPermissions'
}, writer);
} else if (provider === 'gemini') {
@@ -977,9 +982,18 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model,
model: model || geminiModels.DEFAULT,
skipPermissions: true // CLI mode bypasses permissions
}, writer);
} else if (provider === 'opencode') {
console.log('Starting OpenCode CLI session');
await spawnOpenCode(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion

View File

@@ -1,12 +1,12 @@
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import express from 'express';
import express from "express";
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { parseFrontMatter } from '../shared/frontmatter.js';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
import { providerModelsService } from "../modules/providers/services/provider-models.service.js";
import { parseFrontMatter } from "../shared/frontmatter.js";
import { findAppRoot, getModuleDir } from "../utils/runtime-paths.js";
const __dirname = getModuleDir(import.meta.url);
// This route reads the top-level package.json for the status command, so it needs the real
@@ -15,6 +15,77 @@ const APP_ROOT = findAppRoot(__dirname);
const router = express.Router();
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
const MODEL_PROVIDER_LABELS = {
claude: "Claude",
cursor: "Cursor",
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
};
const readModelProvider = (value) => {
if (typeof value !== "string") {
return "claude";
}
const normalized = value.trim().toLowerCase();
return MODEL_PROVIDERS.includes(normalized) ? normalized : "claude";
};
const hasConcreteSessionId = (value) =>
typeof value === "string" && value.trim().length > 0;
const resolveCommandModel = async (provider, catalog, sessionId) => {
if (!hasConcreteSessionId(sessionId)) {
return catalog.DEFAULT;
}
const currentActiveModel = await providerModelsService.getCurrentActiveModel(
provider,
sessionId,
);
return currentActiveModel?.model || catalog.DEFAULT;
};
export const executeModelsCommand = async (args, context) => {
const currentProvider = readModelProvider(context?.provider);
const result = await providerModelsService.getProviderModels(currentProvider);
const catalog = result.models;
const currentModel = await resolveCommandModel(
currentProvider,
catalog,
context?.sessionId,
);
const availableModels = catalog.OPTIONS.map((option) => option.value);
const availableOptions = catalog.OPTIONS.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
}));
return {
type: "builtin",
action: "models",
data: {
current: {
provider: currentProvider,
providerLabel: MODEL_PROVIDER_LABELS[currentProvider],
model: currentModel,
},
available: {
[currentProvider]: availableModels,
},
availableModels,
availableOptions,
defaultModel: catalog.DEFAULT,
cache: result.cache,
message: `Current model: ${currentModel}`,
},
};
};
/**
* Recursively scan directory for command files (.md)
* @param {string} dir - Directory to scan
@@ -36,24 +107,30 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
const subCommands = await scanCommandsDirectory(
fullPath,
baseDir,
namespace,
);
commands.push(...subCommands);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
} else if (entry.isFile() && entry.name.endsWith(".md")) {
// Parse markdown file for metadata
try {
const content = await fs.readFile(fullPath, 'utf8');
const { data: frontmatter, content: commandContent } = parseFrontMatter(content);
const content = await fs.readFile(fullPath, "utf8");
const { data: frontmatter, content: commandContent } =
parseFrontMatter(content);
// Calculate relative path from baseDir for command name
const relativePath = path.relative(baseDir, fullPath);
// Remove .md extension and convert to command name
const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
const commandName =
"/" + relativePath.replace(/\.md$/, "").replace(/\\/g, "/");
// Extract description from frontmatter or first line of content
let description = frontmatter.description || '';
let description = frontmatter.description || "";
if (!description) {
const firstLine = commandContent.trim().split('\n')[0];
description = firstLine.replace(/^#+\s*/, '').trim();
const firstLine = commandContent.trim().split("\n")[0];
description = firstLine.replace(/^#+\s*/, "").trim();
}
commands.push({
@@ -62,7 +139,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
relativePath,
description,
namespace,
metadata: frontmatter
metadata: frontmatter,
});
} catch (err) {
console.error(`Error parsing command file ${fullPath}:`, err.message);
@@ -71,7 +148,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
}
} catch (err) {
// Directory doesn't exist or can't be accessed - this is okay
if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
if (err.code !== "ENOENT" && err.code !== "EACCES") {
console.error(`Error scanning directory ${dir}:`, err.message);
}
}
@@ -84,53 +161,41 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
*/
const builtInCommands = [
{
name: '/help',
description: 'Show help documentation for Claude Code',
namespace: 'builtin',
metadata: { type: 'builtin' }
name: "/help",
description: "Show help documentation for Claude Code",
namespace: "builtin",
metadata: { type: "builtin" },
},
{
name: '/clear',
description: 'Clear the conversation history',
namespace: 'builtin',
metadata: { type: 'builtin' }
name: "/models",
description: "View available models for the current provider",
namespace: "builtin",
metadata: { type: "builtin" },
},
{
name: '/model',
description: 'Switch or view the current AI model',
namespace: 'builtin',
metadata: { type: 'builtin' }
name: "/cost",
description: "Display token usage and cost information",
namespace: "builtin",
metadata: { type: "builtin" },
},
{
name: '/cost',
description: 'Display token usage and cost information',
namespace: 'builtin',
metadata: { type: 'builtin' }
name: "/memory",
description: "Open CLAUDE.md memory file for editing",
namespace: "builtin",
metadata: { type: "builtin" },
},
{
name: '/memory',
description: 'Open CLAUDE.md memory file for editing',
namespace: 'builtin',
metadata: { type: 'builtin' }
name: "/config",
description: "Open settings and configuration",
namespace: "builtin",
metadata: { type: "builtin" },
},
{
name: '/config',
description: 'Open settings and configuration',
namespace: 'builtin',
metadata: { type: 'builtin' }
name: "/status",
description: "Show system status and version information",
namespace: "builtin",
metadata: { type: "builtin" },
},
{
name: '/status',
description: 'Show system status and version information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/rewind',
description: 'Rewind the conversation to a previous state',
namespace: 'builtin',
metadata: { type: 'builtin' }
}
];
/**
@@ -138,14 +203,18 @@ const builtInCommands = [
* Each handler returns { type: 'builtin', action: string, data: any }
*/
const builtInHandlers = {
'/help': async (args, context) => {
"/help": async (args, context) => {
const helpText = `# Claude Code Commands
## Built-in Commands
${builtInCommands.map(cmd => `### ${cmd.name}
${builtInCommands
.map(
(cmd) => `### ${cmd.name}
${cmd.description}
`).join('\n')}
`,
)
.join("\n")}
## Custom Commands
@@ -167,71 +236,40 @@ Custom commands can be created in:
`;
return {
type: 'builtin',
action: 'help',
type: "builtin",
action: "help",
data: {
content: helpText,
format: 'markdown'
}
format: "markdown",
commands: builtInCommands.map((command) => ({
name: command.name,
description: command.description,
namespace: command.namespace,
})),
},
};
},
'/clear': async (args, context) => {
return {
type: 'builtin',
action: 'clear',
data: {
message: 'Conversation history cleared'
}
};
},
"/models": executeModelsCommand,
'/model': async (args, context) => {
// Read available models from centralized constants
const availableModels = {
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
};
const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
return {
type: 'builtin',
action: 'model',
data: {
current: {
provider: currentProvider,
model: currentModel
},
available: availableModels,
message: args.length > 0
? `Switching to model: ${args[0]}`
: `Current model: ${currentModel}`
}
};
},
'/cost': async (args, context) => {
"/cost": async (args, context) => {
const tokenUsage = context?.tokenUsage || {};
const provider = context?.provider || 'claude';
const model =
context?.model ||
(provider === 'cursor'
? CURSOR_MODELS.DEFAULT
: provider === 'codex'
? CODEX_MODELS.DEFAULT
: CLAUDE_MODELS.DEFAULT);
const provider = readModelProvider(context?.provider);
const catalog = (await providerModelsService.getProviderModels(provider)).models;
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
const used =
Number(
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
) || 0;
const total =
Number(
tokenUsage.total ??
tokenUsage.contextWindow ??
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
parseInt(process.env.CONTEXT_WINDOW || "160000", 10),
) || 160000;
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const percentage =
total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw =
Number(
@@ -260,7 +298,9 @@ Custom commands can be created in:
// If we only have total used tokens, treat them as input for display/estimation.
const inputTokens =
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0
? inputTokensRaw + cacheTokens
: used;
// Rough default rates by provider (USD / 1M tokens).
const pricingByProvider = {
@@ -275,76 +315,96 @@ Custom commands can be created in:
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
type: "builtin",
action: "cost",
data: {
tokenUsage: {
used,
total,
percentage,
},
tokenBreakdown: {
input: inputTokens,
output: outputTokens,
cache: cacheTokens,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
},
provider,
model,
},
};
},
'/status': async (args, context) => {
"/status": async (args, context) => {
// Read version from package.json
const packageJsonPath = path.join(APP_ROOT, 'package.json');
let version = 'unknown';
let packageName = 'claude-code-ui';
const packageJsonPath = path.join(APP_ROOT, "package.json");
let version = "unknown";
let packageName = "claude-code-ui";
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const packageJson = JSON.parse(
await fs.readFile(packageJsonPath, "utf8"),
);
version = packageJson.version;
packageName = packageJson.name;
} catch (err) {
console.error('Error reading package.json:', err);
console.error("Error reading package.json:", err);
}
const uptime = process.uptime();
const uptimeMinutes = Math.floor(uptime / 60);
const uptimeHours = Math.floor(uptimeMinutes / 60);
const uptimeFormatted = uptimeHours > 0
? `${uptimeHours}h ${uptimeMinutes % 60}m`
: `${uptimeMinutes}m`;
const uptimeFormatted =
uptimeHours > 0
? `${uptimeHours}h ${uptimeMinutes % 60}m`
: `${uptimeMinutes}m`;
const statusProvider = readModelProvider(context?.provider);
const statusCatalog = (await providerModelsService.getProviderModels(statusProvider)).models;
const model = await resolveCommandModel(statusProvider, statusCatalog, context?.sessionId);
const memoryUsage = process.memoryUsage();
return {
type: 'builtin',
action: 'status',
type: "builtin",
action: "status",
data: {
version,
packageName,
uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime),
model: context?.model || CLAUDE_MODELS.DEFAULT,
provider: context?.provider || 'claude',
model,
provider: statusProvider,
nodeVersion: process.version,
platform: process.platform
}
platform: process.platform,
pid: process.pid,
memoryUsage: {
rssMb: Math.round(memoryUsage.rss / 1024 / 1024),
heapUsedMb: Math.round(memoryUsage.heapUsed / 1024 / 1024),
heapTotalMb: Math.round(memoryUsage.heapTotal / 1024 / 1024),
},
},
};
},
'/memory': async (args, context) => {
"/memory": async (args, context) => {
const projectPath = context?.projectPath;
if (!projectPath) {
return {
type: 'builtin',
action: 'memory',
type: "builtin",
action: "memory",
data: {
error: 'No project selected',
message: 'Please select a project to access its CLAUDE.md file'
}
error: "No project selected",
message: "Please select a project to access its CLAUDE.md file",
},
};
}
const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
const claudeMdPath = path.join(projectPath, "CLAUDE.md");
// Check if CLAUDE.md exists
let exists = false;
@@ -356,85 +416,63 @@ Custom commands can be created in:
}
return {
type: 'builtin',
action: 'memory',
type: "builtin",
action: "memory",
data: {
path: claudeMdPath,
exists,
message: exists
? `Opening CLAUDE.md at ${claudeMdPath}`
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
}
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`,
},
};
},
'/config': async (args, context) => {
"/config": async (args, context) => {
return {
type: 'builtin',
action: 'config',
type: "builtin",
action: "config",
data: {
message: 'Opening settings...'
}
message: "Opening settings...",
},
};
},
'/rewind': async (args, context) => {
const steps = args[0] ? parseInt(args[0]) : 1;
if (isNaN(steps) || steps < 1) {
return {
type: 'builtin',
action: 'rewind',
data: {
error: 'Invalid steps parameter',
message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
}
};
}
return {
type: 'builtin',
action: 'rewind',
data: {
steps,
message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
}
};
}
};
/**
* POST /api/commands/list
* List all available commands from project and user directories
*/
router.post('/list', async (req, res) => {
router.post("/list", async (req, res) => {
try {
const { projectPath } = req.body;
const allCommands = [...builtInCommands];
// Scan project-level commands (.claude/commands/)
if (projectPath) {
const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
const projectCommandsDir = path.join(projectPath, ".claude", "commands");
const projectCommands = await scanCommandsDirectory(
projectCommandsDir,
projectCommandsDir,
'project'
"project",
);
allCommands.push(...projectCommands);
}
// Scan user-level commands (~/.claude/commands/)
const homeDir = os.homedir();
const userCommandsDir = path.join(homeDir, '.claude', 'commands');
const userCommandsDir = path.join(homeDir, ".claude", "commands");
const userCommands = await scanCommandsDirectory(
userCommandsDir,
userCommandsDir,
'user'
"user",
);
allCommands.push(...userCommands);
// Separate built-in and custom commands
const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
const customCommands = allCommands.filter(
(cmd) => cmd.namespace !== "builtin",
);
// Sort commands alphabetically by name
customCommands.sort((a, b) => a.name.localeCompare(b.name));
@@ -442,13 +480,13 @@ router.post('/list', async (req, res) => {
res.json({
builtIn: builtInCommands,
custom: customCommands,
count: allCommands.length
count: allCommands.length,
});
} catch (error) {
console.error('Error listing commands:', error);
console.error("Error listing commands:", error);
res.status(500).json({
error: 'Failed to list commands',
message: error.message
error: "Failed to list commands",
message: error.message,
});
}
});
@@ -459,13 +497,13 @@ router.post('/list', async (req, res) => {
* This endpoint prepares the command content but doesn't execute bash commands yet
* (that will be handled in the command parser utility)
*/
router.post('/execute', async (req, res) => {
router.post("/execute", async (req, res) => {
try {
const { commandName, commandPath, args = [], context = {} } = req.body;
if (!commandName) {
return res.status(400).json({
error: 'Command name is required'
error: "Command name is required",
});
}
@@ -476,14 +514,17 @@ router.post('/execute', async (req, res) => {
const result = await handler(args, context);
return res.json({
...result,
command: commandName
command: commandName,
});
} catch (error) {
console.error(`Error executing built-in command ${commandName}:`, error);
console.error(
`Error executing built-in command ${commandName}:`,
error,
);
return res.status(500).json({
error: 'Command execution failed',
error: "Command execution failed",
message: error.message,
command: commandName
command: commandName,
});
}
}
@@ -491,7 +532,7 @@ router.post('/execute', async (req, res) => {
// Handle custom commands
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required for custom commands'
error: "Command path is required for custom commands",
});
}
@@ -499,56 +540,62 @@ router.post('/execute', async (req, res) => {
// Security: validate commandPath is within allowed directories
{
const resolvedPath = path.resolve(commandPath);
const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
const userBase = path.resolve(
path.join(os.homedir(), ".claude", "commands"),
);
const projectBase = context?.projectPath
? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
? path.resolve(path.join(context.projectPath, ".claude", "commands"))
: null;
const isUnder = (base) => {
const rel = path.relative(base, resolvedPath);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
};
if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
error: "Access denied",
message: "Command must be in .claude/commands directory",
});
}
}
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontMatter(content);
const content = await fs.readFile(commandPath, "utf8");
const { data: metadata, content: commandContent } =
parseFrontMatter(content);
// Basic argument replacement (will be enhanced in command parser utility)
let processedContent = commandContent;
// Replace $ARGUMENTS with all arguments joined
const argsString = args.join(' ');
const argsString = args.join(" ");
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
// Replace $1, $2, etc. with positional arguments
args.forEach((arg, index) => {
const placeholder = `$${index + 1}`;
processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
processedContent = processedContent.replace(
new RegExp(`\\${placeholder}\\b`, "g"),
arg,
);
});
res.json({
type: 'custom',
type: "custom",
command: commandName,
content: processedContent,
metadata,
hasFileIncludes: processedContent.includes('@'),
hasBashCommands: processedContent.includes('!')
hasFileIncludes: processedContent.includes("@"),
hasBashCommands: processedContent.includes("!"),
});
} catch (error) {
if (error.code === 'ENOENT') {
if (error.code === "ENOENT") {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
error: "Command not found",
message: `Command file not found: ${req.body.commandPath}`,
});
}
console.error('Error executing command:', error);
console.error("Error executing command:", error);
res.status(500).json({
error: 'Failed to execute command',
message: error.message
error: "Failed to execute command",
message: error.message,
});
}
});

View File

@@ -2,7 +2,7 @@ import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { CURSOR_FALLBACK_MODELS } from '../modules/providers/list/cursor/cursor-models.provider.js';
const router = express.Router();
@@ -29,7 +29,7 @@ router.get('/config', async (req, res) => {
config: {
version: 1,
model: {
modelId: CURSOR_MODELS.DEFAULT,
modelId: CURSOR_FALLBACK_MODELS.DEFAULT,
displayName: 'GPT-5',
},
permissions: {

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeModelsCommand } from '../commands.js';
import { providerModelsService } from '../../modules/providers/services/provider-models.service.js';
test('models command returns available models only for the active provider', async () => {
const originalGetProviderModels = providerModelsService.getProviderModels;
const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
let getCurrentActiveModelCalls = 0;
providerModelsService.getProviderModels = async () => ({
models: {
OPTIONS: [{ value: 'gpt-5.4', label: 'gpt-5.4' }],
DEFAULT: 'gpt-5.4',
},
cache: {
updatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-04T00:00:00.000Z',
source: 'fresh',
},
});
providerModelsService.getCurrentActiveModel = async () => {
getCurrentActiveModelCalls += 1;
return {
model: 'gpt-5.3-codex',
};
};
try {
const result = await executeModelsCommand([], {
provider: 'codex',
model: 'gpt-5.4',
});
assert.equal(result.type, 'builtin');
assert.equal(result.action, 'models');
assert.equal(result.data.current.provider, 'codex');
assert.equal(result.data.current.model, 'gpt-5.4');
assert.deepEqual(Object.keys(result.data.available), ['codex']);
assert.deepEqual(result.data.available.codex, result.data.availableModels);
assert.ok(result.data.availableModels.includes('gpt-5.4'));
assert.equal(result.data.available.claude, undefined);
assert.equal(result.data.available.cursor, undefined);
assert.equal(getCurrentActiveModelCalls, 0);
} finally {
providerModelsService.getProviderModels = originalGetProviderModels;
providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
}
});
test('models command falls back to claude for unsupported providers', async () => {
const originalGetProviderModels = providerModelsService.getProviderModels;
const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
providerModelsService.getProviderModels = async () => ({
models: {
OPTIONS: [{ value: 'default', label: 'Default (recommended)' }],
DEFAULT: 'default',
},
cache: {
updatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-04T00:00:00.000Z',
source: 'fresh',
},
});
providerModelsService.getCurrentActiveModel = async () => ({
model: 'default',
});
try {
const result = await executeModelsCommand([], {
provider: 'unknown-provider',
});
assert.equal(result.data.current.provider, 'claude');
assert.deepEqual(Object.keys(result.data.available), ['claude']);
} finally {
providerModelsService.getProviderModels = originalGetProviderModels;
providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
}
});