refactor(server): split opencode config/auth/ui-auth into domain modules (#454)
* docs(agents): map opencode module documentation * docs(opencode): document split module responsibilities * refactor(opencode): centralize shared config and file helpers * refactor(opencode): move auth storage helpers into domain module * refactor(opencode): move UI auth implementation into domain module * refactor(opencode): isolate agent scope and CRUD logic * refactor(opencode): isolate command scope and CRUD logic * refactor(opencode): isolate provider config helpers * refactor(opencode): isolate skill discovery and CRUD logic * refactor(opencode): define single public module entrypoint * refactor(opencode): remove legacy opencode-config module * refactor(opencode): remove obsolete opencode-config typings * refactor(opencode): remove legacy opencode-auth module * refactor(opencode): remove legacy ui-auth shim * refactor(server): import opencode APIs from new module paths * refactor(tts): consume auth helpers from opencode domain module * refactor(quota): point claude provider auth import to opencode domain * refactor(quota): point codex provider auth import to opencode domain * refactor(quota): point copilot provider auth import to opencode domain * refactor(quota): point google auth import to opencode domain * refactor(quota): point kimi provider auth import to opencode domain * refactor(quota): point nanogpt provider auth import to opencode domain * refactor(quota): point openai provider auth import to opencode domain * refactor(quota): point openrouter provider auth import to opencode domain * refactor(quota): point zai provider auth import to opencode domain * fix(opencode): restore provider source and disconnect semantics
This commit is contained in:
@@ -45,6 +45,10 @@ Git repository operations for the web server runtime.
|
|||||||
GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.
|
GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.
|
||||||
- Module docs: `packages/web/server/lib/github/DOCUMENTATION.md`
|
- Module docs: `packages/web/server/lib/github/DOCUMENTATION.md`
|
||||||
|
|
||||||
|
##### opencode
|
||||||
|
OpenCode server integration utilities including config management, provider authentication, and UI authentication.
|
||||||
|
- Module docs: `packages/web/server/lib/opencode/DOCUMENTATION.md`
|
||||||
|
|
||||||
## Build / dev commands (verified)
|
## Build / dev commands (verified)
|
||||||
All scripts are in `package.json`.
|
All scripts are in `package.json`.
|
||||||
- Validate: `bun run type-check`, `bun run lint`
|
- Validate: `bun run type-check`, `bun run lint`
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { WebSocketServer } from 'ws';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { createUiAuth } from './lib/ui-auth.js';
|
import { createUiAuth } from './lib/opencode/ui-auth.js';
|
||||||
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
||||||
import { prepareNotificationLastMessage } from './lib/notification-message.js';
|
import { prepareNotificationLastMessage } from './lib/notification-message.js';
|
||||||
import {
|
import {
|
||||||
@@ -6642,7 +6642,7 @@ async function main(options = {}) {
|
|||||||
removeProviderConfig,
|
removeProviderConfig,
|
||||||
AGENT_SCOPE,
|
AGENT_SCOPE,
|
||||||
COMMAND_SCOPE
|
COMMAND_SCOPE
|
||||||
} = await import('./lib/opencode-config.js');
|
} = await import('./lib/opencode/index.js');
|
||||||
|
|
||||||
app.get('/api/config/agents/:name', async (req, res) => {
|
app.get('/api/config/agents/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -6891,7 +6891,7 @@ async function main(options = {}) {
|
|||||||
deleteSkillSupportingFile,
|
deleteSkillSupportingFile,
|
||||||
SKILL_SCOPE,
|
SKILL_SCOPE,
|
||||||
SKILL_DIR,
|
SKILL_DIR,
|
||||||
} = await import('./lib/opencode-config.js');
|
} = await import('./lib/opencode/index.js');
|
||||||
|
|
||||||
const findWorktreeRootForSkills = (workingDirectory) => {
|
const findWorktreeRootForSkills = (workingDirectory) => {
|
||||||
if (!workingDirectory) return null;
|
if (!workingDirectory) return null;
|
||||||
@@ -7588,7 +7588,7 @@ async function main(options = {}) {
|
|||||||
let authLibrary = null;
|
let authLibrary = null;
|
||||||
const getAuthLibrary = async () => {
|
const getAuthLibrary = async () => {
|
||||||
if (!authLibrary) {
|
if (!authLibrary) {
|
||||||
authLibrary = await import('./lib/opencode-auth.js');
|
authLibrary = await import('./lib/opencode/auth.js');
|
||||||
}
|
}
|
||||||
return authLibrary;
|
return authLibrary;
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
-12
@@ -1,12 +0,0 @@
|
|||||||
declare module "../server/lib/opencode-config.js" {
|
|
||||||
export function getAgentSources(agentName: string): {
|
|
||||||
md: { exists: boolean; path: string | null; fields: string[] };
|
|
||||||
json: { exists: boolean; path: string | null; fields: string[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createAgent(agentName: string, config: Record<string, unknown>): void;
|
|
||||||
|
|
||||||
export function updateAgent(agentName: string, updates: Record<string, unknown>): void;
|
|
||||||
|
|
||||||
export function deleteAgent(agentName: string): void;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# OpenCode Module Documentation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This module provides OpenCode server integration utilities for the web server runtime, including configuration management, provider authentication, and UI authentication with rate limiting.
|
||||||
|
|
||||||
|
## Entrypoints and structure
|
||||||
|
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
|
||||||
|
- `packages/web/server/lib/opencode/auth.js`: provider authentication file operations.
|
||||||
|
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
|
||||||
|
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
|
||||||
|
|
||||||
|
## Public exports (auth.js)
|
||||||
|
- `readAuthFile()`: Reads and parses `~/.local/share/opencode/auth.json`.
|
||||||
|
- `writeAuthFile(auth)`: Writes auth file with automatic backup.
|
||||||
|
- `removeProviderAuth(providerId)`: Removes a provider's auth entry.
|
||||||
|
- `getProviderAuth(providerId)`: Returns auth for a specific provider or null.
|
||||||
|
- `listProviderAuths()`: Returns list of provider IDs with configured auth.
|
||||||
|
- `AUTH_FILE`: Auth file path constant.
|
||||||
|
- `OPENCODE_DATA_DIR`: OpenCode data directory path constant.
|
||||||
|
|
||||||
|
## Public exports (shared.js)
|
||||||
|
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
|
||||||
|
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
||||||
|
- `ensureDirs()`: Creates required OpenCode directories.
|
||||||
|
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
||||||
|
- `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom).
|
||||||
|
- `writeConfig(config, filePath)`: Writes config with automatic backup.
|
||||||
|
- `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry.
|
||||||
|
- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates.
|
||||||
|
- `getAncestors(startDir, stopDir)`, `findWorktreeRoot(startDir)`: Git worktree helpers.
|
||||||
|
- `isPromptFileReference(value)`, `resolvePromptFilePath(reference)`, `writePromptFile(filePath, content)`: Prompt file reference handling.
|
||||||
|
- `walkSkillMdFiles(rootDir)`: Recursively finds all SKILL.md files.
|
||||||
|
- `addSkillFromMdFile(skillsMap, skillMdPath, scope, source)`: Parses and indexes a skill file.
|
||||||
|
- `resolveSkillSearchDirectories(workingDirectory)`: Returns skill search path order (config, project, home, custom).
|
||||||
|
- `listSkillSupportingFiles(skillDir)`, `readSkillSupportingFile(skillDir, relativePath)`, `writeSkillSupportingFile(skillDir, relativePath, content)`, `deleteSkillSupportingFile(skillDir, relativePath)`: Skill supporting file management.
|
||||||
|
|
||||||
|
## Public exports (ui-auth.js)
|
||||||
|
- `createUiAuth({ password, cookieName, sessionTtlMs })`: Creates UI auth instance with methods:
|
||||||
|
- `enabled`: Boolean indicating if auth is configured.
|
||||||
|
- `requireAuth(req, res, next)`: Express middleware to enforce authentication.
|
||||||
|
- `handleSessionStatus(req, res)`: Returns authentication status.
|
||||||
|
- `handleSessionCreate(req, res)`: Handles login with rate limiting.
|
||||||
|
- `ensureSessionToken(req, res)`: Returns or creates session token.
|
||||||
|
- `dispose()`: Cleans up timers and state.
|
||||||
|
|
||||||
|
## Storage and configuration
|
||||||
|
- Provider auth: `~/.local/share/opencode/auth.json`.
|
||||||
|
- User config: `~/.config/opencode/opencode.json`.
|
||||||
|
- Project config: `<workingDirectory>/.opencode/opencode.json` or `opencode.json`.
|
||||||
|
- Custom config: `OPENCODE_CONFIG` env var path.
|
||||||
|
- Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars.
|
||||||
|
|
||||||
|
## Notes for contributors
|
||||||
|
- This module serves as foundation for OpenCode-related server utilities.
|
||||||
|
- Index.js is currently a baseline placeholder; direct imports use submodule paths.
|
||||||
|
- All file writes include automatic backup before modification.
|
||||||
|
- Config merging follows priority: custom > project > user.
|
||||||
|
- UI auth uses scrypt for password hashing with constant-time comparison.
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
CONFIG_FILE,
|
||||||
|
AGENT_DIR,
|
||||||
|
AGENT_SCOPE,
|
||||||
|
ensureDirs,
|
||||||
|
parseMdFile,
|
||||||
|
writeMdFile,
|
||||||
|
readConfigLayers,
|
||||||
|
readConfigFile,
|
||||||
|
writeConfig,
|
||||||
|
getJsonEntrySource,
|
||||||
|
getJsonWriteTarget,
|
||||||
|
isPromptFileReference,
|
||||||
|
resolvePromptFilePath,
|
||||||
|
writePromptFile,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
// ============== AGENT SCOPE HELPERS ==============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure project-level agent directory exists
|
||||||
|
*/
|
||||||
|
function ensureProjectAgentDir(workingDirectory) {
|
||||||
|
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
|
||||||
|
if (!fs.existsSync(projectAgentDir)) {
|
||||||
|
fs.mkdirSync(projectAgentDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
|
||||||
|
if (!fs.existsSync(legacyProjectAgentDir)) {
|
||||||
|
fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return projectAgentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project-level agent path
|
||||||
|
*/
|
||||||
|
function getProjectAgentPath(workingDirectory, agentName) {
|
||||||
|
const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`);
|
||||||
|
const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-level agent path
|
||||||
|
*/
|
||||||
|
function getUserAgentPath(agentName) {
|
||||||
|
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
||||||
|
const legacyPath = path.join(AGENT_DIR, '..', 'agent', `${agentName}.md`);
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine agent scope based on where the .md file exists
|
||||||
|
* Priority: project level > user level > null (built-in only)
|
||||||
|
*/
|
||||||
|
function getAgentScope(agentName, workingDirectory) {
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
||||||
|
if (fs.existsSync(projectPath)) {
|
||||||
|
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPath = getUserAgentPath(agentName);
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
return { scope: AGENT_SCOPE.USER, path: userPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scope: null, path: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path where an agent should be written based on scope
|
||||||
|
*/
|
||||||
|
function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
||||||
|
// For updates: check existing location first (project takes precedence)
|
||||||
|
const existing = getAgentScope(agentName, workingDirectory);
|
||||||
|
if (existing.path) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new agents or built-in overrides: use requested scope or default to user
|
||||||
|
const scope = requestedScope || AGENT_SCOPE.USER;
|
||||||
|
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
||||||
|
return {
|
||||||
|
scope: AGENT_SCOPE.PROJECT,
|
||||||
|
path: getProjectAgentPath(workingDirectory, agentName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: AGENT_SCOPE.USER,
|
||||||
|
path: getUserAgentPath(agentName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect where an agent's permission field is currently defined
|
||||||
|
* Priority: project .md > user .md > project JSON > user JSON
|
||||||
|
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
|
||||||
|
*/
|
||||||
|
function getAgentPermissionSource(agentName, workingDirectory) {
|
||||||
|
// Check project-level .md first
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
||||||
|
if (fs.existsSync(projectMdPath)) {
|
||||||
|
const { frontmatter } = parseMdFile(projectMdPath);
|
||||||
|
if (frontmatter.permission !== undefined) {
|
||||||
|
return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user-level .md
|
||||||
|
const userMdPath = getUserAgentPath(agentName);
|
||||||
|
if (fs.existsSync(userMdPath)) {
|
||||||
|
const { frontmatter } = parseMdFile(userMdPath);
|
||||||
|
if (frontmatter.permission !== undefined) {
|
||||||
|
return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON layers (project > user)
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
|
||||||
|
// Project opencode.json
|
||||||
|
const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission;
|
||||||
|
if (projectJsonPermission !== undefined && layers.paths.projectPath) {
|
||||||
|
return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// User opencode.json
|
||||||
|
const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission;
|
||||||
|
if (userJsonPermission !== undefined) {
|
||||||
|
return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom config (env var)
|
||||||
|
const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission;
|
||||||
|
if (customJsonPermission !== undefined && layers.paths.customPath) {
|
||||||
|
return { source: 'json', scope: 'custom', path: layers.paths.customPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: null, scope: null, path: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName) {
|
||||||
|
if (!permissionSource.source || !permissionSource.path) {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingPermission = null;
|
||||||
|
if (permissionSource.source === 'md') {
|
||||||
|
const { frontmatter } = parseMdFile(permissionSource.path);
|
||||||
|
existingPermission = frontmatter.permission;
|
||||||
|
} else if (permissionSource.source === 'json') {
|
||||||
|
const config = readConfigFile(permissionSource.path);
|
||||||
|
existingPermission = config?.agent?.[agentName]?.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingPermission || typeof existingPermission === 'string') {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPermission == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof newPermission === 'string') {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonWildcardPatterns = {};
|
||||||
|
for (const [permKey, permValue] of Object.entries(existingPermission)) {
|
||||||
|
if (permKey === '*') continue;
|
||||||
|
|
||||||
|
if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) {
|
||||||
|
const nonWildcards = {};
|
||||||
|
for (const [pattern, action] of Object.entries(permValue)) {
|
||||||
|
if (pattern !== '*') {
|
||||||
|
nonWildcards[pattern] = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(nonWildcards).length > 0) {
|
||||||
|
nonWildcardPatterns[permKey] = nonWildcards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(nonWildcardPatterns).length === 0) {
|
||||||
|
return newPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = { ...newPermission };
|
||||||
|
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
|
||||||
|
const newValue = merged[permKey];
|
||||||
|
if (typeof newValue === 'string') {
|
||||||
|
merged[permKey] = { '*': newValue, ...patterns };
|
||||||
|
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||||
|
merged[permKey] = { ...patterns, ...newValue };
|
||||||
|
} else {
|
||||||
|
const existingValue = existingPermission[permKey];
|
||||||
|
if (typeof existingValue === 'object' && existingValue !== null) {
|
||||||
|
const wildcard = existingValue['*'];
|
||||||
|
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentSources(agentName, workingDirectory) {
|
||||||
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||||
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||||
|
|
||||||
|
const userPath = getUserAgentPath(agentName);
|
||||||
|
const userExists = fs.existsSync(userPath);
|
||||||
|
|
||||||
|
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
|
||||||
|
const mdExists = !!mdPath;
|
||||||
|
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||||
|
const jsonSection = jsonSource.section;
|
||||||
|
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
||||||
|
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
||||||
|
|
||||||
|
const sources = {
|
||||||
|
md: {
|
||||||
|
exists: mdExists,
|
||||||
|
path: mdPath,
|
||||||
|
scope: mdScope,
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
exists: jsonSource.exists,
|
||||||
|
path: jsonPath,
|
||||||
|
scope: jsonSource.exists ? jsonScope : null,
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
projectMd: {
|
||||||
|
exists: projectExists,
|
||||||
|
path: projectPath
|
||||||
|
},
|
||||||
|
userMd: {
|
||||||
|
exists: userExists,
|
||||||
|
path: userPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mdExists) {
|
||||||
|
const { frontmatter, body } = parseMdFile(mdPath);
|
||||||
|
sources.md.fields = Object.keys(frontmatter);
|
||||||
|
if (body) {
|
||||||
|
sources.md.fields.push('prompt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonSection) {
|
||||||
|
sources.json.fields = Object.keys(jsonSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentConfig(agentName, workingDirectory) {
|
||||||
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||||
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||||
|
|
||||||
|
const userPath = getUserAgentPath(agentName);
|
||||||
|
const userExists = fs.existsSync(userPath);
|
||||||
|
|
||||||
|
if (projectExists || userExists) {
|
||||||
|
const mdPath = projectExists ? projectPath : userPath;
|
||||||
|
const { frontmatter, body } = parseMdFile(mdPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'md',
|
||||||
|
scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER,
|
||||||
|
config: {
|
||||||
|
...frontmatter,
|
||||||
|
...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||||
|
|
||||||
|
if (jsonSource.exists && jsonSource.section) {
|
||||||
|
const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
||||||
|
return {
|
||||||
|
source: 'json',
|
||||||
|
scope,
|
||||||
|
config: { ...jsonSource.section },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'none',
|
||||||
|
scope: null,
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgent(agentName, config, workingDirectory, scope) {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||||
|
const userPath = getUserAgentPath(agentName);
|
||||||
|
|
||||||
|
if (projectPath && fs.existsSync(projectPath)) {
|
||||||
|
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||||
|
if (jsonSource.exists) {
|
||||||
|
throw new Error(`Agent ${agentName} already exists in opencode.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPath;
|
||||||
|
let targetScope;
|
||||||
|
|
||||||
|
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
||||||
|
ensureProjectAgentDir(workingDirectory);
|
||||||
|
targetPath = projectPath;
|
||||||
|
targetScope = AGENT_SCOPE.PROJECT;
|
||||||
|
} else {
|
||||||
|
targetPath = userPath;
|
||||||
|
targetScope = AGENT_SCOPE.USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
|
||||||
|
|
||||||
|
writeMdFile(targetPath, frontmatter, prompt || '');
|
||||||
|
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAgent(agentName, updates, workingDirectory) {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory);
|
||||||
|
const mdExists = mdPath && fs.existsSync(mdPath);
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||||
|
const jsonSection = jsonSource.section;
|
||||||
|
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
||||||
|
const jsonTarget = jsonSource.exists
|
||||||
|
? { config: jsonSource.config, path: jsonSource.path }
|
||||||
|
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
||||||
|
let config = jsonTarget.config || {};
|
||||||
|
|
||||||
|
const isBuiltinOverride = !mdExists && !hasJsonFields;
|
||||||
|
|
||||||
|
let targetPath = mdPath;
|
||||||
|
let targetScope = scope;
|
||||||
|
|
||||||
|
if (!mdExists && isBuiltinOverride) {
|
||||||
|
targetPath = getUserAgentPath(agentName);
|
||||||
|
targetScope = AGENT_SCOPE.USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
|
||||||
|
|
||||||
|
let mdModified = false;
|
||||||
|
let jsonModified = false;
|
||||||
|
const creatingNewMd = isBuiltinOverride;
|
||||||
|
|
||||||
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
|
if (field === 'prompt') {
|
||||||
|
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||||
|
|
||||||
|
if (mdExists || creatingNewMd) {
|
||||||
|
if (mdData) {
|
||||||
|
mdData.body = normalizedValue;
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else if (isPromptFileReference(jsonSection?.prompt)) {
|
||||||
|
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
|
||||||
|
if (!promptFilePath) {
|
||||||
|
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
|
||||||
|
}
|
||||||
|
writePromptFile(promptFilePath, normalizedValue);
|
||||||
|
continue;
|
||||||
|
} else if (isPromptFileReference(normalizedValue)) {
|
||||||
|
if (!config.agent) config.agent = {};
|
||||||
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
|
config.agent[agentName].prompt = normalizedValue;
|
||||||
|
jsonModified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.agent) config.agent = {};
|
||||||
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
|
config.agent[agentName].prompt = normalizedValue;
|
||||||
|
jsonModified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'permission') {
|
||||||
|
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
|
||||||
|
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName);
|
||||||
|
|
||||||
|
if (permissionSource.source === 'md') {
|
||||||
|
const existingMdData = parseMdFile(permissionSource.path);
|
||||||
|
existingMdData.frontmatter.permission = newPermission;
|
||||||
|
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
|
||||||
|
console.log(`Updated permission in .md file: ${permissionSource.path}`);
|
||||||
|
} else if (permissionSource.source === 'json') {
|
||||||
|
const existingConfig = readConfigFile(permissionSource.path);
|
||||||
|
if (!existingConfig.agent) existingConfig.agent = {};
|
||||||
|
if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {};
|
||||||
|
existingConfig.agent[agentName].permission = newPermission;
|
||||||
|
writeConfig(existingConfig, permissionSource.path);
|
||||||
|
console.log(`Updated permission in JSON: ${permissionSource.path}`);
|
||||||
|
} else {
|
||||||
|
if ((mdExists || creatingNewMd) && mdData) {
|
||||||
|
mdData.frontmatter.permission = newPermission;
|
||||||
|
mdModified = true;
|
||||||
|
} else if (hasJsonFields) {
|
||||||
|
if (!config.agent) config.agent = {};
|
||||||
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
|
config.agent[agentName].permission = newPermission;
|
||||||
|
jsonModified = true;
|
||||||
|
} else {
|
||||||
|
const writeTarget = workingDirectory
|
||||||
|
? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath }
|
||||||
|
: { config: layers.userConfig || {}, path: layers.paths.userPath };
|
||||||
|
if (!writeTarget.config.agent) writeTarget.config.agent = {};
|
||||||
|
if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {};
|
||||||
|
writeTarget.config.agent[agentName].permission = newPermission;
|
||||||
|
writeConfig(writeTarget.config, writeTarget.path);
|
||||||
|
console.log(`Created permission in JSON: ${writeTarget.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
||||||
|
const inJson = jsonSection?.[field] !== undefined;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
if (mdData && inMd) {
|
||||||
|
delete mdData.frontmatter[field];
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inJson && config.agent?.[agentName]) {
|
||||||
|
delete config.agent[agentName][field];
|
||||||
|
|
||||||
|
if (Object.keys(config.agent[agentName]).length === 0) {
|
||||||
|
delete config.agent[agentName];
|
||||||
|
}
|
||||||
|
if (Object.keys(config.agent).length === 0) {
|
||||||
|
delete config.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonModified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inJson) {
|
||||||
|
if (!config.agent) config.agent = {};
|
||||||
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
|
config.agent[agentName][field] = value;
|
||||||
|
jsonModified = true;
|
||||||
|
} else if (inMd || creatingNewMd) {
|
||||||
|
if (mdData) {
|
||||||
|
mdData.frontmatter[field] = value;
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ((mdExists || creatingNewMd) && mdData) {
|
||||||
|
mdData.frontmatter[field] = value;
|
||||||
|
mdModified = true;
|
||||||
|
} else {
|
||||||
|
if (!config.agent) config.agent = {};
|
||||||
|
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||||
|
config.agent[agentName][field] = value;
|
||||||
|
jsonModified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mdModified && mdData) {
|
||||||
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonModified) {
|
||||||
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAgent(agentName, workingDirectory) {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
||||||
|
if (fs.existsSync(projectPath)) {
|
||||||
|
fs.unlinkSync(projectPath);
|
||||||
|
console.log(`Deleted project-level agent .md file: ${projectPath}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPath = getUserAgentPath(agentName);
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
fs.unlinkSync(userPath);
|
||||||
|
console.log(`Deleted user-level agent .md file: ${userPath}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||||
|
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
|
||||||
|
if (!jsonSource.config.agent) jsonSource.config.agent = {};
|
||||||
|
delete jsonSource.config.agent[agentName];
|
||||||
|
writeConfig(jsonSource.config, jsonSource.path);
|
||||||
|
console.log(`Removed agent from opencode.json: ${agentName}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
|
||||||
|
const targetConfig = jsonTarget.config || {};
|
||||||
|
if (!targetConfig.agent) targetConfig.agent = {};
|
||||||
|
targetConfig.agent[agentName] = { disable: true };
|
||||||
|
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
|
||||||
|
console.log(`Disabled built-in agent: ${agentName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ensureProjectAgentDir,
|
||||||
|
getProjectAgentPath,
|
||||||
|
getUserAgentPath,
|
||||||
|
getAgentScope,
|
||||||
|
getAgentWritePath,
|
||||||
|
getAgentPermissionSource,
|
||||||
|
getAgentSources,
|
||||||
|
getAgentConfig,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
deleteAgent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
CONFIG_FILE,
|
||||||
|
OPENCODE_CONFIG_DIR,
|
||||||
|
COMMAND_DIR,
|
||||||
|
COMMAND_SCOPE,
|
||||||
|
ensureDirs,
|
||||||
|
parseMdFile,
|
||||||
|
writeMdFile,
|
||||||
|
readConfigLayers,
|
||||||
|
writeConfig,
|
||||||
|
getJsonEntrySource,
|
||||||
|
getJsonWriteTarget,
|
||||||
|
isPromptFileReference,
|
||||||
|
resolvePromptFilePath,
|
||||||
|
writePromptFile,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
// ============== COMMAND SCOPE HELPERS ==============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure project-level command directory exists
|
||||||
|
*/
|
||||||
|
function ensureProjectCommandDir(workingDirectory) {
|
||||||
|
const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands');
|
||||||
|
if (!fs.existsSync(projectCommandDir)) {
|
||||||
|
fs.mkdirSync(projectCommandDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command');
|
||||||
|
if (!fs.existsSync(legacyProjectCommandDir)) {
|
||||||
|
fs.mkdirSync(legacyProjectCommandDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return projectCommandDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project-level command path
|
||||||
|
*/
|
||||||
|
function getProjectCommandPath(workingDirectory, commandName) {
|
||||||
|
const pluralPath = path.join(workingDirectory, '.opencode', 'commands', `${commandName}.md`);
|
||||||
|
const legacyPath = path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`);
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-level command path
|
||||||
|
*/
|
||||||
|
function getUserCommandPath(commandName) {
|
||||||
|
const pluralPath = path.join(COMMAND_DIR, `${commandName}.md`);
|
||||||
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'command', `${commandName}.md`);
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine command scope based on where the .md file exists
|
||||||
|
* Priority: project level > user level > null (built-in only)
|
||||||
|
*/
|
||||||
|
function getCommandScope(commandName, workingDirectory) {
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
||||||
|
if (fs.existsSync(projectPath)) {
|
||||||
|
return { scope: COMMAND_SCOPE.PROJECT, path: projectPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPath = getUserCommandPath(commandName);
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
return { scope: COMMAND_SCOPE.USER, path: userPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scope: null, path: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path where a command should be written based on scope
|
||||||
|
*/
|
||||||
|
function getCommandWritePath(commandName, workingDirectory, requestedScope) {
|
||||||
|
// For updates: check existing location first (project takes precedence)
|
||||||
|
const existing = getCommandScope(commandName, workingDirectory);
|
||||||
|
if (existing.path) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new commands or built-in overrides: use requested scope or default to user
|
||||||
|
const scope = requestedScope || COMMAND_SCOPE.USER;
|
||||||
|
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
|
||||||
|
return {
|
||||||
|
scope: COMMAND_SCOPE.PROJECT,
|
||||||
|
path: getProjectCommandPath(workingDirectory, commandName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: COMMAND_SCOPE.USER,
|
||||||
|
path: getUserCommandPath(commandName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommandSources(commandName, workingDirectory) {
|
||||||
|
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
|
||||||
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||||
|
|
||||||
|
const userPath = getUserCommandPath(commandName);
|
||||||
|
const userExists = fs.existsSync(userPath);
|
||||||
|
|
||||||
|
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
|
||||||
|
const mdExists = !!mdPath;
|
||||||
|
const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null);
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||||
|
const jsonSection = jsonSource.section;
|
||||||
|
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
||||||
|
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
|
||||||
|
|
||||||
|
const sources = {
|
||||||
|
md: {
|
||||||
|
exists: mdExists,
|
||||||
|
path: mdPath,
|
||||||
|
scope: mdScope,
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
exists: jsonSource.exists,
|
||||||
|
path: jsonPath,
|
||||||
|
scope: jsonSource.exists ? jsonScope : null,
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
projectMd: {
|
||||||
|
exists: projectExists,
|
||||||
|
path: projectPath
|
||||||
|
},
|
||||||
|
userMd: {
|
||||||
|
exists: userExists,
|
||||||
|
path: userPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mdExists) {
|
||||||
|
const { frontmatter, body } = parseMdFile(mdPath);
|
||||||
|
sources.md.fields = Object.keys(frontmatter);
|
||||||
|
if (body) {
|
||||||
|
sources.md.fields.push('template');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonSection) {
|
||||||
|
sources.json.fields = Object.keys(jsonSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommand(commandName, config, workingDirectory, scope) {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
|
||||||
|
const userPath = getUserCommandPath(commandName);
|
||||||
|
|
||||||
|
if (projectPath && fs.existsSync(projectPath)) {
|
||||||
|
throw new Error(`Command ${commandName} already exists as project-level .md file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
throw new Error(`Command ${commandName} already exists as user-level .md file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||||
|
if (jsonSource.exists) {
|
||||||
|
throw new Error(`Command ${commandName} already exists in opencode.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPath;
|
||||||
|
let targetScope;
|
||||||
|
|
||||||
|
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
|
||||||
|
ensureProjectCommandDir(workingDirectory);
|
||||||
|
targetPath = projectPath;
|
||||||
|
targetScope = COMMAND_SCOPE.PROJECT;
|
||||||
|
} else {
|
||||||
|
targetPath = userPath;
|
||||||
|
targetScope = COMMAND_SCOPE.USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { template, scope: _scopeFromConfig, ...frontmatter } = config;
|
||||||
|
|
||||||
|
writeMdFile(targetPath, frontmatter, template || '');
|
||||||
|
console.log(`Created new command: ${commandName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCommand(commandName, updates, workingDirectory) {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
const { scope, path: mdPath } = getCommandWritePath(commandName, workingDirectory);
|
||||||
|
const mdExists = mdPath && fs.existsSync(mdPath);
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||||
|
const jsonSection = jsonSource.section;
|
||||||
|
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
||||||
|
const jsonTarget = jsonSource.exists
|
||||||
|
? { config: jsonSource.config, path: jsonSource.path }
|
||||||
|
: getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER);
|
||||||
|
let config = jsonTarget.config || {};
|
||||||
|
|
||||||
|
const isBuiltinOverride = !mdExists && !hasJsonFields;
|
||||||
|
|
||||||
|
let targetPath = mdPath;
|
||||||
|
let targetScope = scope;
|
||||||
|
|
||||||
|
if (!mdExists && isBuiltinOverride) {
|
||||||
|
targetPath = getUserCommandPath(commandName);
|
||||||
|
targetScope = COMMAND_SCOPE.USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
|
||||||
|
|
||||||
|
let mdModified = false;
|
||||||
|
let jsonModified = false;
|
||||||
|
const creatingNewMd = isBuiltinOverride;
|
||||||
|
|
||||||
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
|
if (field === 'template') {
|
||||||
|
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||||
|
|
||||||
|
if (mdExists || creatingNewMd) {
|
||||||
|
if (mdData) {
|
||||||
|
mdData.body = normalizedValue;
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else if (isPromptFileReference(jsonSection?.template)) {
|
||||||
|
const templateFilePath = resolvePromptFilePath(jsonSection.template);
|
||||||
|
if (!templateFilePath) {
|
||||||
|
throw new Error(`Invalid template file reference for command ${commandName}`);
|
||||||
|
}
|
||||||
|
writePromptFile(templateFilePath, normalizedValue);
|
||||||
|
continue;
|
||||||
|
} else if (isPromptFileReference(normalizedValue)) {
|
||||||
|
if (!config.command) config.command = {};
|
||||||
|
if (!config.command[commandName]) config.command[commandName] = {};
|
||||||
|
config.command[commandName].template = normalizedValue;
|
||||||
|
jsonModified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.command) config.command = {};
|
||||||
|
if (!config.command[commandName]) config.command[commandName] = {};
|
||||||
|
config.command[commandName].template = normalizedValue;
|
||||||
|
jsonModified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
||||||
|
const inJson = jsonSection?.[field] !== undefined;
|
||||||
|
|
||||||
|
if (inJson) {
|
||||||
|
if (!config.command) config.command = {};
|
||||||
|
if (!config.command[commandName]) config.command[commandName] = {};
|
||||||
|
config.command[commandName][field] = value;
|
||||||
|
jsonModified = true;
|
||||||
|
} else if (inMd || creatingNewMd) {
|
||||||
|
if (mdData) {
|
||||||
|
mdData.frontmatter[field] = value;
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ((mdExists || creatingNewMd) && mdData) {
|
||||||
|
mdData.frontmatter[field] = value;
|
||||||
|
mdModified = true;
|
||||||
|
} else {
|
||||||
|
if (!config.command) config.command = {};
|
||||||
|
if (!config.command[commandName]) config.command[commandName] = {};
|
||||||
|
config.command[commandName][field] = value;
|
||||||
|
jsonModified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mdModified && mdData) {
|
||||||
|
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonModified) {
|
||||||
|
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCommand(commandName, workingDirectory) {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
||||||
|
if (fs.existsSync(projectPath)) {
|
||||||
|
fs.unlinkSync(projectPath);
|
||||||
|
console.log(`Deleted project-level command .md file: ${projectPath}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPath = getUserCommandPath(commandName);
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
fs.unlinkSync(userPath);
|
||||||
|
console.log(`Deleted user-level command .md file: ${userPath}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||||
|
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
|
||||||
|
if (!jsonSource.config.command) jsonSource.config.command = {};
|
||||||
|
delete jsonSource.config.command[commandName];
|
||||||
|
writeConfig(jsonSource.config, jsonSource.path);
|
||||||
|
console.log(`Removed command from opencode.json: ${commandName}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error(`Command "${commandName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ensureProjectCommandDir,
|
||||||
|
getProjectCommandPath,
|
||||||
|
getUserCommandPath,
|
||||||
|
getCommandScope,
|
||||||
|
getCommandWritePath,
|
||||||
|
getCommandSources,
|
||||||
|
createCommand,
|
||||||
|
updateCommand,
|
||||||
|
deleteCommand,
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export {
|
||||||
|
AGENT_DIR,
|
||||||
|
COMMAND_DIR,
|
||||||
|
SKILL_DIR,
|
||||||
|
CONFIG_FILE,
|
||||||
|
AGENT_SCOPE,
|
||||||
|
COMMAND_SCOPE,
|
||||||
|
SKILL_SCOPE,
|
||||||
|
readConfig,
|
||||||
|
writeConfig,
|
||||||
|
readSkillSupportingFile,
|
||||||
|
writeSkillSupportingFile,
|
||||||
|
deleteSkillSupportingFile,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getAgentScope,
|
||||||
|
getAgentPermissionSource,
|
||||||
|
getAgentSources,
|
||||||
|
getAgentConfig,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
deleteAgent,
|
||||||
|
} from './agents.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getCommandScope,
|
||||||
|
getCommandSources,
|
||||||
|
createCommand,
|
||||||
|
updateCommand,
|
||||||
|
deleteCommand,
|
||||||
|
} from './commands.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getSkillSources,
|
||||||
|
getSkillScope,
|
||||||
|
discoverSkills,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
deleteSkill,
|
||||||
|
} from './skills.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getProviderSources,
|
||||||
|
removeProviderConfig,
|
||||||
|
} from './providers.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
readAuthFile,
|
||||||
|
writeAuthFile,
|
||||||
|
removeProviderAuth,
|
||||||
|
getProviderAuth,
|
||||||
|
listProviderAuths,
|
||||||
|
AUTH_FILE,
|
||||||
|
OPENCODE_DATA_DIR,
|
||||||
|
} from './auth.js';
|
||||||
|
|
||||||
|
export { createUiAuth } from './ui-auth.js';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
CONFIG_FILE,
|
||||||
|
readConfigLayers,
|
||||||
|
isPlainObject,
|
||||||
|
getConfigForPath,
|
||||||
|
writeConfig,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
function getProviderSources(providerId, workingDirectory) {
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||||
|
|
||||||
|
const customProviders = isPlainObject(customConfig?.provider) ? customConfig.provider : {};
|
||||||
|
const customProvidersAlias = isPlainObject(customConfig?.providers) ? customConfig.providers : {};
|
||||||
|
const projectProviders = isPlainObject(projectConfig?.provider) ? projectConfig.provider : {};
|
||||||
|
const projectProvidersAlias = isPlainObject(projectConfig?.providers) ? projectConfig.providers : {};
|
||||||
|
const userProviders = isPlainObject(userConfig?.provider) ? userConfig.provider : {};
|
||||||
|
const userProvidersAlias = isPlainObject(userConfig?.providers) ? userConfig.providers : {};
|
||||||
|
|
||||||
|
const customExists =
|
||||||
|
Object.prototype.hasOwnProperty.call(customProviders, providerId) ||
|
||||||
|
Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId);
|
||||||
|
const projectExists =
|
||||||
|
Object.prototype.hasOwnProperty.call(projectProviders, providerId) ||
|
||||||
|
Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId);
|
||||||
|
const userExists =
|
||||||
|
Object.prototype.hasOwnProperty.call(userProviders, providerId) ||
|
||||||
|
Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: {
|
||||||
|
auth: { exists: false },
|
||||||
|
user: { exists: userExists, path: paths.userPath },
|
||||||
|
project: { exists: projectExists, path: paths.projectPath || null },
|
||||||
|
custom: { exists: customExists, path: paths.customPath }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||||
|
if (!providerId || typeof providerId !== 'string') {
|
||||||
|
throw new Error('Provider ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
let targetPath = layers.paths.userPath;
|
||||||
|
|
||||||
|
if (scope === 'project') {
|
||||||
|
if (!workingDirectory) {
|
||||||
|
throw new Error('Working directory is required for project scope');
|
||||||
|
}
|
||||||
|
targetPath = layers.paths.projectPath || targetPath;
|
||||||
|
} else if (scope === 'custom') {
|
||||||
|
if (!layers.paths.customPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
targetPath = layers.paths.customPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetConfig = getConfigForPath(layers, targetPath);
|
||||||
|
const providerConfig = isPlainObject(targetConfig.provider) ? targetConfig.provider : {};
|
||||||
|
const providersConfig = isPlainObject(targetConfig.providers) ? targetConfig.providers : {};
|
||||||
|
const removedProvider = Object.prototype.hasOwnProperty.call(providerConfig, providerId);
|
||||||
|
const removedProviders = Object.prototype.hasOwnProperty.call(providersConfig, providerId);
|
||||||
|
|
||||||
|
if (!removedProvider && !removedProviders) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedProvider) {
|
||||||
|
delete providerConfig[providerId];
|
||||||
|
if (Object.keys(providerConfig).length === 0) {
|
||||||
|
delete targetConfig.provider;
|
||||||
|
} else {
|
||||||
|
targetConfig.provider = providerConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedProviders) {
|
||||||
|
delete providersConfig[providerId];
|
||||||
|
if (Object.keys(providersConfig).length === 0) {
|
||||||
|
delete targetConfig.providers;
|
||||||
|
} else {
|
||||||
|
targetConfig.providers = providersConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeConfig(targetConfig, targetPath || CONFIG_FILE);
|
||||||
|
console.log(`Removed provider ${providerId} from config: ${targetPath}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getProviderSources,
|
||||||
|
removeProviderConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import yaml from 'yaml';
|
||||||
|
import { parse as parseJsonc } from 'jsonc-parser';
|
||||||
|
|
||||||
|
// ============== PATH CONSTANTS ==============
|
||||||
|
|
||||||
|
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||||
|
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
||||||
|
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
||||||
|
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
||||||
|
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
||||||
|
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||||
|
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||||
|
: null;
|
||||||
|
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
||||||
|
|
||||||
|
// ============== SCOPE TYPE CONSTANTS ==============
|
||||||
|
|
||||||
|
const AGENT_SCOPE = {
|
||||||
|
USER: 'user',
|
||||||
|
PROJECT: 'project'
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMAND_SCOPE = {
|
||||||
|
USER: 'user',
|
||||||
|
PROJECT: 'project'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL_SCOPE = {
|
||||||
|
USER: 'user',
|
||||||
|
PROJECT: 'project'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============== DIRECTORY OPERATIONS ==============
|
||||||
|
|
||||||
|
function ensureDirs() {
|
||||||
|
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(AGENT_DIR)) {
|
||||||
|
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(COMMAND_DIR)) {
|
||||||
|
fs.mkdirSync(COMMAND_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(SKILL_DIR)) {
|
||||||
|
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== MARKDOWN FILE OPERATIONS ==============
|
||||||
|
|
||||||
|
function parseMdFile(filePath) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { frontmatter: {}, body: content.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
let frontmatter = {};
|
||||||
|
try {
|
||||||
|
frontmatter = yaml.parse(match[1]) || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
|
||||||
|
frontmatter = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = match[2].trim();
|
||||||
|
return { frontmatter, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMdFile(filePath, frontmatter, body) {
|
||||||
|
try {
|
||||||
|
const cleanedFrontmatter = Object.fromEntries(
|
||||||
|
Object.entries(frontmatter).filter(([, value]) => value != null)
|
||||||
|
);
|
||||||
|
const yamlStr = yaml.stringify(cleanedFrontmatter);
|
||||||
|
const content = `---\n${yamlStr}---\n\n${body}`;
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
console.log(`Successfully wrote markdown file: ${filePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to write markdown file ${filePath}:`, error);
|
||||||
|
throw new Error('Failed to write agent markdown file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== CONFIG FILE OPERATIONS ==============
|
||||||
|
|
||||||
|
function getProjectConfigCandidates(workingDirectory) {
|
||||||
|
if (!workingDirectory) return [];
|
||||||
|
return [
|
||||||
|
path.join(workingDirectory, 'opencode.json'),
|
||||||
|
path.join(workingDirectory, 'opencode.jsonc'),
|
||||||
|
path.join(workingDirectory, '.opencode', 'opencode.json'),
|
||||||
|
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectConfigPath(workingDirectory) {
|
||||||
|
if (!workingDirectory) return null;
|
||||||
|
|
||||||
|
const candidates = getProjectConfigCandidates(workingDirectory);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPaths(workingDirectory) {
|
||||||
|
return {
|
||||||
|
userPath: CONFIG_FILE,
|
||||||
|
projectPath: getProjectConfigPath(workingDirectory),
|
||||||
|
customPath: CUSTOM_CONFIG_FILE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfigFile(filePath) {
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const normalized = content.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parseJsonc(normalized, [], { allowTrailingComma: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to read config file: ${filePath}`, error);
|
||||||
|
throw new Error('Failed to read OpenCode configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfigs(base, override) {
|
||||||
|
if (!isPlainObject(base) || !isPlainObject(override)) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
const result = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
if (key in result) {
|
||||||
|
const baseValue = result[key];
|
||||||
|
if (isPlainObject(baseValue) && isPlainObject(value)) {
|
||||||
|
result[key] = mergeConfigs(baseValue, value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfigLayers(workingDirectory) {
|
||||||
|
const { userPath, projectPath, customPath } = getConfigPaths(workingDirectory);
|
||||||
|
const userConfig = readConfigFile(userPath);
|
||||||
|
const projectConfig = readConfigFile(projectPath);
|
||||||
|
const customConfig = readConfigFile(customPath);
|
||||||
|
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userConfig,
|
||||||
|
projectConfig,
|
||||||
|
customConfig,
|
||||||
|
mergedConfig,
|
||||||
|
paths: { userPath, projectPath, customPath }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(workingDirectory) {
|
||||||
|
return readConfigLayers(workingDirectory).mergedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigForPath(layers, targetPath) {
|
||||||
|
if (!targetPath) {
|
||||||
|
return layers.userConfig;
|
||||||
|
}
|
||||||
|
if (layers.paths.customPath && targetPath === layers.paths.customPath) {
|
||||||
|
return layers.customConfig;
|
||||||
|
}
|
||||||
|
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) {
|
||||||
|
return layers.projectConfig;
|
||||||
|
}
|
||||||
|
return layers.userConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(config, filePath = CONFIG_FILE) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const backupFile = `${filePath}.openchamber.backup`;
|
||||||
|
fs.copyFileSync(filePath, backupFile);
|
||||||
|
console.log(`Created config backup: ${backupFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
console.log(`Successfully wrote config file: ${filePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to write config file: ${filePath}`, error);
|
||||||
|
throw new Error('Failed to write OpenCode configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJsonEntrySource(layers, sectionKey, entryName) {
|
||||||
|
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||||
|
const customSection = customConfig?.[sectionKey]?.[entryName];
|
||||||
|
if (customSection !== undefined) {
|
||||||
|
return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSection = projectConfig?.[sectionKey]?.[entryName];
|
||||||
|
if (projectSection !== undefined) {
|
||||||
|
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSection = userConfig?.[sectionKey]?.[entryName];
|
||||||
|
if (userSection !== undefined) {
|
||||||
|
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { section: null, config: null, path: null, exists: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJsonWriteTarget(layers, preferredScope) {
|
||||||
|
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||||
|
if (paths.customPath) {
|
||||||
|
return { config: customConfig, path: paths.customPath };
|
||||||
|
}
|
||||||
|
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
|
||||||
|
return { config: projectConfig, path: paths.projectPath };
|
||||||
|
}
|
||||||
|
if (paths.projectPath) {
|
||||||
|
return { config: projectConfig, path: paths.projectPath };
|
||||||
|
}
|
||||||
|
return { config: userConfig, path: paths.userPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== GIT/WORKTREE HELPERS ==============
|
||||||
|
|
||||||
|
function getAncestors(startDir, stopDir) {
|
||||||
|
if (!startDir) return [];
|
||||||
|
const result = [];
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
result.push(current);
|
||||||
|
if (resolvedStop && current === resolvedStop) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWorktreeRoot(startDir) {
|
||||||
|
if (!startDir) return null;
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (fs.existsSync(path.join(current, '.git'))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== PROMPT FILE HELPERS ==============
|
||||||
|
|
||||||
|
function isPromptFileReference(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return PROMPT_FILE_PATTERN.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePromptFilePath(reference) {
|
||||||
|
const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null;
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let target = match[1].trim();
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.startsWith('./')) {
|
||||||
|
target = target.slice(2);
|
||||||
|
target = path.join(OPENCODE_CONFIG_DIR, target);
|
||||||
|
} else if (!path.isAbsolute(target)) {
|
||||||
|
target = path.join(OPENCODE_CONFIG_DIR, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePromptFile(filePath, content) {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content ?? '', 'utf8');
|
||||||
|
console.log(`Updated prompt file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== SKILL FILE OPERATIONS ==============
|
||||||
|
|
||||||
|
function walkSkillMdFiles(rootDir) {
|
||||||
|
if (!rootDir || !fs.existsSync(rootDir)) return [];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const walk = (dir) => {
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isFile() && entry.name === 'SKILL.md') {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(rootDir);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = parseMdFile(skillMdPath);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof parsed.frontmatter?.name === 'string'
|
||||||
|
? parsed.frontmatter.name.trim()
|
||||||
|
: '';
|
||||||
|
const description = typeof parsed.frontmatter?.description === 'string'
|
||||||
|
? parsed.frontmatter.description
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsMap.set(name, {
|
||||||
|
name,
|
||||||
|
path: skillMdPath,
|
||||||
|
scope,
|
||||||
|
source,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSkillSearchDirectories(workingDirectory) {
|
||||||
|
const directories = [];
|
||||||
|
const pushDir = (dir) => {
|
||||||
|
if (!dir) return;
|
||||||
|
const resolved = path.resolve(dir);
|
||||||
|
if (!directories.includes(resolved)) {
|
||||||
|
directories.push(resolved);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pushDir(OPENCODE_CONFIG_DIR);
|
||||||
|
|
||||||
|
if (workingDirectory) {
|
||||||
|
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||||
|
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
|
||||||
|
.map((dir) => path.join(dir, '.opencode'));
|
||||||
|
projectDirs.forEach(pushDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
pushDir(path.join(os.homedir(), '.opencode'));
|
||||||
|
|
||||||
|
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||||
|
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||||
|
: null;
|
||||||
|
pushDir(customConfigDir);
|
||||||
|
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSkillSupportingFiles(skillDir) {
|
||||||
|
if (!fs.existsSync(skillDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
function walkDir(dir, relativePath = '') {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkDir(fullPath, relPath);
|
||||||
|
} else if (entry.name !== 'SKILL.md') {
|
||||||
|
files.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: relPath,
|
||||||
|
fullPath: fullPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkDir(skillDir);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSkillSupportingFile(skillDir, relativePath) {
|
||||||
|
const fullPath = path.join(skillDir, relativePath);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return fs.readFileSync(fullPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSkillSupportingFile(skillDir, relativePath, content) {
|
||||||
|
const fullPath = path.join(skillDir, relativePath);
|
||||||
|
const dir = path.dirname(fullPath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSkillSupportingFile(skillDir, relativePath) {
|
||||||
|
const fullPath = path.join(skillDir, relativePath);
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
let parentDir = path.dirname(fullPath);
|
||||||
|
while (parentDir !== skillDir) {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(parentDir);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
fs.rmdirSync(parentDir);
|
||||||
|
parentDir = path.dirname(parentDir);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
OPENCODE_CONFIG_DIR,
|
||||||
|
AGENT_DIR,
|
||||||
|
COMMAND_DIR,
|
||||||
|
SKILL_DIR,
|
||||||
|
CONFIG_FILE,
|
||||||
|
CUSTOM_CONFIG_FILE,
|
||||||
|
PROMPT_FILE_PATTERN,
|
||||||
|
AGENT_SCOPE,
|
||||||
|
COMMAND_SCOPE,
|
||||||
|
SKILL_SCOPE,
|
||||||
|
ensureDirs,
|
||||||
|
parseMdFile,
|
||||||
|
writeMdFile,
|
||||||
|
getProjectConfigCandidates,
|
||||||
|
getProjectConfigPath,
|
||||||
|
getConfigPaths,
|
||||||
|
readConfigFile,
|
||||||
|
isPlainObject,
|
||||||
|
mergeConfigs,
|
||||||
|
readConfigLayers,
|
||||||
|
readConfig,
|
||||||
|
getConfigForPath,
|
||||||
|
writeConfig,
|
||||||
|
getJsonEntrySource,
|
||||||
|
getJsonWriteTarget,
|
||||||
|
getAncestors,
|
||||||
|
findWorktreeRoot,
|
||||||
|
isPromptFileReference,
|
||||||
|
resolvePromptFilePath,
|
||||||
|
writePromptFile,
|
||||||
|
walkSkillMdFiles,
|
||||||
|
addSkillFromMdFile,
|
||||||
|
resolveSkillSearchDirectories,
|
||||||
|
listSkillSupportingFiles,
|
||||||
|
readSkillSupportingFile,
|
||||||
|
writeSkillSupportingFile,
|
||||||
|
deleteSkillSupportingFile,
|
||||||
|
};
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import {
|
||||||
|
SKILL_DIR,
|
||||||
|
OPENCODE_CONFIG_DIR,
|
||||||
|
SKILL_SCOPE,
|
||||||
|
ensureDirs,
|
||||||
|
parseMdFile,
|
||||||
|
writeMdFile,
|
||||||
|
readConfigLayers,
|
||||||
|
readConfig,
|
||||||
|
walkSkillMdFiles,
|
||||||
|
addSkillFromMdFile,
|
||||||
|
resolveSkillSearchDirectories,
|
||||||
|
listSkillSupportingFiles,
|
||||||
|
readSkillSupportingFile,
|
||||||
|
writeSkillSupportingFile,
|
||||||
|
deleteSkillSupportingFile,
|
||||||
|
getAncestors,
|
||||||
|
findWorktreeRoot,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
function ensureProjectSkillDir(workingDirectory) {
|
||||||
|
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
||||||
|
if (!fs.existsSync(projectSkillDir)) {
|
||||||
|
fs.mkdirSync(projectSkillDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
||||||
|
if (!fs.existsSync(legacyProjectSkillDir)) {
|
||||||
|
fs.mkdirSync(legacyProjectSkillDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return projectSkillDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectSkillDir(workingDirectory, skillName) {
|
||||||
|
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName);
|
||||||
|
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName);
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectSkillPath(workingDirectory, skillName) {
|
||||||
|
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName, 'SKILL.md');
|
||||||
|
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName, 'SKILL.md');
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserSkillDir(skillName) {
|
||||||
|
const pluralPath = path.join(SKILL_DIR, skillName);
|
||||||
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName);
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserSkillPath(skillName) {
|
||||||
|
const pluralPath = path.join(SKILL_DIR, skillName, 'SKILL.md');
|
||||||
|
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName, 'SKILL.md');
|
||||||
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
||||||
|
return pluralPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClaudeSkillDir(workingDirectory, skillName) {
|
||||||
|
return path.join(workingDirectory, '.claude', 'skills', skillName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClaudeSkillPath(workingDirectory, skillName) {
|
||||||
|
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserAgentsSkillDir(skillName) {
|
||||||
|
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserAgentsSkillPath(skillName) {
|
||||||
|
return path.join(getUserAgentsSkillDir(skillName), 'SKILL.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectAgentsSkillDir(workingDirectory, skillName) {
|
||||||
|
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectAgentsSkillPath(workingDirectory, skillName) {
|
||||||
|
return path.join(getProjectAgentsSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillScope(skillName, workingDirectory) {
|
||||||
|
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||||
|
if (discovered?.path) {
|
||||||
|
return { scope: discovered.scope || null, path: discovered.path, source: discovered.source || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
||||||
|
if (fs.existsSync(projectPath)) {
|
||||||
|
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
|
||||||
|
if (fs.existsSync(claudePath)) {
|
||||||
|
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPath = getUserSkillPath(skillName);
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scope: null, path: null, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillWritePath(skillName, workingDirectory, requestedScope) {
|
||||||
|
const existing = getSkillScope(skillName, workingDirectory);
|
||||||
|
if (existing.path) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = requestedScope || SKILL_SCOPE.USER;
|
||||||
|
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||||
|
return {
|
||||||
|
scope: SKILL_SCOPE.PROJECT,
|
||||||
|
path: getProjectSkillPath(workingDirectory, skillName),
|
||||||
|
source: 'opencode'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: SKILL_SCOPE.USER,
|
||||||
|
path: getUserSkillPath(skillName),
|
||||||
|
source: 'opencode'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverSkills(workingDirectory) {
|
||||||
|
const skills = new Map();
|
||||||
|
|
||||||
|
for (const externalRootName of ['.claude', '.agents']) {
|
||||||
|
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
|
||||||
|
const source = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||||
|
for (const skillMdPath of walkSkillMdFiles(homeRoot)) {
|
||||||
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingDirectory) {
|
||||||
|
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||||
|
const ancestors = getAncestors(workingDirectory, worktreeRoot);
|
||||||
|
for (const ancestor of ancestors) {
|
||||||
|
for (const externalRootName of ['.claude', '.agents']) {
|
||||||
|
const source = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||||
|
const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills');
|
||||||
|
for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) {
|
||||||
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configDirectories = resolveSkillSearchDirectories(workingDirectory);
|
||||||
|
const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode'));
|
||||||
|
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||||
|
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||||
|
: null;
|
||||||
|
for (const dir of configDirectories) {
|
||||||
|
for (const subDir of ['skill', 'skills']) {
|
||||||
|
const root = path.join(dir, subDir);
|
||||||
|
for (const skillMdPath of walkSkillMdFiles(root)) {
|
||||||
|
const isUserConfigDir = dir === OPENCODE_CONFIG_DIR
|
||||||
|
|| dir === homeOpencodeDir
|
||||||
|
|| (customConfigDir && dir === customConfigDir);
|
||||||
|
const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT;
|
||||||
|
addSkillFromMdFile(skills, skillMdPath, scope, 'opencode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuredPaths = [];
|
||||||
|
try {
|
||||||
|
const config = readConfig(workingDirectory);
|
||||||
|
configuredPaths = Array.isArray(config?.skills?.paths) ? config.skills.paths : [];
|
||||||
|
} catch {
|
||||||
|
configuredPaths = [];
|
||||||
|
}
|
||||||
|
for (const skillPath of configuredPaths) {
|
||||||
|
if (typeof skillPath !== 'string' || !skillPath.trim()) continue;
|
||||||
|
const expanded = skillPath.startsWith('~/')
|
||||||
|
? path.join(os.homedir(), skillPath.slice(2))
|
||||||
|
: skillPath;
|
||||||
|
const resolved = path.isAbsolute(expanded)
|
||||||
|
? path.resolve(expanded)
|
||||||
|
: path.resolve(workingDirectory || process.cwd(), expanded);
|
||||||
|
for (const skillMdPath of walkSkillMdFiles(resolved)) {
|
||||||
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheCandidates = [];
|
||||||
|
if (process.env.XDG_CACHE_HOME) {
|
||||||
|
cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills'));
|
||||||
|
}
|
||||||
|
cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills'));
|
||||||
|
cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills'));
|
||||||
|
|
||||||
|
for (const cacheRoot of cacheCandidates) {
|
||||||
|
if (!fs.existsSync(cacheRoot)) continue;
|
||||||
|
const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const skillRoot = path.join(cacheRoot, entry.name);
|
||||||
|
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
|
||||||
|
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(skills.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||||
|
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
||||||
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||||
|
const projectDir = projectExists ? path.dirname(projectPath) : null;
|
||||||
|
|
||||||
|
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
|
||||||
|
const claudeExists = claudePath && fs.existsSync(claudePath);
|
||||||
|
const claudeDir = claudeExists ? path.dirname(claudePath) : null;
|
||||||
|
|
||||||
|
const userPath = getUserSkillPath(skillName);
|
||||||
|
const userExists = fs.existsSync(userPath);
|
||||||
|
const userDir = userExists ? path.dirname(userPath) : null;
|
||||||
|
|
||||||
|
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
||||||
|
? discoveredSkill
|
||||||
|
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||||
|
|
||||||
|
let mdPath = null;
|
||||||
|
let mdScope = null;
|
||||||
|
let mdSource = null;
|
||||||
|
let mdDir = null;
|
||||||
|
|
||||||
|
if (projectExists) {
|
||||||
|
mdPath = projectPath;
|
||||||
|
mdScope = SKILL_SCOPE.PROJECT;
|
||||||
|
mdSource = 'opencode';
|
||||||
|
mdDir = projectDir;
|
||||||
|
} else if (claudeExists) {
|
||||||
|
mdPath = claudePath;
|
||||||
|
mdScope = SKILL_SCOPE.PROJECT;
|
||||||
|
mdSource = 'claude';
|
||||||
|
mdDir = claudeDir;
|
||||||
|
} else if (userExists) {
|
||||||
|
mdPath = userPath;
|
||||||
|
mdScope = SKILL_SCOPE.USER;
|
||||||
|
mdSource = 'opencode';
|
||||||
|
mdDir = userDir;
|
||||||
|
} else if (matchedDiscovered?.path) {
|
||||||
|
mdPath = matchedDiscovered.path;
|
||||||
|
mdScope = matchedDiscovered.scope || null;
|
||||||
|
mdSource = matchedDiscovered.source || null;
|
||||||
|
mdDir = path.dirname(matchedDiscovered.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdExists = !!mdPath;
|
||||||
|
|
||||||
|
const sources = {
|
||||||
|
md: {
|
||||||
|
exists: mdExists,
|
||||||
|
path: mdPath,
|
||||||
|
dir: mdDir,
|
||||||
|
scope: mdScope,
|
||||||
|
source: mdSource,
|
||||||
|
fields: [],
|
||||||
|
supportingFiles: []
|
||||||
|
},
|
||||||
|
projectMd: {
|
||||||
|
exists: projectExists,
|
||||||
|
path: projectPath,
|
||||||
|
dir: projectDir
|
||||||
|
},
|
||||||
|
claudeMd: {
|
||||||
|
exists: claudeExists,
|
||||||
|
path: claudePath,
|
||||||
|
dir: claudeDir
|
||||||
|
},
|
||||||
|
userMd: {
|
||||||
|
exists: userExists,
|
||||||
|
path: userPath,
|
||||||
|
dir: userDir
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mdExists && mdDir) {
|
||||||
|
const { frontmatter, body } = parseMdFile(mdPath);
|
||||||
|
sources.md.fields = Object.keys(frontmatter);
|
||||||
|
sources.md.description = frontmatter.description || '';
|
||||||
|
sources.md.name = frontmatter.name || skillName;
|
||||||
|
if (body) {
|
||||||
|
sources.md.fields.push('instructions');
|
||||||
|
sources.md.instructions = body;
|
||||||
|
} else {
|
||||||
|
sources.md.instructions = '';
|
||||||
|
}
|
||||||
|
sources.md.supportingFiles = listSkillSupportingFiles(mdDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSkill(skillName, config, workingDirectory, scope) {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||||
|
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getSkillScope(skillName, workingDirectory);
|
||||||
|
if (existing.path) {
|
||||||
|
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetDir;
|
||||||
|
let targetPath;
|
||||||
|
let targetScope;
|
||||||
|
|
||||||
|
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
|
||||||
|
const requestedSource = config?.source === 'agents' ? 'agents' : 'opencode';
|
||||||
|
|
||||||
|
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||||
|
ensureProjectSkillDir(workingDirectory);
|
||||||
|
if (requestedSource === 'agents') {
|
||||||
|
targetDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||||
|
targetPath = getProjectAgentsSkillPath(workingDirectory, skillName);
|
||||||
|
} else {
|
||||||
|
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||||
|
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
||||||
|
}
|
||||||
|
targetScope = SKILL_SCOPE.PROJECT;
|
||||||
|
} else {
|
||||||
|
if (requestedSource === 'agents') {
|
||||||
|
targetDir = getUserAgentsSkillDir(skillName);
|
||||||
|
targetPath = getUserAgentsSkillPath(skillName);
|
||||||
|
} else {
|
||||||
|
targetDir = getUserSkillDir(skillName);
|
||||||
|
targetPath = getUserSkillPath(skillName);
|
||||||
|
}
|
||||||
|
targetScope = SKILL_SCOPE.USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const { instructions, scope: _scopeFromConfig, source: _sourceFromConfig, supportingFiles, ...frontmatter } = config;
|
||||||
|
void _scopeFromConfig;
|
||||||
|
void _sourceFromConfig;
|
||||||
|
|
||||||
|
if (!frontmatter.name) {
|
||||||
|
frontmatter.name = skillName;
|
||||||
|
}
|
||||||
|
if (!frontmatter.description) {
|
||||||
|
throw new Error('Skill description is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
writeMdFile(targetPath, frontmatter, instructions || '');
|
||||||
|
|
||||||
|
if (supportingFiles && Array.isArray(supportingFiles)) {
|
||||||
|
for (const file of supportingFiles) {
|
||||||
|
if (file.path && file.content !== undefined) {
|
||||||
|
writeSkillSupportingFile(targetDir, file.path, file.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSkill(skillName, updates, workingDirectory) {
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
const existing = getSkillScope(skillName, workingDirectory);
|
||||||
|
if (!existing.path) {
|
||||||
|
throw new Error(`Skill "${skillName}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdPath = existing.path;
|
||||||
|
const mdDir = path.dirname(mdPath);
|
||||||
|
const mdData = parseMdFile(mdPath);
|
||||||
|
|
||||||
|
let mdModified = false;
|
||||||
|
|
||||||
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
|
if (field === 'scope') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'instructions') {
|
||||||
|
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||||
|
mdData.body = normalizedValue;
|
||||||
|
mdModified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'supportingFiles') {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const file of value) {
|
||||||
|
if (file.delete && file.path) {
|
||||||
|
deleteSkillSupportingFile(mdDir, file.path);
|
||||||
|
} else if (file.path && file.content !== undefined) {
|
||||||
|
writeSkillSupportingFile(mdDir, file.path, file.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mdData.frontmatter[field] = value;
|
||||||
|
mdModified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mdModified) {
|
||||||
|
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated skill: ${skillName} (path: ${mdPath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSkill(skillName, workingDirectory) {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
if (workingDirectory) {
|
||||||
|
const projectDir = getProjectSkillDir(workingDirectory, skillName);
|
||||||
|
if (fs.existsSync(projectDir)) {
|
||||||
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||||
|
console.log(`Deleted project-level skill directory: ${projectDir}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
|
||||||
|
if (fs.existsSync(claudeDir)) {
|
||||||
|
fs.rmSync(claudeDir, { recursive: true, force: true });
|
||||||
|
console.log(`Deleted claude-compat skill directory: ${claudeDir}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||||
|
if (fs.existsSync(projectAgentsDir)) {
|
||||||
|
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
|
||||||
|
console.log(`Deleted project-level agents skill directory: ${projectAgentsDir}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDir = getUserSkillDir(skillName);
|
||||||
|
if (fs.existsSync(userDir)) {
|
||||||
|
fs.rmSync(userDir, { recursive: true, force: true });
|
||||||
|
console.log(`Deleted user-level skill directory: ${userDir}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgentsDir = getUserAgentsSkillDir(skillName);
|
||||||
|
if (fs.existsSync(userAgentsDir)) {
|
||||||
|
fs.rmSync(userAgentsDir, { recursive: true, force: true });
|
||||||
|
console.log(`Deleted user-level agents skill directory: ${userAgentsDir}`);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error(`Skill "${skillName}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getSkillSources,
|
||||||
|
getSkillScope,
|
||||||
|
getSkillWritePath,
|
||||||
|
discoverSkills,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
deleteSkill,
|
||||||
|
};
|
||||||
@@ -4,18 +4,15 @@ const SESSION_COOKIE_NAME = 'oc_ui_session';
|
|||||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
// Login rate limit configuration
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||||
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
||||||
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
|
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
|
||||||
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
|
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
|
||||||
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
|
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
|
||||||
|
|
||||||
// Rate limit tracker: IP -> { count, lastAttempt, lockedUntil }
|
|
||||||
const loginRateLimiter = new Map();
|
const loginRateLimiter = new Map();
|
||||||
let rateLimitCleanupTimer = null;
|
let rateLimitCleanupTimer = null;
|
||||||
|
|
||||||
// Concurrency control: key -> Promise<void>
|
|
||||||
const rateLimitLocks = new Map();
|
const rateLimitLocks = new Map();
|
||||||
|
|
||||||
const getClientIp = (req) => {
|
const getClientIp = (req) => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
asNonEmptyString,
|
asNonEmptyString,
|
||||||
toTimestamp
|
toTimestamp
|
||||||
} from '../../utils/index.js';
|
} from '../../utils/index.js';
|
||||||
import { readAuthFile } from '../../../opencode-auth.js';
|
import { readAuthFile } from '../../../opencode/auth.js';
|
||||||
import { parseGoogleRefreshToken } from './transforms.js';
|
import { parseGoogleRefreshToken } from './transforms.js';
|
||||||
|
|
||||||
const ANTIGRAVITY_GOOGLE_CLIENT_ID =
|
const ANTIGRAVITY_GOOGLE_CLIENT_ID =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readAuthFile } from '../../opencode-auth.js';
|
import { readAuthFile } from '../../opencode/auth.js';
|
||||||
import {
|
import {
|
||||||
getAuthEntry,
|
getAuthEntry,
|
||||||
normalizeAuthEntry,
|
normalizeAuthEntry,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { readAuthFile } from './opencode-auth.js';
|
import { readAuthFile } from './opencode/auth.js';
|
||||||
|
|
||||||
// Voice options from OpenAI
|
// Voice options from OpenAI
|
||||||
export const TTS_VOICES = [
|
export const TTS_VOICES = [
|
||||||
|
|||||||
Reference in New Issue
Block a user