From 0c5e183c62718a638a6e5cc63a3e30452395c55e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 23:10:14 +0300 Subject: [PATCH 01/23] fix(electron): load self-signed loopback pages --- packages/electron/README.md | 4 +- packages/electron/browser-panel-security.mjs | 12 ++++++ .../electron/browser-panel-security.test.mjs | 41 +++++++++++++++++++ packages/electron/main.mjs | 10 +++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/electron/browser-panel-security.mjs create mode 100644 packages/electron/browser-panel-security.test.mjs diff --git a/packages/electron/README.md b/packages/electron/README.md index abda712a..a7763d48 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -159,7 +159,9 @@ Use an explicit override when testing a different OpenCode CLI build or when a u grants permission requests by default when no handler is set, and the panel loads whatever address the user types. Tab favicons are fetched in this session too, so icons behind the page's own login resolve and the app's origin - never requests anything from a third-party host. + never requests anything from a third-party host. Self-signed loopback HTTPS + pages may use an untrusted certificate authority; certificate failures for + external hosts and all other certificate errors remain blocked. ## IPC Pattern diff --git a/packages/electron/browser-panel-security.mjs b/packages/electron/browser-panel-security.mjs new file mode 100644 index 00000000..532148d2 --- /dev/null +++ b/packages/electron/browser-panel-security.mjs @@ -0,0 +1,12 @@ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +export const shouldAllowBrowserPanelCertificateError = ({ url, error }) => { + if (error !== 'net::ERR_CERT_AUTHORITY_INVALID') return false; + + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' && LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase()); + } catch { + return false; + } +}; diff --git a/packages/electron/browser-panel-security.test.mjs b/packages/electron/browser-panel-security.test.mjs new file mode 100644 index 00000000..a81a9e87 --- /dev/null +++ b/packages/electron/browser-panel-security.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; + +test('allows untrusted certificate authorities for loopback HTTPS pages', () => { + for (const url of [ + 'https://localhost:58580/', + 'https://127.0.0.1:58580/', + 'https://[::1]:58580/', + ]) { + assert.equal(shouldAllowBrowserPanelCertificateError({ + url, + error: 'net::ERR_CERT_AUTHORITY_INVALID', + }), true); + } +}); + +test('keeps certificate validation for non-loopback pages', () => { + for (const url of [ + 'https://example.com/', + 'https://localhost.example.com/', + 'https://0.0.0.0:58580/', + ]) { + assert.equal(shouldAllowBrowserPanelCertificateError({ + url, + error: 'net::ERR_CERT_AUTHORITY_INVALID', + }), false); + } +}); + +test('does not bypass other certificate failures or malformed URLs', () => { + assert.equal(shouldAllowBrowserPanelCertificateError({ + url: 'https://localhost:58580/', + error: 'net::ERR_CERT_DATE_INVALID', + }), false); + assert.equal(shouldAllowBrowserPanelCertificateError({ + url: 'not a url', + error: 'net::ERR_CERT_AUTHORITY_INVALID', + }), false); +}); diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0bcc1de9..c6fe8edb 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -31,6 +31,7 @@ import { setLinuxAutostartEnabled, } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; +import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -1186,6 +1187,15 @@ const resolveBrowserPanelContents = (rawId) => { const hardenBrowserPanelSession = () => { const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION); + app.on('certificate-error', (event, contents, url, error, _certificate, callback) => { + if (contents.session === panelSession && shouldAllowBrowserPanelCertificateError({ url, error })) { + event.preventDefault(); + callback(true); + return; + } + callback(false); + }); + panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => { log.info('[electron] browser panel denied a permission request', { permission, From 99873a7b1234d176b4511f264cb74dc6456d5633 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 23:16:46 +0300 Subject: [PATCH 02/23] fix(files): harden drag-and-drop uploads Co-authored-by: Serhii Dziupin Co-authored-by: Alan Chen <2144783+alanzchen@users.noreply.github.com> --- CHANGELOG.md | 1 + .../components/layout/SidebarFilesTree.tsx | 10 ++ .../ui/src/components/views/FilesView.tsx | 32 +++- .../src/lib/fileContentInvalidation.test.ts | 38 ++++ .../ui/src/lib/fileContentInvalidation.ts | 25 +++ packages/web/README.md | 1 + packages/web/server/lib/fs/DOCUMENTATION.md | 2 +- packages/web/server/lib/fs/routes.js | 99 ++++++++--- packages/web/server/lib/fs/routes.test.js | 162 +++++++++++++----- 9 files changed, 297 insertions(+), 73 deletions(-) create mode 100644 packages/ui/src/lib/fileContentInvalidation.test.ts create mode 100644 packages/ui/src/lib/fileContentInvalidation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 349fb84d..ceaf6d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. - **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). - **Stability/Proxy:** the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). +- Files: drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen). - Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 8d56c831..624083c2 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -44,6 +44,7 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; import { isFilesystemError } from '@/lib/api/files-errors'; +import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation'; import { isBrowserClientRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; @@ -1044,9 +1045,18 @@ export const SidebarFilesTree: React.FC = () => { const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length; const failedCount = outcomes.filter((outcome) => outcome === 'failed').length; const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict'); + const uploadedPaths = droppedFiles.flatMap((file, index) => { + const name = getUploadName(file); + return outcomes[index] === 'uploaded' && name + ? [normalizePath(`${directory}/${name}`)] + : []; + }); const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime; try { + if (uploadedPaths.length > 0) { + notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths }); + } if (uploadedCount > 0 && isCurrentDestination) { await refreshDirectory(directory); } diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 6452099d..23e73735 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -48,8 +48,9 @@ import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth'; -import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; +import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; @@ -920,6 +921,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const lastLoadedFileStatRef = React.useRef(null); const activeFileLoadIdRef = React.useRef(0); const loadingFilePathRef = React.useRef(null); + const [fileContentRevision, setFileContentRevision] = React.useState(0); const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); const [diagramSaved, setDiagramSaved] = React.useState(false); const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false); @@ -2046,13 +2048,33 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { loadingFilePathRef.current = null; } }); - }, [loadSelectedFile, loadedFilePath, selectedFile]); + }, [fileContentRevision, loadSelectedFile, loadedFilePath, selectedFile]); // Sync isDirty to a ref so the polling interval can read the latest value // without isDirty in its dependency array (avoids interval restart on every edit/save). const isDirtyRef = React.useRef(isDirty); isDirtyRef.current = isDirty; + React.useEffect(() => subscribeToFileContentInvalidation(({ runtimeKey, paths }) => { + const selectedPath = selectedFile?.path; + if ( + runtimeKey !== getRuntimeKey() + || !selectedPath + || isDirtyRef.current + || !paths.includes(normalizePath(selectedPath)) + ) { + return; + } + + activeFileLoadIdRef.current += 1; + loadingFilePathRef.current = null; + lastLoadedFileStatRef.current = null; + setDesktopImageSrc(''); + setFileError(null); + setLoadedFilePath(null); + setFileContentRevision((revision) => revision + 1); + }), [selectedFile?.path]); + // Poll open file for external changes. // When a change is detected, reset loadedFilePath so the effect above // triggers a single reload — no double-load. @@ -3006,11 +3028,11 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ); const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf - ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}` + ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}|${fileContentRevision}` : ''; const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode - ? selectedFile.path + ? `${selectedFile.path}|${fileContentRevision}` : ''; const assetAuthErrorFallback = t('filesView.error.readFileFailed'); @@ -3113,7 +3135,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { URL.revokeObjectURL(objectUrl); } }; - }, [files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]); + }, [fileContentRevision, files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]); const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []); diff --git a/packages/ui/src/lib/fileContentInvalidation.test.ts b/packages/ui/src/lib/fileContentInvalidation.test.ts new file mode 100644 index 00000000..ed05e4b8 --- /dev/null +++ b/packages/ui/src/lib/fileContentInvalidation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { + notifyFileContentInvalidated, + subscribeToFileContentInvalidation, +} from './fileContentInvalidation'; + +describe('fileContentInvalidation', () => { + test('publishes normalized paths within the captured runtime', () => { + const received: Array<{ runtimeKey: string; paths: readonly string[] }> = []; + const unsubscribe = subscribeToFileContentInvalidation((invalidation) => { + received.push(invalidation); + }); + + notifyFileContentInvalidated({ + runtimeKey: ' runtime-a ', + paths: [' /repo/a.txt ', '/repo/a.txt', '', '/repo/b.txt'], + }); + unsubscribe(); + + expect(received).toEqual([{ + runtimeKey: 'runtime-a', + paths: ['/repo/a.txt', '/repo/b.txt'], + }]); + }); + + test('stops publishing after unsubscribe', () => { + let calls = 0; + const unsubscribe = subscribeToFileContentInvalidation(() => { + calls += 1; + }); + unsubscribe(); + + notifyFileContentInvalidated({ runtimeKey: 'runtime-a', paths: ['/repo/a.txt'] }); + + expect(calls).toBe(0); + }); +}); diff --git a/packages/ui/src/lib/fileContentInvalidation.ts b/packages/ui/src/lib/fileContentInvalidation.ts new file mode 100644 index 00000000..5cddd169 --- /dev/null +++ b/packages/ui/src/lib/fileContentInvalidation.ts @@ -0,0 +1,25 @@ +type FileContentInvalidation = { + runtimeKey: string; + paths: readonly string[]; +}; + +type FileContentInvalidationListener = (invalidation: FileContentInvalidation) => void; + +const listeners = new Set(); + +export const notifyFileContentInvalidated = (invalidation: FileContentInvalidation): void => { + const runtimeKey = invalidation.runtimeKey.trim(); + const paths = Array.from(new Set(invalidation.paths.map((path) => path.trim()).filter(Boolean))); + if (!runtimeKey || paths.length === 0) return; + + for (const listener of listeners) { + listener({ runtimeKey, paths }); + } +}; + +export const subscribeToFileContentInvalidation = ( + listener: FileContentInvalidationListener, +): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; diff --git a/packages/web/README.md b/packages/web/README.md index 03e9e752..da27a1ef 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -115,6 +115,7 @@ OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber | `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small | | `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses | | `OPENCHAMBER_COMPRESS_API` | Set to `true` to force `/api/*` compression, or `false` to disable it. Desktop runtime disables API compression by default to reduce local sidecar CPU use | +| `OPENCHAMBER_FS_UPLOAD_MAX_BYTES` | Maximum file upload size in bytes (default: 100 MiB) | | `OPENCHAMBER_TERMINAL_SHELL` | Preferred terminal shell executable used by the `Auto` setting before platform defaults | diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index cdf2fd62..07b166fd 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -39,4 +39,4 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks. - If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document. - `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks. -- `POST /api/fs/upload` accepts one `application/octet-stream` body (up to 100 MB) with `path` and optional `overwrite=true` query parameters. It rejects existing files with `409` unless overwrite is explicit, and resolves the destination parent before writing so uploads cannot escape through workspace symlinks. +- `POST /api/fs/upload` accepts one `application/octet-stream` body with `path` and optional `overwrite=true` query parameters. The body streams into a same-directory temp file with a 100 MiB default cap configurable through `OPENCHAMBER_FS_UPLOAD_MAX_BYTES`; failed and oversized uploads clean up that temp file. New files commit through an atomic no-replace link, existing files return `409` unless overwrite is explicit, directory targets are rejected, and the destination parent resolves before writing so uploads cannot escape through workspace symlinks. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 7888742c..96f07fda 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -108,6 +108,12 @@ const createGitCheckIgnoreTimeoutMs = () => { return 2500; }; +const createUploadMaxBytes = () => { + const raw = Number(process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES); + if (Number.isFinite(raw) && raw > 0) return Math.floor(raw); + return 100 * 1024 * 1024; +}; + const FILE_MIME_MAP = Object.freeze({ '.html': 'text/html', '.htm': 'text/html', @@ -139,28 +145,26 @@ const FILE_MIME_MAP = Object.freeze({ }); const MAX_SERVE_BYTES = 100 * 1024 * 1024; -const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; -const readUploadBody = async (req) => { - const declaredSize = Number.parseInt(req.headers?.['content-length'] || '0', 10); - if (Number.isFinite(declaredSize) && declaredSize > MAX_UPLOAD_BYTES) { - req.resume?.(); - return null; - } - - const chunks = []; - let size = 0; +const streamUploadBody = async (req, handle, maxBytes) => { + let received = 0; for await (const chunk of req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - size += buffer.length; - if (size > MAX_UPLOAD_BYTES) { + received += buffer.length; + if (received > maxBytes) { req.resume?.(); - return null; + throw Object.assign(new Error('Upload exceeds the maximum allowed size'), { uploadTooLarge: true }); } - chunks.push(buffer); - } - return Buffer.concat(chunks, size); + let offset = 0; + while (offset < buffer.length) { + const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, null); + if (!Number.isFinite(bytesWritten) || bytesWritten <= 0) { + throw new Error('Failed to write upload'); + } + offset += bytesWritten; + } + } }; // Only deterministic, side-effect-free git plumbing path queries are cacheable. @@ -1074,6 +1078,13 @@ export const registerFsRoutes = (app, dependencies) => { return res.status(415).json({ error: 'Content-Type must be application/octet-stream' }); } + const maxUploadBytes = createUploadMaxBytes(); + const declaredSize = Number(req.headers?.['content-length']); + if (Number.isFinite(declaredSize) && declaredSize > maxUploadBytes) { + req.resume?.(); + return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` }); + } + try { const resolved = await resolveWorkspacePathFromContext({ req, @@ -1106,22 +1117,50 @@ export const registerFsRoutes = (app, dependencies) => { return res.status(403).json({ error: 'Access denied' }); } - const body = await readUploadBody(req); - if (!body) { - return res.status(413).json({ error: `File exceeds maximum size of ${MAX_UPLOAD_BYTES} bytes` }); + if (existingPath) { + const stats = await fsPromises.stat(existingPath); + if (stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is a directory' }); + } + if (!overwrite) { + req.resume?.(); + return res.status(409).json({ error: 'File already exists', reason: 'already-exists' }); + } } - if (!overwrite) { - await fsPromises.writeFile(writePath, body, { flag: 'wx' }); - } else { - const tmp = `${writePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const tmp = `${writePath}.upload-${crypto.randomUUID()}`; + let tempExists = false; + try { + const handle = await fsPromises.open(tmp, 'wx'); + tempExists = true; + let streamError = null; try { - await fsPromises.writeFile(tmp, body, { flag: 'wx' }); - await fsPromises.rename(tmp, writePath); + await streamUploadBody(req, handle, maxUploadBytes); } catch (error) { - await fsPromises.unlink(tmp).catch(() => {}); - throw error; + streamError = error; } + try { + await handle.close(); + } catch (error) { + if (!streamError) throw error; + } + if (streamError) throw streamError; + + if (overwrite) { + await fsPromises.rename(tmp, writePath); + } else { + // A same-directory hard link commits without replacing a target that + // appeared after the existence check. The temp file is already fully + // flushed, so readers never observe a partial upload. + await fsPromises.link(tmp, writePath); + await fsPromises.unlink(tmp).catch(() => {}); + } + tempExists = false; + } catch (error) { + if (tempExists) { + await fsPromises.unlink(tmp).catch(() => {}); + } + throw error; } return res.json({ success: true, path: resolved.resolved }); @@ -1133,6 +1172,12 @@ export const registerFsRoutes = (app, dependencies) => { if (err && typeof err === 'object' && err.code === 'ENOENT') { return res.status(404).json({ error: 'Destination directory not found', reason: 'not-found' }); } + if (err && typeof err === 'object' && err.uploadTooLarge) { + return res.status(413).json({ error: `File exceeds maximum size of ${maxUploadBytes} bytes` }); + } + if (err && typeof err === 'object' && (err.code === 'EISDIR' || err.code === 'ENOTDIR')) { + return res.status(400).json({ error: 'Specified path is a directory' }); + } if (isOsPermissionError(err)) { return sendOsPermissionDenied(res, 'Access denied'); } diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index dbda0a27..64467af7 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -146,7 +146,11 @@ const registerUpload = (fsPromises) => { os: { homedir: () => '/home/user' }, path: path.posix, fsPromises: { - realpath: async (targetPath) => targetPath, + realpath: async (targetPath) => { + if (targetPath === '/repo') return targetPath; + throw Object.assign(new Error('not found'), { code: 'ENOENT' }); + }, + stat: async () => ({ isDirectory: () => false }), ...fsPromises, }, spawn: vi.fn(), @@ -253,16 +257,22 @@ const callWrite = async (handler, body) => { return res; }; -const callUpload = async (handler, { body = Buffer.from('upload'), path: filePath = '/repo/file.bin', overwrite = false } = {}) => { +const callUpload = async (handler, { + body = Buffer.from('upload'), + chunks, + includeContentLength = true, + path: filePath = '/repo/file.bin', + overwrite = false, +} = {}) => { const res = createMockResponse(); + const uploadChunks = chunks ?? [body]; + const headers = { 'content-type': 'application/octet-stream' }; + if (includeContentLength) headers['content-length'] = String(body.length); const req = { - headers: { - 'content-type': 'application/octet-stream', - 'content-length': String(body.length), - }, + headers, query: { path: filePath, overwrite: overwrite ? 'true' : undefined }, async *[Symbol.asyncIterator]() { - yield body; + yield* uploadChunks; }, }; await handler(req, res); @@ -377,29 +387,40 @@ describe('fs write', () => { }); describe('fs upload', () => { - it('creates a binary file without overwriting existing content', async () => { + it('streams a binary file to temp storage before committing it without overwrite', async () => { + const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length })); + const close = vi.fn(async () => undefined); const fsPromises = { - writeFile: vi.fn(async () => undefined), + open: vi.fn(async () => ({ write, close })), + link: vi.fn(async () => undefined), rename: vi.fn(async () => undefined), unlink: vi.fn(async () => undefined), }; const handler = registerUpload(fsPromises); - const res = await callUpload(handler, { body: Buffer.from([0, 1, 2, 255]) }); + const body = Buffer.from([0, 1, 2, 255]); + const res = await callUpload(handler, { + body, + chunks: [body.subarray(0, 2), body.subarray(2)], + }); expect(res.body).toEqual({ success: true, path: '/repo/file.bin' }); - expect(fsPromises.writeFile).toHaveBeenCalledWith( - '/repo/file.bin', - Buffer.from([0, 1, 2, 255]), - { flag: 'wx' }, - ); + const tmp = fsPromises.open.mock.calls[0][0]; + expect(tmp).toMatch(/^\/repo\/file\.bin\.upload-/); + expect(fsPromises.open).toHaveBeenCalledWith(tmp, 'wx'); + expect(write).toHaveBeenNthCalledWith(1, Buffer.from([0, 1]), 0, 2, null); + expect(write).toHaveBeenNthCalledWith(2, Buffer.from([2, 255]), 0, 2, null); + expect(close).toHaveBeenCalledTimes(1); + expect(fsPromises.link).toHaveBeenCalledWith(tmp, '/repo/file.bin'); + expect(fsPromises.unlink).toHaveBeenCalledWith(tmp); expect(fsPromises.rename).not.toHaveBeenCalled(); }); it('returns a conflict instead of silently replacing an existing file', async () => { - const error = Object.assign(new Error('exists'), { code: 'EEXIST' }); const fsPromises = { - writeFile: vi.fn(async () => { throw error; }), + realpath: vi.fn(async (targetPath) => targetPath), + stat: vi.fn(async () => ({ isDirectory: () => false })), + open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })), }; const handler = registerUpload(fsPromises); @@ -407,11 +428,15 @@ describe('fs upload', () => { expect(res.statusCode).toBe(409); expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' }); + expect(fsPromises.open).not.toHaveBeenCalled(); }); it('atomically replaces a file only when overwrite is explicit', async () => { + const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length })); const fsPromises = { - writeFile: vi.fn(async () => undefined), + realpath: vi.fn(async (targetPath) => targetPath), + stat: vi.fn(async () => ({ isDirectory: () => false })), + open: vi.fn(async () => ({ write, close: vi.fn(async () => undefined) })), rename: vi.fn(async () => undefined), unlink: vi.fn(async () => undefined), }; @@ -420,16 +445,31 @@ describe('fs upload', () => { const res = await callUpload(handler, { overwrite: true }); expect(res.body).toEqual({ success: true, path: '/repo/file.bin' }); - const tmp = fsPromises.writeFile.mock.calls[0][0]; - expect(tmp).toMatch(/^\/repo\/file\.bin\.tmp-/); - expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, Buffer.from('upload'), { flag: 'wx' }); + const tmp = fsPromises.open.mock.calls[0][0]; + expect(tmp).toMatch(/^\/repo\/file\.bin\.upload-/); + expect(write).toHaveBeenCalledWith(Buffer.from('upload'), 0, 6, null); expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.bin'); }); + it('rejects an existing directory before reading the upload body', async () => { + const fsPromises = { + realpath: vi.fn(async (targetPath) => targetPath), + stat: vi.fn(async () => ({ isDirectory: () => true })), + open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })), + }; + const handler = registerUpload(fsPromises); + + const res = await callUpload(handler); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'Specified path is a directory' }); + expect(fsPromises.open).not.toHaveBeenCalled(); + }); + it('rejects a destination parent that resolves outside the workspace', async () => { const fsPromises = { realpath: vi.fn(async (targetPath) => targetPath === '/repo/link' ? '/outside' : targetPath), - writeFile: vi.fn(async () => undefined), + open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })), }; const handler = registerUpload(fsPromises); @@ -437,31 +477,73 @@ describe('fs upload', () => { expect(res.statusCode).toBe(403); expect(res.body).toEqual({ error: 'Access denied' }); - expect(fsPromises.writeFile).not.toHaveBeenCalled(); + expect(fsPromises.open).not.toHaveBeenCalled(); }); - it('rejects streamed bodies larger than 100 MB', async () => { + it('cleans up a partial temp file when the configured streaming limit is exceeded', async () => { + const previous = process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES; + process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = '5'; + const write = vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length })); const fsPromises = { - writeFile: vi.fn(async () => undefined), + open: vi.fn(async () => ({ write, close: vi.fn(async () => undefined) })), + link: vi.fn(async () => undefined), + unlink: vi.fn(async () => undefined), + }; + try { + const handler = registerUpload(fsPromises); + const res = await callUpload(handler, { + body: Buffer.from('123456'), + chunks: [Buffer.from('123'), Buffer.from('456')], + includeContentLength: false, + }); + + expect(res.statusCode).toBe(413); + expect(res.body).toEqual({ error: 'File exceeds maximum size of 5 bytes' }); + expect(write).toHaveBeenCalledWith(Buffer.from('123'), 0, 3, null); + expect(fsPromises.link).not.toHaveBeenCalled(); + expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringMatching(/^\/repo\/file\.bin\.upload-/)); + } finally { + if (previous === undefined) delete process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES; + else process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = previous; + } + }); + + it('rejects a declared oversized upload before opening a temp file', async () => { + const previous = process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES; + process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = '5'; + const fsPromises = { + open: vi.fn(async () => ({ write: vi.fn(), close: vi.fn() })), + }; + try { + const handler = registerUpload(fsPromises); + const res = await callUpload(handler, { body: Buffer.from('123456') }); + + expect(res.statusCode).toBe(413); + expect(res.body).toEqual({ error: 'File exceeds maximum size of 5 bytes' }); + expect(fsPromises.open).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES; + else process.env.OPENCHAMBER_FS_UPLOAD_MAX_BYTES = previous; + } + }); + + it('keeps the existing file when a target appears before the atomic commit', async () => { + const error = Object.assign(new Error('exists'), { code: 'EEXIST' }); + const fsPromises = { + open: vi.fn(async () => ({ + write: vi.fn(async (_buffer, _offset, length) => ({ bytesWritten: length })), + close: vi.fn(async () => undefined), + })), + link: vi.fn(async () => { throw error; }), + unlink: vi.fn(async () => undefined), }; const handler = registerUpload(fsPromises); - const chunk = Buffer.alloc(1024 * 1024); - const req = { - headers: { 'content-type': 'application/octet-stream' }, - query: { path: '/repo/file.bin' }, - async *[Symbol.asyncIterator]() { - for (let index = 0; index < 101; index += 1) { - yield chunk; - } - }, - }; - const res = createMockResponse(); - await handler(req, res); + const res = await callUpload(handler); - expect(res.statusCode).toBe(413); - expect(res.body).toEqual({ error: `File exceeds maximum size of ${100 * 1024 * 1024} bytes` }); - expect(fsPromises.writeFile).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(409); + expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' }); + expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringMatching(/^\/repo\/file\.bin\.upload-/)); }); }); From a66903c73cac264720a2876f6ed20417dc566fc1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 23:50:43 +0300 Subject: [PATCH 03/23] chore: update unreleased changelog entries --- CHANGELOG.md | 20 ++++++++++++++------ packages/vscode/CHANGELOG.md | 7 +++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceaf6d51..76315f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,29 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans you pin travel with every message you send in that project until you unpin them. -- **Work status:** the Context sources section now names each pinned note and plan riding along with your messages, and its pin button unpins them from there. -- **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. -- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). -- **Stability/Proxy:** the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). -- Files: drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen). +- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be pinned as project context. +- **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen). +- Work status: the Context sources section now names each pinned note and plan available as standing project context, and its pin button unpins them from there. +- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). +- Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). +- Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). - Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. +- Usage/Command Code: Command Code plan limits now appear in the Usage page and work status panel. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). +- Git/Worktrees: creating a worktree from a pull request now falls back to GitHub's pull-request reference when the source fork was deleted or cannot be reached, instead of failing before creating the worktree (thanks to @makeittech). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. +- Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech). - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). - Chat: saved chats in the context panel open again instead of staying blank. - Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies). +- Chat/Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context. - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept. +- Files: files reached through a symlink inside the workspace now open correctly instead of being rejected as outside the workspace. - Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507). +- Mobile: connecting through an ngrok address now bypasses ngrok's browser warning page instead of failing the server check. +- Mobile/iOS: text selection in the chat composer now uses native CodeMirror selection handles. +- Desktop: browser pages served from a self-signed loopback HTTPS address now load instead of being blocked by the certificate warning. - Browser: typing a comment on a page no longer triggers app shortcuts. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index d922c4b6..17fd41a0 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,10 +1,13 @@ ## [Unreleased] -- **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. +- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, preventing high CPU use while the chat is idle (thanks to @makeittech). +- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). +- Usage: Command Code plan limits now appear in the Usage page and work status panel. - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. -- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). +- Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech). - The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds (thanks to @pocharlies). +- Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context. - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech). From f9d1ded81a3909f46db896335b8822b4c865c093 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 19 Aug 2026 00:07:57 +0300 Subject: [PATCH 04/23] fix(knowledge): scope pins to sessions --- CHANGELOG.md | 4 +- .../work-status/WorkStatusContextSection.tsx | 51 ++++++-------- .../session/project-context/DOCUMENTATION.md | 19 +++--- .../session/project-context/NotesSection.tsx | 25 ++++--- .../session/project-context/PlansSection.tsx | 19 +++--- .../project-context/ProjectNotesTodoPanel.tsx | 43 ++++++++++++ packages/ui/src/lib/sessionKnowledgeApi.ts | 32 ++++++++- .../ui/src/sync/__tests__/issue-2039.test.ts | 21 ++++++ packages/ui/src/sync/session-ui-store.ts | 38 ++++++++--- .../server/lib/context-obligatory/runtime.js | 8 ++- .../lib/context-obligatory/runtime.test.js | 27 ++++++-- .../lib/project-context/DOCUMENTATION.md | 9 +-- .../lib/session-knowledge/DOCUMENTATION.md | 11 ++- .../server/lib/session-knowledge/routes.js | 22 +++++- .../server/lib/session-knowledge/runtime.js | 68 ++++++++++++++++--- .../lib/session-knowledge/runtime.test.js | 49 +++++++++++-- 16 files changed, 340 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76315f42..826d88ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. -- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be pinned as project context. +- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be attached to the current session or its new-session draft without affecting other sessions. - **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen). -- Work status: the Context sources section now names each pinned note and plan available as standing project context, and its pin button unpins them from there. +- Work status: the Context sources section now names each note and plan attached to that session, and its pin button detaches them from there. - Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). - Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index 73671442..f5800f04 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -5,12 +5,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSession } from '@/sync/sync-context'; import { getLinkedIssues } from '@/lib/linkedIssues'; -import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; -import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; +import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; @@ -50,7 +47,7 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory }, [directory, loadSkills]); /** - * What the project sends along with every message. Read from the server + * What this session carries. Read from the server * rather than from the notes panel's store, because this must be right * whether or not that panel has ever been opened. */ @@ -58,41 +55,34 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory { notes: [], plans: [], memory: { global: 0, project: 0 } }, ); - // Re-read whenever the stores that own pins or memory change, not only when - // the directory does. Unpinning is a write those stores make, and a panel - // that keeps listing what was just unpinned tells the user it is still going - // to the agent when it is not. + // Re-read when source content or memory changes, not only when the session does. const contextEntries = useProjectContextStore((state) => state.entries); const memoryProject = useAgentMemoryStore((state) => state.project); const memoryGlobal = useAgentMemoryStore((state) => state.global); React.useEffect(() => { let cancelled = false; - void fetchSessionKnowledgeSummary(directory).then((summary) => { + void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => { if (!cancelled) setKnowledge(summary); }); return () => { cancelled = true; }; - }, [directory, contextEntries, memoryProject, memoryGlobal]); - - const projects = useProjectsStore((state) => state.projects); - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const setNotePinned = useProjectContextStore((state) => state.setNotePinned); - const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned); - - const projectRef = React.useMemo(() => { - const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? ''); - return resolved ? { id: resolved.id, path: resolved.path } : null; - }, [availableWorktreesByProject, directory, projects]); + }, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]); // Unpinning from here, like the pinned-messages section: a panel that says // what is attached should be able to detach it, or the user has to go find // the surface that can. const unpinNote = React.useCallback((noteId: string) => { - if (projectRef) void setNotePinned(projectRef, noteId, false); - }, [projectRef, setNotePinned]); + if (!directory || !sessionId) return; + void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => { + if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) })); + }); + }, [directory, sessionId]); const unpinPlan = React.useCallback((planId: string) => { - if (projectRef) void setPlanPinned(projectRef, planId, false); - }, [projectRef, setPlanPinned]); + if (!directory || !sessionId) return; + void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => { + if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) })); + }); + }, [directory, sessionId]); const memoryCount = knowledge.memory.global + knowledge.memory.project; const pinnedCount = knowledge.notes.length + knowledge.plans.length; @@ -131,9 +121,7 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory ? t('chat.workStatus.breakdown.prCountSingle', { count: prCount }) : t('chat.workStatus.breakdown.prCountPlural', { count: prCount })); } - // Pinned knowledge outranks the ambient counts in the summary: it is - // something the user chose for this project, not something that happens to - // be installed. + // Pinned knowledge outranks ambient counts because the user chose it for this session. if (summaryParts.length === 0 && pinnedCount > 0) { summaryParts.push(pinnedCount === 1 ? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount }) @@ -182,8 +170,7 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory /> ))} - {/* Named individually: a count alone would not tell the user which note - is riding along with every message they send. */} + {/* Named individually: a count alone would not identify this session's context. */} {/* The pin is the control, exactly as in the pinned-messages section above: same icon, same placement, same behaviour. Two pins that look different in one panel would read as two different things. */} @@ -194,7 +181,7 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory leading={( + ); +}; + export const SkillsCatalogPage: React.FC = ({ mode, onModeChange, showModeTabs = true }) => { const { t } = useI18n(); const { @@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo setSelectedSource, loadCatalog, loadSource, - loadMoreClawdHub, isLoadingCatalog, isLoadingSource, - isLoadingMore, loadedSourceIds, - clawdhubHasMoreBySource, lastCatalogError, } = useSkillsCatalogStore(useShallow((s) => ({ sources: s.sources, @@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo setSelectedSource: s.setSelectedSource, loadCatalog: s.loadCatalog, loadSource: s.loadSource, - loadMoreClawdHub: s.loadMoreClawdHub, isLoadingCatalog: s.isLoadingCatalog, isLoadingSource: s.isLoadingSource, - isLoadingMore: s.isLoadingMore, loadedSourceIds: s.loadedSourceIds, - clawdhubHasMoreBySource: s.clawdhubHasMoreBySource, lastCatalogError: s.lastCatalogError, }))); @@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo const [installItem, setInstallItem] = React.useState(null); const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false); const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false); + const searchInputRef = React.useRef(null); React.useEffect(() => { void loadCatalog(); }, [loadCatalog]); + // Load every source in the background so global search covers all of them. React.useEffect(() => { - if (!selectedSourceId) { + const unloaded = sources.filter((src) => !loadedSourceIds[src.id]); + if (unloaded.length === 0) { return; } - if (!loadedSourceIds[selectedSourceId]) { - void loadSource(selectedSourceId); + let cancelled = false; + const loadRest = async () => { + for (const src of unloaded) { + if (cancelled) { + return; + } + await loadSource(src.id); + } + }; + void loadRest(); + return () => { + cancelled = true; + }; + }, [sources, loadedSourceIds, loadSource]); + + React.useEffect(() => { + if (!selectedSourceId || loadedSourceIds[selectedSourceId]) { + return; } + void loadSource(selectedSourceId); }, [selectedSourceId, loadedSourceIds, loadSource]); - const items = React.useMemo(() => { - if (!selectedSourceId) return []; - return itemsBySource[selectedSourceId] || []; - }, [itemsBySource, selectedSourceId]); + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + const isSearching = search.trim().length > 0; const filtered = React.useMemo(() => { const q = search.trim().toLowerCase(); - if (!q) return items; - return items.filter((item) => { - const name = item.skillName.toLowerCase(); - const desc = (item.description || '').toLowerCase(); - const fm = (item.frontmatterName || '').toLowerCase(); - return name.includes(q) || desc.includes(q) || fm.includes(q); - }); - }, [items, search]); + const matches = (item: SkillsCatalogItem) => + item.skillName.toLowerCase().includes(q) + || (item.description || '').toLowerCase().includes(q) + || (item.frontmatterName || '').toLowerCase().includes(q); + + if (isSearching) { + return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches)); + } + if (!selectedSourceId) { + return []; + } + return itemsBySource[selectedSourceId] || []; + }, [sources, itemsBySource, selectedSourceId, search, isSearching]); const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]); const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:')); - const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub'; - const hasMoreClawdHub = Boolean( - selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true) - ); const removeSelectedCatalog = async () => { if (!selectedSourceId || !isCustomSource) { @@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo } }; + const listTitle = isSearching + ? t('settings.skills.catalog.page.list.searchTitle') + : (selectedSource?.label ?? ''); + + // The selected source has no items yet and a load is in flight — show the + // loading state instead of a stale list from the previously selected source. + const isSelectedSourceLoading = !isSearching + && selectedSourceId !== null + && !loadedSourceIds[selectedSourceId] + && (isLoadingSource || isLoadingCatalog); + return ( <> = ({ mode, onMo )} +

+ {t('settings.skills.catalog.page.subtitle')} +

- +
+
+ + setSearch(e.target.value)} + placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')} + className={cn('h-8 pl-8 w-full', search && 'pr-8')} + /> + {search && ( + + )} +
+
-
- +
+ {sources.map((src) => ( + setSelectedSource(src.id)} + t={t} + /> + ))} - - - {isCustomSource && ( - - )} - - -
- -
-
- - setSearch(e.target.value)} - placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')} - className="h-7 pl-8 w-full sm:w-64" - /> -
- - {isLoadingCatalog - ? t('settings.skills.catalog.page.loading.catalog') - : t('settings.skills.catalog.page.foundCount', { count: filtered.length })} +
+ + + {t('settings.skills.catalog.page.source.addOwnTitle')} + + + {t('settings.skills.catalog.page.source.addOwnDescription')} + + + +
{lastCatalogError && ( @@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo )} - {filtered.length === 0 && !isLoadingSource ? ( -
-

{t('settings.skills.catalog.page.empty.noSkillsTitle')}

-

{t('settings.skills.catalog.page.empty.noSkillsDescription')}

-
- ) : isLoadingSource ? ( +
+
+ + {listTitle} + + + {t('settings.skills.catalog.page.foundCount', { count: filtered.length })} + +
+
+ + {isCustomSource && !isSearching && ( + + )} +
+
+ + {isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (

{t('settings.skills.catalog.page.loading.skills')}

+ ) : filtered.length === 0 ? ( +
+

{t('settings.skills.catalog.page.empty.noSkillsTitle')}

+

{t('settings.skills.catalog.page.empty.noSkillsDescription')}

+
) : (
{filtered.map((item) => { const installed = item.installed?.isInstalled; const installedScope = item.installed?.scope; + const skillUrl = getSkillUrl(item); return (
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
{t('settings.skills.catalog.shared.noDescription')}
)} - {item.clawdhub && ( -
- {item.clawdhub.owner && ( - {t('settings.skills.catalog.page.byOwnerPrefix')} {item.clawdhub.owner} - )} - - - {item.clawdhub.downloads?.toLocaleString() ?? 0} - - {(item.clawdhub.stars ?? 0) > 0 && ( - - - {item.clawdhub.stars} - - )} - v{item.clawdhub.version} -
- )} +
+ {skillUrl ? ( + + + {item.repoSource} + + ) : ( + {item.repoSource} + )} + {item.skillDir && ( + <> + · + {item.skillDir} + + )} +
{item.warnings?.length ? (
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo ) : null}
- +
+ {skillUrl && ( + + )} + {installed ? ( + + + + ) : ( + + )} +
); })} )} - {isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && ( -
- -
- )}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 58f126cb..98944c6b 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1247,7 +1247,7 @@ export type RuntimeAPISelector = (apis: RuntimeAPIs) => TValue; type SkillsCatalogSourceId = string; -type SkillsCatalogSourceType = 'github' | 'clawdhub'; +type SkillsCatalogSourceType = 'github'; export interface SkillsCatalogSource { id: SkillsCatalogSourceId; @@ -1256,6 +1256,10 @@ export interface SkillsCatalogSource { source: string; defaultSubpath?: string; sourceType?: SkillsCatalogSourceType; + /** GitHub repository star count (null when unavailable) */ + stars?: number | null; + /** GitHub repository last-push timestamp, ISO (null when unavailable) */ + repoUpdatedAt?: string | null; } interface SkillsCatalogItemInstalledBadge { @@ -1264,18 +1268,6 @@ interface SkillsCatalogItemInstalledBadge { source?: 'opencode' | 'agents' | 'claude'; } -interface ClawdHubSkillMetadata { - slug: string; - version: string; - displayName?: string; - owner?: string; - downloads?: number; - stars?: number; - versionsCount?: number; - createdAt?: number; - updatedAt?: number; -} - export interface SkillsCatalogItem { sourceId: SkillsCatalogSourceId; repoSource: string; @@ -1288,22 +1280,18 @@ export interface SkillsCatalogItem { installable: boolean; warnings?: string[]; installed?: SkillsCatalogItemInstalledBadge; - /** ClawdHub-specific metadata (present only for ClawdHub sources) */ - clawdhub?: ClawdHubSkillMetadata; } export interface SkillsCatalogResponse { ok: boolean; sources?: SkillsCatalogSource[]; itemsBySource?: Record; - pageInfoBySource?: Record; error?: { kind: string; message: string }; } export interface SkillsCatalogSourceResponse { ok: boolean; items?: SkillsCatalogItem[]; - nextCursor?: string | null; error?: { kind: string; message: string }; } @@ -1328,11 +1316,6 @@ export interface SkillsRepoScanResponse { interface SkillsInstallSelection { skillDir: string; - /** ClawdHub-specific metadata for installation */ - clawdhub?: { - slug: string; - version: string; - }; } export interface SkillsInstallRequest { diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 2d771338..02ee5eeb 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -845,16 +845,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': 'Manuell', 'settings.skills.catalog.page.mode.external': 'Extern', 'settings.skills.catalog.page.title': 'Fähigkeitskatalog', + 'settings.skills.catalog.page.subtitle': 'Installiere fertige Skills aus kuratierten Repositories oder füge eine eigene Quelle hinzu.', + 'settings.skills.catalog.page.section.sources': 'Quellen', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Skills in allen Quellen suchen…', + 'settings.skills.catalog.page.search.clear': 'Suche löschen', + 'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}', + 'settings.skills.catalog.page.source.stars': 'Sterne: {count}', + 'settings.skills.catalog.page.source.updated': 'Aktualisiert {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Eigene Quelle hinzufügen', + 'settings.skills.catalog.page.source.addOwnDescription': 'Beliebiges Git-Repository mit Skills', + 'settings.skills.catalog.page.source.viewRepo': 'Repository auf GitHub öffnen', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Skill auf GitHub ansehen', + 'settings.skills.catalog.page.list.searchTitle': 'Suchergebnisse', 'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen', 'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen', 'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen', 'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden', 'settings.skills.catalog.page.loading.catalog': 'Wird geladen...', 'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...', - 'settings.skills.catalog.page.loading.more': 'Wird geladen...', 'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden', 'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden', @@ -862,7 +872,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'installiert ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar', 'settings.skills.catalog.page.badge.unknown': 'unbekannt', - 'settings.skills.catalog.page.byOwnerPrefix': 'von', 'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen', 'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2853e0b7..5e58a605 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -897,16 +897,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': 'Manual', 'settings.skills.catalog.page.mode.external': 'External', 'settings.skills.catalog.page.title': 'Skills Catalog', + 'settings.skills.catalog.page.subtitle': 'Install ready-made skills from curated repositories, or add your own source.', + 'settings.skills.catalog.page.section.sources': 'Sources', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Search skills across all sources…', + 'settings.skills.catalog.page.search.clear': 'Clear search', + 'settings.skills.catalog.page.source.skillsCount': '{count} skills', + 'settings.skills.catalog.page.source.stars': '{count} stars', + 'settings.skills.catalog.page.source.updated': 'Updated {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Add your own source', + 'settings.skills.catalog.page.source.addOwnDescription': 'Any Git repository with skills', + 'settings.skills.catalog.page.source.viewRepo': 'Open repository on GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'View skill on GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Search results', 'settings.skills.catalog.page.section.sourceRepository': 'Source Repository', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source', 'settings.skills.catalog.page.actions.refreshTitle': 'Refresh', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog', 'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog', 'settings.skills.catalog.page.actions.removeCatalog': 'Remove Catalog', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Load More Skills', 'settings.skills.catalog.page.loading.catalog': 'Loading...', 'settings.skills.catalog.page.loading.skills': 'Loading skills...', - 'settings.skills.catalog.page.loading.more': 'Loading...', 'settings.skills.catalog.page.foundCount': '{count} skill(s) found', 'settings.skills.catalog.page.error.catalogTitle': 'Catalog error', 'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found', @@ -914,7 +924,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'installed ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'not installable', 'settings.skills.catalog.page.badge.unknown': 'unknown', - 'settings.skills.catalog.page.byOwnerPrefix': 'by', 'settings.skills.catalog.page.removeDialog.title': 'Remove Catalog', 'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 85629ae7..bd06384c 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { "settings.skills.catalog.page.mode.manual": "Manual", "settings.skills.catalog.page.mode.external": "Externo", "settings.skills.catalog.page.title": "Catálogo de habilidades", + 'settings.skills.catalog.page.subtitle': 'Instala skills listos desde repositorios curados o añade tu propia fuente.', + 'settings.skills.catalog.page.section.sources': 'Fuentes', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Buscar skills en todas las fuentes…', + 'settings.skills.catalog.page.search.clear': 'Borrar búsqueda', + 'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}', + 'settings.skills.catalog.page.source.stars': 'Estrellas: {count}', + 'settings.skills.catalog.page.source.updated': 'Actualizado {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Añadir tu propia fuente', + 'settings.skills.catalog.page.source.addOwnDescription': 'Cualquier repositorio Git con skills', + 'settings.skills.catalog.page.source.viewRepo': 'Abrir repositorio en GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill en GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Resultados de búsqueda', "settings.skills.catalog.page.section.sourceRepository": "Repositorio de origen", "settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen", "settings.skills.catalog.page.actions.refreshTitle": "Actualizar", "settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo", "settings.skills.catalog.page.actions.addCatalog": "Añadir catálogo", "settings.skills.catalog.page.actions.removeCatalog": "Eliminar catálogo", - "settings.skills.catalog.page.actions.loadMoreSkills": "Cargar más habilidades", "settings.skills.catalog.page.loading.catalog": "Cargando...", "settings.skills.catalog.page.loading.skills": "Cargando habilidades...", - "settings.skills.catalog.page.loading.more": "Cargando...", "settings.skills.catalog.page.foundCount": "{count} habilidad(es) encontrada(s)", "settings.skills.catalog.page.error.catalogTitle": "Error del catálogo", "settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades", @@ -882,7 +892,6 @@ export const settingsDict = { "settings.skills.catalog.page.badge.installed": "instalado ({scope})", "settings.skills.catalog.page.badge.notInstallable": "no instalable", "settings.skills.catalog.page.badge.unknown": "desconocido", - "settings.skills.catalog.page.byOwnerPrefix": "por", "settings.skills.catalog.page.removeDialog.title": "Eliminar catálogo", "settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este catálogo?", "settings.openchamber.passkeys.title": "Claves de paso", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 635db3e1..b75e1c35 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -783,16 +783,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': 'Manuel', 'settings.skills.catalog.page.mode.external': 'Externe', 'settings.skills.catalog.page.title': 'Catalogue de skills', + 'settings.skills.catalog.page.subtitle': "Installez des skills prêts à l'emploi depuis des dépôts curatés ou ajoutez votre propre source.", + 'settings.skills.catalog.page.section.sources': 'Sources', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Rechercher des skills dans toutes les sources…', + 'settings.skills.catalog.page.search.clear': 'Effacer la recherche', + 'settings.skills.catalog.page.source.skillsCount': 'Skills : {count}', + 'settings.skills.catalog.page.source.stars': 'Étoiles : {count}', + 'settings.skills.catalog.page.source.updated': 'Mis à jour {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Ajouter votre propre source', + 'settings.skills.catalog.page.source.addOwnDescription': "N'importe quel dépôt Git avec des skills", + 'settings.skills.catalog.page.source.viewRepo': 'Ouvrir le dépôt sur GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Voir le skill sur GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Résultats de recherche', 'settings.skills.catalog.page.section.sourceRepository': 'Dépôt source', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source', 'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue', 'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue', 'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le catalogue', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Charger plus de skills', 'settings.skills.catalog.page.loading.catalog': 'Chargement...', 'settings.skills.catalog.page.loading.skills': 'Chargement des skills...', - 'settings.skills.catalog.page.loading.more': 'Chargement...', 'settings.skills.catalog.page.foundCount': '{count} skill(s) trouvé(s)', 'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé', @@ -800,7 +810,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'installé ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'non installable', 'settings.skills.catalog.page.badge.unknown': 'inconnu', - 'settings.skills.catalog.page.byOwnerPrefix': 'par', 'settings.skills.catalog.page.removeDialog.title': 'Supprimer le catalogue', 'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?', 'settings.openchamber.passkeys.title': 'Mots-clés', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 96b21151..00319089 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -898,16 +898,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '手動', 'settings.skills.catalog.page.mode.external': '外部', 'settings.skills.catalog.page.title': 'スキルカタログ', + 'settings.skills.catalog.page.subtitle': 'キュレーションされたリポジトリからすぐ使えるスキルをインストール、または独自のソースを追加。', + 'settings.skills.catalog.page.section.sources': 'ソース', + 'settings.skills.catalog.page.searchAllPlaceholder': 'すべてのソースのスキルを検索…', + 'settings.skills.catalog.page.search.clear': '検索をクリア', + 'settings.skills.catalog.page.source.skillsCount': 'スキル数: {count}', + 'settings.skills.catalog.page.source.stars': 'スター: {count}', + 'settings.skills.catalog.page.source.updated': '更新: {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '独自のソースを追加', + 'settings.skills.catalog.page.source.addOwnDescription': 'スキルを含む任意の Git リポジトリ', + 'settings.skills.catalog.page.source.viewRepo': 'GitHub でリポジトリを開く', + 'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub でスキルを表示', + 'settings.skills.catalog.page.list.searchTitle': '検索結果', 'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択', 'settings.skills.catalog.page.actions.refreshTitle': '更新', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除', 'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加', 'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む', 'settings.skills.catalog.page.loading.catalog': '読み込み中...', 'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...', - 'settings.skills.catalog.page.loading.more': '読み込み中...', 'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました', 'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません', @@ -915,7 +925,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'インストール不可', 'settings.skills.catalog.page.badge.unknown': '不明', - 'settings.skills.catalog.page.byOwnerPrefix': '提供', 'settings.skills.catalog.page.removeDialog.title': 'カタログを削除', 'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?', 'settings.openchamber.passkeys.title': 'パスキー', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index b76b0098..74c6bc98 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '수동', 'settings.skills.catalog.page.mode.external': 'External', 'settings.skills.catalog.page.title': '스킬 카탈로그', + 'settings.skills.catalog.page.subtitle': '선별된 저장소에서 바로 사용 가능한 스킬을 설치하거나 직접 소스를 추가하세요.', + 'settings.skills.catalog.page.section.sources': '소스', + 'settings.skills.catalog.page.searchAllPlaceholder': '모든 소스에서 스킬 검색…', + 'settings.skills.catalog.page.search.clear': '검색 지우기', + 'settings.skills.catalog.page.source.skillsCount': '스킬: {count}개', + 'settings.skills.catalog.page.source.stars': '스타: {count}', + 'settings.skills.catalog.page.source.updated': '업데이트: {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '직접 소스 추가', + 'settings.skills.catalog.page.source.addOwnDescription': '스킬이 있는 아무 Git 저장소', + 'settings.skills.catalog.page.source.viewRepo': 'GitHub에서 저장소 열기', + 'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub에서 스킬 보기', + 'settings.skills.catalog.page.list.searchTitle': '검색 결과', 'settings.skills.catalog.page.section.sourceRepository': '카탈로그 저장소', 'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택', 'settings.skills.catalog.page.actions.refreshTitle': '새로고침', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거', 'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가', 'settings.skills.catalog.page.actions.removeCatalog': 'Catalog 제거', - 'settings.skills.catalog.page.actions.loadMoreSkills': '스킬 더 불러오기', 'settings.skills.catalog.page.loading.catalog': '로딩 중...', 'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...', - 'settings.skills.catalog.page.loading.more': '로딩 중...', 'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견', 'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류', 'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다', @@ -882,7 +892,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': '설치됨({scope})', 'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음', 'settings.skills.catalog.page.badge.unknown': '알 수 없음', - 'settings.skills.catalog.page.byOwnerPrefix': '작성자', 'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거', 'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 7e352832..9092163f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1825,21 +1825,18 @@ export const settingsDict = { 'settings.skills.catalog.installSkill.toast.installFailed': 'Nie udało się zainstalować umiejętności', 'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana', 'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Załaduj więcej umiejętności', 'settings.skills.catalog.page.actions.refreshTitle': 'Odśwież', 'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog', 'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować', 'settings.skills.catalog.page.badge.unknown': 'nieznane', - 'settings.skills.catalog.page.byOwnerPrefix': 'autor:', 'settings.skills.catalog.page.empty.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności', 'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło', 'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności', 'settings.skills.catalog.page.loading.catalog': 'Ładowanie...', - 'settings.skills.catalog.page.loading.more': 'Ładowanie...', 'settings.skills.catalog.page.loading.skills': 'Ładowanie umiejętności...', 'settings.skills.catalog.page.mode.external': 'Zewnętrzny', 'settings.skills.catalog.page.mode.manual': 'Ręczny', @@ -1847,6 +1844,18 @@ export const settingsDict = { 'settings.skills.catalog.page.removeDialog.title': 'Usuń katalog', 'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe', 'settings.skills.catalog.page.title': 'Katalog umiejętności', + 'settings.skills.catalog.page.subtitle': 'Instaluj gotowe umiejętności z kuratorowanych repozytoriów lub dodaj własne źródło.', + 'settings.skills.catalog.page.section.sources': 'Źródła', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Szukaj umiejętności we wszystkich źródłach…', + 'settings.skills.catalog.page.search.clear': 'Wyczyść wyszukiwanie', + 'settings.skills.catalog.page.source.skillsCount': 'Umiejętności: {count}', + 'settings.skills.catalog.page.source.stars': 'Gwiazdki: {count}', + 'settings.skills.catalog.page.source.updated': 'Zaktualizowano {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Dodaj własne źródło', + 'settings.skills.catalog.page.source.addOwnDescription': 'Dowolne repozytorium Git z umiejętnościami', + 'settings.skills.catalog.page.source.viewRepo': 'Otwórz repozytorium na GitHubie', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Zobacz umiejętność na GitHubie', + 'settings.skills.catalog.page.list.searchTitle': 'Wyniki wyszukiwania', 'settings.skills.catalog.shared.actions.install': 'Zainstaluj', 'settings.skills.catalog.shared.actions.installing': 'Instalowanie...', 'settings.skills.catalog.shared.actions.scan': 'Skanuj', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 8059055c..d85f354c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { "settings.skills.catalog.page.mode.manual": "Manual", "settings.skills.catalog.page.mode.external": "Externo", "settings.skills.catalog.page.title": "Catálogo de habilidades", + 'settings.skills.catalog.page.subtitle': 'Instale skills prontas de repositórios curados ou adicione sua própria fonte.', + 'settings.skills.catalog.page.section.sources': 'Fontes', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Pesquisar skills em todas as fontes…', + 'settings.skills.catalog.page.search.clear': 'Limpar pesquisa', + 'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}', + 'settings.skills.catalog.page.source.stars': 'Estrelas: {count}', + 'settings.skills.catalog.page.source.updated': 'Atualizado {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Adicionar sua própria fonte', + 'settings.skills.catalog.page.source.addOwnDescription': 'Qualquer repositório Git com skills', + 'settings.skills.catalog.page.source.viewRepo': 'Abrir repositório no GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill no GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Resultados da pesquisa', "settings.skills.catalog.page.section.sourceRepository": "Repositório de origem", "settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem", "settings.skills.catalog.page.actions.refreshTitle": "Atualizar", "settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo", "settings.skills.catalog.page.actions.addCatalog": "Adicionar catálogo", "settings.skills.catalog.page.actions.removeCatalog": "Excluir catálogo", - "settings.skills.catalog.page.actions.loadMoreSkills": "Carregar mais habilidades", "settings.skills.catalog.page.loading.catalog": "Carregando...", "settings.skills.catalog.page.loading.skills": "Carregando habilidades...", - "settings.skills.catalog.page.loading.more": "Carregando...", "settings.skills.catalog.page.foundCount": "{count} habilidade(es) encontrada(s)", "settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo", "settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada", @@ -882,7 +892,6 @@ export const settingsDict = { "settings.skills.catalog.page.badge.installed": "instalado ({scope})", "settings.skills.catalog.page.badge.notInstallable": "não instalável", "settings.skills.catalog.page.badge.unknown": "desconhecido", - "settings.skills.catalog.page.byOwnerPrefix": "por", "settings.skills.catalog.page.removeDialog.title": "Excluir catálogo", "settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?", "settings.openchamber.passkeys.title": "Chaves de acesso", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f6c91f0a..efb4bfbe 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { "settings.skills.catalog.page.mode.manual": "Вручну", "settings.skills.catalog.page.mode.external": "зовнішній", "settings.skills.catalog.page.title": "Каталог навичок", + 'settings.skills.catalog.page.subtitle': 'Встановлюйте готові скіли з курованих репозиторіїв або додайте власне джерело.', + 'settings.skills.catalog.page.section.sources': 'Джерела', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Пошук скілів у всіх джерелах…', + 'settings.skills.catalog.page.search.clear': 'Очистити пошук', + 'settings.skills.catalog.page.source.skillsCount': 'Скілів: {count}', + 'settings.skills.catalog.page.source.stars': 'Зірок: {count}', + 'settings.skills.catalog.page.source.updated': 'Оновлено {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Додати власне джерело', + 'settings.skills.catalog.page.source.addOwnDescription': 'Будь-який git-репозиторій зі скілами', + 'settings.skills.catalog.page.source.viewRepo': 'Відкрити репозиторій на GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Переглянути скіл на GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Результати пошуку', "settings.skills.catalog.page.section.sourceRepository": "Репозиторій вихідного коду", "settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело", "settings.skills.catalog.page.actions.refreshTitle": "Оновити", "settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог", "settings.skills.catalog.page.actions.addCatalog": "Додати каталог", "settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог", - "settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички", "settings.skills.catalog.page.loading.catalog": "Завантаження...", "settings.skills.catalog.page.loading.skills": "Завантаження навичок...", - "settings.skills.catalog.page.loading.more": "Завантаження...", "settings.skills.catalog.page.foundCount": "Знайдено навички {count}", "settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу", "settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено", @@ -882,7 +892,6 @@ export const settingsDict = { "settings.skills.catalog.page.badge.installed": "встановлено ({scope})", "settings.skills.catalog.page.badge.notInstallable": "не встановлюється", "settings.skills.catalog.page.badge.unknown": "невідомий", - "settings.skills.catalog.page.byOwnerPrefix": "за", "settings.skills.catalog.page.removeDialog.title": "Видалити каталог", "settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?", "settings.openchamber.passkeys.title": "Ключі доступу", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f8a7d85c..ee4a87bc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '手动', 'settings.skills.catalog.page.mode.external': '外部', 'settings.skills.catalog.page.title': '技能目录', + 'settings.skills.catalog.page.subtitle': '从精选仓库安装现成技能,或添加你自己的来源。', + 'settings.skills.catalog.page.section.sources': '来源', + 'settings.skills.catalog.page.searchAllPlaceholder': '在所有来源中搜索技能…', + 'settings.skills.catalog.page.search.clear': '清除搜索', + 'settings.skills.catalog.page.source.skillsCount': '技能数:{count}', + 'settings.skills.catalog.page.source.stars': '星标:{count}', + 'settings.skills.catalog.page.source.updated': '更新于 {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '添加自己的来源', + 'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 仓库', + 'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上打开仓库', + 'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上查看技能', + 'settings.skills.catalog.page.list.searchTitle': '搜索结果', 'settings.skills.catalog.page.section.sourceRepository': '来源仓库', 'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源', 'settings.skills.catalog.page.actions.refreshTitle': '刷新', 'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录', 'settings.skills.catalog.page.actions.addCatalog': '添加目录', 'settings.skills.catalog.page.actions.removeCatalog': '移除目录', - 'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能', 'settings.skills.catalog.page.loading.catalog': '加载中...', 'settings.skills.catalog.page.loading.skills': '正在加载技能...', - 'settings.skills.catalog.page.loading.more': '加载中...', 'settings.skills.catalog.page.foundCount': '找到 {count} 个技能', 'settings.skills.catalog.page.error.catalogTitle': '目录错误', 'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能', @@ -882,7 +892,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': '已安装({scope})', 'settings.skills.catalog.page.badge.notInstallable': '不可安装', 'settings.skills.catalog.page.badge.unknown': '未知', - 'settings.skills.catalog.page.byOwnerPrefix': '作者', 'settings.skills.catalog.page.removeDialog.title': '移除目录', 'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 8d7ab828..8a7241ae 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -862,16 +862,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '手動', 'settings.skills.catalog.page.mode.external': '外部', 'settings.skills.catalog.page.title': 'Skills 目錄', + 'settings.skills.catalog.page.subtitle': '從精選儲存庫安裝現成技能,或新增你自己的來源。', + 'settings.skills.catalog.page.section.sources': '來源', + 'settings.skills.catalog.page.searchAllPlaceholder': '在所有來源中搜尋技能…', + 'settings.skills.catalog.page.search.clear': '清除搜尋', + 'settings.skills.catalog.page.source.skillsCount': '技能數:{count}', + 'settings.skills.catalog.page.source.stars': '星標:{count}', + 'settings.skills.catalog.page.source.updated': '更新於 {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '新增自己的來源', + 'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 儲存庫', + 'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上開啟儲存庫', + 'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上檢視技能', + 'settings.skills.catalog.page.list.searchTitle': '搜尋結果', 'settings.skills.catalog.page.section.sourceRepository': '來源儲存庫', 'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源', 'settings.skills.catalog.page.actions.refreshTitle': '重新整理', 'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄', 'settings.skills.catalog.page.actions.addCatalog': '新增目錄', 'settings.skills.catalog.page.actions.removeCatalog': '移除目錄', - 'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills', 'settings.skills.catalog.page.loading.catalog': '載入中...', 'settings.skills.catalog.page.loading.skills': '正在載入 skills...', - 'settings.skills.catalog.page.loading.more': '載入中...', 'settings.skills.catalog.page.foundCount': '找到 {count} 個 skill(s)', 'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤', 'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills', @@ -879,7 +889,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': '已安裝({scope})', 'settings.skills.catalog.page.badge.notInstallable': '不可安裝', 'settings.skills.catalog.page.badge.unknown': '未知', - 'settings.skills.catalog.page.byOwnerPrefix': '作者', 'settings.skills.catalog.page.removeDialog.title': '移除目錄', 'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts b/packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts deleted file mode 100644 index 5fbd101a..00000000 --- a/packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; - -mock.module('@/lib/opencode/client', () => ({ - opencodeClient: { - getDirectory: () => undefined, - }, -})); - -mock.module('@/stores/useProjectsStore', () => ({ - useProjectsStore: { - getState: () => ({ - getActiveProject: () => null, - }), - }, -})); - -mock.module('@/lib/runtime-fetch', () => ({ - runtimeFetch: async () => new Response('{}', { status: 500 }), -})); - -mock.module('@/stores/useSkillsStore', () => ({ - invalidateSkillsLoadCache: () => undefined, - refreshSkillsAfterOpenCodeRestart: async () => undefined, - useSkillsStore: { - getState: () => ({}), - }, -})); - -mock.module('@/lib/configUpdate', () => ({ - startConfigUpdate: () => undefined, - finishConfigUpdate: () => undefined, - updateConfigUpdateMessage: () => undefined, -})); - -const { useSkillsCatalogStore } = await import('./useSkillsCatalogStore'); - -describe('skills catalog ClawHub label', () => { - beforeEach(() => { - useSkillsCatalogStore.setState({ - sources: useSkillsCatalogStore.getState().sources, - }); - }); - - test('fallback sources label ClawHub correctly', () => { - const clawhub = useSkillsCatalogStore.getState().sources.find((source) => source.id === 'clawdhub'); - expect(clawhub).toBeDefined(); - expect(clawhub?.label).toBe('ClawHub'); - }); -}); diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 17100aae..2107676d 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -30,11 +30,27 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [ sourceType: 'github', }, { - id: 'clawdhub', - label: 'ClawHub', - description: 'Community skill registry with vector search', - source: 'clawdhub:registry', - sourceType: 'clawdhub', + id: 'openai', + label: 'OpenAI', + description: "OpenAI's curated skills", + source: 'openai/skills', + defaultSubpath: 'skills/.curated', + sourceType: 'github', + }, + { + id: 'cursor', + label: 'Cursor', + description: "Cursor's plugin skills", + source: 'cursor/plugins', + defaultSubpath: 'pstack/skills', + sourceType: 'github', + }, + { + id: 'mattpocock', + label: 'Matt Pocock', + description: 'Matt Pocock skills collection', + source: 'mattpocock/skills', + sourceType: 'github', }, ]; @@ -42,6 +58,8 @@ const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__'; const skillsCatalogLastLoadedAt = new Map(); const skillsCatalogLoadInFlight = new Map>(); +const sourceLoadInFlight = new Map>(); +let activeSourceLoads = 0; const getSkillsCatalogCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY; @@ -71,13 +89,10 @@ export interface SkillsCatalogState { sources: SkillsCatalogSource[]; itemsBySource: Record; selectedSourceId: string | null; - pageInfoBySource: Record; loadedSourceIds: Record; - clawdhubHasMoreBySource: Record; isLoadingCatalog: boolean; isLoadingSource: boolean; - isLoadingMore: boolean; isScanning: boolean; isInstalling: boolean; @@ -91,7 +106,6 @@ export interface SkillsCatalogState { loadCatalog: (options?: { refresh?: boolean }) => Promise; loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise; - loadMoreClawdHub: () => Promise; scanRepo: (request: SkillsRepoScanRequest) => Promise; installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise; } @@ -102,13 +116,10 @@ export const useSkillsCatalogStore = create()( sources: FALLBACK_SOURCES, itemsBySource: {}, selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null, - pageInfoBySource: {}, loadedSourceIds: {}, - clawdhubHasMoreBySource: {}, isLoadingCatalog: false, isLoadingSource: false, - isLoadingMore: false, isScanning: false, isInstalling: false, @@ -141,9 +152,7 @@ export const useSkillsCatalogStore = create()( const previous = { sources: get().sources, itemsBySource: get().itemsBySource, - pageInfoBySource: get().pageInfoBySource, loadedSourceIds: get().loadedSourceIds, - clawdhubHasMoreBySource: get().clawdhubHasMoreBySource, }; let lastError: SkillsCatalogResponse['error'] | null = null; @@ -168,9 +177,7 @@ export const useSkillsCatalogStore = create()( const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources; const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {}); - const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {}); const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {}); - const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {}); const currentSelected = get().selectedSourceId; const selectedSourceId = (currentSelected && sources.some((s) => s.id === currentSelected)) @@ -180,9 +187,7 @@ export const useSkillsCatalogStore = create()( set({ sources, itemsBySource, - pageInfoBySource, loadedSourceIds, - clawdhubHasMoreBySource, selectedSourceId, }); @@ -197,9 +202,7 @@ export const useSkillsCatalogStore = create()( set({ sources: previous.sources, itemsBySource: previous.itemsBySource, - pageInfoBySource: previous.pageInfoBySource, loadedSourceIds: previous.loadedSourceIds, - clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource, lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' }, }); @@ -222,136 +225,83 @@ export const useSkillsCatalogStore = create()( return false; } + // Deduplicate concurrent loads of the same source: the background + // loader effect can restart while a request for this source is + // already in flight. + if (!options?.refresh) { + const inFlight = sourceLoadInFlight.get(sourceId); + if (inFlight) { + return inFlight; + } + } + + activeSourceLoads += 1; set({ isLoadingSource: true, lastCatalogError: null }); - try { - const currentDirectory = getRequestDirectory(); - const refresh = options?.refresh ? '&refresh=true' : ''; - const queryParams = currentDirectory - ? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}` - : `?sourceId=${encodeURIComponent(sourceId)}${refresh}`; + const request = (async () => { + try { + const currentDirectory = getRequestDirectory(); + const refresh = options?.refresh ? '&refresh=true' : ''; + const queryParams = currentDirectory + ? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}` + : `?sourceId=${encodeURIComponent(sourceId)}${refresh}`; - const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; - const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items); - if (!response.ok || (!payload?.ok && !hasItems)) { - const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { method: 'GET', headers: { Accept: 'application/json' }, }); - const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null; - const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId]; - if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) { - set((state) => ({ - itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems }, - pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } }, - loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, - clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false }, - })); - return true; + + const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; + const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items); + if (!response.ok || (!payload?.ok && !hasItems)) { + const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null; + const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId]; + if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) { + set((state) => ({ + itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems }, + loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, + })); + return true; + } + + set({ + lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` }, + }); + return false; } + const items = payload?.items || []; + + set((state) => ({ + itemsBySource: { ...state.itemsBySource, [sourceId]: items }, + loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, + })); + + return true; + } catch (error) { set({ - lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` }, + lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) }, }); return false; - } - - const items = payload?.items || []; - const nextCursor = payload?.nextCursor ?? null; - - set((state) => ({ - itemsBySource: { ...state.itemsBySource, [sourceId]: items }, - pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } }, - loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, - clawdhubHasMoreBySource: { - ...state.clawdhubHasMoreBySource, - [sourceId]: items.length > 0, - }, - })); - - return true; - } catch (error) { - set({ - lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) }, - }); - return false; - } finally { - set({ isLoadingSource: false }); - } - }, - - loadMoreClawdHub: async () => { - const selectedSourceId = get().selectedSourceId; - if (!selectedSourceId) { - return false; - } - - const pageInfo = get().pageInfoBySource[selectedSourceId]; - const cursor = pageInfo?.nextCursor || null; - - set({ isLoadingMore: true }); - try { - const currentDirectory = getRequestDirectory(); - const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`]; - if (currentDirectory) { - parts.push(`directory=${encodeURIComponent(currentDirectory)}`); - } - if (cursor) { - parts.push(`cursor=${encodeURIComponent(cursor)}`); - } - const queryParams = `?${parts.join('&')}`; - - const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; - if (!response.ok || !payload?.ok) { - return false; - } - - const nextCursor = payload.nextCursor ?? null; - const currentItems = get().itemsBySource[selectedSourceId] || []; - const items = payload.items || []; - const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item])); - let newCount = 0; - - for (const item of items) { - const key = `${item.sourceId}:${item.skillDir}`; - if (!merged.has(key)) { - newCount += 1; + } finally { + activeSourceLoads -= 1; + if (activeSourceLoads === 0) { + set({ isLoadingSource: false }); } - merged.set(key, item); } + })(); - const noMore = items.length === 0 || newCount === 0; - - set((state) => ({ - itemsBySource: { - ...state.itemsBySource, - [selectedSourceId]: Array.from(merged.values()), - }, - pageInfoBySource: { - ...state.pageInfoBySource, - [selectedSourceId]: { nextCursor }, - }, - clawdhubHasMoreBySource: { - ...state.clawdhubHasMoreBySource, - [selectedSourceId]: !noMore, - }, - })); - - return true; - } catch { - return false; + sourceLoadInFlight.set(sourceId, request); + try { + return await request; } finally { - set({ isLoadingMore: false }); + if (sourceLoadInFlight.get(sourceId) === request) { + sourceLoadInFlight.delete(sourceId); + } } }, diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index cefd3512..f9a171f6 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -33,15 +33,6 @@ type SkillFrontmatter = { [key: string]: unknown; }; -type ClawdHubSkillMetadata = { - slug: string; - version: string; - displayName?: string; - owner?: string; - downloads?: number; - stars?: number; -}; - type SkillsCatalogItem = { repoSource: string; repoSubpath?: string; @@ -51,9 +42,7 @@ type SkillsCatalogItem = { description?: string; installable: boolean; warnings?: string[]; - clawdhub?: ClawdHubSkillMetadata; }; - type SkillsCatalogItemWithBadge = SkillsCatalogItem & { sourceId: string; installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource }; @@ -84,143 +73,27 @@ const CURATED_SOURCES: CuratedSource[] = [ defaultSubpath: 'skills', }, { - id: 'clawdhub', - label: 'ClawHub', - description: 'Community skill registry with vector search', - source: 'clawdhub:registry', + id: 'openai', + label: 'OpenAI', + description: "OpenAI's curated skills", + source: 'openai/skills', + defaultSubpath: 'skills/.curated', + }, + { + id: 'cursor', + label: 'Cursor', + description: "Cursor's plugin skills", + source: 'cursor/plugins', + defaultSubpath: 'pstack/skills', + }, + { + id: 'mattpocock', + label: 'Matt Pocock', + description: 'Matt Pocock skills collection', + source: 'mattpocock/skills', }, ]; -// ============== ClawdHub API ============== - -const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; -const CLAWDHUB_PAGE_LIMIT = 25; -const CLAWDHUB_RATE_LIMIT_MS = 100; -let clawdhubLastRequest = 0; - -function isClawdHubSource(source: string): boolean { - return typeof source === 'string' && source.startsWith('clawdhub:'); -} - -async function clawdhubFetch(url: string, options?: RequestInit): Promise { - const maxAttempts = 10; - let lastResponse: Response | null = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const now = Date.now(); - const elapsed = now - clawdhubLastRequest; - if (elapsed < CLAWDHUB_RATE_LIMIT_MS) { - await new Promise((resolve) => setTimeout(resolve, CLAWDHUB_RATE_LIMIT_MS - elapsed)); - } - clawdhubLastRequest = Date.now(); - - const response = await fetch(url, { - ...options, - headers: { - Accept: 'application/json', - 'User-Agent': 'OpenChamber-VSCode/1.0', - ...options?.headers, - }, - }); - - lastResponse = response; - - if (response.status === 429 || response.status >= 500) { - if (attempt < maxAttempts - 1) { - const waitMs = 50 * (attempt + 1); - await new Promise((resolve) => setTimeout(resolve, waitMs)); - continue; - } - } - - return response; - } - - return lastResponse as Response; -} - -type ClawdHubSkillListItem = { - slug: string; - displayName?: string; - summary?: string; - tags?: { latest?: string }; - latestVersion?: { version?: string }; - stats?: { downloads?: number; stars?: number }; - owner?: { handle?: string }; -}; - -type ClawdHubSkillsResponse = { - items: ClawdHubSkillListItem[]; - nextCursor?: string; -}; - -async function scanClawdHub(): Promise { - try { - const allItems: SkillsCatalogItem[] = []; - let cursor: string | null = null; - const maxPages = 20; - - for (let page = 0; page < maxPages; page++) { - const url = cursor - ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}` - : `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`; - - let data: ClawdHubSkillsResponse; - - try { - const response = await clawdhubFetch(url); - if (!response.ok) { - throw new Error(`ClawdHub API error: ${response.status}`); - } - - data = (await response.json()) as ClawdHubSkillsResponse; - } catch (error) { - if (page > 0 && allItems.length > 0) { - break; - } - throw error; - } - - for (const item of data.items || []) { - const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; - - allItems.push({ - repoSource: 'clawdhub:registry', - skillDir: item.slug, - skillName: item.slug, - frontmatterName: item.displayName || item.slug, - description: item.summary || undefined, - installable: true, - clawdhub: { - slug: item.slug, - version: latestVersion, - displayName: item.displayName, - owner: item.owner?.handle, - downloads: item.stats?.downloads || 0, - stars: item.stats?.stars || 0, - }, - }); - } - - if (!data.nextCursor) break; - cursor = data.nextCursor; - } - - // Sort by downloads (most popular first) - allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); - - return { ok: true, items: allItems }; - } catch (error) { - return { - ok: false, - error: { - kind: 'networkError', - message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', - }, - }; - } -} - function validateSkillName(skillName: string): boolean { if (skillName.length < 1 || skillName.length > 64) return false; return SKILL_NAME_PATTERN.test(skillName); @@ -716,40 +589,6 @@ export async function getSkillsCatalog( const itemsBySource: Record = {}; for (const src of sources) { - // Handle ClawdHub sources separately (API-based, not git-based) - if (isClawdHubSource(src.source)) { - const cacheKey = 'clawdhub:registry'; - let cached = !refresh ? catalogCache.get(cacheKey) : null; - if (cached && Date.now() >= cached.expiresAt) { - catalogCache.delete(cacheKey); - cached = null; - } - - let items: SkillsCatalogItem[] = []; - if (cached) { - items = cached.items; - } else { - const scanned = await scanClawdHub(); - if (!scanned.ok) { - itemsBySource[src.id] = []; - continue; - } - items = scanned.items || []; - catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items }); - } - - itemsBySource[src.id] = items.map((item) => { - const installed = installedByName.get(item.skillName); - return { - sourceId: src.id, - ...item, - installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false }, - }; - }); - continue; - } - - // Handle GitHub sources (git clone based) const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { itemsBySource[src.id] = []; diff --git a/packages/web/package.json b/packages/web/package.json index 5c7b84f1..156e0b7f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -27,7 +27,6 @@ "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "1.18.18", "@simplewebauthn/server": "13.3.1", - "adm-zip": "^0.6.0", "bun-pty": "^0.4.5", "compression": "^1.8.1", "cron-parser": "^4.9.0", @@ -63,7 +62,6 @@ "@remixicon/react": "^4.7.0", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/postcss": "^4.0.0", - "@types/adm-zip": "^0.5.7", "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -89,8 +87,8 @@ "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "strip-json-comments": "^5.0.3", - "tailwind-merge": "^3.3.1", "supertest": "^7.2.2", + "tailwind-merge": "^3.3.1", "tailwindcss": "^4.0.0", "tsx": "^4.20.6", "tw-animate-css": "^1.3.8", diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 5e33fb09..f36f0c45 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -45,12 +45,11 @@ import { import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js'; import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js'; import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js'; -import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js'; -import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js'; +import { getCacheKey, scanWithCache } from '../skills-catalog/cache.js'; +import { parseSkillRepoSource } from '../skills-catalog/source.js'; import { scanSkillsRepository } from '../skills-catalog/scan.js'; import { installSkillsFromRepository } from '../skills-catalog/install.js'; -import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js'; -import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js'; +import { fetchGitHubRepoMetas } from '../skills-catalog/github-meta.js'; export const createFeatureRoutesRuntime = (dependencies) => { const { @@ -287,14 +286,11 @@ export const createFeatureRoutesRuntime = (dependencies) => { SKILL_DIR, getCuratedSkillsSources, getCacheKey, - getCachedScan, - setCachedScan, + scanWithCache, parseSkillRepoSource, scanSkillsRepository, installSkillsFromRepository, - scanClawdHubPage, - installSkillsFromClawdHub, - isClawdHubSource, + fetchGitHubRepoMetas, getProfiles, getProfile, }); diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index ba366ddc..0abe7f37 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -40,14 +40,11 @@ export const registerSkillRoutes = (app, dependencies) => { SKILL_DIR, getCuratedSkillsSources, getCacheKey, - getCachedScan, - setCachedScan, + scanWithCache, parseSkillRepoSource, scanSkillsRepository, installSkillsFromRepository, - scanClawdHubPage, - installSkillsFromClawdHub, - isClawdHubSource, + fetchGitHubRepoMetas, getProfiles, getProfile, } = dependencies; @@ -305,9 +302,26 @@ export const registerSkillRoutes = (app, dependencies) => { })); const sources = [...curatedSources, ...customSources]; - const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); - res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} }); + const githubRepos = sources + .map((src) => parseSkillRepoSource(src.source)) + .filter((parsed) => parsed.ok && parsed.host === 'github.com') + .map((parsed) => parsed.normalizedRepo); + const repoMetas = await fetchGitHubRepoMetas(githubRepos); + + const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => { + const parsed = parseSkillRepoSource(rest.source); + const meta = parsed.ok && parsed.host === 'github.com' + ? repoMetas[parsed.normalizedRepo] || {} + : {}; + return { + ...rest, + stars: typeof meta.stars === 'number' ? meta.stars : null, + repoUpdatedAt: typeof meta.repoUpdatedAt === 'string' ? meta.repoUpdatedAt : null, + }; + }); + + res.json({ ok: true, sources: sourcesForUi, itemsBySource: {} }); } catch (error) { console.error('Failed to load skills catalog:', error); res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); @@ -327,7 +341,6 @@ export const registerSkillRoutes = (app, dependencies) => { } const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null; const curatedSources = getCuratedSkillsSources(); const settings = await readSettingsFromDisk(); @@ -355,26 +368,6 @@ export const registerSkillRoutes = (app, dependencies) => { ); const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s])); - if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { - const scanned = await scanClawdHubPage({ cursor: cursor || null }); - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - const items = (scanned.items || []).map((item) => { - const installed = installedByName.get(item.skillName); - return { - ...item, - sourceId: src.id, - installed: installed - ? { isInstalled: true, scope: installed.scope, source: installed.source } - : { isInstalled: false }, - }; - }); - - return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null }); - } - const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { return res.status(400).json({ ok: false, error: parsed.error }); @@ -387,21 +380,19 @@ export const registerSkillRoutes = (app, dependencies) => { identityId: src.gitIdentityId || '', }); - let scanResult = !refresh ? getCachedScan(cacheKey) : null; - if (!scanResult) { - const scanned = await scanSkillsRepository({ + const scanResult = await scanWithCache( + cacheKey, + () => scanSkillsRepository({ source: src.source, subpath: src.defaultSubpath, defaultSubpath: src.defaultSubpath, identity: resolveGitIdentity(src.gitIdentityId), - }); + }), + { refresh }, + ); - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - scanResult = scanned; - setCachedScan(cacheKey, scanResult); + if (!scanResult.ok) { + return res.status(500).json({ ok: false, error: scanResult.error }); } const items = (scanResult.items || []).map((item) => { @@ -483,41 +474,6 @@ export const registerSkillRoutes = (app, dependencies) => { workingDirectory = resolved.directory; } - if (isClawdHubSource(source)) { - const result = await installSkillsFromClawdHub({ - scope, - targetSource, - workingDirectory, - userSkillDir: SKILL_DIR, - selections, - conflictPolicy, - conflictDecisions, - }); - - if (!result.ok) { - if (result.error?.kind === 'conflicts') { - return res.status(409).json({ ok: false, error: result.error }); - } - return res.status(400).json({ ok: false, error: result.error }); - } - - const installed = result.installed || []; - const skipped = result.skipped || []; - const requiresRestart = installed.length > 0; - - return res.json({ - ok: true, - installed, - skipped, - ...(requiresRestart - ? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.') - : { - requiresReload: false, - message: 'No skills were installed', - }), - }); - } - const identity = resolveGitIdentity(gitIdentityId); const result = await installSkillsFromRepository({ diff --git a/packages/web/server/lib/opencode/skill-routes.test.js b/packages/web/server/lib/opencode/skill-routes.test.js index 3ba8526e..6212d76f 100644 --- a/packages/web/server/lib/opencode/skill-routes.test.js +++ b/packages/web/server/lib/opencode/skill-routes.test.js @@ -69,14 +69,11 @@ const startSkillsApp = ({ projectRoot }) => { SKILL_DIR, getCuratedSkillsSources: () => [], getCacheKey: () => 'k', - getCachedScan: () => null, - setCachedScan: () => {}, + scanWithCache: async (_key, loader) => loader(), parseSkillRepoSource: () => ({ ok: false }), scanSkillsRepository: async () => ({ ok: false }), installSkillsFromRepository: async () => ({ ok: false }), - scanClawdHubPage: async () => ({ ok: false }), - installSkillsFromClawdHub: async () => ({ ok: false }), - isClawdHubSource: () => false, + fetchGitHubRepoMetas: async () => ({}), getProfiles: () => [], getProfile: () => null, }); diff --git a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md index 199a0271..5c0b020e 100644 --- a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md +++ b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md @@ -1,21 +1,17 @@ # Skills Catalog Module Documentation ## Purpose -This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports multiple skill sources including git repositories and the ClawHub registry, with caching and conflict resolution for skill installation. +This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports skill sources backed by git repositories, with caching and conflict resolution for skill installation. ## Entrypoints and structure - `packages/web/server/lib/skills-catalog/`: Skills catalog module directory containing all skill-related functionality. - `cache.js`: In-memory cache for scan results with TTL support. - - `curated-sources.js`: Predefined skill sources (Anthropic, ClawHub). + - `curated-sources.js`: Predefined skill sources (Anthropic, OpenAI, Cursor, Matt Pocock). + - `github-meta.js`: Best-effort GitHub repository metadata (stars, last push) with in-memory TTL cache. - `git.js`: Git operations helpers for cloning and auth error detection. - `install.js`: Skills installation from git repositories. - `scan.js`: Skills scanning from git repositories. - `source.js`: Source string parsing for git repositories. - - `clawdhub/`: ClawHub registry integration. - - `index.js`: Public API exports for ClawHub. - - `scan.js`: Scanning ClawHub registry with pagination. - - `install.js`: Installation from ClawHub (ZIP download). - - `api.js`: ClawHub API client with rate limiting. ## Public API @@ -24,13 +20,19 @@ The following functions are exported and used by the web server: ### Cache (`cache.js`) - `getCacheKey({ normalizedRepo, subpath, identityId })`: Generate cache key for scan results. - `getCachedScan(key)`: Retrieve cached scan result if not expired. -- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 30 minutes). +- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 3 hours). +- `scanWithCache(key, loader, { refresh })`: Run a scan loader with cache lookup, in-flight deduplication, and a global concurrency limit (2 concurrent scans); only `ok: true` results are cached. - `clearCache()`: Clear all cached scan results. +- Scan results persist to `skills-catalog-cache.json` in the OpenChamber data dir (debounced, atomic rename) and survive server restarts within the TTL. ### Curated Sources (`curated-sources.js`) -- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, ClawHub). +- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, OpenAI, Cursor, Matt Pocock). - `CURATED_SKILLS_SOURCES`: Constant array of predefined sources. +### GitHub Repository Metadata (`github-meta.js`) +- `fetchGitHubRepoMetas(normalizedRepos)`: Fetch `{ stars, repoUpdatedAt }` for GitHub `owner/repo` strings. Best-effort: failures resolve to `null`; in-flight requests deduplicate; results cached in memory and on disk (`skills-github-meta.json`) for three hours. +- `clearGitHubMetaCache()`: Test-only cache reset. + ### Source Parsing (`source.js`) - `parseSkillRepoSource(source, { subpath })`: Parse git repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format. @@ -40,20 +42,6 @@ The following functions are exported and used by the web server: ### Git Repository Installation (`install.js`) - `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from git repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency. -### ClawHub Integration (`clawdhub/index.js`) -- `isClawdHubSource(source)`: Check if source string refers to ClawHub. -- `scanClawdHub()`: Scan entire ClawHub registry for all skills (paginated, max 20 pages). -- `scanClawdHubPage({ cursor })`: Scan a single page of ClawHub results with cursor-based pagination. -- `installSkillsFromClawdHub({ scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from ClawHub by downloading ZIP files. -- `fetchClawdHubSkills({ cursor })`: Fetch paginated skills list from ClawHub API. -- `fetchClawdHubSkillVersion(slug, version)`: Fetch specific skill version details. -- `fetchClawdHubSkillInfo(slug)`: Fetch skill metadata without version details. -- `downloadClawdHubSkill(slug, version)`: Download skill package as ZIP buffer. - -### ClawHub Constants (`clawdhub/index.js`) -- `CLAWDHUB_SOURCE_ID`: Source identifier for curated sources. -- `CLAWDHUB_SOURCE_STRING`: Source string format. - ## Internal Helpers The following functions are internal helpers used by exported functions: @@ -63,10 +51,10 @@ The following functions are internal helpers used by exported functions: - `looksLikeAuthError(message)`: Detect if error message indicates authentication failure (permission denied, publickey, etc.). - `assertGitAvailable()`: Check if git is available in PATH. -### Skill Name Validation (used in `install.js`, `scan.js`, `clawdhub/install.js`) +### Skill Name Validation (used in `install.js`, `scan.js`) - `validateSkillName(skillName)`: Validate skill name against pattern `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars, lowercase alphanumeric with hyphens). -### File System Helpers (`install.js`, `scan.js`, `clawdhub/install.js`) +### File System Helpers (`install.js`, `scan.js`) - `safeRm(dir)`: Safely remove directory recursively (ignores errors). - `ensureDir(dirPath)`: Ensure directory exists with recursive creation. - `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection. @@ -82,10 +70,6 @@ The following functions are internal helpers used by exported functions: - `toFsPath(repoDir, repoRelPosixPath)`: Convert POSIX path to filesystem path. - `getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName })`: Determine target installation directory based on scope (user/project), targetSource (opencode/agents), and skill name. -### ClawHub API Helpers (`clawdhub/api.js`) -- `rateLimitedFetch(url, options)`: Fetch with rate limiting (120 req/min limit, 100ms delay between requests, exponential backoff on 429/500 errors). -- `mapClawdHubItem(item)`: Transform ClawHub API response to SkillsCatalogItem format. - ## Response Contracts ### Scan Skills Repository Response @@ -101,12 +85,6 @@ The following functions are internal helpers used by exported functions: - `skipped`: Array of skipped skills with `{ skillName, reason }`. - `error`: Error object with `{ kind, message, conflicts? }` on failure. Kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`. -### ClawHub Scan Response -- `ok`: Boolean indicating success. -- `items`: Array of skill items with ClawHub-specific metadata in `clawdhub` property. -- `nextCursor`: Pagination cursor for next page (only for `scanClawdHubPage`). -- `error`: Error object with `{ kind, message }` on failure. - ### Parse Source Response - `ok`: Boolean indicating success. - `host`: Git host (e.g., `github.com`, `gitlab.com`). @@ -129,7 +107,7 @@ The following functions are internal helpers used by exported functions: ### Skill Name Validation - All skill names must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars). -- Skill names are derived from directory basenames for git repos and slugs for ClawHub. +- Skill names are derived from directory basenames for git repos. - Invalid names result in non-installable skills with appropriate warnings. ### Git Cloning Strategy @@ -144,17 +122,12 @@ The following functions are internal helpers used by exported functions: - Per-skill decisions override global policy via `conflictDecisions` map. - Conflict response includes `{ skillName, scope, source }` for each conflict. -### ClawHub Integration -- ClawHub API base URL: `https://clawdhub.com/api/v1`. -- Pagination uses cursor-based approach with `MAX_PAGES=20` safety limit. -- Rate limiting: 120 req/min with 100ms delay between requests. -- Downloaded skills are extracted from ZIP files using `adm-zip`. -- Always validate `SKILL.md` exists before installation. - ### Cache Management - Cache keys include `normalizedRepo`, `subpath`, and `identityId` for isolation. -- Default TTL is 30 minutes; can be overridden via `ttlMs` parameter. -- Cache is in-memory (not persisted across restarts). +- Default TTL is 3 hours for both scan results and GitHub repository metadata. +- Scan and GitHub metadata caches persist to JSON files in the OpenChamber data dir, so app restarts and page refreshes reuse previous results instead of re-hitting GitHub. +- Scans run through a global concurrency limiter (2 at a time) with per-key in-flight deduplication. +- The refresh button passes `refresh: true` and bypasses the cache. ### Security Considerations - Path traversal protection in `copyDirectoryNoSymlinks`: resolves real paths and checks containment. diff --git a/packages/web/server/lib/skills-catalog/cache.js b/packages/web/server/lib/skills-catalog/cache.js index 3fbbae5e..8e80b968 100644 --- a/packages/web/server/lib/skills-catalog/cache.js +++ b/packages/web/server/lib/skills-catalog/cache.js @@ -1,6 +1,58 @@ -const DEFAULT_TTL_MS = 30 * 60 * 1000; +import { readDiskCache, writeDiskCache } from './disk-cache.js'; + +const DEFAULT_TTL_MS = 3 * 60 * 60 * 1000; +const DISK_CACHE_FILE = 'skills-catalog-cache.json'; +const MAX_CONCURRENT_SCANS = 2; const cache = new Map(); +const inFlight = new Map(); + +let diskLoaded = false; +let diskWriteTimer = null; + +const loadDiskEntries = () => { + if (diskLoaded) { + return; + } + diskLoaded = true; + const persisted = readDiskCache(DISK_CACHE_FILE); + if (!persisted) { + return; + } + const now = Date.now(); + for (const [key, entry] of Object.entries(persisted)) { + if ( + entry + && typeof entry === 'object' + && typeof entry.expiresAt === 'number' + && entry.expiresAt > now + && entry.value + && typeof entry.value === 'object' + ) { + cache.set(key, entry); + } + } +}; + +const scheduleDiskWrite = () => { + if (diskWriteTimer) { + return; + } + diskWriteTimer = setTimeout(() => { + diskWriteTimer = null; + const now = Date.now(); + const persisted = {}; + for (const [key, entry] of cache.entries()) { + if (entry.expiresAt > now) { + persisted[key] = entry; + } + } + writeDiskCache(DISK_CACHE_FILE, persisted); + }, 1000); + if (typeof diskWriteTimer.unref === 'function') { + diskWriteTimer.unref(); + } +}; export function getCacheKey({ normalizedRepo, subpath, identityId }) { const safeRepo = String(normalizedRepo || '').trim(); @@ -10,6 +62,7 @@ export function getCacheKey({ normalizedRepo, subpath, identityId }) { } export function getCachedScan(key) { + loadDiskEntries(); const entry = cache.get(key); if (!entry) return null; if (Date.now() >= entry.expiresAt) { @@ -22,4 +75,70 @@ export function getCachedScan(key) { export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) { const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS; cache.set(key, { expiresAt: Date.now() + ttl, value }); + scheduleDiskWrite(); +} + +export function clearCache() { + cache.clear(); + inFlight.clear(); +} + +// ─── Concurrency-limited scan orchestration ─── + +let activeScans = 0; +const scanQueue = []; + +const acquireScanSlot = () => new Promise((resolve) => { + scanQueue.push(resolve); + pumpScanQueue(); +}); + +const releaseScanSlot = () => { + activeScans -= 1; + pumpScanQueue(); +}; + +const pumpScanQueue = () => { + while (activeScans < MAX_CONCURRENT_SCANS && scanQueue.length > 0) { + const resolve = scanQueue.shift(); + activeScans += 1; + resolve(); + } +}; + +/** + * Run `loader` for a scan cache key with deduplication and a global + * concurrency limit. Concurrent callers for the same key share one loader + * run; at most MAX_CONCURRENT_SCANS loaders run at once. Only successful + * (`ok: true`) results are cached. + */ +export async function scanWithCache(key, loader, { refresh = false } = {}) { + if (!refresh) { + const cached = getCachedScan(key); + if (cached) { + return cached; + } + } + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const run = (async () => { + await acquireScanSlot(); + try { + const result = await loader(); + if (result && result.ok) { + setCachedScan(key, result); + } + return result; + } finally { + releaseScanSlot(); + inFlight.delete(key); + } + })(); + + inFlight.set(key, run); + return run; } diff --git a/packages/web/server/lib/skills-catalog/cache.test.js b/packages/web/server/lib/skills-catalog/cache.test.js new file mode 100644 index 00000000..43306548 --- /dev/null +++ b/packages/web/server/lib/skills-catalog/cache.test.js @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearCache, scanWithCache, setCachedScan, getCachedScan } from './cache.js'; + +let tempDataDir; + +beforeEach(() => { + tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cache-test-')); + process.env.OPENCHAMBER_DATA_DIR = tempDataDir; +}); + +afterEach(() => { + delete process.env.OPENCHAMBER_DATA_DIR; + clearCache(); + vi.restoreAllMocks(); + fs.rmSync(tempDataDir, { recursive: true, force: true }); +}); + +const flushDiskWrites = async () => new Promise((resolve) => setTimeout(resolve, 1200)); + +describe('scanWithCache', () => { + it('deduplicates concurrent loaders for the same key', async () => { + const loader = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { ok: true, items: [] }; + }); + + const [a, b] = await Promise.all([ + scanWithCache('k', loader), + scanWithCache('k', loader), + ]); + + expect(loader).toHaveBeenCalledTimes(1); + expect(a).toEqual(b); + }); + + it('limits concurrent scans across different keys', async () => { + let running = 0; + let peak = 0; + const loader = async () => { + running += 1; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 20)); + running -= 1; + return { ok: true, items: [] }; + }; + + await Promise.all(Array.from({ length: 6 }, (_, i) => scanWithCache(`key-${i}`, loader))); + + expect(peak).toBeLessThanOrEqual(2); + }); + + it('does not cache failed scans', async () => { + await scanWithCache('bad', async () => ({ ok: false, error: { kind: 'networkError', message: 'x' } })); + + expect(getCachedScan('bad')).toBeNull(); + }); + + it('refresh bypasses the cache', async () => { + setCachedScan('fresh', { ok: true, items: ['cached'] }); + + const result = await scanWithCache('fresh', async () => ({ ok: true, items: ['reloaded'] }), { refresh: true }); + + expect(result.items).toEqual(['reloaded']); + expect(getCachedScan('fresh').items).toEqual(['reloaded']); + }); + + it('persists successful scans to disk for later processes', async () => { + await scanWithCache('persisted', async () => ({ ok: true, items: [{ skillName: 'x' }] })); + await flushDiskWrites(); + + const onDisk = JSON.parse(fs.readFileSync(path.join(tempDataDir, 'skills-catalog-cache.json'), 'utf8')); + expect(onDisk.persisted.value.items).toEqual([{ skillName: 'x' }]); + }); +}); diff --git a/packages/web/server/lib/skills-catalog/clawdhub/api.js b/packages/web/server/lib/skills-catalog/clawdhub/api.js deleted file mode 100644 index b0f23986..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/api.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * ClawdHub API client - * - * ClawdHub is a public skill registry at https://clawdhub.com - * This client provides methods to fetch skills list and download skill packages. - */ - -const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; -const CLAWDHUB_PAGE_LIMIT = 25; - -// Rate limiting: ClawdHub allows 120 requests/minute -const RATE_LIMIT_DELAY_MS = 100; -let lastRequestTime = 0; - -async function rateLimitedFetch(url, options = {}) { - const maxAttempts = 10; - - let lastResponse = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const now = Date.now(); - const elapsed = now - lastRequestTime; - if (elapsed < RATE_LIMIT_DELAY_MS) { - await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed)); - } - lastRequestTime = Date.now(); - - const response = await fetch(url, { - ...options, - headers: { - Accept: 'application/json', - 'User-Agent': 'OpenChamber/1.0', - ...options.headers, - }, - }); - - lastResponse = response; - - if (response.status === 429 || response.status >= 500) { - if (attempt < maxAttempts - 1) { - const waitMs = 50 * (attempt + 1); - await new Promise((resolve) => setTimeout(resolve, waitMs)); - continue; - } - } - - return response; - } - - return lastResponse; -} - -/** - * Fetch paginated list of skills from ClawdHub - * @param {Object} options - * @param {string} [options.cursor] - Pagination cursor from previous response - * @returns {Promise<{ items: Array, nextCursor?: string }>} - */ -export async function fetchClawdHubSkills({ cursor } = {}) { - const url = cursor - ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}` - : `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`; - - const response = await rateLimitedFetch(url); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`); - } - - const data = await response.json(); - const nextCursor = - (typeof data.nextCursor === 'string' && data.nextCursor) || - (typeof data.next_cursor === 'string' && data.next_cursor) || - (typeof data.next === 'string' && data.next) || - (typeof data.cursor === 'string' && data.cursor) || - null; - - return { - items: data.items || [], - nextCursor, - }; -} - -/** - * Download a skill package as a ZIP buffer - * @param {string} slug - Skill slug/identifier - * @param {string} version - Specific version string - * @returns {Promise} - ZIP file contents - */ -export async function downloadClawdHubSkill(slug, version) { - const versionParam = typeof version === 'string' && version !== 'latest' - ? `&version=${encodeURIComponent(version)}` - : '&tag=latest'; - const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}${versionParam}`; - - const response = await rateLimitedFetch(url, { - headers: { - Accept: 'application/zip', - }, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`); - } - - return response.arrayBuffer(); -} - -/** - * Get skill metadata without version details - * @param {string} slug - Skill slug/identifier - * @returns {Promise} - */ -export async function fetchClawdHubSkillInfo(slug) { - const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`; - const response = await rateLimitedFetch(url); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`); - } - - return response.json(); -} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/install.js b/packages/web/server/lib/skills-catalog/clawdhub/install.js deleted file mode 100644 index 753d1da4..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/install.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * ClawdHub skill installation - * - * Downloads skills from ClawdHub as ZIP files and extracts them - * to the appropriate skill directory. - */ - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import AdmZip from 'adm-zip'; - -import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js'; - -const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; - -function normalizeUserSkillDir(userSkillDir) { - if (!userSkillDir) return null; - const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill'); - const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills'); - if (userSkillDir === legacySkillDir) { - if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir; - return pluralSkillDir; - } - return userSkillDir; -} - -function validateSkillName(skillName) { - if (typeof skillName !== 'string') return false; - if (skillName.length < 1 || skillName.length > 64) return false; - return SKILL_NAME_PATTERN.test(skillName); -} - -async function safeRm(dir) { - try { - await fs.promises.rm(dir, { recursive: true, force: true }); - } catch { - // ignore - } -} - -async function ensureDir(dirPath) { - await fs.promises.mkdir(dirPath, { recursive: true }); -} - -function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) { - const source = targetSource === 'agents' ? 'agents' : 'opencode'; - - if (scope === 'user') { - if (source === 'agents') { - return path.join(os.homedir(), '.agents', 'skills', skillName); - } - return path.join(userSkillDir, skillName); - } - - if (!workingDirectory) { - throw new Error('workingDirectory is required for project installs'); - } - - if (source === 'agents') { - return path.join(workingDirectory, '.agents', 'skills', skillName); - } - - return path.join(workingDirectory, '.opencode', 'skills', skillName); -} - -/** - * Install skills from ClawdHub registry - * @param {Object} options - * @param {string} options.scope - 'user' or 'project' - * @param {string} [options.targetSource] - 'opencode' or 'agents' - * @param {string} [options.workingDirectory] - Required for project scope - * @param {string} options.userSkillDir - User skills directory - * @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } } - * @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll' - * @param {Object} [options.conflictDecisions] - Per-skill conflict decisions - * @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>} - */ -export async function installSkillsFromClawdHub({ - scope, - targetSource, - workingDirectory, - userSkillDir, - selections, - conflictPolicy, - conflictDecisions, -} = {}) { - if (scope !== 'user' && scope !== 'project') { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } }; - } - - if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } }; - } - - if (!userSkillDir) { - return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } }; - } - - const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir); - if (normalizedUserSkillDir) { - userSkillDir = normalizedUserSkillDir; - } - - if (scope === 'project' && !workingDirectory) { - return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; - } - - const requestedSkills = Array.isArray(selections) ? selections : []; - if (requestedSkills.length === 0) { - return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; - } - - // Build installation plans - const skillPlans = requestedSkills.map((sel) => { - const slug = sel.clawdhub?.slug || sel.skillDir; - const version = sel.clawdhub?.version || 'latest'; - return { - slug, - version, - installable: validateSkillName(slug), - }; - }); - - // Check for conflicts before downloading - const conflicts = []; - for (const plan of skillPlans) { - if (!plan.installable) { - continue; - } - - const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug }); - if (fs.existsSync(targetDir)) { - const decision = conflictDecisions?.[plan.slug]; - const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll'; - if (!decision && !hasAutoPolicy) { - conflicts.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' }); - } - } - } - - if (conflicts.length > 0) { - return { - ok: false, - error: { - kind: 'conflicts', - message: 'Some skills already exist in the selected scope', - conflicts, - }, - }; - } - - const installed = []; - const skipped = []; - - for (const plan of skillPlans) { - if (!plan.installable) { - skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' }); - continue; - } - - try { - // Resolve 'latest' version if needed - let resolvedVersion = plan.version; - if (resolvedVersion === 'latest') { - try { - const info = await fetchClawdHubSkillInfo(plan.slug); - const latest = info.skill?.tags?.latest || info.latestVersion?.version || null; - if (latest) { - resolvedVersion = latest; - } - } catch { - // ignore - } - - if (resolvedVersion === 'latest') { - skipped.push({ skillName: plan.slug, reason: 'Unable to resolve latest version' }); - continue; - } - } - - const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug }); - const exists = fs.existsSync(targetDir); - - // Determine conflict resolution - let decision = conflictDecisions?.[plan.slug] || null; - if (!decision) { - if (exists && conflictPolicy === 'skipAll') decision = 'skip'; - if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite'; - if (!exists) decision = 'overwrite'; // No conflict, proceed - } - - if (exists && decision === 'skip') { - skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' }); - continue; - } - - if (exists && decision === 'overwrite') { - await safeRm(targetDir); - } - - // Download the skill ZIP - const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion); - - // Extract to a temp directory first for validation - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`)); - - try { - const zip = new AdmZip(Buffer.from(zipBuffer)); - zip.extractAllTo(tempDir, true); - - // Verify SKILL.md exists - const skillMdPath = path.join(tempDir, 'SKILL.md'); - if (!fs.existsSync(skillMdPath)) { - skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' }); - continue; - } - - // Move to target directory - await ensureDir(path.dirname(targetDir)); - await fs.promises.rename(tempDir, targetDir); - - installed.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' }); - } catch (extractError) { - await safeRm(tempDir); - throw extractError; - } - } catch (error) { - console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error); - skipped.push({ - skillName: plan.slug, - reason: error instanceof Error ? error.message : 'Failed to download or extract skill', - }); - } - } - - return { ok: true, installed, skipped }; -} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/install.test.js b/packages/web/server/lib/skills-catalog/clawdhub/install.test.js deleted file mode 100644 index e57a29ba..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/install.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import AdmZip from 'adm-zip'; - -// Mock the ClawdHub network client so no real HTTP happens. The download -// function is what feeds the ZIP buffer into adm-zip inside install.js. -vi.mock('./api.js', () => ({ - downloadClawdHubSkill: vi.fn(), - fetchClawdHubSkillInfo: vi.fn(), -})); - -const { downloadClawdHubSkill } = await import('./api.js'); -const { installSkillsFromClawdHub } = await import('./install.js'); - -/** - * Build a real ZIP archive with adm-zip (the dependency under test). - * Returns the raw Buffer, mirroring what downloadClawdHubSkill resolves to. - */ -function buildSkillZip(entries) { - const zip = new AdmZip(); - for (const [entryName, content] of Object.entries(entries)) { - zip.addFile(entryName, Buffer.from(content, 'utf8')); - } - return zip.toBuffer(); -} - -describe('installSkillsFromClawdHub (adm-zip extraction path)', () => { - let userSkillDir; - - beforeEach(async () => { - // Keep the target dir under os.tmpdir() so the temp->target rename in - // install.js stays on one filesystem (avoids EXDEV cross-device errors). - userSkillDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'clawdhub-test-skills-')); - vi.clearAllMocks(); - }); - - afterEach(async () => { - await fs.promises.rm(userSkillDir, { recursive: true, force: true }).catch(() => {}); - }); - - it('extracts a real ZIP (incl. nested subdirectories) into the target skill dir', async () => { - const skillMd = 'name: demo-skill\ndescription: adm-zip extraction regression guard\n'; - const nested = 'nested file content for subdirectory extraction check\n'; - downloadClawdHubSkill.mockResolvedValue( - buildSkillZip({ 'SKILL.md': skillMd, 'nested/data.txt': nested }), - ); - - const result = await installSkillsFromClawdHub({ - scope: 'user', - targetSource: 'opencode', - userSkillDir, - // Non-'latest' version avoids the fetchClawdHubSkillInfo resolve branch. - selections: [{ clawdhub: { slug: 'demo-skill', version: '1.0.0' } }], - }); - - expect(result.ok).toBe(true); - expect(result.installed).toEqual([ - { skillName: 'demo-skill', scope: 'user', source: 'opencode' }, - ]); - expect(result.skipped).toEqual([]); - - // downloadClawdHubSkill received the resolved (non-latest) version. - expect(downloadClawdHubSkill).toHaveBeenCalledWith('demo-skill', '1.0.0'); - - // adm-zip actually wrote the files, preserving the nested subdirectory. - const targetDir = path.join(userSkillDir, 'demo-skill'); - const skillMdPath = path.join(targetDir, 'SKILL.md'); - const nestedPath = path.join(targetDir, 'nested', 'data.txt'); - - expect(fs.existsSync(skillMdPath)).toBe(true); - expect(fs.existsSync(nestedPath)).toBe(true); - expect(fs.readFileSync(skillMdPath, 'utf8')).toBe(skillMd); - expect(fs.readFileSync(nestedPath, 'utf8')).toBe(nested); - }); - - it('skips a package whose extracted contents lack SKILL.md', async () => { - // Valid ZIP, but no SKILL.md at the root -> install.js must skip it and - // must NOT create the target dir. This exercises the extractAllTo path - // followed by the post-extraction validation. - downloadClawdHubSkill.mockResolvedValue( - buildSkillZip({ 'README.md': 'no skill manifest here\n' }), - ); - - const result = await installSkillsFromClawdHub({ - scope: 'user', - targetSource: 'opencode', - userSkillDir, - selections: [{ clawdhub: { slug: 'broken-skill', version: '1.0.0' } }], - }); - - expect(result.ok).toBe(true); - expect(result.installed).toEqual([]); - expect(result.skipped).toEqual([ - { skillName: 'broken-skill', reason: 'SKILL.md not found in downloaded package' }, - ]); - expect(fs.existsSync(path.join(userSkillDir, 'broken-skill'))).toBe(false); - }); -}); diff --git a/packages/web/server/lib/skills-catalog/clawdhub/scan.js b/packages/web/server/lib/skills-catalog/clawdhub/scan.js deleted file mode 100644 index 5a8a6e4d..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/scan.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * ClawdHub skill scanning - * - * Fetches all available skills from the ClawdHub registry - * and transforms them into SkillsCatalogItem format. - */ - -import { fetchClawdHubSkills } from './api.js'; - -const CLAWDHUB_PAGE_LIMIT = 25; - -const mapClawdHubItem = (item) => { - const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; - - return { - sourceId: 'clawdhub', - repoSource: 'clawdhub:registry', - repoSubpath: null, - gitIdentityId: null, - skillDir: item.slug, - skillName: item.slug, - frontmatterName: item.displayName || item.slug, - description: item.summary || null, - installable: true, - warnings: [], - // ClawdHub-specific metadata - clawdhub: { - slug: item.slug, - version: latestVersion, - displayName: item.displayName, - owner: item.owner?.handle || null, - downloads: item.stats?.downloads || 0, - stars: item.stats?.stars || 0, - versionsCount: item.stats?.versions || 1, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }, - }; -}; - -/** - * Scan a single ClawdHub page (cursor-based) - * @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>} - */ -export async function scanClawdHubPage({ cursor } = {}) { - try { - const { items, nextCursor } = await fetchClawdHubSkills({ cursor }); - const mapped = (items || []).map(mapClawdHubItem).slice(0, CLAWDHUB_PAGE_LIMIT); - mapped.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); - return { ok: true, items: mapped, nextCursor: nextCursor || null }; - } catch (error) { - console.error('ClawdHub page scan error:', error); - return { - ok: false, - error: { - kind: 'networkError', - message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', - }, - }; - } -} diff --git a/packages/web/server/lib/skills-catalog/curated-sources.js b/packages/web/server/lib/skills-catalog/curated-sources.js index ba62696d..19f5c1c3 100644 --- a/packages/web/server/lib/skills-catalog/curated-sources.js +++ b/packages/web/server/lib/skills-catalog/curated-sources.js @@ -8,11 +8,27 @@ const CURATED_SKILLS_SOURCES = [ sourceType: 'github', }, { - id: 'clawdhub', - label: 'ClawHub', - description: 'Community skill registry with vector search', - source: 'clawdhub:registry', - sourceType: 'clawdhub', + id: 'openai', + label: 'OpenAI', + description: "OpenAI's curated skills", + source: 'openai/skills', + defaultSubpath: 'skills/.curated', + sourceType: 'github', + }, + { + id: 'cursor', + label: 'Cursor', + description: "Cursor's plugin skills", + source: 'cursor/plugins', + defaultSubpath: 'pstack/skills', + sourceType: 'github', + }, + { + id: 'mattpocock', + label: 'Matt Pocock', + description: 'Matt Pocock skills collection', + source: 'mattpocock/skills', + sourceType: 'github', }, ]; diff --git a/packages/web/server/lib/skills-catalog/curated-sources.test.js b/packages/web/server/lib/skills-catalog/curated-sources.test.js index 7db92de5..dfb568dd 100644 --- a/packages/web/server/lib/skills-catalog/curated-sources.test.js +++ b/packages/web/server/lib/skills-catalog/curated-sources.test.js @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'; import { getCuratedSkillsSources } from './curated-sources.js'; describe('getCuratedSkillsSources', () => { - it('labels the ClawHub curated source as ClawHub', () => { - const clawhub = getCuratedSkillsSources().find((source) => source.id === 'clawdhub'); - expect(clawhub).toBeDefined(); - expect(clawhub.label).toBe('ClawHub'); + it('includes the Anthropic curated source', () => { + const anthropic = getCuratedSkillsSources().find((source) => source.id === 'anthropic'); + expect(anthropic).toBeDefined(); + expect(anthropic.label).toBe('Anthropic'); }); }); diff --git a/packages/web/server/lib/skills-catalog/disk-cache.js b/packages/web/server/lib/skills-catalog/disk-cache.js new file mode 100644 index 00000000..6fb60ffe --- /dev/null +++ b/packages/web/server/lib/skills-catalog/disk-cache.js @@ -0,0 +1,52 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const resolveDataDir = () => (process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber')); + +const readJsonFile = (filePath) => { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}; + +/** + * Read a persisted cache object from the OpenChamber data directory. + * Returns null when the file is missing, unreadable, or malformed. + */ +export const readDiskCache = (fileName) => { + try { + return readJsonFile(path.join(resolveDataDir(), fileName)); + } catch { + return null; + } +}; + +/** + * Persist a cache object to the OpenChamber data directory with an atomic + * temp-file rename. Failures are ignored: the in-memory cache stays + * authoritative and the next successful write retries persistence. + */ +export const writeDiskCache = (fileName, data) => { + const filePath = path.join(resolveDataDir(), fileName); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(tempPath, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, filePath); + return true; + } catch { + try { + fs.unlinkSync(tempPath); + } catch { + // ignore + } + return false; + } +}; diff --git a/packages/web/server/lib/skills-catalog/github-meta.js b/packages/web/server/lib/skills-catalog/github-meta.js new file mode 100644 index 00000000..08a14aad --- /dev/null +++ b/packages/web/server/lib/skills-catalog/github-meta.js @@ -0,0 +1,139 @@ +import { readDiskCache, writeDiskCache } from './disk-cache.js'; + +const GITHUB_API_BASE = 'https://api.github.com'; +const CACHE_TTL_MS = 3 * 60 * 60 * 1000; +const FAILURE_CACHE_TTL_MS = 5 * 60 * 1000; +// Keep well under the catalog route's client request deadline so optional +// metadata enrichment can never abort catalog loading. +const FETCH_TIMEOUT_MS = 1500; +const DISK_CACHE_FILE = 'skills-github-meta.json'; + +const metaCache = new Map(); +const inFlight = new Map(); + +let diskLoaded = false; +let diskWriteTimer = null; + +const loadDiskEntries = () => { + if (diskLoaded) { + return; + } + diskLoaded = true; + const persisted = readDiskCache(DISK_CACHE_FILE); + if (!persisted) { + return; + } + const now = Date.now(); + for (const [repo, entry] of Object.entries(persisted)) { + if ( + entry + && typeof entry === 'object' + && typeof entry.expiresAt === 'number' + && entry.expiresAt > now + && entry.value + && typeof entry.value === 'object' + ) { + metaCache.set(repo, entry); + } + } +}; + +const scheduleDiskWrite = () => { + if (diskWriteTimer) { + return; + } + diskWriteTimer = setTimeout(() => { + diskWriteTimer = null; + const now = Date.now(); + const persisted = {}; + for (const [repo, entry] of metaCache.entries()) { + if (entry.expiresAt > now) { + persisted[repo] = entry; + } + } + writeDiskCache(DISK_CACHE_FILE, persisted); + }, 1000); + if (typeof diskWriteTimer.unref === 'function') { + diskWriteTimer.unref(); + } +}; + +const parseMeta = (payload) => { + if (!payload || typeof payload !== 'object') { + return null; + } + const pushedAt = payload.pushed_at; + return { + stars: Number.isFinite(payload.stargazers_count) ? payload.stargazers_count : null, + repoUpdatedAt: typeof pushedAt === 'string' && pushedAt ? pushedAt : null, + }; +}; + +const fetchRepoMeta = async (normalizedRepo) => { + loadDiskEntries(); + const cached = metaCache.get(normalizedRepo); + if (cached && Date.now() < cached.expiresAt) { + return cached.value; + } + + const existing = inFlight.get(normalizedRepo); + if (existing) { + return existing; + } + + const run = (async () => { + try { + const response = await fetch(`${GITHUB_API_BASE}/repos/${normalizedRepo}`, { + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + // Cache failures briefly so repeated catalog loads do not re-hit a + // rate-limited or failing API for the same repository. + metaCache.set(normalizedRepo, { + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS, + value: { stars: null, repoUpdatedAt: null }, + }); + scheduleDiskWrite(); + return null; + } + + const value = parseMeta(await response.json()); + if (value) { + metaCache.set(normalizedRepo, { expiresAt: Date.now() + CACHE_TTL_MS, value }); + scheduleDiskWrite(); + } + return value; + } catch { + metaCache.set(normalizedRepo, { + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS, + value: { stars: null, repoUpdatedAt: null }, + }); + scheduleDiskWrite(); + return null; + } finally { + inFlight.delete(normalizedRepo); + } + })(); + + inFlight.set(normalizedRepo, run); + return run; +}; + +/** + * Fetch GitHub repository metadata (stars, last push) for a list of + * `owner/repo` strings. Best-effort: failed lookups resolve to null and + * never block the catalog response. + */ +export async function fetchGitHubRepoMetas(normalizedRepos) { + const unique = [...new Set(normalizedRepos.filter(Boolean))]; + const entries = await Promise.all(unique.map(async (repo) => [repo, await fetchRepoMeta(repo)])); + return Object.fromEntries(entries); +} + +/** For tests only: clear the in-memory repository metadata cache. */ +export function clearGitHubMetaCache() { + metaCache.clear(); + inFlight.clear(); + diskLoaded = true; +} diff --git a/packages/web/server/lib/skills-catalog/github-meta.test.js b/packages/web/server/lib/skills-catalog/github-meta.test.js new file mode 100644 index 00000000..f0a5c84e --- /dev/null +++ b/packages/web/server/lib/skills-catalog/github-meta.test.js @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearGitHubMetaCache, fetchGitHubRepoMetas } from './github-meta.js'; + +const originalFetch = globalThis.fetch; + +let tempDataDir; + +beforeEach(() => { + tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-meta-test-')); + process.env.OPENCHAMBER_DATA_DIR = tempDataDir; +}); + +afterEach(() => { + delete process.env.OPENCHAMBER_DATA_DIR; + globalThis.fetch = originalFetch; + clearGitHubMetaCache(); + vi.restoreAllMocks(); + fs.rmSync(tempDataDir, { recursive: true, force: true }); +}); + +describe('fetchGitHubRepoMetas', () => { + it('returns stars and pushed_at from the GitHub API', async () => { + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ stargazers_count: 42, pushed_at: '2026-08-01T00:00:00Z' }), + { status: 200 }, + )); + globalThis.fetch = fetchMock; + + const metas = await fetchGitHubRepoMetas(['anthropics/skills']); + + expect(metas).toEqual({ + 'anthropics/skills': { stars: 42, repoUpdatedAt: '2026-08-01T00:00:00Z' }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('resolves failed lookups to null without throwing', async () => { + globalThis.fetch = vi.fn(async () => new Response('rate limited', { status: 403 })); + + const metas = await fetchGitHubRepoMetas(['anthropics/skills']); + + expect(metas).toEqual({ 'anthropics/skills': null }); + }); + + it('caches failed lookups briefly to avoid repeat hits', async () => { + const fetchMock = vi.fn(async () => new Response('rate limited', { status: 403 })); + globalThis.fetch = fetchMock; + + await fetchGitHubRepoMetas(['anthropics/skills']); + const second = await fetchGitHubRepoMetas(['anthropics/skills']); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second).toEqual({ 'anthropics/skills': { stars: null, repoUpdatedAt: null } }); + }); + + it('deduplicates repositories', async () => { + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ stargazers_count: 1, pushed_at: null }), + { status: 200 }, + )); + globalThis.fetch = fetchMock; + + const metas = await fetchGitHubRepoMetas(['a/b', 'a/b', null]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(metas['a/b']).toEqual({ stars: 1, repoUpdatedAt: null }); + }); +}); diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js index 24e1dd8f..5af2a100 100644 --- a/packages/web/server/lib/skills-catalog/source.js +++ b/packages/web/server/lib/skills-catalog/source.js @@ -1,5 +1,4 @@ const GITHUB_HOST = 'github.com'; -const CLAWDHUB_SOURCE_PREFIX = 'clawdhub:'; function normalizeGitOwnerRepo(owner, repo) { @@ -86,7 +85,3 @@ export function parseSkillRepoSource(input, options = {}) { return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } }; } - -export function isClawdHubSource(input) { - return typeof input === 'string' && input.trim().toLowerCase().startsWith(CLAWDHUB_SOURCE_PREFIX); -} From 8cb9e4838a4f2e5cc345dd243d815e314d61a415 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 01:51:29 +0300 Subject: [PATCH 22/23] docs: add communication style skill and enable it globally Adds a new communication-style skill for rewriting text in a more human voice Replaces the old unslop skill with the new name and guidance Updates agent instructions to apply the communication-style skill to every task --- .agents/skills/{unslop => communication-style}/SKILL.md | 6 +++--- AGENTS.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) rename .agents/skills/{unslop => communication-style}/SKILL.md (98%) diff --git a/.agents/skills/unslop/SKILL.md b/.agents/skills/communication-style/SKILL.md similarity index 98% rename from .agents/skills/unslop/SKILL.md rename to .agents/skills/communication-style/SKILL.md index 4fd0f03d..d1760fb8 100644 --- a/.agents/skills/unslop/SKILL.md +++ b/.agents/skills/communication-style/SKILL.md @@ -1,10 +1,10 @@ --- -name: unslop -description: Must always apply. +name: communication-style +description: Use it always. author: poteto (pstack) --- -# Unslop +# Communication style Edit text to remove AI patterns and add human voice. diff --git a/AGENTS.md b/AGENTS.md index 0ea1ebb6..dee36e98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ process violation. | iOS Simulator build, launch, preview, gestures, or `serve-sim` control | `serve-sim` | | Drafting or updating user-facing CHANGELOG entries for the `[Unreleased]` section (main app or VS Code extension) | `changelog-authoring` | | Creating or editing skills, `AGENTS.md`, or docs reached through agent instructions/context pointers | `writing-for-agents` | +| Every task, always | `communication-style` | Pure code-reading or explanation does not require implementation skills unless needed to interpret a specialized subsystem. From d80a011cbd0f43030c4ca2f93201b7ab244a7d22 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 01:54:08 +0300 Subject: [PATCH 23/23] docs: require loading communication-style skill at task start Adds a mandatory early load step for the communication-style skill. Removes the blanket always-on trigger from the skills table. Clarifies that the guidance applies to all task output. --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index dee36e98..9d2a437d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,10 @@ task-required reference named by those skills. Skills are canonical for their detailed workflows and checklists. Treating this table as optional advice is a process violation. +**Always load `.agents/skills/communication-style/SKILL.md` at the start of +every task, before any analysis, tool call, or response. Apply its guidance to +all messages and written output, not only to user-facing copy or documentation.** + | Trigger | Required skill | |---|---| | Source/dependency changes, exports or package contracts, build/generated assets, or module ownership | `openchamber-change-discipline` | @@ -95,7 +99,6 @@ process violation. | iOS Simulator build, launch, preview, gestures, or `serve-sim` control | `serve-sim` | | Drafting or updating user-facing CHANGELOG entries for the `[Unreleased]` section (main app or VS Code extension) | `changelog-authoring` | | Creating or editing skills, `AGENTS.md`, or docs reached through agent instructions/context pointers | `writing-for-agents` | -| Every task, always | `communication-style` | Pure code-reading or explanation does not require implementation skills unless needed to interpret a specialized subsystem.