@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.
- package/dist/assets/{ToolOutputDialog-PTbkCnSK.js → ToolOutputDialog-C4dfK_Ah.js} +2 -2
- package/dist/assets/index-DRQKzuPI.css +1 -0
- package/dist/assets/index-Do__y_zo.js +2 -0
- package/dist/assets/main-Dl6b90os.js +236 -0
- package/dist/assets/{vendor-.bun-BrcdMJ39.js → vendor-.bun-CaD8MAGl.js} +92 -92
- package/dist/index.html +3 -3
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/index.js +1086 -60
- package/server/lib/git-service.js +59 -0
- package/server/lib/github-auth.js +149 -0
- package/server/lib/github-device-flow.js +50 -0
- package/server/lib/github-octokit.js +10 -0
- package/server/lib/github-repo.js +55 -0
- package/server/lib/opencode-config.js +79 -17
- package/server/lib/skills-catalog/clawdhub/install.js +17 -1
- package/server/lib/skills-catalog/install.js +19 -3
- package/dist/assets/index-DMDb0aQz.css +0 -1
- package/dist/assets/index-X4_48EOB.js +0 -2
- package/dist/assets/main-E3fIOPkV.js +0 -158
|
@@ -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, '
|
|
9
|
-
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, '
|
|
10
|
-
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, '
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
1502
|
+
// 1. Project level .opencode/skills/ (highest priority)
|
|
1467
1503
|
if (workingDirectory) {
|
|
1468
|
-
const projectSkillDir = path.join(workingDirectory, '.opencode', '
|
|
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/
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
127
|
-
|
|
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
|
}
|