ikanban-web 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin/cli.js +1055 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +16 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-BQJPAP5f.css +1 -0
- package/dist/assets/index-MBuF9PXk.js +2 -0
- package/dist/assets/main-C1GggQif.css +1 -0
- package/dist/assets/main-Dyy8WvPJ.js +311 -0
- package/dist/assets/vendor--DEobTDja.css +1 -0
- package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/worker-suRQxAtO.js +155 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +325 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +16 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +22 -0
- package/dist/sw.js +1 -0
- package/package.json +102 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +16 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +15 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +16 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +16 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +22 -0
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
- package/server/index.d.ts +28 -0
- package/server/index.js +11181 -0
- package/server/lib/cloudflare-tunnel.js +196 -0
- package/server/lib/git-credentials.js +74 -0
- package/server/lib/git-identity-storage.js +110 -0
- package/server/lib/git-service.js +2690 -0
- package/server/lib/github-auth.js +307 -0
- package/server/lib/github-device-flow.js +50 -0
- package/server/lib/github-octokit.js +10 -0
- package/server/lib/github-repo.js +55 -0
- package/server/lib/notification-message.js +49 -0
- package/server/lib/notification-message.test.js +59 -0
- package/server/lib/opencode-auth.js +81 -0
- package/server/lib/opencode-config.js +1840 -0
- package/server/lib/opencode-config.js.d.ts +12 -0
- package/server/lib/package-manager.js +255 -0
- package/server/lib/quota/DOCUMENTATION.md +52 -0
- package/server/lib/quota/index.js +21 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +111 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +128 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/utils/auth.js +46 -0
- package/server/lib/quota/utils/formatters.js +76 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +223 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +76 -0
- package/server/lib/skills-catalog/install.js +280 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +85 -0
- package/server/lib/summarization-service.js +171 -0
- package/server/lib/terminal-input-ws-protocol.js +66 -0
- package/server/lib/terminal-input-ws-protocol.test.js +138 -0
- package/server/lib/tts-service.js +162 -0
- package/server/lib/ui-auth.js +518 -0
|
@@ -0,0 +1,1840 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
6
|
+
|
|
7
|
+
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
|
8
|
+
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
|
9
|
+
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
|
10
|
+
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
|
11
|
+
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
|
12
|
+
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
|
13
|
+
? path.resolve(process.env.OPENCODE_CONFIG)
|
|
14
|
+
: null;
|
|
15
|
+
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
|
16
|
+
|
|
17
|
+
// Scope types (shared by agents and commands)
|
|
18
|
+
const AGENT_SCOPE = {
|
|
19
|
+
USER: 'user',
|
|
20
|
+
PROJECT: 'project'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const COMMAND_SCOPE = {
|
|
24
|
+
USER: 'user',
|
|
25
|
+
PROJECT: 'project'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SKILL_SCOPE = {
|
|
29
|
+
USER: 'user',
|
|
30
|
+
PROJECT: 'project'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function ensureDirs() {
|
|
34
|
+
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
|
|
35
|
+
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
if (!fs.existsSync(AGENT_DIR)) {
|
|
38
|
+
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
if (!fs.existsSync(COMMAND_DIR)) {
|
|
41
|
+
fs.mkdirSync(COMMAND_DIR, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
if (!fs.existsSync(SKILL_DIR)) {
|
|
44
|
+
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============== AGENT SCOPE HELPERS ==============
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ensure project-level agent directory exists
|
|
52
|
+
*/
|
|
53
|
+
function ensureProjectAgentDir(workingDirectory) {
|
|
54
|
+
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
|
|
55
|
+
if (!fs.existsSync(projectAgentDir)) {
|
|
56
|
+
fs.mkdirSync(projectAgentDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
|
|
59
|
+
if (!fs.existsSync(legacyProjectAgentDir)) {
|
|
60
|
+
fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
return projectAgentDir;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get project-level agent path
|
|
67
|
+
*/
|
|
68
|
+
function getProjectAgentPath(workingDirectory, agentName) {
|
|
69
|
+
const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`);
|
|
70
|
+
const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
|
|
71
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
72
|
+
return pluralPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get user-level agent path
|
|
77
|
+
*/
|
|
78
|
+
function getUserAgentPath(agentName) {
|
|
79
|
+
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
|
|
80
|
+
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`);
|
|
81
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
82
|
+
return pluralPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Determine agent scope based on where the .md file exists
|
|
87
|
+
* Priority: project level > user level > null (built-in only)
|
|
88
|
+
*/
|
|
89
|
+
function getAgentScope(agentName, workingDirectory) {
|
|
90
|
+
if (workingDirectory) {
|
|
91
|
+
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
92
|
+
if (fs.existsSync(projectPath)) {
|
|
93
|
+
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const userPath = getUserAgentPath(agentName);
|
|
98
|
+
if (fs.existsSync(userPath)) {
|
|
99
|
+
return { scope: AGENT_SCOPE.USER, path: userPath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { scope: null, path: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the path where an agent should be written based on scope
|
|
107
|
+
*/
|
|
108
|
+
function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
|
109
|
+
// For updates: check existing location first (project takes precedence)
|
|
110
|
+
const existing = getAgentScope(agentName, workingDirectory);
|
|
111
|
+
if (existing.path) {
|
|
112
|
+
return existing;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// For new agents or built-in overrides: use requested scope or default to user
|
|
116
|
+
const scope = requestedScope || AGENT_SCOPE.USER;
|
|
117
|
+
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
|
118
|
+
return {
|
|
119
|
+
scope: AGENT_SCOPE.PROJECT,
|
|
120
|
+
path: getProjectAgentPath(workingDirectory, agentName)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
scope: AGENT_SCOPE.USER,
|
|
126
|
+
path: getUserAgentPath(agentName)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect where an agent's permission field is currently defined
|
|
132
|
+
* Priority: project .md > user .md > project JSON > user JSON
|
|
133
|
+
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
|
|
134
|
+
*/
|
|
135
|
+
function getAgentPermissionSource(agentName, workingDirectory) {
|
|
136
|
+
// Check project-level .md first
|
|
137
|
+
if (workingDirectory) {
|
|
138
|
+
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
|
139
|
+
if (fs.existsSync(projectMdPath)) {
|
|
140
|
+
const { frontmatter } = parseMdFile(projectMdPath);
|
|
141
|
+
if (frontmatter.permission !== undefined) {
|
|
142
|
+
return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check user-level .md
|
|
148
|
+
const userMdPath = getUserAgentPath(agentName);
|
|
149
|
+
if (fs.existsSync(userMdPath)) {
|
|
150
|
+
const { frontmatter } = parseMdFile(userMdPath);
|
|
151
|
+
if (frontmatter.permission !== undefined) {
|
|
152
|
+
return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check JSON layers (project > user)
|
|
157
|
+
const layers = readConfigLayers(workingDirectory);
|
|
158
|
+
|
|
159
|
+
// Project opencode.json
|
|
160
|
+
const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission;
|
|
161
|
+
if (projectJsonPermission !== undefined && layers.paths.projectPath) {
|
|
162
|
+
return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// User opencode.json
|
|
166
|
+
const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission;
|
|
167
|
+
if (userJsonPermission !== undefined) {
|
|
168
|
+
return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Custom config (env var)
|
|
172
|
+
const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission;
|
|
173
|
+
if (customJsonPermission !== undefined && layers.paths.customPath) {
|
|
174
|
+
return { source: 'json', scope: 'custom', path: layers.paths.customPath };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { source: null, scope: null, path: null };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============== COMMAND SCOPE HELPERS ==============
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Ensure project-level command directory exists
|
|
184
|
+
*/
|
|
185
|
+
function ensureProjectCommandDir(workingDirectory) {
|
|
186
|
+
const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands');
|
|
187
|
+
if (!fs.existsSync(projectCommandDir)) {
|
|
188
|
+
fs.mkdirSync(projectCommandDir, { recursive: true });
|
|
189
|
+
}
|
|
190
|
+
const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command');
|
|
191
|
+
if (!fs.existsSync(legacyProjectCommandDir)) {
|
|
192
|
+
fs.mkdirSync(legacyProjectCommandDir, { recursive: true });
|
|
193
|
+
}
|
|
194
|
+
return projectCommandDir;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get project-level command path
|
|
199
|
+
*/
|
|
200
|
+
function getProjectCommandPath(workingDirectory, commandName) {
|
|
201
|
+
const pluralPath = path.join(workingDirectory, '.opencode', 'commands', `${commandName}.md`);
|
|
202
|
+
const legacyPath = path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`);
|
|
203
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
204
|
+
return pluralPath;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get user-level command path
|
|
209
|
+
*/
|
|
210
|
+
function getUserCommandPath(commandName) {
|
|
211
|
+
const pluralPath = path.join(COMMAND_DIR, `${commandName}.md`);
|
|
212
|
+
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'command', `${commandName}.md`);
|
|
213
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
214
|
+
return pluralPath;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Determine command scope based on where the .md file exists
|
|
219
|
+
* Priority: project level > user level > null (built-in only)
|
|
220
|
+
*/
|
|
221
|
+
function getCommandScope(commandName, workingDirectory) {
|
|
222
|
+
if (workingDirectory) {
|
|
223
|
+
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
|
224
|
+
if (fs.existsSync(projectPath)) {
|
|
225
|
+
return { scope: COMMAND_SCOPE.PROJECT, path: projectPath };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const userPath = getUserCommandPath(commandName);
|
|
230
|
+
if (fs.existsSync(userPath)) {
|
|
231
|
+
return { scope: COMMAND_SCOPE.USER, path: userPath };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { scope: null, path: null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get the path where a command should be written based on scope
|
|
239
|
+
*/
|
|
240
|
+
function getCommandWritePath(commandName, workingDirectory, requestedScope) {
|
|
241
|
+
// For updates: check existing location first (project takes precedence)
|
|
242
|
+
const existing = getCommandScope(commandName, workingDirectory);
|
|
243
|
+
if (existing.path) {
|
|
244
|
+
return existing;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// For new commands or built-in overrides: use requested scope or default to user
|
|
248
|
+
const scope = requestedScope || COMMAND_SCOPE.USER;
|
|
249
|
+
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
|
|
250
|
+
return {
|
|
251
|
+
scope: COMMAND_SCOPE.PROJECT,
|
|
252
|
+
path: getProjectCommandPath(workingDirectory, commandName)
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
scope: COMMAND_SCOPE.USER,
|
|
258
|
+
path: getUserCommandPath(commandName)
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ============== SKILL SCOPE HELPERS ==============
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Ensure project-level skill directory exists
|
|
266
|
+
*/
|
|
267
|
+
function ensureProjectSkillDir(workingDirectory) {
|
|
268
|
+
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
|
269
|
+
if (!fs.existsSync(projectSkillDir)) {
|
|
270
|
+
fs.mkdirSync(projectSkillDir, { recursive: true });
|
|
271
|
+
}
|
|
272
|
+
const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
|
273
|
+
if (!fs.existsSync(legacyProjectSkillDir)) {
|
|
274
|
+
fs.mkdirSync(legacyProjectSkillDir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
return projectSkillDir;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Get project-level skill directory path (.opencode/skill/{name}/)
|
|
281
|
+
*/
|
|
282
|
+
function getProjectSkillDir(workingDirectory, skillName) {
|
|
283
|
+
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName);
|
|
284
|
+
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName);
|
|
285
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
286
|
+
return pluralPath;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get project-level skill SKILL.md path
|
|
291
|
+
*/
|
|
292
|
+
function getProjectSkillPath(workingDirectory, skillName) {
|
|
293
|
+
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName, 'SKILL.md');
|
|
294
|
+
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName, 'SKILL.md');
|
|
295
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
296
|
+
return pluralPath;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get user-level skill directory path
|
|
301
|
+
*/
|
|
302
|
+
function getUserSkillDir(skillName) {
|
|
303
|
+
const pluralPath = path.join(SKILL_DIR, skillName);
|
|
304
|
+
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName);
|
|
305
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
306
|
+
return pluralPath;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get user-level skill SKILL.md path
|
|
311
|
+
*/
|
|
312
|
+
function getUserSkillPath(skillName) {
|
|
313
|
+
const pluralPath = path.join(SKILL_DIR, skillName, 'SKILL.md');
|
|
314
|
+
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName, 'SKILL.md');
|
|
315
|
+
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
316
|
+
return pluralPath;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get Claude-compatible skill directory path (.claude/skills/{name}/)
|
|
321
|
+
*/
|
|
322
|
+
function getClaudeSkillDir(workingDirectory, skillName) {
|
|
323
|
+
return path.join(workingDirectory, '.claude', 'skills', skillName);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get Claude-compatible skill SKILL.md path
|
|
328
|
+
*/
|
|
329
|
+
function getClaudeSkillPath(workingDirectory, skillName) {
|
|
330
|
+
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Determine skill scope based on where the SKILL.md file exists
|
|
335
|
+
* Priority: project level (.opencode) > user level > claude-compat (.claude/skills)
|
|
336
|
+
*/
|
|
337
|
+
function getSkillScope(skillName, workingDirectory) {
|
|
338
|
+
if (workingDirectory) {
|
|
339
|
+
// Check .opencode/skill first
|
|
340
|
+
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
|
341
|
+
if (fs.existsSync(projectPath)) {
|
|
342
|
+
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Check .claude/skills (claude-compat)
|
|
346
|
+
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
|
|
347
|
+
if (fs.existsSync(claudePath)) {
|
|
348
|
+
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const userPath = getUserSkillPath(skillName);
|
|
353
|
+
if (fs.existsSync(userPath)) {
|
|
354
|
+
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { scope: null, path: null, source: null };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get the path where a skill should be written based on scope
|
|
362
|
+
* Note: We never write to .claude/skills, only read from there
|
|
363
|
+
*/
|
|
364
|
+
function getSkillWritePath(skillName, workingDirectory, requestedScope) {
|
|
365
|
+
// For updates: check existing location first
|
|
366
|
+
const existing = getSkillScope(skillName, workingDirectory);
|
|
367
|
+
if (existing.path) {
|
|
368
|
+
// If it's from .claude/skills, we still edit in place
|
|
369
|
+
return existing;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// For new skills: use requested scope or default to user
|
|
373
|
+
const scope = requestedScope || SKILL_SCOPE.USER;
|
|
374
|
+
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
|
375
|
+
return {
|
|
376
|
+
scope: SKILL_SCOPE.PROJECT,
|
|
377
|
+
path: getProjectSkillPath(workingDirectory, skillName),
|
|
378
|
+
source: 'opencode'
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
scope: SKILL_SCOPE.USER,
|
|
384
|
+
path: getUserSkillPath(skillName),
|
|
385
|
+
source: 'opencode'
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* List all supporting files in a skill directory (excluding SKILL.md)
|
|
391
|
+
*/
|
|
392
|
+
function listSkillSupportingFiles(skillDir) {
|
|
393
|
+
if (!fs.existsSync(skillDir)) {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const files = [];
|
|
398
|
+
|
|
399
|
+
function walkDir(dir, relativePath = '') {
|
|
400
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
const fullPath = path.join(dir, entry.name);
|
|
403
|
+
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
404
|
+
|
|
405
|
+
if (entry.isDirectory()) {
|
|
406
|
+
walkDir(fullPath, relPath);
|
|
407
|
+
} else if (entry.name !== 'SKILL.md') {
|
|
408
|
+
files.push({
|
|
409
|
+
name: entry.name,
|
|
410
|
+
path: relPath,
|
|
411
|
+
fullPath: fullPath
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
walkDir(skillDir);
|
|
418
|
+
return files;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Read a supporting file content
|
|
423
|
+
*/
|
|
424
|
+
function readSkillSupportingFile(skillDir, relativePath) {
|
|
425
|
+
const fullPath = path.join(skillDir, relativePath);
|
|
426
|
+
if (!fs.existsSync(fullPath)) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
return fs.readFileSync(fullPath, 'utf8');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Write a supporting file
|
|
434
|
+
*/
|
|
435
|
+
function writeSkillSupportingFile(skillDir, relativePath, content) {
|
|
436
|
+
const fullPath = path.join(skillDir, relativePath);
|
|
437
|
+
const dir = path.dirname(fullPath);
|
|
438
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
439
|
+
fs.writeFileSync(fullPath, content, 'utf8');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Delete a supporting file
|
|
444
|
+
*/
|
|
445
|
+
function deleteSkillSupportingFile(skillDir, relativePath) {
|
|
446
|
+
const fullPath = path.join(skillDir, relativePath);
|
|
447
|
+
if (fs.existsSync(fullPath)) {
|
|
448
|
+
fs.unlinkSync(fullPath);
|
|
449
|
+
// Clean up empty parent directories
|
|
450
|
+
let parentDir = path.dirname(fullPath);
|
|
451
|
+
while (parentDir !== skillDir) {
|
|
452
|
+
try {
|
|
453
|
+
const entries = fs.readdirSync(parentDir);
|
|
454
|
+
if (entries.length === 0) {
|
|
455
|
+
fs.rmdirSync(parentDir);
|
|
456
|
+
parentDir = path.dirname(parentDir);
|
|
457
|
+
} else {
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isPromptFileReference(value) {
|
|
468
|
+
if (typeof value !== 'string') {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return PROMPT_FILE_PATTERN.test(value.trim());
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function resolvePromptFilePath(reference) {
|
|
475
|
+
const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null;
|
|
476
|
+
if (!match) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
let target = match[1].trim();
|
|
480
|
+
if (!target) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (target.startsWith('./')) {
|
|
485
|
+
target = target.slice(2);
|
|
486
|
+
target = path.join(OPENCODE_CONFIG_DIR, target);
|
|
487
|
+
} else if (!path.isAbsolute(target)) {
|
|
488
|
+
target = path.join(OPENCODE_CONFIG_DIR, target);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return target;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function writePromptFile(filePath, content) {
|
|
495
|
+
const dir = path.dirname(filePath);
|
|
496
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
497
|
+
fs.writeFileSync(filePath, content ?? '', 'utf8');
|
|
498
|
+
console.log(`Updated prompt file: ${filePath}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Get all possible project config paths in priority order
|
|
503
|
+
* Priority: root > .opencode/, json > jsonc
|
|
504
|
+
*/
|
|
505
|
+
function getProjectConfigCandidates(workingDirectory) {
|
|
506
|
+
if (!workingDirectory) return [];
|
|
507
|
+
return [
|
|
508
|
+
path.join(workingDirectory, 'opencode.json'),
|
|
509
|
+
path.join(workingDirectory, 'opencode.jsonc'),
|
|
510
|
+
path.join(workingDirectory, '.opencode', 'opencode.json'),
|
|
511
|
+
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Find existing project config file or return default path for new config
|
|
517
|
+
*/
|
|
518
|
+
function getProjectConfigPath(workingDirectory) {
|
|
519
|
+
if (!workingDirectory) return null;
|
|
520
|
+
|
|
521
|
+
const candidates = getProjectConfigCandidates(workingDirectory);
|
|
522
|
+
|
|
523
|
+
// Return first existing config file
|
|
524
|
+
for (const candidate of candidates) {
|
|
525
|
+
if (fs.existsSync(candidate)) {
|
|
526
|
+
return candidate;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Default to root opencode.json for new configs
|
|
531
|
+
return candidates[0];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Merge new permission config with existing non-wildcard patterns
|
|
536
|
+
* Non-wildcard patterns (patterns other than "*") are preserved from existing config
|
|
537
|
+
* @param {object|string|null} newPermission - New permission config from UI (wildcards only)
|
|
538
|
+
* @param {object} permissionSource - Result from getAgentPermissionSource
|
|
539
|
+
* @param {string} agentName - Agent name
|
|
540
|
+
* @param {string|null} workingDirectory - Working directory
|
|
541
|
+
* @returns {object|string|null} Merged permission config
|
|
542
|
+
*/
|
|
543
|
+
function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName, workingDirectory) {
|
|
544
|
+
// If no existing permission, return new permission as-is
|
|
545
|
+
if (!permissionSource.source || !permissionSource.path) {
|
|
546
|
+
return newPermission;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Get existing permission config
|
|
550
|
+
let existingPermission = null;
|
|
551
|
+
if (permissionSource.source === 'md') {
|
|
552
|
+
const { frontmatter } = parseMdFile(permissionSource.path);
|
|
553
|
+
existingPermission = frontmatter.permission;
|
|
554
|
+
} else if (permissionSource.source === 'json') {
|
|
555
|
+
const config = readConfigFile(permissionSource.path);
|
|
556
|
+
existingPermission = config?.agent?.[agentName]?.permission;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// If no existing permission or it's a simple string, return new permission as-is
|
|
560
|
+
if (!existingPermission || typeof existingPermission === 'string') {
|
|
561
|
+
return newPermission;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// If new permission is null/undefined, return null to clear it
|
|
565
|
+
if (newPermission == null) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// If new permission is a simple string (e.g., "allow"), return it as-is
|
|
570
|
+
if (typeof newPermission === 'string') {
|
|
571
|
+
return newPermission;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Extract non-wildcard patterns from existing permission
|
|
575
|
+
const nonWildcardPatterns = {};
|
|
576
|
+
for (const [permKey, permValue] of Object.entries(existingPermission)) {
|
|
577
|
+
if (permKey === '*') continue; // Skip global default
|
|
578
|
+
|
|
579
|
+
if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) {
|
|
580
|
+
// Permission has pattern-based config (e.g., { "npm *": "allow", "*": "ask" })
|
|
581
|
+
const nonWildcards = {};
|
|
582
|
+
for (const [pattern, action] of Object.entries(permValue)) {
|
|
583
|
+
if (pattern !== '*') {
|
|
584
|
+
nonWildcards[pattern] = action;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (Object.keys(nonWildcards).length > 0) {
|
|
588
|
+
nonWildcardPatterns[permKey] = nonWildcards;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Simple string values (e.g., "allow") don't have patterns, skip them
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// If no non-wildcard patterns to preserve, return new permission as-is
|
|
595
|
+
if (Object.keys(nonWildcardPatterns).length === 0) {
|
|
596
|
+
return newPermission;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Merge non-wildcards into new permission
|
|
600
|
+
const merged = { ...newPermission };
|
|
601
|
+
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
|
|
602
|
+
const newValue = merged[permKey];
|
|
603
|
+
if (typeof newValue === 'string') {
|
|
604
|
+
// Convert string to object with wildcard + preserved patterns
|
|
605
|
+
merged[permKey] = { '*': newValue, ...patterns };
|
|
606
|
+
} else if (typeof newValue === 'object' && newValue !== null) {
|
|
607
|
+
// Merge patterns, new wildcards take precedence
|
|
608
|
+
merged[permKey] = { ...patterns, ...newValue };
|
|
609
|
+
} else {
|
|
610
|
+
// Permission not in new config - preserve existing patterns with their wildcard if it existed
|
|
611
|
+
const existingValue = existingPermission[permKey];
|
|
612
|
+
if (typeof existingValue === 'object' && existingValue !== null) {
|
|
613
|
+
const wildcard = existingValue['*'];
|
|
614
|
+
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return merged;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function getConfigPaths(workingDirectory) {
|
|
623
|
+
return {
|
|
624
|
+
userPath: CONFIG_FILE,
|
|
625
|
+
projectPath: getProjectConfigPath(workingDirectory),
|
|
626
|
+
customPath: CUSTOM_CONFIG_FILE
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function readConfigFile(filePath) {
|
|
631
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
632
|
+
return {};
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
636
|
+
const normalized = content.trim();
|
|
637
|
+
if (!normalized) {
|
|
638
|
+
return {};
|
|
639
|
+
}
|
|
640
|
+
// jsonc-parser handles comments, trailing commas, unquoted keys
|
|
641
|
+
return parseJsonc(normalized, [], { allowTrailingComma: true });
|
|
642
|
+
} catch (error) {
|
|
643
|
+
console.error(`Failed to read config file: ${filePath}`, error);
|
|
644
|
+
throw new Error('Failed to read OpenCode configuration');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isPlainObject(value) {
|
|
649
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function mergeConfigs(base, override) {
|
|
653
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
654
|
+
return override;
|
|
655
|
+
}
|
|
656
|
+
const result = { ...base };
|
|
657
|
+
for (const [key, value] of Object.entries(override)) {
|
|
658
|
+
if (key in result) {
|
|
659
|
+
const baseValue = result[key];
|
|
660
|
+
if (isPlainObject(baseValue) && isPlainObject(value)) {
|
|
661
|
+
result[key] = mergeConfigs(baseValue, value);
|
|
662
|
+
} else {
|
|
663
|
+
result[key] = value;
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
result[key] = value;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return result;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function readConfigLayers(workingDirectory) {
|
|
673
|
+
const { userPath, projectPath, customPath } = getConfigPaths(workingDirectory);
|
|
674
|
+
const userConfig = readConfigFile(userPath);
|
|
675
|
+
const projectConfig = readConfigFile(projectPath);
|
|
676
|
+
const customConfig = readConfigFile(customPath);
|
|
677
|
+
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
userConfig,
|
|
681
|
+
projectConfig,
|
|
682
|
+
customConfig,
|
|
683
|
+
mergedConfig,
|
|
684
|
+
paths: { userPath, projectPath, customPath }
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function readConfig(workingDirectory) {
|
|
689
|
+
return readConfigLayers(workingDirectory).mergedConfig;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function getConfigForPath(layers, targetPath) {
|
|
693
|
+
if (!targetPath) {
|
|
694
|
+
return layers.userConfig;
|
|
695
|
+
}
|
|
696
|
+
if (layers.paths.customPath && targetPath === layers.paths.customPath) {
|
|
697
|
+
return layers.customConfig;
|
|
698
|
+
}
|
|
699
|
+
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) {
|
|
700
|
+
return layers.projectConfig;
|
|
701
|
+
}
|
|
702
|
+
return layers.userConfig;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function writeConfig(config, filePath = CONFIG_FILE) {
|
|
706
|
+
try {
|
|
707
|
+
if (fs.existsSync(filePath)) {
|
|
708
|
+
const backupFile = `${filePath}.ikanban.backup`;
|
|
709
|
+
fs.copyFileSync(filePath, backupFile);
|
|
710
|
+
console.log(`Created config backup: ${backupFile}`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
714
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
715
|
+
console.log(`Successfully wrote config file: ${filePath}`);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
console.error(`Failed to write config file: ${filePath}`, error);
|
|
718
|
+
throw new Error('Failed to write OpenCode configuration');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function getJsonEntrySource(layers, sectionKey, entryName) {
|
|
723
|
+
const { userConfig, projectConfig, customConfig, paths } = layers;
|
|
724
|
+
const customSection = customConfig?.[sectionKey]?.[entryName];
|
|
725
|
+
if (customSection !== undefined) {
|
|
726
|
+
return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const projectSection = projectConfig?.[sectionKey]?.[entryName];
|
|
730
|
+
if (projectSection !== undefined) {
|
|
731
|
+
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const userSection = userConfig?.[sectionKey]?.[entryName];
|
|
735
|
+
if (userSection !== undefined) {
|
|
736
|
+
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return { section: null, config: null, path: null, exists: false };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function getJsonWriteTarget(layers, preferredScope) {
|
|
743
|
+
const { userConfig, projectConfig, customConfig, paths } = layers;
|
|
744
|
+
if (paths.customPath) {
|
|
745
|
+
return { config: customConfig, path: paths.customPath };
|
|
746
|
+
}
|
|
747
|
+
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
|
|
748
|
+
return { config: projectConfig, path: paths.projectPath };
|
|
749
|
+
}
|
|
750
|
+
if (paths.projectPath) {
|
|
751
|
+
return { config: projectConfig, path: paths.projectPath };
|
|
752
|
+
}
|
|
753
|
+
return { config: userConfig, path: paths.userPath };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function parseMdFile(filePath) {
|
|
757
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
758
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
759
|
+
|
|
760
|
+
if (!match) {
|
|
761
|
+
return { frontmatter: {}, body: content.trim() };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let frontmatter = {};
|
|
765
|
+
try {
|
|
766
|
+
frontmatter = yaml.parse(match[1]) || {};
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
|
|
769
|
+
frontmatter = {};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const body = match[2].trim();
|
|
773
|
+
return { frontmatter, body };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function writeMdFile(filePath, frontmatter, body) {
|
|
777
|
+
try {
|
|
778
|
+
// Filter out null/undefined values - OpenCode expects keys to be omitted rather than set to null
|
|
779
|
+
const cleanedFrontmatter = Object.fromEntries(
|
|
780
|
+
Object.entries(frontmatter).filter(([, value]) => value != null)
|
|
781
|
+
);
|
|
782
|
+
const yamlStr = yaml.stringify(cleanedFrontmatter);
|
|
783
|
+
const content = `---\n${yamlStr}---\n\n${body}`;
|
|
784
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
785
|
+
console.log(`Successfully wrote markdown file: ${filePath}`);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
console.error(`Failed to write markdown file ${filePath}:`, error);
|
|
788
|
+
throw new Error('Failed to write agent markdown file');
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function getAgentSources(agentName, workingDirectory) {
|
|
793
|
+
// Check project level first (takes precedence)
|
|
794
|
+
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
795
|
+
const projectExists = projectPath && fs.existsSync(projectPath);
|
|
796
|
+
|
|
797
|
+
// Then check user level
|
|
798
|
+
const userPath = getUserAgentPath(agentName);
|
|
799
|
+
const userExists = fs.existsSync(userPath);
|
|
800
|
+
|
|
801
|
+
// Determine which md file to use (project takes precedence)
|
|
802
|
+
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
|
|
803
|
+
const mdExists = !!mdPath;
|
|
804
|
+
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
|
|
805
|
+
|
|
806
|
+
const layers = readConfigLayers(workingDirectory);
|
|
807
|
+
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
808
|
+
const jsonSection = jsonSource.section;
|
|
809
|
+
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
|
810
|
+
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
|
811
|
+
|
|
812
|
+
const sources = {
|
|
813
|
+
md: {
|
|
814
|
+
exists: mdExists,
|
|
815
|
+
path: mdPath,
|
|
816
|
+
scope: mdScope,
|
|
817
|
+
fields: []
|
|
818
|
+
},
|
|
819
|
+
json: {
|
|
820
|
+
exists: jsonSource.exists,
|
|
821
|
+
path: jsonPath,
|
|
822
|
+
scope: jsonSource.exists ? jsonScope : null,
|
|
823
|
+
fields: []
|
|
824
|
+
},
|
|
825
|
+
// Additional info about both levels
|
|
826
|
+
projectMd: {
|
|
827
|
+
exists: projectExists,
|
|
828
|
+
path: projectPath
|
|
829
|
+
},
|
|
830
|
+
userMd: {
|
|
831
|
+
exists: userExists,
|
|
832
|
+
path: userPath
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
if (mdExists) {
|
|
837
|
+
const { frontmatter, body } = parseMdFile(mdPath);
|
|
838
|
+
sources.md.fields = Object.keys(frontmatter);
|
|
839
|
+
if (body) {
|
|
840
|
+
sources.md.fields.push('prompt');
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (jsonSection) {
|
|
845
|
+
sources.json.fields = Object.keys(jsonSection);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return sources;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function getAgentConfig(agentName, workingDirectory) {
|
|
852
|
+
// Prefer markdown agents (project > user)
|
|
853
|
+
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
854
|
+
const projectExists = projectPath && fs.existsSync(projectPath);
|
|
855
|
+
|
|
856
|
+
const userPath = getUserAgentPath(agentName);
|
|
857
|
+
const userExists = fs.existsSync(userPath);
|
|
858
|
+
|
|
859
|
+
if (projectExists || userExists) {
|
|
860
|
+
const mdPath = projectExists ? projectPath : userPath;
|
|
861
|
+
const { frontmatter, body } = parseMdFile(mdPath);
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
source: 'md',
|
|
865
|
+
scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER,
|
|
866
|
+
config: {
|
|
867
|
+
...frontmatter,
|
|
868
|
+
...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}),
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Then fall back to opencode.json (highest-precedence entry)
|
|
874
|
+
const layers = readConfigLayers(workingDirectory);
|
|
875
|
+
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
876
|
+
|
|
877
|
+
if (jsonSource.exists && jsonSource.section) {
|
|
878
|
+
const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
|
879
|
+
return {
|
|
880
|
+
source: 'json',
|
|
881
|
+
scope,
|
|
882
|
+
config: { ...jsonSource.section },
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
source: 'none',
|
|
888
|
+
scope: null,
|
|
889
|
+
config: {},
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function createAgent(agentName, config, workingDirectory, scope) {
|
|
894
|
+
ensureDirs();
|
|
895
|
+
|
|
896
|
+
// Check if agent already exists at either level
|
|
897
|
+
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
|
898
|
+
const userPath = getUserAgentPath(agentName);
|
|
899
|
+
|
|
900
|
+
if (projectPath && fs.existsSync(projectPath)) {
|
|
901
|
+
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (fs.existsSync(userPath)) {
|
|
905
|
+
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const layers = readConfigLayers(workingDirectory);
|
|
909
|
+
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
910
|
+
if (jsonSource.exists) {
|
|
911
|
+
throw new Error(`Agent ${agentName} already exists in opencode.json`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Determine target path based on requested scope
|
|
915
|
+
let targetPath;
|
|
916
|
+
let targetScope;
|
|
917
|
+
|
|
918
|
+
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
|
919
|
+
ensureProjectAgentDir(workingDirectory);
|
|
920
|
+
targetPath = projectPath;
|
|
921
|
+
targetScope = AGENT_SCOPE.PROJECT;
|
|
922
|
+
} else {
|
|
923
|
+
targetPath = userPath;
|
|
924
|
+
targetScope = AGENT_SCOPE.USER;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Extract scope and prompt from config - scope is only used for path determination, not written to file
|
|
928
|
+
const { prompt, scope: _scopeFromConfig, ...frontmatter } = config;
|
|
929
|
+
|
|
930
|
+
writeMdFile(targetPath, frontmatter, prompt || '');
|
|
931
|
+
console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function updateAgent(agentName, updates, workingDirectory) {
|
|
935
|
+
ensureDirs();
|
|
936
|
+
|
|
937
|
+
// Determine correct path: project level takes precedence
|
|
938
|
+
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory);
|
|
939
|
+
const mdExists = mdPath && fs.existsSync(mdPath);
|
|
940
|
+
|
|
941
|
+
// Check if agent exists in opencode.json across all config layers
|
|
942
|
+
const layers = readConfigLayers(workingDirectory);
|
|
943
|
+
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
944
|
+
const jsonSection = jsonSource.section;
|
|
945
|
+
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
|
946
|
+
const jsonTarget = jsonSource.exists
|
|
947
|
+
? { config: jsonSource.config, path: jsonSource.path }
|
|
948
|
+
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
949
|
+
let config = jsonTarget.config || {};
|
|
950
|
+
|
|
951
|
+
// Determine if we should create a new md file:
|
|
952
|
+
// Only for built-in agents (no md file AND no json config)
|
|
953
|
+
const isBuiltinOverride = !mdExists && !hasJsonFields;
|
|
954
|
+
|
|
955
|
+
let targetPath = mdPath;
|
|
956
|
+
let targetScope = scope;
|
|
957
|
+
|
|
958
|
+
if (!mdExists && isBuiltinOverride) {
|
|
959
|
+
// Built-in agent override - create at user level
|
|
960
|
+
targetPath = getUserAgentPath(agentName);
|
|
961
|
+
targetScope = AGENT_SCOPE.USER;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Only create md data for existing md files or built-in overrides
|
|
965
|
+
let mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
|
|
966
|
+
|
|
967
|
+
let mdModified = false;
|
|
968
|
+
let jsonModified = false;
|
|
969
|
+
// Only create new md if it's a built-in override
|
|
970
|
+
let creatingNewMd = isBuiltinOverride;
|
|
971
|
+
|
|
972
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
973
|
+
|
|
974
|
+
if (field === 'prompt') {
|
|
975
|
+
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
|
976
|
+
|
|
977
|
+
if (mdExists || creatingNewMd) {
|
|
978
|
+
if (mdData) {
|
|
979
|
+
mdData.body = normalizedValue;
|
|
980
|
+
mdModified = true;
|
|
981
|
+
}
|
|
982
|
+
continue;
|
|
983
|
+
} else if (isPromptFileReference(jsonSection?.prompt)) {
|
|
984
|
+
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
|
|
985
|
+
if (!promptFilePath) {
|
|
986
|
+
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
|
|
987
|
+
}
|
|
988
|
+
writePromptFile(promptFilePath, normalizedValue);
|
|
989
|
+
continue;
|
|
990
|
+
} else if (isPromptFileReference(normalizedValue)) {
|
|
991
|
+
if (!config.agent) config.agent = {};
|
|
992
|
+
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
993
|
+
config.agent[agentName].prompt = normalizedValue;
|
|
994
|
+
jsonModified = true;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// For JSON-only agents, store prompt inline in JSON
|
|
999
|
+
if (!config.agent) config.agent = {};
|
|
1000
|
+
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
1001
|
+
config.agent[agentName].prompt = normalizedValue;
|
|
1002
|
+
jsonModified = true;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Special handling for permission field - uses location detection and preserves non-wildcards
|
|
1007
|
+
if (field === 'permission') {
|
|
1008
|
+
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
|
|
1009
|
+
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName, workingDirectory);
|
|
1010
|
+
|
|
1011
|
+
if (permissionSource.source === 'md') {
|
|
1012
|
+
// Write to existing .md file
|
|
1013
|
+
const existingMdData = parseMdFile(permissionSource.path);
|
|
1014
|
+
existingMdData.frontmatter.permission = newPermission;
|
|
1015
|
+
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
|
|
1016
|
+
console.log(`Updated permission in .md file: ${permissionSource.path}`);
|
|
1017
|
+
} else if (permissionSource.source === 'json') {
|
|
1018
|
+
// Write to existing JSON location
|
|
1019
|
+
const existingConfig = readConfigFile(permissionSource.path);
|
|
1020
|
+
if (!existingConfig.agent) existingConfig.agent = {};
|
|
1021
|
+
if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {};
|
|
1022
|
+
existingConfig.agent[agentName].permission = newPermission;
|
|
1023
|
+
writeConfig(existingConfig, permissionSource.path);
|
|
1024
|
+
console.log(`Updated permission in JSON: ${permissionSource.path}`);
|
|
1025
|
+
} else {
|
|
1026
|
+
// Permission not defined anywhere - use agent's source location
|
|
1027
|
+
if ((mdExists || creatingNewMd) && mdData) {
|
|
1028
|
+
mdData.frontmatter.permission = newPermission;
|
|
1029
|
+
mdModified = true;
|
|
1030
|
+
} else if (hasJsonFields) {
|
|
1031
|
+
// Agent exists in JSON - add permission there
|
|
1032
|
+
if (!config.agent) config.agent = {};
|
|
1033
|
+
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
1034
|
+
config.agent[agentName].permission = newPermission;
|
|
1035
|
+
jsonModified = true;
|
|
1036
|
+
} else {
|
|
1037
|
+
// Built-in agent with no config - write to project JSON if available, else user JSON
|
|
1038
|
+
const writeTarget = workingDirectory
|
|
1039
|
+
? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath }
|
|
1040
|
+
: { config: layers.userConfig || {}, path: layers.paths.userPath };
|
|
1041
|
+
if (!writeTarget.config.agent) writeTarget.config.agent = {};
|
|
1042
|
+
if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {};
|
|
1043
|
+
writeTarget.config.agent[agentName].permission = newPermission;
|
|
1044
|
+
writeConfig(writeTarget.config, writeTarget.path);
|
|
1045
|
+
console.log(`Created permission in JSON: ${writeTarget.path}`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
|
1052
|
+
const inJson = jsonSection?.[field] !== undefined;
|
|
1053
|
+
|
|
1054
|
+
if (value === null) {
|
|
1055
|
+
// Treat null as a request to remove the field.
|
|
1056
|
+
if (mdData && inMd) {
|
|
1057
|
+
delete mdData.frontmatter[field];
|
|
1058
|
+
mdModified = true;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (inJson) {
|
|
1062
|
+
if (config.agent?.[agentName]) {
|
|
1063
|
+
delete config.agent[agentName][field];
|
|
1064
|
+
|
|
1065
|
+
if (Object.keys(config.agent[agentName]).length === 0) {
|
|
1066
|
+
delete config.agent[agentName];
|
|
1067
|
+
}
|
|
1068
|
+
if (Object.keys(config.agent).length === 0) {
|
|
1069
|
+
delete config.agent;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
jsonModified = true;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// JSON takes precedence over md, so update JSON first if field exists there
|
|
1080
|
+
if (inJson) {
|
|
1081
|
+
if (!config.agent) config.agent = {};
|
|
1082
|
+
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
1083
|
+
config.agent[agentName][field] = value;
|
|
1084
|
+
jsonModified = true;
|
|
1085
|
+
} else if (inMd || creatingNewMd) {
|
|
1086
|
+
if (mdData) {
|
|
1087
|
+
mdData.frontmatter[field] = value;
|
|
1088
|
+
mdModified = true;
|
|
1089
|
+
}
|
|
1090
|
+
} else {
|
|
1091
|
+
// New field - add to the appropriate location based on agent source
|
|
1092
|
+
if ((mdExists || creatingNewMd) && mdData) {
|
|
1093
|
+
mdData.frontmatter[field] = value;
|
|
1094
|
+
mdModified = true;
|
|
1095
|
+
} else {
|
|
1096
|
+
// JSON-only agent or has JSON fields - add to JSON
|
|
1097
|
+
if (!config.agent) config.agent = {};
|
|
1098
|
+
if (!config.agent[agentName]) config.agent[agentName] = {};
|
|
1099
|
+
config.agent[agentName][field] = value;
|
|
1100
|
+
jsonModified = true;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (mdModified && mdData) {
|
|
1106
|
+
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (jsonModified) {
|
|
1110
|
+
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
console.log(`Updated agent: ${agentName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function deleteAgent(agentName, workingDirectory) {
|
|
1117
|
+
let deleted = false;
|
|
1118
|
+
|
|
1119
|
+
// Check project level first (takes precedence)
|
|
1120
|
+
if (workingDirectory) {
|
|
1121
|
+
const projectPath = getProjectAgentPath(workingDirectory, agentName);
|
|
1122
|
+
if (fs.existsSync(projectPath)) {
|
|
1123
|
+
fs.unlinkSync(projectPath);
|
|
1124
|
+
console.log(`Deleted project-level agent .md file: ${projectPath}`);
|
|
1125
|
+
deleted = true;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Then check user level
|
|
1130
|
+
const userPath = getUserAgentPath(agentName);
|
|
1131
|
+
if (fs.existsSync(userPath)) {
|
|
1132
|
+
fs.unlinkSync(userPath);
|
|
1133
|
+
console.log(`Deleted user-level agent .md file: ${userPath}`);
|
|
1134
|
+
deleted = true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Also check json config (highest precedence entry only)
|
|
1138
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1139
|
+
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
|
1140
|
+
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
|
|
1141
|
+
if (!jsonSource.config.agent) jsonSource.config.agent = {};
|
|
1142
|
+
delete jsonSource.config.agent[agentName];
|
|
1143
|
+
writeConfig(jsonSource.config, jsonSource.path);
|
|
1144
|
+
console.log(`Removed agent from opencode.json: ${agentName}`);
|
|
1145
|
+
deleted = true;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// If nothing was deleted (built-in agent), disable it in highest-precedence config
|
|
1149
|
+
if (!deleted) {
|
|
1150
|
+
const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
|
|
1151
|
+
const targetConfig = jsonTarget.config || {};
|
|
1152
|
+
if (!targetConfig.agent) targetConfig.agent = {};
|
|
1153
|
+
targetConfig.agent[agentName] = { disable: true };
|
|
1154
|
+
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
|
|
1155
|
+
console.log(`Disabled built-in agent: ${agentName}`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function getCommandSources(commandName, workingDirectory) {
|
|
1160
|
+
// Check project level first (takes precedence)
|
|
1161
|
+
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
|
|
1162
|
+
const projectExists = projectPath && fs.existsSync(projectPath);
|
|
1163
|
+
|
|
1164
|
+
// Then check user level
|
|
1165
|
+
const userPath = getUserCommandPath(commandName);
|
|
1166
|
+
const userExists = fs.existsSync(userPath);
|
|
1167
|
+
|
|
1168
|
+
// Determine which md file to use (project takes precedence)
|
|
1169
|
+
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
|
|
1170
|
+
const mdExists = !!mdPath;
|
|
1171
|
+
const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null);
|
|
1172
|
+
|
|
1173
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1174
|
+
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
|
1175
|
+
const jsonSection = jsonSource.section;
|
|
1176
|
+
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
|
1177
|
+
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
|
|
1178
|
+
|
|
1179
|
+
const sources = {
|
|
1180
|
+
md: {
|
|
1181
|
+
exists: mdExists,
|
|
1182
|
+
path: mdPath,
|
|
1183
|
+
scope: mdScope,
|
|
1184
|
+
fields: []
|
|
1185
|
+
},
|
|
1186
|
+
json: {
|
|
1187
|
+
exists: jsonSource.exists,
|
|
1188
|
+
path: jsonPath,
|
|
1189
|
+
scope: jsonSource.exists ? jsonScope : null,
|
|
1190
|
+
fields: []
|
|
1191
|
+
},
|
|
1192
|
+
// Additional info about both levels
|
|
1193
|
+
projectMd: {
|
|
1194
|
+
exists: projectExists,
|
|
1195
|
+
path: projectPath
|
|
1196
|
+
},
|
|
1197
|
+
userMd: {
|
|
1198
|
+
exists: userExists,
|
|
1199
|
+
path: userPath
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
if (mdExists) {
|
|
1204
|
+
const { frontmatter, body } = parseMdFile(mdPath);
|
|
1205
|
+
sources.md.fields = Object.keys(frontmatter);
|
|
1206
|
+
if (body) {
|
|
1207
|
+
sources.md.fields.push('template');
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (jsonSection) {
|
|
1212
|
+
sources.json.fields = Object.keys(jsonSection);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return sources;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function createCommand(commandName, config, workingDirectory, scope) {
|
|
1219
|
+
ensureDirs();
|
|
1220
|
+
|
|
1221
|
+
// Check if command already exists at either level
|
|
1222
|
+
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
|
|
1223
|
+
const userPath = getUserCommandPath(commandName);
|
|
1224
|
+
|
|
1225
|
+
if (projectPath && fs.existsSync(projectPath)) {
|
|
1226
|
+
throw new Error(`Command ${commandName} already exists as project-level .md file`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (fs.existsSync(userPath)) {
|
|
1230
|
+
throw new Error(`Command ${commandName} already exists as user-level .md file`);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1234
|
+
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
|
1235
|
+
if (jsonSource.exists) {
|
|
1236
|
+
throw new Error(`Command ${commandName} already exists in opencode.json`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Determine target path based on requested scope
|
|
1240
|
+
let targetPath;
|
|
1241
|
+
let targetScope;
|
|
1242
|
+
|
|
1243
|
+
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
|
|
1244
|
+
ensureProjectCommandDir(workingDirectory);
|
|
1245
|
+
targetPath = projectPath;
|
|
1246
|
+
targetScope = COMMAND_SCOPE.PROJECT;
|
|
1247
|
+
} else {
|
|
1248
|
+
targetPath = userPath;
|
|
1249
|
+
targetScope = COMMAND_SCOPE.USER;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Extract scope from config - it's only used for path determination, not written to file
|
|
1253
|
+
const { template, scope: _scopeFromConfig, ...frontmatter } = config;
|
|
1254
|
+
|
|
1255
|
+
writeMdFile(targetPath, frontmatter, template || '');
|
|
1256
|
+
console.log(`Created new command: ${commandName} (scope: ${targetScope}, path: ${targetPath})`);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function updateCommand(commandName, updates, workingDirectory) {
|
|
1260
|
+
ensureDirs();
|
|
1261
|
+
|
|
1262
|
+
// Determine correct path: project level takes precedence
|
|
1263
|
+
const { scope, path: mdPath } = getCommandWritePath(commandName, workingDirectory);
|
|
1264
|
+
const mdExists = mdPath && fs.existsSync(mdPath);
|
|
1265
|
+
|
|
1266
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1267
|
+
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
|
1268
|
+
const jsonSection = jsonSource.section;
|
|
1269
|
+
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
|
1270
|
+
const jsonTarget = jsonSource.exists
|
|
1271
|
+
? { config: jsonSource.config, path: jsonSource.path }
|
|
1272
|
+
: getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER);
|
|
1273
|
+
let config = jsonTarget.config || {};
|
|
1274
|
+
|
|
1275
|
+
// Only create a new md file for built-in overrides (no md + no json)
|
|
1276
|
+
const isBuiltinOverride = !mdExists && !hasJsonFields;
|
|
1277
|
+
|
|
1278
|
+
let targetPath = mdPath;
|
|
1279
|
+
let targetScope = scope;
|
|
1280
|
+
|
|
1281
|
+
if (!mdExists && isBuiltinOverride) {
|
|
1282
|
+
// Built-in command override - create at user level
|
|
1283
|
+
targetPath = getUserCommandPath(commandName);
|
|
1284
|
+
targetScope = COMMAND_SCOPE.USER;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const mdData = mdExists ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {}, body: '' } : null);
|
|
1288
|
+
|
|
1289
|
+
let mdModified = false;
|
|
1290
|
+
let jsonModified = false;
|
|
1291
|
+
let creatingNewMd = isBuiltinOverride;
|
|
1292
|
+
|
|
1293
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
1294
|
+
|
|
1295
|
+
if (field === 'template') {
|
|
1296
|
+
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
|
1297
|
+
|
|
1298
|
+
if (mdExists || creatingNewMd) {
|
|
1299
|
+
if (mdData) {
|
|
1300
|
+
mdData.body = normalizedValue;
|
|
1301
|
+
mdModified = true;
|
|
1302
|
+
}
|
|
1303
|
+
continue;
|
|
1304
|
+
} else if (isPromptFileReference(jsonSection?.template)) {
|
|
1305
|
+
const templateFilePath = resolvePromptFilePath(jsonSection.template);
|
|
1306
|
+
if (!templateFilePath) {
|
|
1307
|
+
throw new Error(`Invalid template file reference for command ${commandName}`);
|
|
1308
|
+
}
|
|
1309
|
+
writePromptFile(templateFilePath, normalizedValue);
|
|
1310
|
+
continue;
|
|
1311
|
+
} else if (isPromptFileReference(normalizedValue)) {
|
|
1312
|
+
if (!config.command) config.command = {};
|
|
1313
|
+
if (!config.command[commandName]) config.command[commandName] = {};
|
|
1314
|
+
config.command[commandName].template = normalizedValue;
|
|
1315
|
+
jsonModified = true;
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// For JSON-only commands, store template inline in JSON
|
|
1320
|
+
if (!config.command) config.command = {};
|
|
1321
|
+
if (!config.command[commandName]) config.command[commandName] = {};
|
|
1322
|
+
config.command[commandName].template = normalizedValue;
|
|
1323
|
+
jsonModified = true;
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
|
1328
|
+
const inJson = jsonSection?.[field] !== undefined;
|
|
1329
|
+
|
|
1330
|
+
// JSON takes precedence over md, so update JSON first if field exists there
|
|
1331
|
+
if (inJson) {
|
|
1332
|
+
if (!config.command) config.command = {};
|
|
1333
|
+
if (!config.command[commandName]) config.command[commandName] = {};
|
|
1334
|
+
config.command[commandName][field] = value;
|
|
1335
|
+
jsonModified = true;
|
|
1336
|
+
} else if (inMd || creatingNewMd) {
|
|
1337
|
+
if (mdData) {
|
|
1338
|
+
mdData.frontmatter[field] = value;
|
|
1339
|
+
mdModified = true;
|
|
1340
|
+
}
|
|
1341
|
+
} else {
|
|
1342
|
+
// New field - add to appropriate location based on command source
|
|
1343
|
+
if ((mdExists || creatingNewMd) && mdData) {
|
|
1344
|
+
mdData.frontmatter[field] = value;
|
|
1345
|
+
mdModified = true;
|
|
1346
|
+
} else {
|
|
1347
|
+
if (!config.command) config.command = {};
|
|
1348
|
+
if (!config.command[commandName]) config.command[commandName] = {};
|
|
1349
|
+
config.command[commandName][field] = value;
|
|
1350
|
+
jsonModified = true;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (mdModified && mdData) {
|
|
1356
|
+
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (jsonModified) {
|
|
1360
|
+
writeConfig(config, jsonTarget.path || CONFIG_FILE);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function getProviderSources(providerId, workingDirectory) {
|
|
1367
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1368
|
+
const { userConfig, projectConfig, customConfig, paths } = layers;
|
|
1369
|
+
|
|
1370
|
+
const customProviders = isPlainObject(customConfig?.provider) ? customConfig.provider : {};
|
|
1371
|
+
const customProvidersAlias = isPlainObject(customConfig?.providers) ? customConfig.providers : {};
|
|
1372
|
+
const projectProviders = isPlainObject(projectConfig?.provider) ? projectConfig.provider : {};
|
|
1373
|
+
const projectProvidersAlias = isPlainObject(projectConfig?.providers) ? projectConfig.providers : {};
|
|
1374
|
+
const userProviders = isPlainObject(userConfig?.provider) ? userConfig.provider : {};
|
|
1375
|
+
const userProvidersAlias = isPlainObject(userConfig?.providers) ? userConfig.providers : {};
|
|
1376
|
+
|
|
1377
|
+
const customExists =
|
|
1378
|
+
(customProviders && Object.prototype.hasOwnProperty.call(customProviders, providerId)) ||
|
|
1379
|
+
(customProvidersAlias && Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId));
|
|
1380
|
+
const projectExists =
|
|
1381
|
+
(projectProviders && Object.prototype.hasOwnProperty.call(projectProviders, providerId)) ||
|
|
1382
|
+
(projectProvidersAlias && Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId));
|
|
1383
|
+
const userExists =
|
|
1384
|
+
(userProviders && Object.prototype.hasOwnProperty.call(userProviders, providerId)) ||
|
|
1385
|
+
(userProvidersAlias && Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId));
|
|
1386
|
+
|
|
1387
|
+
return {
|
|
1388
|
+
sources: {
|
|
1389
|
+
auth: { exists: false },
|
|
1390
|
+
user: { exists: userExists, path: paths.userPath },
|
|
1391
|
+
project: { exists: projectExists, path: paths.projectPath || null },
|
|
1392
|
+
custom: { exists: customExists, path: paths.customPath }
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
|
1398
|
+
if (!providerId || typeof providerId !== 'string') {
|
|
1399
|
+
throw new Error('Provider ID is required');
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1403
|
+
let targetPath = layers.paths.userPath;
|
|
1404
|
+
|
|
1405
|
+
if (scope === 'project') {
|
|
1406
|
+
if (!workingDirectory) {
|
|
1407
|
+
throw new Error('Working directory is required for project scope');
|
|
1408
|
+
}
|
|
1409
|
+
targetPath = layers.paths.projectPath || targetPath;
|
|
1410
|
+
} else if (scope === 'custom') {
|
|
1411
|
+
if (!layers.paths.customPath) {
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
targetPath = layers.paths.customPath;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const targetConfig = getConfigForPath(layers, targetPath);
|
|
1418
|
+
const providerConfig = isPlainObject(targetConfig.provider) ? targetConfig.provider : {};
|
|
1419
|
+
const providersConfig = isPlainObject(targetConfig.providers) ? targetConfig.providers : {};
|
|
1420
|
+
const removedProvider = providerConfig && Object.prototype.hasOwnProperty.call(providerConfig, providerId);
|
|
1421
|
+
const removedProviders = providersConfig && Object.prototype.hasOwnProperty.call(providersConfig, providerId);
|
|
1422
|
+
|
|
1423
|
+
if (!removedProvider && !removedProviders) {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (removedProvider) {
|
|
1428
|
+
delete providerConfig[providerId];
|
|
1429
|
+
if (Object.keys(providerConfig).length === 0) {
|
|
1430
|
+
delete targetConfig.provider;
|
|
1431
|
+
} else {
|
|
1432
|
+
targetConfig.provider = providerConfig;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
if (removedProviders) {
|
|
1437
|
+
delete providersConfig[providerId];
|
|
1438
|
+
if (Object.keys(providersConfig).length === 0) {
|
|
1439
|
+
delete targetConfig.providers;
|
|
1440
|
+
} else {
|
|
1441
|
+
targetConfig.providers = providersConfig;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
writeConfig(targetConfig, targetPath || CONFIG_FILE);
|
|
1446
|
+
console.log(`Removed provider ${providerId} from config: ${targetPath}`);
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function deleteCommand(commandName, workingDirectory) {
|
|
1451
|
+
let deleted = false;
|
|
1452
|
+
|
|
1453
|
+
// Check project level first (takes precedence)
|
|
1454
|
+
if (workingDirectory) {
|
|
1455
|
+
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
|
1456
|
+
if (fs.existsSync(projectPath)) {
|
|
1457
|
+
fs.unlinkSync(projectPath);
|
|
1458
|
+
console.log(`Deleted project-level command .md file: ${projectPath}`);
|
|
1459
|
+
deleted = true;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Then check user level
|
|
1464
|
+
const userPath = getUserCommandPath(commandName);
|
|
1465
|
+
if (fs.existsSync(userPath)) {
|
|
1466
|
+
fs.unlinkSync(userPath);
|
|
1467
|
+
console.log(`Deleted user-level command .md file: ${userPath}`);
|
|
1468
|
+
deleted = true;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Also check json config (highest precedence entry only)
|
|
1472
|
+
const layers = readConfigLayers(workingDirectory);
|
|
1473
|
+
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
|
1474
|
+
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
|
|
1475
|
+
if (!jsonSource.config.command) jsonSource.config.command = {};
|
|
1476
|
+
delete jsonSource.config.command[commandName];
|
|
1477
|
+
writeConfig(jsonSource.config, jsonSource.path);
|
|
1478
|
+
console.log(`Removed command from opencode.json: ${commandName}`);
|
|
1479
|
+
deleted = true;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (!deleted) {
|
|
1483
|
+
throw new Error(`Command "${commandName}" not found`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// ============== SKILL CRUD ==============
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Discover all skills from all sources
|
|
1491
|
+
*/
|
|
1492
|
+
function discoverSkills(workingDirectory) {
|
|
1493
|
+
const skills = new Map();
|
|
1494
|
+
|
|
1495
|
+
// Helper to add skill if not already found (first found wins by priority)
|
|
1496
|
+
const addSkill = (name, skillPath, scope, source) => {
|
|
1497
|
+
if (!skills.has(name)) {
|
|
1498
|
+
skills.set(name, { name, path: skillPath, scope, source });
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
// 1. Project level .opencode/skills/ (highest priority)
|
|
1503
|
+
if (workingDirectory) {
|
|
1504
|
+
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
|
1505
|
+
if (fs.existsSync(projectSkillDir)) {
|
|
1506
|
+
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
|
|
1507
|
+
for (const entry of entries) {
|
|
1508
|
+
if (entry.isDirectory()) {
|
|
1509
|
+
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
|
|
1510
|
+
if (fs.existsSync(skillMdPath)) {
|
|
1511
|
+
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
|
1518
|
+
if (fs.existsSync(legacyProjectSkillDir)) {
|
|
1519
|
+
const entries = fs.readdirSync(legacyProjectSkillDir, { withFileTypes: true });
|
|
1520
|
+
for (const entry of entries) {
|
|
1521
|
+
if (entry.isDirectory()) {
|
|
1522
|
+
const skillMdPath = path.join(legacyProjectSkillDir, entry.name, 'SKILL.md');
|
|
1523
|
+
if (fs.existsSync(skillMdPath)) {
|
|
1524
|
+
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// 2. Claude-compatible .claude/skills/
|
|
1531
|
+
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
|
|
1532
|
+
if (fs.existsSync(claudeSkillDir)) {
|
|
1533
|
+
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
|
|
1534
|
+
for (const entry of entries) {
|
|
1535
|
+
if (entry.isDirectory()) {
|
|
1536
|
+
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
|
|
1537
|
+
if (fs.existsSync(skillMdPath)) {
|
|
1538
|
+
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// 3. User level ~/.config/opencode/skills/
|
|
1546
|
+
if (fs.existsSync(SKILL_DIR)) {
|
|
1547
|
+
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
|
|
1548
|
+
for (const entry of entries) {
|
|
1549
|
+
if (entry.isDirectory()) {
|
|
1550
|
+
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
|
|
1551
|
+
if (fs.existsSync(skillMdPath)) {
|
|
1552
|
+
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const legacyUserSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
|
1559
|
+
if (fs.existsSync(legacyUserSkillDir)) {
|
|
1560
|
+
const entries = fs.readdirSync(legacyUserSkillDir, { withFileTypes: true });
|
|
1561
|
+
for (const entry of entries) {
|
|
1562
|
+
if (entry.isDirectory()) {
|
|
1563
|
+
const skillMdPath = path.join(legacyUserSkillDir, entry.name, 'SKILL.md');
|
|
1564
|
+
if (fs.existsSync(skillMdPath)) {
|
|
1565
|
+
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
return Array.from(skills.values());
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function getSkillSources(skillName, workingDirectory) {
|
|
1575
|
+
// Check all possible locations
|
|
1576
|
+
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
|
1577
|
+
const projectExists = projectPath && fs.existsSync(projectPath);
|
|
1578
|
+
const projectDir = projectExists ? path.dirname(projectPath) : null;
|
|
1579
|
+
|
|
1580
|
+
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
|
|
1581
|
+
const claudeExists = claudePath && fs.existsSync(claudePath);
|
|
1582
|
+
const claudeDir = claudeExists ? path.dirname(claudePath) : null;
|
|
1583
|
+
|
|
1584
|
+
const userPath = getUserSkillPath(skillName);
|
|
1585
|
+
const userExists = fs.existsSync(userPath);
|
|
1586
|
+
const userDir = userExists ? path.dirname(userPath) : null;
|
|
1587
|
+
|
|
1588
|
+
// Determine which md file to use (priority: project > claude > user)
|
|
1589
|
+
let mdPath = null;
|
|
1590
|
+
let mdScope = null;
|
|
1591
|
+
let mdSource = null;
|
|
1592
|
+
let mdDir = null;
|
|
1593
|
+
|
|
1594
|
+
if (projectExists) {
|
|
1595
|
+
mdPath = projectPath;
|
|
1596
|
+
mdScope = SKILL_SCOPE.PROJECT;
|
|
1597
|
+
mdSource = 'opencode';
|
|
1598
|
+
mdDir = projectDir;
|
|
1599
|
+
} else if (claudeExists) {
|
|
1600
|
+
mdPath = claudePath;
|
|
1601
|
+
mdScope = SKILL_SCOPE.PROJECT;
|
|
1602
|
+
mdSource = 'claude';
|
|
1603
|
+
mdDir = claudeDir;
|
|
1604
|
+
} else if (userExists) {
|
|
1605
|
+
mdPath = userPath;
|
|
1606
|
+
mdScope = SKILL_SCOPE.USER;
|
|
1607
|
+
mdSource = 'opencode';
|
|
1608
|
+
mdDir = userDir;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const mdExists = !!mdPath;
|
|
1612
|
+
|
|
1613
|
+
const sources = {
|
|
1614
|
+
md: {
|
|
1615
|
+
exists: mdExists,
|
|
1616
|
+
path: mdPath,
|
|
1617
|
+
dir: mdDir,
|
|
1618
|
+
scope: mdScope,
|
|
1619
|
+
source: mdSource,
|
|
1620
|
+
fields: [],
|
|
1621
|
+
supportingFiles: []
|
|
1622
|
+
},
|
|
1623
|
+
// Additional info about all locations
|
|
1624
|
+
projectMd: {
|
|
1625
|
+
exists: projectExists,
|
|
1626
|
+
path: projectPath,
|
|
1627
|
+
dir: projectDir
|
|
1628
|
+
},
|
|
1629
|
+
claudeMd: {
|
|
1630
|
+
exists: claudeExists,
|
|
1631
|
+
path: claudePath,
|
|
1632
|
+
dir: claudeDir
|
|
1633
|
+
},
|
|
1634
|
+
userMd: {
|
|
1635
|
+
exists: userExists,
|
|
1636
|
+
path: userPath,
|
|
1637
|
+
dir: userDir
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
if (mdExists && mdDir) {
|
|
1642
|
+
const { frontmatter, body } = parseMdFile(mdPath);
|
|
1643
|
+
sources.md.fields = Object.keys(frontmatter);
|
|
1644
|
+
// Include actual content values
|
|
1645
|
+
sources.md.description = frontmatter.description || '';
|
|
1646
|
+
sources.md.name = frontmatter.name || skillName;
|
|
1647
|
+
if (body) {
|
|
1648
|
+
sources.md.fields.push('instructions');
|
|
1649
|
+
sources.md.instructions = body;
|
|
1650
|
+
} else {
|
|
1651
|
+
sources.md.instructions = '';
|
|
1652
|
+
}
|
|
1653
|
+
sources.md.supportingFiles = listSkillSupportingFiles(mdDir);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
return sources;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function createSkill(skillName, config, workingDirectory, scope) {
|
|
1660
|
+
ensureDirs();
|
|
1661
|
+
|
|
1662
|
+
// Validate skill name (must be lowercase alphanumeric with hyphens, max 64 chars)
|
|
1663
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
|
1664
|
+
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Check if skill already exists at any location
|
|
1668
|
+
const existing = getSkillScope(skillName, workingDirectory);
|
|
1669
|
+
if (existing.path) {
|
|
1670
|
+
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Determine target path based on requested scope
|
|
1674
|
+
let targetDir;
|
|
1675
|
+
let targetPath;
|
|
1676
|
+
let targetScope;
|
|
1677
|
+
|
|
1678
|
+
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
|
1679
|
+
ensureProjectSkillDir(workingDirectory);
|
|
1680
|
+
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
|
1681
|
+
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
|
1682
|
+
targetScope = SKILL_SCOPE.PROJECT;
|
|
1683
|
+
} else {
|
|
1684
|
+
targetDir = getUserSkillDir(skillName);
|
|
1685
|
+
targetPath = getUserSkillPath(skillName);
|
|
1686
|
+
targetScope = SKILL_SCOPE.USER;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// Create skill directory
|
|
1690
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1691
|
+
|
|
1692
|
+
// Extract fields - scope is only for path determination
|
|
1693
|
+
const { instructions, scope: _scopeFromConfig, supportingFiles, ...frontmatter } = config;
|
|
1694
|
+
|
|
1695
|
+
// Ensure required fields
|
|
1696
|
+
if (!frontmatter.name) {
|
|
1697
|
+
frontmatter.name = skillName;
|
|
1698
|
+
}
|
|
1699
|
+
if (!frontmatter.description) {
|
|
1700
|
+
throw new Error('Skill description is required');
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
writeMdFile(targetPath, frontmatter, instructions || '');
|
|
1704
|
+
|
|
1705
|
+
// Write supporting files if provided
|
|
1706
|
+
if (supportingFiles && Array.isArray(supportingFiles)) {
|
|
1707
|
+
for (const file of supportingFiles) {
|
|
1708
|
+
if (file.path && file.content !== undefined) {
|
|
1709
|
+
writeSkillSupportingFile(targetDir, file.path, file.content);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function updateSkill(skillName, updates, workingDirectory) {
|
|
1718
|
+
ensureDirs();
|
|
1719
|
+
|
|
1720
|
+
// Get existing skill location
|
|
1721
|
+
const existing = getSkillScope(skillName, workingDirectory);
|
|
1722
|
+
if (!existing.path) {
|
|
1723
|
+
throw new Error(`Skill "${skillName}" not found`);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const mdPath = existing.path;
|
|
1727
|
+
const mdDir = path.dirname(mdPath);
|
|
1728
|
+
const mdData = parseMdFile(mdPath);
|
|
1729
|
+
|
|
1730
|
+
let mdModified = false;
|
|
1731
|
+
|
|
1732
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
1733
|
+
// Skip scope field - it's metadata only
|
|
1734
|
+
if (field === 'scope') {
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (field === 'instructions') {
|
|
1739
|
+
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
|
1740
|
+
mdData.body = normalizedValue;
|
|
1741
|
+
mdModified = true;
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (field === 'supportingFiles') {
|
|
1746
|
+
// Handle supporting files updates
|
|
1747
|
+
if (Array.isArray(value)) {
|
|
1748
|
+
for (const file of value) {
|
|
1749
|
+
if (file.delete && file.path) {
|
|
1750
|
+
deleteSkillSupportingFile(mdDir, file.path);
|
|
1751
|
+
} else if (file.path && file.content !== undefined) {
|
|
1752
|
+
writeSkillSupportingFile(mdDir, file.path, file.content);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Update frontmatter field
|
|
1760
|
+
mdData.frontmatter[field] = value;
|
|
1761
|
+
mdModified = true;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (mdModified) {
|
|
1765
|
+
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
console.log(`Updated skill: ${skillName} (path: ${mdPath})`);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function deleteSkill(skillName, workingDirectory) {
|
|
1772
|
+
let deleted = false;
|
|
1773
|
+
|
|
1774
|
+
// Check and delete from all locations
|
|
1775
|
+
|
|
1776
|
+
// Project level .opencode/skill/
|
|
1777
|
+
if (workingDirectory) {
|
|
1778
|
+
const projectDir = getProjectSkillDir(workingDirectory, skillName);
|
|
1779
|
+
if (fs.existsSync(projectDir)) {
|
|
1780
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
1781
|
+
console.log(`Deleted project-level skill directory: ${projectDir}`);
|
|
1782
|
+
deleted = true;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// Claude-compat .claude/skills/ - we allow deletion here too
|
|
1786
|
+
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
|
|
1787
|
+
if (fs.existsSync(claudeDir)) {
|
|
1788
|
+
fs.rmSync(claudeDir, { recursive: true, force: true });
|
|
1789
|
+
console.log(`Deleted claude-compat skill directory: ${claudeDir}`);
|
|
1790
|
+
deleted = true;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// User level
|
|
1795
|
+
const userDir = getUserSkillDir(skillName);
|
|
1796
|
+
if (fs.existsSync(userDir)) {
|
|
1797
|
+
fs.rmSync(userDir, { recursive: true, force: true });
|
|
1798
|
+
console.log(`Deleted user-level skill directory: ${userDir}`);
|
|
1799
|
+
deleted = true;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (!deleted) {
|
|
1803
|
+
throw new Error(`Skill "${skillName}" not found`);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
export {
|
|
1808
|
+
getAgentSources,
|
|
1809
|
+
getAgentScope,
|
|
1810
|
+
getAgentPermissionSource,
|
|
1811
|
+
getAgentConfig,
|
|
1812
|
+
createAgent,
|
|
1813
|
+
updateAgent,
|
|
1814
|
+
deleteAgent,
|
|
1815
|
+
getCommandSources,
|
|
1816
|
+
getCommandScope,
|
|
1817
|
+
createCommand,
|
|
1818
|
+
updateCommand,
|
|
1819
|
+
deleteCommand,
|
|
1820
|
+
getSkillSources,
|
|
1821
|
+
getSkillScope,
|
|
1822
|
+
discoverSkills,
|
|
1823
|
+
createSkill,
|
|
1824
|
+
updateSkill,
|
|
1825
|
+
deleteSkill,
|
|
1826
|
+
readSkillSupportingFile,
|
|
1827
|
+
writeSkillSupportingFile,
|
|
1828
|
+
deleteSkillSupportingFile,
|
|
1829
|
+
readConfig,
|
|
1830
|
+
writeConfig,
|
|
1831
|
+
getProviderSources,
|
|
1832
|
+
removeProviderConfig,
|
|
1833
|
+
AGENT_DIR,
|
|
1834
|
+
COMMAND_DIR,
|
|
1835
|
+
SKILL_DIR,
|
|
1836
|
+
CONFIG_FILE,
|
|
1837
|
+
AGENT_SCOPE,
|
|
1838
|
+
COMMAND_SCOPE,
|
|
1839
|
+
SKILL_SCOPE
|
|
1840
|
+
};
|