@openchamber/web 1.11.1 → 1.11.2

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 (42) hide show
  1. package/dist/assets/{JsonTreeView-bLRkNPS9.js → JsonTreeView-9F0tH9yA.js} +1 -1
  2. package/dist/assets/{MarkdownRendererImpl-FSTq-eVA.js → MarkdownRendererImpl-C3QofAGm.js} +1 -1
  3. package/dist/assets/{MultiRunWindow-DezP_Pyy.js → MultiRunWindow-wnUGv0Dl.js} +1 -1
  4. package/dist/assets/{OnboardingScreen-CeZMVgH8.js → OnboardingScreen-17dAs0NH.js} +2 -2
  5. package/dist/assets/{SettingsWindow-Dlfk0yzA.js → SettingsWindow-BgyVY5gz.js} +1 -1
  6. package/dist/assets/TerminalView-D11XIZuz.js +1 -0
  7. package/dist/assets/{ToolOutputDialog-CePopKV7.js → ToolOutputDialog-DFG7ANVw.js} +6 -6
  8. package/dist/assets/es-Chl2Hu6K.js +15 -0
  9. package/dist/assets/index-0bVkxg-Z.css +1 -0
  10. package/dist/assets/{index-CKlDk4Io.js → index-BV2XTsJJ.js} +1 -1
  11. package/dist/assets/ko-BSrH3F9n.js +15 -0
  12. package/dist/assets/{main-BvaFBcXN.js → main-BHkNwOz1.js} +2 -2
  13. package/dist/assets/main-Bqf4fXgq.js +225 -0
  14. package/dist/assets/miniChat-BmB-E5xo.js +2 -0
  15. package/dist/assets/{modelPrefsAutoSave-HUPrH1r2.js → modelPrefsAutoSave-2uwW8uD9.js} +98 -96
  16. package/dist/assets/pl-YlGvPmFg.js +15 -0
  17. package/dist/assets/pt-BR-BonIMDN_.js +15 -0
  18. package/dist/assets/{renderElectronMiniChatApp-EXGwfTjq.js → renderElectronMiniChatApp-B_qrXCU2.js} +2 -2
  19. package/dist/assets/uk-lPqA3MHn.js +15 -0
  20. package/dist/assets/{vendor-.bun-BFTPeDgG.js → vendor-.bun-Boz6Tqcq.js} +20 -20
  21. package/dist/assets/zh-CN-C5nQQsUL.js +15 -0
  22. package/dist/index.html +4 -4
  23. package/dist/mini-chat.html +4 -4
  24. package/package.json +1 -1
  25. package/server/lib/git/DOCUMENTATION.md +1 -0
  26. package/server/lib/git/routes.js +26 -0
  27. package/server/lib/git/service.js +96 -10
  28. package/server/lib/git/service.test.js +39 -0
  29. package/server/lib/opencode/settings-helpers.js +10 -1
  30. package/server/lib/opencode/settings-helpers.test.js +35 -0
  31. package/server/lib/opencode/skill-routes.js +43 -49
  32. package/server/lib/opencode/skills.js +78 -10
  33. package/dist/assets/TerminalView-CdCfCaFq.js +0 -1
  34. package/dist/assets/es-CyjenLd8.js +0 -15
  35. package/dist/assets/index-NnYXwoao.css +0 -1
  36. package/dist/assets/ko-Cs7yF9Jn.js +0 -15
  37. package/dist/assets/main-DcpUnRRo.js +0 -225
  38. package/dist/assets/miniChat-Dmzb8Mwv.js +0 -2
  39. package/dist/assets/pl-XjTt7Hsk.js +0 -15
  40. package/dist/assets/pt-BR-knvpJ94d.js +0 -15
  41. package/dist/assets/uk-DUPvcQAj.js +0 -15
  42. package/dist/assets/zh-CN-DWiTG93s.js +0 -15
package/dist/index.html CHANGED
@@ -532,10 +532,10 @@
532
532
  pointer-events: none;
533
533
  }
534
534
  </style>
535
- <script type="module" crossorigin src="/assets/main-BvaFBcXN.js"></script>
536
- <link rel="modulepreload" crossorigin href="/assets/index-CKlDk4Io.js">
537
- <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-BFTPeDgG.js">
538
- <link rel="stylesheet" crossorigin href="/assets/index-NnYXwoao.css">
535
+ <script type="module" crossorigin src="/assets/main-BHkNwOz1.js"></script>
536
+ <link rel="modulepreload" crossorigin href="/assets/index-BV2XTsJJ.js">
537
+ <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-Boz6Tqcq.js">
538
+ <link rel="stylesheet" crossorigin href="/assets/index-0bVkxg-Z.css">
539
539
  <link rel="stylesheet" crossorigin href="/assets/vendor--V65Sl9C2.css">
540
540
  </head>
541
541
  <body class="h-full bg-background text-foreground">
@@ -4,10 +4,10 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
6
  <title>OpenChamber Mini Chat</title>
7
- <script type="module" crossorigin src="/assets/miniChat-Dmzb8Mwv.js"></script>
8
- <link rel="modulepreload" crossorigin href="/assets/index-CKlDk4Io.js">
9
- <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-BFTPeDgG.js">
10
- <link rel="stylesheet" crossorigin href="/assets/index-NnYXwoao.css">
7
+ <script type="module" crossorigin src="/assets/miniChat-BmB-E5xo.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/assets/index-BV2XTsJJ.js">
9
+ <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-Boz6Tqcq.js">
10
+ <link rel="stylesheet" crossorigin href="/assets/index-0bVkxg-Z.css">
11
11
  <link rel="stylesheet" crossorigin href="/assets/vendor--V65Sl9C2.css">
12
12
  </head>
13
13
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/web",
3
- "version": "1.11.1",
3
+ "version": "1.11.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -58,6 +58,7 @@ The following functions are exported and used by the web server:
58
58
  ### Log Operations
59
59
  - `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
60
60
  - `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
61
+ - `getCommitFileDiff(directory, hash, filePath, isBinary)`: Get before/after content for a specific file in a commit. Returns `{ original, modified, isBinary }`. Runs `git show <hash>^:<path>` and `git show <hash>:<path>` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases).
61
62
 
62
63
  ### Merge and Rebase Operations
63
64
  - `rebase(directory, options)`: Start a rebase onto a target branch.
@@ -943,4 +943,30 @@ export function registerGitRoutes(app) {
943
943
  }
944
944
  });
945
945
 
946
+ app.get('/api/git/commit-file-diff', async (req, res) => {
947
+ const { getCommitFileDiff } = await getGitLibraries();
948
+ try {
949
+ const { directory, hash, path: filePath } = req.query;
950
+ if (!directory || typeof directory !== 'string') {
951
+ return res.status(400).json({ error: 'directory parameter is required' });
952
+ }
953
+ if (!hash || typeof hash !== 'string') {
954
+ return res.status(400).json({ error: 'hash parameter is required' });
955
+ }
956
+ if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) {
957
+ return res.status(400).json({ error: 'hash must be a valid commit SHA' });
958
+ }
959
+ if (!filePath || typeof filePath !== 'string') {
960
+ return res.status(400).json({ error: 'path parameter is required' });
961
+ }
962
+
963
+ const isBinary = req.query.binary === 'true';
964
+ const result = await getCommitFileDiff(directory, hash, filePath, isBinary);
965
+ res.json(result);
966
+ } catch (error) {
967
+ console.error('Failed to get commit file diff:', error);
968
+ res.status(500).json({ error: error.message || 'Failed to get commit file diff' });
969
+ }
970
+ });
971
+
946
972
  }
@@ -1611,6 +1611,27 @@ const parseIsBinaryFromNumstat = (raw) => {
1611
1611
  return added === '-' || deleted === '-';
1612
1612
  };
1613
1613
 
1614
+ const extractGitStatusPath = (status, pathPart) => {
1615
+ if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
1616
+ return pathPart.split('\t').pop() || pathPart;
1617
+ }
1618
+ return pathPart;
1619
+ };
1620
+
1621
+ const extractGitNumstatDestinationPath = (filePath) => {
1622
+ if (!filePath.includes(' => ')) {
1623
+ return filePath;
1624
+ }
1625
+
1626
+ const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
1627
+ if (braceMatch) {
1628
+ const [, prefix, , destination, suffix] = braceMatch;
1629
+ return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
1630
+ }
1631
+
1632
+ return filePath.split(' => ').pop()?.trim() || filePath;
1633
+ };
1634
+
1614
1635
  const looksBinaryBySniff = async (absolutePath) => {
1615
1636
  try {
1616
1637
  const handle = await fsp.open(absolutePath, 'r');
@@ -2683,14 +2704,52 @@ export async function deleteBranch(directory, branch, options = {}) {
2683
2704
  }
2684
2705
  }
2685
2706
 
2707
+ /**
2708
+ * Resolve a log base ref using local-first semantics.
2709
+ *
2710
+ * - If `from` is falsy / whitespace → return undefined.
2711
+ * - If the local ref resolves → return it unchanged (caller's intent preserved).
2712
+ * - If the local ref is absent but `origin/<from>` exists → return `origin/<from>`
2713
+ * (common when the user has never checked out the base branch locally).
2714
+ * - If neither resolves → return `from` unchanged so git surfaces a meaningful error.
2715
+ *
2716
+ * @param {string | undefined} from - The raw `from` option value.
2717
+ * @param {(ref: string) => Promise<boolean>} checkRef - Returns true when the ref resolves.
2718
+ * @returns {Promise<string | undefined>}
2719
+ */
2720
+ export async function resolveBaseRefForLog(from, checkRef) {
2721
+ const normalized = typeof from === 'string' ? from.trim() : undefined;
2722
+ if (!normalized) return undefined;
2723
+
2724
+ if (await checkRef(normalized)) return normalized;
2725
+
2726
+ const originRef = `refs/remotes/origin/${normalized}`;
2727
+ if (await checkRef(originRef)) return `origin/${normalized}`;
2728
+
2729
+ return normalized;
2730
+ }
2731
+
2686
2732
  export async function getLog(directory, options = {}) {
2687
2733
  const git = await createGit(directory);
2688
2734
 
2689
2735
  try {
2690
2736
  const maxCount = options.maxCount || 50;
2737
+
2738
+ // Prefer the local ref; fall back to origin/<from> only when the local ref
2739
+ // cannot be resolved (e.g. user has never checked out the base branch).
2740
+ const checkRef = async (ref) => {
2741
+ try {
2742
+ const out = await git.raw(['rev-parse', '--verify', ref]);
2743
+ return Boolean(out && out.trim());
2744
+ } catch {
2745
+ return false;
2746
+ }
2747
+ };
2748
+ const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
2749
+
2691
2750
  const baseLog = await git.log({
2692
2751
  maxCount,
2693
- from: options.from,
2752
+ from: resolvedFrom,
2694
2753
  to: options.to,
2695
2754
  file: options.file
2696
2755
  });
@@ -2703,10 +2762,10 @@ export async function getLog(directory, options = {}) {
2703
2762
  '--shortstat'
2704
2763
  ];
2705
2764
 
2706
- if (options.from && options.to) {
2707
- logArgs.push(`${options.from}..${options.to}`);
2708
- } else if (options.from) {
2709
- logArgs.push(`${options.from}..HEAD`);
2765
+ if (resolvedFrom && options.to) {
2766
+ logArgs.push(`${resolvedFrom}..${options.to}`);
2767
+ } else if (resolvedFrom) {
2768
+ logArgs.push(`${resolvedFrom}..HEAD`);
2710
2769
  } else if (options.to) {
2711
2770
  logArgs.push(options.to);
2712
2771
  }
@@ -2990,15 +3049,13 @@ export async function getCommitFiles(directory, commitHash) {
2990
3049
  for (const line of statusLines) {
2991
3050
  const match = line.match(/^([AMDRC])\d*\t(.+)$/);
2992
3051
  if (match) {
2993
- const [, status, path] = match;
2994
- statusMap.set(path, status);
3052
+ const [, status, pathPart] = match;
3053
+ statusMap.set(extractGitStatusPath(status, pathPart), status);
2995
3054
  }
2996
3055
  }
2997
3056
 
2998
3057
  for (const file of files) {
2999
- const basePath = file.path.includes(' => ')
3000
- ? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path
3001
- : file.path;
3058
+ const basePath = extractGitNumstatDestinationPath(file.path);
3002
3059
 
3003
3060
  const status = statusMap.get(basePath) || statusMap.get(file.path);
3004
3061
  if (status) {
@@ -3072,6 +3129,9 @@ export async function getRemotes(directory) {
3072
3129
  pushUrl: remote.refs.push
3073
3130
  }));
3074
3131
  } catch (error) {
3132
+ if (isNotGitRepositoryError(error)) {
3133
+ return [];
3134
+ }
3075
3135
  console.error('Failed to get remotes:', error);
3076
3136
  throw error;
3077
3137
  }
@@ -3341,3 +3401,29 @@ export async function getConflictDetails(directory) {
3341
3401
  throw error;
3342
3402
  }
3343
3403
  }
3404
+
3405
+ export async function getCommitFileDiff(directory, hash, filePath, isBinary) {
3406
+ if (!directory || !hash || !filePath) {
3407
+ throw new Error('directory, hash, and path are required for getCommitFileDiff');
3408
+ }
3409
+
3410
+ if (isBinary) {
3411
+ return { original: '', modified: '', isBinary: true };
3412
+ }
3413
+
3414
+ const directoryPath = normalizeDirectoryPath(directory);
3415
+
3416
+ const [originalResult, modifiedResult] = await Promise.all([
3417
+ runGitCommand(directoryPath, ['show', `${hash}^:${filePath}`]),
3418
+ runGitCommand(directoryPath, ['show', `${hash}:${filePath}`]),
3419
+ ]);
3420
+
3421
+ const original = originalResult.success ? originalResult.stdout : '';
3422
+ const modified = modifiedResult.success ? modifiedResult.stdout : '';
3423
+
3424
+ if (!originalResult.success && !modifiedResult.success) {
3425
+ throw new Error(`Failed to read file content at commit ${hash}: ${originalResult.stderr || modifiedResult.stderr}`);
3426
+ }
3427
+
3428
+ return { original, modified, isBinary: false };
3429
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveBaseRefForLog } from './service.js';
4
+
5
+ describe('resolveBaseRefForLog', () => {
6
+ it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
7
+ // Both local 'main' and 'refs/remotes/origin/main' are present.
8
+ // The local ref takes precedence — callers that ask for 'main' get 'main'.
9
+ const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
10
+ expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
11
+ });
12
+
13
+ it('falls back to origin/<from> when local ref cannot be resolved but origin can', async () => {
14
+ // Local 'main' is absent (e.g. user never checked it out), but origin/main exists.
15
+ const checkRef = async (ref) => ref === 'refs/remotes/origin/main';
16
+ expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main');
17
+ });
18
+
19
+ it('returns the original ref when neither local nor origin ref can be resolved', async () => {
20
+ // Neither ref exists; return as-is so git surfaces a meaningful error.
21
+ const checkRef = async () => false;
22
+ expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
23
+ });
24
+
25
+ it('returns undefined when from is undefined', async () => {
26
+ const checkRef = async () => true;
27
+ expect(await resolveBaseRefForLog(undefined, checkRef)).toBeUndefined();
28
+ });
29
+
30
+ it('returns undefined when from is an empty string', async () => {
31
+ const checkRef = async () => true;
32
+ expect(await resolveBaseRefForLog('', checkRef)).toBeUndefined();
33
+ });
34
+
35
+ it('returns undefined when from is a whitespace-only string', async () => {
36
+ const checkRef = async () => true;
37
+ expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined();
38
+ });
39
+ });
@@ -168,6 +168,9 @@ export const createSettingsHelpers = (dependencies) => {
168
168
  if (typeof candidate.showReasoningTraces === 'boolean') {
169
169
  result.showReasoningTraces = candidate.showReasoningTraces;
170
170
  }
171
+ if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
172
+ result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
173
+ }
171
174
  if (typeof candidate.showTextJustificationActivity === 'boolean') {
172
175
  result.showTextJustificationActivity = candidate.showTextJustificationActivity;
173
176
  }
@@ -728,7 +731,13 @@ export const createSettingsHelpers = (dependencies) => {
728
731
  ? settings.showReasoningTraces
729
732
  : typeof sanitized.showReasoningTraces === 'boolean'
730
733
  ? sanitized.showReasoningTraces
731
- : false
734
+ : false,
735
+ collapsibleThinkingBlocks:
736
+ typeof settings.collapsibleThinkingBlocks === 'boolean'
737
+ ? settings.collapsibleThinkingBlocks
738
+ : typeof sanitized.collapsibleThinkingBlocks === 'boolean'
739
+ ? sanitized.collapsibleThinkingBlocks
740
+ : true,
732
741
  };
733
742
  };
734
743
 
@@ -71,4 +71,39 @@ describe('settings helpers', () => {
71
71
 
72
72
  expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'fixed-layout' })).toEqual({});
73
73
  });
74
+
75
+ it('accepts collapsibleThinkingBlocks as a persisted shared setting', () => {
76
+ const helpers = createTestHelpers();
77
+
78
+ expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: true })).toEqual({
79
+ collapsibleThinkingBlocks: true,
80
+ });
81
+ expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: false })).toEqual({
82
+ collapsibleThinkingBlocks: false,
83
+ });
84
+ });
85
+
86
+ it('rejects non-boolean collapsibleThinkingBlocks values', () => {
87
+ const helpers = createTestHelpers();
88
+
89
+ expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: 'true' })).toEqual({});
90
+ expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: 1 })).toEqual({});
91
+ });
92
+
93
+ it('includes collapsibleThinkingBlocks in formatSettingsResponse', () => {
94
+ const helpers = createTestHelpers();
95
+
96
+ const response = helpers.formatSettingsResponse({ collapsibleThinkingBlocks: false });
97
+ expect(response.collapsibleThinkingBlocks).toBe(false);
98
+
99
+ const responseTrue = helpers.formatSettingsResponse({ collapsibleThinkingBlocks: true });
100
+ expect(responseTrue.collapsibleThinkingBlocks).toBe(true);
101
+ });
102
+
103
+ it('defaults collapsibleThinkingBlocks to true in formatSettingsResponse when absent', () => {
104
+ const helpers = createTestHelpers();
105
+
106
+ const response = helpers.formatSettingsResponse({});
107
+ expect(response.collapsibleThinkingBlocks).toBe(true);
108
+ });
74
109
  });
@@ -1,3 +1,5 @@
1
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
2
+
1
3
  export const registerSkillRoutes = (app, dependencies) => {
2
4
  const {
3
5
  fs,
@@ -14,7 +16,6 @@ export const registerSkillRoutes = (app, dependencies) => {
14
16
  getOpenCodeAuthHeaders,
15
17
  getOpenCodePort,
16
18
  getSkillSources,
17
- discoverSkills,
18
19
  createSkill,
19
20
  updateSkill,
20
21
  deleteSkill,
@@ -114,31 +115,23 @@ export const registerSkillRoutes = (app, dependencies) => {
114
115
 
115
116
  const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
116
117
  if (!getOpenCodePort()) {
117
- return null;
118
+ return [];
118
119
  }
119
120
 
120
121
  try {
121
- const url = new URL(buildOpenCodeUrl('/skill', ''));
122
- if (workingDirectory) {
123
- url.searchParams.set('directory', workingDirectory);
124
- }
125
-
126
- const response = await fetch(url.toString(), {
127
- method: 'GET',
128
- headers: {
129
- Accept: 'application/json',
130
- ...getOpenCodeAuthHeaders(),
131
- },
132
- signal: AbortSignal.timeout(8_000),
122
+ const client = createOpencodeClient({
123
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
124
+ directory: workingDirectory || undefined,
125
+ headers: getOpenCodeAuthHeaders(),
126
+ fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
133
127
  });
134
128
 
135
- if (!response.ok) {
136
- return null;
137
- }
138
-
139
- const payload = await response.json();
129
+ const response = await client.app.skills(
130
+ workingDirectory ? { directory: workingDirectory } : undefined,
131
+ );
132
+ const payload = response?.data;
140
133
  if (!Array.isArray(payload)) {
141
- return null;
134
+ return [];
142
135
  }
143
136
 
144
137
  return payload
@@ -146,7 +139,7 @@ export const registerSkillRoutes = (app, dependencies) => {
146
139
  const name = typeof item?.name === 'string' ? item.name.trim() : '';
147
140
  const location = typeof item?.location === 'string' ? item.location : '';
148
141
  const description = typeof item?.description === 'string' ? item.description : '';
149
- if (!name || !location) {
142
+ if (!name || !location || location === '<built-in>') {
150
143
  return null;
151
144
  }
152
145
  const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
@@ -159,8 +152,9 @@ export const registerSkillRoutes = (app, dependencies) => {
159
152
  };
160
153
  })
161
154
  .filter(Boolean);
162
- } catch {
163
- return null;
155
+ } catch (error) {
156
+ console.error('Failed to list OpenCode skills:', error);
157
+ return [];
164
158
  }
165
159
  };
166
160
 
@@ -191,11 +185,11 @@ export const registerSkillRoutes = (app, dependencies) => {
191
185
 
192
186
  app.get('/api/config/skills', async (req, res) => {
193
187
  try {
194
- const { directory, error } = await resolveProjectDirectory(req);
195
- if (!directory) {
188
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
189
+ if (error) {
196
190
  return res.status(400).json({ error });
197
191
  }
198
- const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
192
+ const skills = await fetchOpenCodeDiscoveredSkills(directory);
199
193
 
200
194
  const enrichedSkills = skills.map((skill) => {
201
195
  const sources = getSkillSources(skill.name, directory, skill);
@@ -277,9 +271,7 @@ export const registerSkillRoutes = (app, dependencies) => {
277
271
  return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
278
272
  }
279
273
 
280
- const discovered = directory
281
- ? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
282
- : [];
274
+ const discovered = await fetchOpenCodeDiscoveredSkills(directory);
283
275
  const installedByName = new Map(discovered.map((s) => [s.name, s]));
284
276
 
285
277
  if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
@@ -504,11 +496,11 @@ export const registerSkillRoutes = (app, dependencies) => {
504
496
  app.get('/api/config/skills/:name', async (req, res) => {
505
497
  try {
506
498
  const skillName = req.params.name;
507
- const { directory, error } = await resolveProjectDirectory(req);
508
- if (!directory) {
499
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
500
+ if (error) {
509
501
  return res.status(400).json({ error });
510
502
  }
511
- const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
503
+ const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
512
504
  .find((skill) => skill.name === skillName) || null;
513
505
  const sources = getSkillSources(skillName, directory, discoveredSkill);
514
506
 
@@ -532,12 +524,12 @@ export const registerSkillRoutes = (app, dependencies) => {
532
524
  if (isUnsafeSkillRelativePath(filePath)) {
533
525
  return res.status(400).json({ error: 'Invalid file path' });
534
526
  }
535
- const { directory, error } = await resolveProjectDirectory(req);
536
- if (!directory) {
527
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
528
+ if (error) {
537
529
  return res.status(400).json({ error });
538
530
  }
539
531
 
540
- const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
532
+ const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
541
533
  .find((skill) => skill.name === skillName) || null;
542
534
  const sources = getSkillSources(skillName, directory, discoveredSkill);
543
535
  if (!sources.md.exists || !sources.md.dir) {
@@ -563,9 +555,11 @@ export const registerSkillRoutes = (app, dependencies) => {
563
555
  try {
564
556
  const skillName = req.params.name;
565
557
  const { scope, source: skillSource, ...config } = req.body;
566
- const { directory, error } = await resolveProjectDirectory(req);
567
- if (!directory) {
568
- return res.status(400).json({ error });
558
+ const { directory, error } = scope === SKILL_SCOPE.PROJECT
559
+ ? await resolveProjectDirectory(req)
560
+ : await resolveOptionalProjectDirectory(req);
561
+ if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) {
562
+ return res.status(400).json({ error: error || 'Project skill creation requires a directory' });
569
563
  }
570
564
 
571
565
  console.log('[Server] Creating skill:', skillName);
@@ -590,15 +584,15 @@ export const registerSkillRoutes = (app, dependencies) => {
590
584
  try {
591
585
  const skillName = req.params.name;
592
586
  const updates = req.body;
593
- const { directory, error } = await resolveProjectDirectory(req);
594
- if (!directory) {
587
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
588
+ if (error) {
595
589
  return res.status(400).json({ error });
596
590
  }
597
591
 
598
592
  console.log(`[Server] Updating skill: ${skillName}`);
599
593
  console.log('[Server] Working directory:', directory);
600
594
 
601
- updateSkill(skillName, updates, directory);
595
+ updateSkill(skillName, updates, directory, updates?.targetPath);
602
596
  await refreshOpenCodeAfterConfigChange('skill update');
603
597
 
604
598
  res.json({
@@ -621,12 +615,12 @@ export const registerSkillRoutes = (app, dependencies) => {
621
615
  return res.status(400).json({ error: 'Invalid file path' });
622
616
  }
623
617
  const { content } = req.body;
624
- const { directory, error } = await resolveProjectDirectory(req);
625
- if (!directory) {
618
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
619
+ if (error) {
626
620
  return res.status(400).json({ error });
627
621
  }
628
622
 
629
- const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
623
+ const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
630
624
  .find((skill) => skill.name === skillName) || null;
631
625
  const sources = getSkillSources(skillName, directory, discoveredSkill);
632
626
  if (!sources.md.exists || !sources.md.dir) {
@@ -655,12 +649,12 @@ export const registerSkillRoutes = (app, dependencies) => {
655
649
  if (isUnsafeSkillRelativePath(filePath)) {
656
650
  return res.status(400).json({ error: 'Invalid file path' });
657
651
  }
658
- const { directory, error } = await resolveProjectDirectory(req);
659
- if (!directory) {
652
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
653
+ if (error) {
660
654
  return res.status(400).json({ error });
661
655
  }
662
656
 
663
- const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
657
+ const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
664
658
  .find((skill) => skill.name === skillName) || null;
665
659
  const sources = getSkillSources(skillName, directory, discoveredSkill);
666
660
  if (!sources.md.exists || !sources.md.dir) {
@@ -685,8 +679,8 @@ export const registerSkillRoutes = (app, dependencies) => {
685
679
  app.delete('/api/config/skills/:name', async (req, res) => {
686
680
  try {
687
681
  const skillName = req.params.name;
688
- const { directory, error } = await resolveProjectDirectory(req);
689
- if (!directory) {
682
+ const { directory, error } = await resolveOptionalProjectDirectory(req);
683
+ if (error) {
690
684
  return res.status(400).json({ error });
691
685
  }
692
686