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.
Files changed (175) hide show
  1. package/README.md +65 -0
  2. package/bin/cli.js +1055 -0
  3. package/dist/apple-touch-icon-120x120.png +0 -0
  4. package/dist/apple-touch-icon-152x152.png +0 -0
  5. package/dist/apple-touch-icon-167x167.png +0 -0
  6. package/dist/apple-touch-icon-180x180.png +0 -0
  7. package/dist/apple-touch-icon.png +0 -0
  8. package/dist/apple-touch-icon.svg +16 -0
  9. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
  69. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  70. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  71. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  72. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  73. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  74. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  75. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  76. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  77. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  78. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  79. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  80. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  81. package/dist/assets/index-BQJPAP5f.css +1 -0
  82. package/dist/assets/index-MBuF9PXk.js +2 -0
  83. package/dist/assets/main-C1GggQif.css +1 -0
  84. package/dist/assets/main-Dyy8WvPJ.js +311 -0
  85. package/dist/assets/vendor--DEobTDja.css +1 -0
  86. package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
  87. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  88. package/dist/assets/worker-suRQxAtO.js +155 -0
  89. package/dist/favicon-16.png +0 -0
  90. package/dist/favicon-32.png +0 -0
  91. package/dist/favicon.png +0 -0
  92. package/dist/favicon.svg +15 -0
  93. package/dist/index.html +325 -0
  94. package/dist/logo-dark-192x192.png +0 -0
  95. package/dist/logo-dark-512x512.svg +16 -0
  96. package/dist/logo-light-192x192.png +0 -0
  97. package/dist/logo-light-512x512.svg +16 -0
  98. package/dist/pwa-192.png +0 -0
  99. package/dist/pwa-512.png +0 -0
  100. package/dist/pwa-maskable-192.png +0 -0
  101. package/dist/pwa-maskable-512.png +0 -0
  102. package/dist/site.webmanifest +22 -0
  103. package/dist/sw.js +1 -0
  104. package/package.json +102 -0
  105. package/public/apple-touch-icon-120x120.png +0 -0
  106. package/public/apple-touch-icon-152x152.png +0 -0
  107. package/public/apple-touch-icon-167x167.png +0 -0
  108. package/public/apple-touch-icon-180x180.png +0 -0
  109. package/public/apple-touch-icon.png +0 -0
  110. package/public/apple-touch-icon.svg +16 -0
  111. package/public/favicon-16.png +0 -0
  112. package/public/favicon-32.png +0 -0
  113. package/public/favicon.png +0 -0
  114. package/public/favicon.svg +15 -0
  115. package/public/logo-dark-192x192.png +0 -0
  116. package/public/logo-dark-512x512.svg +16 -0
  117. package/public/logo-light-192x192.png +0 -0
  118. package/public/logo-light-512x512.svg +16 -0
  119. package/public/pwa-192.png +0 -0
  120. package/public/pwa-512.png +0 -0
  121. package/public/pwa-maskable-192.png +0 -0
  122. package/public/pwa-maskable-512.png +0 -0
  123. package/public/site.webmanifest +22 -0
  124. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  125. package/server/index.d.ts +28 -0
  126. package/server/index.js +11181 -0
  127. package/server/lib/cloudflare-tunnel.js +196 -0
  128. package/server/lib/git-credentials.js +74 -0
  129. package/server/lib/git-identity-storage.js +110 -0
  130. package/server/lib/git-service.js +2690 -0
  131. package/server/lib/github-auth.js +307 -0
  132. package/server/lib/github-device-flow.js +50 -0
  133. package/server/lib/github-octokit.js +10 -0
  134. package/server/lib/github-repo.js +55 -0
  135. package/server/lib/notification-message.js +49 -0
  136. package/server/lib/notification-message.test.js +59 -0
  137. package/server/lib/opencode-auth.js +81 -0
  138. package/server/lib/opencode-config.js +1840 -0
  139. package/server/lib/opencode-config.js.d.ts +12 -0
  140. package/server/lib/package-manager.js +255 -0
  141. package/server/lib/quota/DOCUMENTATION.md +52 -0
  142. package/server/lib/quota/index.js +21 -0
  143. package/server/lib/quota/providers/claude.js +107 -0
  144. package/server/lib/quota/providers/codex.js +111 -0
  145. package/server/lib/quota/providers/copilot.js +165 -0
  146. package/server/lib/quota/providers/google/api.js +92 -0
  147. package/server/lib/quota/providers/google/auth.js +108 -0
  148. package/server/lib/quota/providers/google/index.js +124 -0
  149. package/server/lib/quota/providers/google/transforms.js +109 -0
  150. package/server/lib/quota/providers/index.js +128 -0
  151. package/server/lib/quota/providers/interface.js +55 -0
  152. package/server/lib/quota/providers/kimi.js +108 -0
  153. package/server/lib/quota/providers/nanogpt.js +124 -0
  154. package/server/lib/quota/providers/openai.js +91 -0
  155. package/server/lib/quota/providers/openrouter.js +92 -0
  156. package/server/lib/quota/providers/zai.js +91 -0
  157. package/server/lib/quota/utils/auth.js +46 -0
  158. package/server/lib/quota/utils/formatters.js +76 -0
  159. package/server/lib/quota/utils/index.js +10 -0
  160. package/server/lib/quota/utils/transformers.js +55 -0
  161. package/server/lib/skills-catalog/cache.js +29 -0
  162. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  163. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  164. package/server/lib/skills-catalog/clawdhub/install.js +223 -0
  165. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  166. package/server/lib/skills-catalog/curated-sources.js +21 -0
  167. package/server/lib/skills-catalog/git.js +76 -0
  168. package/server/lib/skills-catalog/install.js +280 -0
  169. package/server/lib/skills-catalog/scan.js +221 -0
  170. package/server/lib/skills-catalog/source.js +85 -0
  171. package/server/lib/summarization-service.js +171 -0
  172. package/server/lib/terminal-input-ws-protocol.js +66 -0
  173. package/server/lib/terminal-input-ws-protocol.test.js +138 -0
  174. package/server/lib/tts-service.js +162 -0
  175. 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
+ };