@openchamber/web 1.5.4 → 1.5.5

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/dist/index.html CHANGED
@@ -191,7 +191,7 @@
191
191
  pointer-events: none;
192
192
  }
193
193
  </style>
194
- <script type="module" crossorigin src="/assets/index-X4_48EOB.js"></script>
194
+ <script type="module" crossorigin src="/assets/index-DCvGkY6V.js"></script>
195
195
  <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-BrcdMJ39.js">
196
196
  <link rel="stylesheet" crossorigin href="/assets/vendor--Jn2c0Clh.css">
197
197
  <link rel="stylesheet" crossorigin href="/assets/index-DMDb0aQz.css">
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- (function(){"use strict";self.addEventListener("install",t=>{t.waitUntil(self.skipWaiting())}),self.addEventListener("activate",t=>{t.waitUntil(self.clients.claim())}),self.addEventListener("push",t=>{const i=t.data?.json()??null;if(!i)return;const n=i.title||"OpenChamber",a=i.body??"",o=i.icon??"/apple-touch-icon-180x180.png",s=i.badge??"/favicon-32.png";t.waitUntil(self.registration.showNotification(n,{body:a,icon:o,badge:s,tag:i.tag,data:i.data}))}),self.addEventListener("notificationclick",t=>{t.notification.close();const n=(t.notification.data??null)?.url??"/";t.waitUntil(self.clients.openWindow(n))})})();
1
+ (function(){"use strict";self.addEventListener("install",t=>{t.waitUntil(self.skipWaiting())}),self.addEventListener("activate",t=>{t.waitUntil(self.clients.claim())}),self.addEventListener("push",t=>{t.waitUntil((async()=>{const i=t.data?.json()??null;if(!i||(await self.clients.matchAll({type:"window",includeUncontrolled:!0})).some(a=>a.visibilityState==="visible"||a.focused))return;const e=i.title||"OpenChamber",s=i.body??"",l=i.icon??"/apple-touch-icon-180x180.png",o=i.badge??"/favicon-32.png";await self.registration.showNotification(e,{body:s,icon:l,badge:o,tag:i.tag,data:i.data})})())}),self.addEventListener("notificationclick",t=>{t.notification.close();const n=(t.notification.data??null)?.url??"/";t.waitUntil(self.clients.openWindow(n))})})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/web",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.js CHANGED
@@ -1009,10 +1009,7 @@ const getUiSessionTokenFromRequest = (req) => {
1009
1009
  return null;
1010
1010
  };
1011
1011
 
1012
- const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
1013
- if (!uiSessionToken) return [];
1014
- const store = await readPushSubscriptionsFromDisk();
1015
- const record = store.subscriptionsBySession?.[uiSessionToken];
1012
+ const normalizePushSubscriptions = (record) => {
1016
1013
  if (!Array.isArray(record)) return [];
1017
1014
  return record
1018
1015
  .map((entry) => {
@@ -1033,6 +1030,13 @@ const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
1033
1030
  .filter(Boolean);
1034
1031
  };
1035
1032
 
1033
+ const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
1034
+ if (!uiSessionToken) return [];
1035
+ const store = await readPushSubscriptionsFromDisk();
1036
+ const record = store.subscriptionsBySession?.[uiSessionToken];
1037
+ return normalizePushSubscriptions(record);
1038
+ };
1039
+
1036
1040
  const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
1037
1041
  if (!uiSessionToken) {
1038
1042
  return;
@@ -1108,71 +1112,75 @@ const buildSessionDeepLinkUrl = (sessionId) => {
1108
1112
  return `/?session=${encodeURIComponent(sessionId)}`;
1109
1113
  };
1110
1114
 
1111
- const sendPushToUiSession = async (uiSessionToken, payload) => {
1115
+ const sendPushToSubscription = async (sub, payload) => {
1112
1116
  await ensurePushInitialized();
1113
-
1114
- const subscriptions = await getPushSubscriptionsForUiSession(uiSessionToken);
1115
- if (subscriptions.length === 0) {
1116
- return;
1117
- }
1118
-
1119
1117
  const body = JSON.stringify(payload);
1120
1118
 
1121
- await Promise.all(subscriptions.map(async (sub) => {
1122
- const pushSubscription = {
1123
- endpoint: sub.endpoint,
1124
- keys: {
1125
- p256dh: sub.p256dh,
1126
- auth: sub.auth,
1127
- }
1128
- };
1119
+ const pushSubscription = {
1120
+ endpoint: sub.endpoint,
1121
+ keys: {
1122
+ p256dh: sub.p256dh,
1123
+ auth: sub.auth,
1124
+ }
1125
+ };
1129
1126
 
1130
- try {
1131
- await webPush.sendNotification(pushSubscription, body);
1132
- } catch (error) {
1133
- const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
1134
- if (statusCode === 410 || statusCode === 404) {
1135
- await removePushSubscriptionFromAllSessions(sub.endpoint);
1136
- return;
1137
- }
1138
- console.warn('[Push] Failed to send notification:', error);
1127
+ try {
1128
+ await webPush.sendNotification(pushSubscription, body);
1129
+ } catch (error) {
1130
+ const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
1131
+ if (statusCode === 410 || statusCode === 404) {
1132
+ await removePushSubscriptionFromAllSessions(sub.endpoint);
1133
+ return;
1139
1134
  }
1140
- }));
1135
+ console.warn('[Push] Failed to send notification:', error);
1136
+ }
1141
1137
  };
1142
1138
 
1143
1139
  const sendPushToAllUiSessions = async (payload, options = {}) => {
1144
1140
  const requireNoSse = options.requireNoSse === true;
1145
1141
  const store = await readPushSubscriptionsFromDisk();
1146
- const tokens = Object.keys(store.subscriptionsBySession || {});
1142
+ const sessions = store.subscriptionsBySession || {};
1143
+ const subscriptionsByEndpoint = new Map();
1144
+
1145
+ for (const [token, record] of Object.entries(sessions)) {
1146
+ const subscriptions = normalizePushSubscriptions(record);
1147
+ if (subscriptions.length === 0) continue;
1148
+
1149
+ for (const sub of subscriptions) {
1150
+ if (!subscriptionsByEndpoint.has(sub.endpoint)) {
1151
+ subscriptionsByEndpoint.set(sub.endpoint, sub);
1152
+ }
1153
+ }
1154
+ }
1147
1155
 
1148
- await Promise.all(tokens.map(async (token) => {
1149
- if (requireNoSse && isUiVisible(token)) {
1156
+ await Promise.all(Array.from(subscriptionsByEndpoint.entries()).map(async ([endpoint, sub]) => {
1157
+ if (requireNoSse && isAnyUiVisible()) {
1150
1158
  return;
1151
1159
  }
1152
- await sendPushToUiSession(token, payload);
1160
+ await sendPushToSubscription(sub, payload);
1153
1161
  }));
1154
1162
  };
1155
1163
 
1156
1164
  let pushInitialized = false;
1157
- const activeUiSseConnections = new Set();
1158
1165
 
1159
1166
 
1160
1167
 
1161
- const VISIBILITY_TTL_MS = 30000;
1162
1168
  const uiVisibilityByToken = new Map();
1169
+ let globalVisibilityState = false;
1163
1170
 
1164
1171
  const updateUiVisibility = (token, visible) => {
1165
1172
  if (!token) return;
1166
- uiVisibilityByToken.set(token, { visible: Boolean(visible), updatedAt: Date.now() });
1167
- };
1173
+ const now = Date.now();
1174
+ const nextVisible = Boolean(visible);
1175
+ uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
1176
+ globalVisibilityState = nextVisible;
1168
1177
 
1169
- const isUiVisible = (token) => {
1170
- const entry = uiVisibilityByToken.get(token);
1171
- if (!entry) return false;
1172
- if (Date.now() - entry.updatedAt > VISIBILITY_TTL_MS) return false;
1173
- return entry.visible === true;
1174
1178
  };
1175
1179
 
1180
+ const isAnyUiVisible = () => globalVisibilityState === true;
1181
+
1182
+ const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true;
1183
+
1176
1184
  const resolveVapidSubject = async () => {
1177
1185
  const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
1178
1186
  if (typeof configured === 'string' && configured.trim().length > 0) {
@@ -2808,15 +2816,6 @@ async function main(options = {}) {
2808
2816
  });
2809
2817
 
2810
2818
  app.get('/api/global/event', async (req, res) => {
2811
- const uiToken = getUiSessionTokenFromRequest(req);
2812
- if (uiToken) {
2813
- activeUiSseConnections.add(uiToken);
2814
- const cleanupUiToken = () => {
2815
- activeUiSseConnections.delete(uiToken);
2816
- };
2817
- req.on('close', cleanupUiToken);
2818
- req.on('error', cleanupUiToken);
2819
- }
2820
2819
  let targetUrl;
2821
2820
  try {
2822
2821
  targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
@@ -2928,15 +2927,6 @@ async function main(options = {}) {
2928
2927
  });
2929
2928
 
2930
2929
  app.get('/api/event', async (req, res) => {
2931
- const uiToken = getUiSessionTokenFromRequest(req);
2932
- if (uiToken) {
2933
- activeUiSseConnections.add(uiToken);
2934
- const cleanupUiToken = () => {
2935
- activeUiSseConnections.delete(uiToken);
2936
- };
2937
- req.on('close', cleanupUiToken);
2938
- req.on('error', cleanupUiToken);
2939
- }
2940
2930
  let targetUrl;
2941
2931
  try {
2942
2932
  targetUrl = new URL(buildOpenCodeUrl('/event', ''));
@@ -5,9 +5,9 @@ import yaml from 'yaml';
5
5
  import { parse as parseJsonc } from 'jsonc-parser';
6
6
 
7
7
  const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
8
- const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
9
- const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
10
- const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skill');
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
11
  const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
12
12
  const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
13
13
  ? path.resolve(process.env.OPENCODE_CONFIG)
@@ -51,10 +51,14 @@ function ensureDirs() {
51
51
  * Ensure project-level agent directory exists
52
52
  */
53
53
  function ensureProjectAgentDir(workingDirectory) {
54
- const projectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
54
+ const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
55
55
  if (!fs.existsSync(projectAgentDir)) {
56
56
  fs.mkdirSync(projectAgentDir, { recursive: true });
57
57
  }
58
+ const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
59
+ if (!fs.existsSync(legacyProjectAgentDir)) {
60
+ fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
61
+ }
58
62
  return projectAgentDir;
59
63
  }
60
64
 
@@ -62,14 +66,20 @@ function ensureProjectAgentDir(workingDirectory) {
62
66
  * Get project-level agent path
63
67
  */
64
68
  function getProjectAgentPath(workingDirectory, agentName) {
65
- return path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
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;
66
73
  }
67
74
 
68
75
  /**
69
76
  * Get user-level agent path
70
77
  */
71
78
  function getUserAgentPath(agentName) {
72
- return path.join(AGENT_DIR, `${agentName}.md`);
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;
73
83
  }
74
84
 
75
85
  /**
@@ -173,10 +183,14 @@ function getAgentPermissionSource(agentName, workingDirectory) {
173
183
  * Ensure project-level command directory exists
174
184
  */
175
185
  function ensureProjectCommandDir(workingDirectory) {
176
- const projectCommandDir = path.join(workingDirectory, '.opencode', 'command');
186
+ const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands');
177
187
  if (!fs.existsSync(projectCommandDir)) {
178
188
  fs.mkdirSync(projectCommandDir, { recursive: true });
179
189
  }
190
+ const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command');
191
+ if (!fs.existsSync(legacyProjectCommandDir)) {
192
+ fs.mkdirSync(legacyProjectCommandDir, { recursive: true });
193
+ }
180
194
  return projectCommandDir;
181
195
  }
182
196
 
@@ -184,14 +198,20 @@ function ensureProjectCommandDir(workingDirectory) {
184
198
  * Get project-level command path
185
199
  */
186
200
  function getProjectCommandPath(workingDirectory, commandName) {
187
- return path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`);
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;
188
205
  }
189
206
 
190
207
  /**
191
208
  * Get user-level command path
192
209
  */
193
210
  function getUserCommandPath(commandName) {
194
- return path.join(COMMAND_DIR, `${commandName}.md`);
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;
195
215
  }
196
216
 
197
217
  /**
@@ -245,10 +265,14 @@ function getCommandWritePath(commandName, workingDirectory, requestedScope) {
245
265
  * Ensure project-level skill directory exists
246
266
  */
247
267
  function ensureProjectSkillDir(workingDirectory) {
248
- const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
268
+ const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
249
269
  if (!fs.existsSync(projectSkillDir)) {
250
270
  fs.mkdirSync(projectSkillDir, { recursive: true });
251
271
  }
272
+ const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
273
+ if (!fs.existsSync(legacyProjectSkillDir)) {
274
+ fs.mkdirSync(legacyProjectSkillDir, { recursive: true });
275
+ }
252
276
  return projectSkillDir;
253
277
  }
254
278
 
@@ -256,28 +280,40 @@ function ensureProjectSkillDir(workingDirectory) {
256
280
  * Get project-level skill directory path (.opencode/skill/{name}/)
257
281
  */
258
282
  function getProjectSkillDir(workingDirectory, skillName) {
259
- return path.join(workingDirectory, '.opencode', 'skill', 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;
260
287
  }
261
288
 
262
289
  /**
263
290
  * Get project-level skill SKILL.md path
264
291
  */
265
292
  function getProjectSkillPath(workingDirectory, skillName) {
266
- return path.join(getProjectSkillDir(workingDirectory, skillName), 'SKILL.md');
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;
267
297
  }
268
298
 
269
299
  /**
270
300
  * Get user-level skill directory path
271
301
  */
272
302
  function getUserSkillDir(skillName) {
273
- return path.join(SKILL_DIR, 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;
274
307
  }
275
308
 
276
309
  /**
277
310
  * Get user-level skill SKILL.md path
278
311
  */
279
312
  function getUserSkillPath(skillName) {
280
- return path.join(getUserSkillDir(skillName), 'SKILL.md');
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;
281
317
  }
282
318
 
283
319
  /**
@@ -1463,9 +1499,9 @@ function discoverSkills(workingDirectory) {
1463
1499
  }
1464
1500
  };
1465
1501
 
1466
- // 1. Project level .opencode/skill/ (highest priority)
1502
+ // 1. Project level .opencode/skills/ (highest priority)
1467
1503
  if (workingDirectory) {
1468
- const projectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
1504
+ const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
1469
1505
  if (fs.existsSync(projectSkillDir)) {
1470
1506
  const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
1471
1507
  for (const entry of entries) {
@@ -1477,6 +1513,19 @@ function discoverSkills(workingDirectory) {
1477
1513
  }
1478
1514
  }
1479
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
+ }
1480
1529
 
1481
1530
  // 2. Claude-compatible .claude/skills/
1482
1531
  const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
@@ -1493,7 +1542,7 @@ function discoverSkills(workingDirectory) {
1493
1542
  }
1494
1543
  }
1495
1544
 
1496
- // 3. User level ~/.config/opencode/skill/
1545
+ // 3. User level ~/.config/opencode/skills/
1497
1546
  if (fs.existsSync(SKILL_DIR)) {
1498
1547
  const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
1499
1548
  for (const entry of entries) {
@@ -1505,6 +1554,19 @@ function discoverSkills(workingDirectory) {
1505
1554
  }
1506
1555
  }
1507
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
+ }
1508
1570
 
1509
1571
  return Array.from(skills.values());
1510
1572
  }
@@ -14,6 +14,17 @@ import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js';
14
14
 
15
15
  const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
16
16
 
17
+ function normalizeUserSkillDir(userSkillDir) {
18
+ if (!userSkillDir) return null;
19
+ const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
20
+ const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
21
+ if (userSkillDir === legacySkillDir) {
22
+ if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
23
+ return pluralSkillDir;
24
+ }
25
+ return userSkillDir;
26
+ }
27
+
17
28
  function validateSkillName(skillName) {
18
29
  if (typeof skillName !== 'string') return false;
19
30
  if (skillName.length < 1 || skillName.length > 64) return false;
@@ -41,7 +52,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
41
52
  throw new Error('workingDirectory is required for project installs');
42
53
  }
43
54
 
44
- return path.join(workingDirectory, '.opencode', 'skill', skillName);
55
+ return path.join(workingDirectory, '.opencode', 'skills', skillName);
45
56
  }
46
57
 
47
58
  /**
@@ -71,6 +82,11 @@ export async function installSkillsFromClawdHub({
71
82
  return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
72
83
  }
73
84
 
85
+ const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
86
+ if (normalizedUserSkillDir) {
87
+ userSkillDir = normalizedUserSkillDir;
88
+ }
89
+
74
90
  if (scope === 'project' && !workingDirectory) {
75
91
  return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
76
92
  }
@@ -7,6 +7,17 @@ import { parseSkillRepoSource } from './source.js';
7
7
 
8
8
  const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
9
9
 
10
+ function normalizeUserSkillDir(userSkillDir) {
11
+ if (!userSkillDir) return null;
12
+ const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
13
+ const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
14
+ if (userSkillDir === legacySkillDir) {
15
+ if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
16
+ return pluralSkillDir;
17
+ }
18
+ return userSkillDir;
19
+ }
20
+
10
21
  function validateSkillName(skillName) {
11
22
  if (typeof skillName !== 'string') return false;
12
23
  if (skillName.length < 1 || skillName.length > 64) return false;
@@ -103,7 +114,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
103
114
  throw new Error('workingDirectory is required for project installs');
104
115
  }
105
116
 
106
- return path.join(workingDirectory, '.opencode', 'skill', skillName);
117
+ return path.join(workingDirectory, '.opencode', 'skills', skillName);
107
118
  }
108
119
 
109
120
  export async function installSkillsFromRepository({
@@ -123,14 +134,19 @@ export async function installSkillsFromRepository({
123
134
  return { ok: false, error: gitCheck.error };
124
135
  }
125
136
 
126
- if (scope !== 'user' && scope !== 'project') {
127
- return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
137
+ const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
138
+ if (normalizedUserSkillDir) {
139
+ userSkillDir = normalizedUserSkillDir;
128
140
  }
129
141
 
130
142
  if (!userSkillDir) {
131
143
  return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
132
144
  }
133
145
 
146
+ if (scope !== 'user' && scope !== 'project') {
147
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
148
+ }
149
+
134
150
  if (scope === 'project' && !workingDirectory) {
135
151
  return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
136
152
  }