@openchamber/web 1.5.4 → 1.5.6

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.
@@ -432,6 +432,65 @@ export async function getDiff(directory, { path, staged = false, contextLines =
432
432
  }
433
433
  }
434
434
 
435
+ export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
436
+ const git = simpleGit(normalizeDirectoryPath(directory));
437
+ const baseRef = typeof base === 'string' ? base.trim() : '';
438
+ const headRef = typeof head === 'string' ? head.trim() : '';
439
+ if (!baseRef || !headRef) {
440
+ throw new Error('base and head are required');
441
+ }
442
+
443
+ // Prefer remote-tracking base ref so merged commits don't reappear
444
+ // when local base branch is stale (common when user stays on feature branch).
445
+ let resolvedBase = baseRef;
446
+ const originCandidate = `refs/remotes/origin/${baseRef}`;
447
+ try {
448
+ const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
449
+ if (verified && verified.trim()) {
450
+ resolvedBase = `origin/${baseRef}`;
451
+ }
452
+ } catch {
453
+ // ignore
454
+ }
455
+
456
+ const args = ['diff', '--no-color'];
457
+ if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
458
+ args.push(`-U${Math.max(0, contextLines)}`);
459
+ }
460
+ args.push(`${resolvedBase}...${headRef}`);
461
+ if (path) {
462
+ args.push('--', path);
463
+ }
464
+ const diff = await git.raw(args);
465
+ return diff;
466
+ }
467
+
468
+ export async function getRangeFiles(directory, { base, head } = {}) {
469
+ const git = simpleGit(normalizeDirectoryPath(directory));
470
+ const baseRef = typeof base === 'string' ? base.trim() : '';
471
+ const headRef = typeof head === 'string' ? head.trim() : '';
472
+ if (!baseRef || !headRef) {
473
+ throw new Error('base and head are required');
474
+ }
475
+
476
+ let resolvedBase = baseRef;
477
+ const originCandidate = `refs/remotes/origin/${baseRef}`;
478
+ try {
479
+ const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
480
+ if (verified && verified.trim()) {
481
+ resolvedBase = `origin/${baseRef}`;
482
+ }
483
+ } catch {
484
+ // ignore
485
+ }
486
+
487
+ const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
488
+ return String(raw || '')
489
+ .split('\n')
490
+ .map((l) => l.trim())
491
+ .filter(Boolean);
492
+ }
493
+
435
494
  const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
436
495
 
437
496
  function isImageFile(filePath) {
@@ -0,0 +1,149 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
6
+ ? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
7
+ : path.join(os.homedir(), '.config', 'openchamber');
8
+
9
+ const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
10
+ const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
11
+ const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
12
+
13
+ const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
14
+ const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
15
+
16
+ function ensureStorageDir() {
17
+ if (!fs.existsSync(STORAGE_DIR)) {
18
+ fs.mkdirSync(STORAGE_DIR, { recursive: true });
19
+ }
20
+ }
21
+
22
+ function readJsonFile() {
23
+ ensureStorageDir();
24
+ if (!fs.existsSync(STORAGE_FILE)) {
25
+ return null;
26
+ }
27
+ try {
28
+ const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
29
+ const trimmed = raw.trim();
30
+ if (!trimmed) {
31
+ return null;
32
+ }
33
+ const parsed = JSON.parse(trimmed);
34
+ if (!parsed || typeof parsed !== 'object') {
35
+ return null;
36
+ }
37
+ return parsed;
38
+ } catch (error) {
39
+ console.error('Failed to read GitHub auth file:', error);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function writeJsonFile(payload) {
45
+ ensureStorageDir();
46
+ fs.writeFileSync(STORAGE_FILE, JSON.stringify(payload, null, 2), 'utf8');
47
+ try {
48
+ fs.chmodSync(STORAGE_FILE, 0o600);
49
+ } catch {
50
+ // best-effort
51
+ }
52
+ }
53
+
54
+ export function getGitHubAuth() {
55
+ const data = readJsonFile();
56
+ if (!data) {
57
+ return null;
58
+ }
59
+ const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
60
+ if (!accessToken) {
61
+ return null;
62
+ }
63
+ return {
64
+ accessToken,
65
+ scope: typeof data.scope === 'string' ? data.scope : '',
66
+ tokenType: typeof data.tokenType === 'string' ? data.tokenType : 'bearer',
67
+ createdAt: typeof data.createdAt === 'number' ? data.createdAt : null,
68
+ user: data.user && typeof data.user === 'object'
69
+ ? {
70
+ login: typeof data.user.login === 'string' ? data.user.login : null,
71
+ avatarUrl: typeof data.user.avatarUrl === 'string' ? data.user.avatarUrl : null,
72
+ id: typeof data.user.id === 'number' ? data.user.id : null,
73
+ name: typeof data.user.name === 'string' ? data.user.name : null,
74
+ email: typeof data.user.email === 'string' ? data.user.email : null,
75
+ }
76
+ : null,
77
+ };
78
+ }
79
+
80
+ export function setGitHubAuth({ accessToken, scope, tokenType, user }) {
81
+ if (!accessToken || typeof accessToken !== 'string') {
82
+ throw new Error('accessToken is required');
83
+ }
84
+ writeJsonFile({
85
+ accessToken,
86
+ scope: typeof scope === 'string' ? scope : '',
87
+ tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
88
+ createdAt: Date.now(),
89
+ user: user && typeof user === 'object'
90
+ ? {
91
+ login: typeof user.login === 'string' ? user.login : undefined,
92
+ avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
93
+ id: typeof user.id === 'number' ? user.id : undefined,
94
+ name: typeof user.name === 'string' ? user.name : undefined,
95
+ email: typeof user.email === 'string' ? user.email : undefined,
96
+ }
97
+ : undefined,
98
+ });
99
+ }
100
+
101
+ export function clearGitHubAuth() {
102
+ try {
103
+ if (fs.existsSync(STORAGE_FILE)) {
104
+ fs.unlinkSync(STORAGE_FILE);
105
+ }
106
+ return true;
107
+ } catch (error) {
108
+ console.error('Failed to clear GitHub auth file:', error);
109
+ return false;
110
+ }
111
+ }
112
+
113
+ export function getGitHubClientId() {
114
+ const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
115
+ const clientId = typeof raw === 'string' ? raw.trim() : '';
116
+ if (clientId) return clientId;
117
+
118
+ try {
119
+ if (fs.existsSync(SETTINGS_FILE)) {
120
+ const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
121
+ const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
122
+ if (stored) return stored;
123
+ }
124
+ } catch {
125
+ // ignore
126
+ }
127
+
128
+ return DEFAULT_GITHUB_CLIENT_ID;
129
+ }
130
+
131
+ export function getGitHubScopes() {
132
+ const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
133
+ const fromEnv = typeof raw === 'string' ? raw.trim() : '';
134
+ if (fromEnv) return fromEnv;
135
+
136
+ try {
137
+ if (fs.existsSync(SETTINGS_FILE)) {
138
+ const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
139
+ const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
140
+ if (stored) return stored;
141
+ }
142
+ } catch {
143
+ // ignore
144
+ }
145
+
146
+ return DEFAULT_GITHUB_SCOPES;
147
+ }
148
+
149
+ export const GITHUB_AUTH_FILE = STORAGE_FILE;
@@ -0,0 +1,50 @@
1
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
2
+ const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
3
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
4
+
5
+ const encodeForm = (params) => {
6
+ const body = new URLSearchParams();
7
+ for (const [key, value] of Object.entries(params)) {
8
+ if (value == null) continue;
9
+ body.set(key, String(value));
10
+ }
11
+ return body.toString();
12
+ };
13
+
14
+ async function postForm(url, params) {
15
+ const response = await fetch(url, {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/x-www-form-urlencoded',
19
+ Accept: 'application/json',
20
+ },
21
+ body: encodeForm(params),
22
+ });
23
+
24
+ const payload = await response.json().catch(() => null);
25
+ if (!response.ok) {
26
+ const message = payload?.error_description || payload?.error || response.statusText;
27
+ const error = new Error(message || 'GitHub request failed');
28
+ error.status = response.status;
29
+ error.payload = payload;
30
+ throw error;
31
+ }
32
+ return payload;
33
+ }
34
+
35
+ export async function startDeviceFlow({ clientId, scope }) {
36
+ return postForm(DEVICE_CODE_URL, {
37
+ client_id: clientId,
38
+ scope,
39
+ });
40
+ }
41
+
42
+ export async function exchangeDeviceCode({ clientId, deviceCode }) {
43
+ // GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
44
+ const payload = await postForm(ACCESS_TOKEN_URL, {
45
+ client_id: clientId,
46
+ device_code: deviceCode,
47
+ grant_type: DEVICE_GRANT_TYPE,
48
+ });
49
+ return payload;
50
+ }
@@ -0,0 +1,10 @@
1
+ import { Octokit } from '@octokit/rest';
2
+ import { getGitHubAuth } from './github-auth.js';
3
+
4
+ export function getOctokitOrNull() {
5
+ const auth = getGitHubAuth();
6
+ if (!auth?.accessToken) {
7
+ return null;
8
+ }
9
+ return new Octokit({ auth: auth.accessToken });
10
+ }
@@ -0,0 +1,55 @@
1
+ import { getRemoteUrl } from './git-service.js';
2
+
3
+ export const parseGitHubRemoteUrl = (raw) => {
4
+ if (typeof raw !== 'string') {
5
+ return null;
6
+ }
7
+ const value = raw.trim();
8
+ if (!value) {
9
+ return null;
10
+ }
11
+
12
+ // git@github.com:OWNER/REPO.git
13
+ if (value.startsWith('git@github.com:')) {
14
+ const rest = value.slice('git@github.com:'.length);
15
+ const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
16
+ const [owner, repo] = cleaned.split('/');
17
+ if (!owner || !repo) return null;
18
+ return { owner, repo, url: `https://github.com/${owner}/${repo}` };
19
+ }
20
+
21
+ // ssh://git@github.com/OWNER/REPO.git
22
+ if (value.startsWith('ssh://git@github.com/')) {
23
+ const rest = value.slice('ssh://git@github.com/'.length);
24
+ const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
25
+ const [owner, repo] = cleaned.split('/');
26
+ if (!owner || !repo) return null;
27
+ return { owner, repo, url: `https://github.com/${owner}/${repo}` };
28
+ }
29
+
30
+ // https://github.com/OWNER/REPO(.git)
31
+ try {
32
+ const url = new URL(value);
33
+ if (url.hostname !== 'github.com') {
34
+ return null;
35
+ }
36
+ const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
37
+ const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
38
+ const [owner, repo] = cleaned.split('/');
39
+ if (!owner || !repo) return null;
40
+ return { owner, repo, url: `https://github.com/${owner}/${repo}` };
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ export async function resolveGitHubRepoFromDirectory(directory) {
47
+ const remoteUrl = await getRemoteUrl(directory).catch(() => null);
48
+ if (!remoteUrl) {
49
+ return { repo: null, remoteUrl: null };
50
+ }
51
+ return {
52
+ repo: parseGitHubRemoteUrl(remoteUrl),
53
+ remoteUrl,
54
+ };
55
+ }
@@ -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
  }