@thevinci/web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +4887 -0
- package/bin/cli.test.js +64 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +528 -0
- package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
- package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
- package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
- package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
- package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
- package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
- package/dist/assets/es-4o9ciP61.js +15 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-DLTDToSP.css +1 -0
- package/dist/assets/index-DgiFEKGN.js +1 -0
- package/dist/assets/ko-B20imCHE.js +15 -0
- package/dist/assets/main-BV3KOtdA.css +1 -0
- package/dist/assets/main-CDKJj0sH.js +226 -0
- package/dist/assets/main-LC-PSNVM.js +2 -0
- package/dist/assets/miniChat-CQUiG_cr.js +2 -0
- package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
- package/dist/assets/pl-DQJ7LSzj.js +15 -0
- package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
- package/dist/assets/uk-BNFxOlO4.js +15 -0
- package/dist/assets/vendor--DBfsbEis.css +1 -0
- package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
- package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
- package/dist/assets/worker-CbT6TVo7.js +155 -0
- package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +528 -0
- package/dist/index.html +607 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.png +0 -0
- package/dist/logo-dark-512x512.svg +528 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.png +0 -0
- package/dist/logo-light-512x512.svg +528 -0
- package/dist/mini-chat.html +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +21 -0
- package/dist/sw.js +1 -0
- package/package.json +118 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +528 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +528 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.png +0 -0
- package/public/logo-dark-512x512.svg +528 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.png +0 -0
- package/public/logo-light-512x512.svg +528 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +21 -0
- package/server/TERMINAL_WS_PROTOCOL.md +48 -0
- package/server/index.d.ts +39 -0
- package/server/index.js +1311 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/event-stream/DOCUMENTATION.md +61 -0
- package/server/lib/event-stream/directory-ws-bridge.js +185 -0
- package/server/lib/event-stream/global-hub.js +158 -0
- package/server/lib/event-stream/global-hub.test.js +140 -0
- package/server/lib/event-stream/global-ws-bridge.js +206 -0
- package/server/lib/event-stream/index.js +25 -0
- package/server/lib/event-stream/protocol.js +131 -0
- package/server/lib/event-stream/protocol.test.js +182 -0
- package/server/lib/event-stream/runtime.js +180 -0
- package/server/lib/event-stream/runtime.test.js +512 -0
- package/server/lib/event-stream/upstream-reader.js +226 -0
- package/server/lib/event-stream/upstream-reader.test.js +276 -0
- package/server/lib/fs/DOCUMENTATION.md +36 -0
- package/server/lib/fs/routes.js +1040 -0
- package/server/lib/fs/search.js +238 -0
- package/server/lib/git/DOCUMENTATION.md +152 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +112 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/routes.js +972 -0
- package/server/lib/git/service.js +3432 -0
- package/server/lib/git/service.test.js +39 -0
- package/server/lib/github/DOCUMENTATION.md +171 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +519 -0
- package/server/lib/github/repo/fork-detection.js +102 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/github/routes.js +1560 -0
- package/server/lib/magic-prompts/routes.js +63 -0
- package/server/lib/magic-prompts/runtime.js +119 -0
- package/server/lib/notifications/DOCUMENTATION.md +122 -0
- package/server/lib/notifications/emitter-runtime.js +102 -0
- package/server/lib/notifications/index.js +4 -0
- package/server/lib/notifications/message.js +52 -0
- package/server/lib/notifications/message.test.js +34 -0
- package/server/lib/notifications/push-runtime.js +304 -0
- package/server/lib/notifications/routes.js +315 -0
- package/server/lib/notifications/runtime.js +566 -0
- package/server/lib/notifications/template-runtime.js +349 -0
- package/server/lib/notifications/template-runtime.test.js +26 -0
- package/server/lib/opencode/DOCUMENTATION.md +362 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth-state-runtime.js +88 -0
- package/server/lib/opencode/auth.js +83 -0
- package/server/lib/opencode/bootstrap-runtime.js +131 -0
- package/server/lib/opencode/cli-entry-runtime.js +43 -0
- package/server/lib/opencode/cli-options.js +128 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/config-entity-routes.js +370 -0
- package/server/lib/opencode/core-routes.js +500 -0
- package/server/lib/opencode/core-routes.test.js +26 -0
- package/server/lib/opencode/env-config.js +74 -0
- package/server/lib/opencode/env-keys.js +68 -0
- package/server/lib/opencode/env-runtime.js +1162 -0
- package/server/lib/opencode/env-runtime.test.js +116 -0
- package/server/lib/opencode/feature-routes-runtime.js +244 -0
- package/server/lib/opencode/hmr-state-runtime.js +85 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/lifecycle.js +1019 -0
- package/server/lib/opencode/lifecycle.test.js +240 -0
- package/server/lib/opencode/mcp.js +278 -0
- package/server/lib/opencode/network-runtime.js +104 -0
- package/server/lib/opencode/network-runtime.test.js +37 -0
- package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
- package/server/lib/opencode/path-utils.js +100 -0
- package/server/lib/opencode/path-utils.test.js +71 -0
- package/server/lib/opencode/project-directory-runtime.js +124 -0
- package/server/lib/opencode/project-icon-routes.js +399 -0
- package/server/lib/opencode/project-icon-routes.test.js +107 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/proxy.js +445 -0
- package/server/lib/opencode/pwa-manifest-routes.js +257 -0
- package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
- package/server/lib/opencode/routes.js +541 -0
- package/server/lib/opencode/server-startup-runtime.js +156 -0
- package/server/lib/opencode/server-utils-runtime.js +168 -0
- package/server/lib/opencode/server-utils-runtime.test.js +135 -0
- package/server/lib/opencode/session-runtime.js +356 -0
- package/server/lib/opencode/session-runtime.test.js +151 -0
- package/server/lib/opencode/settings-helpers.js +770 -0
- package/server/lib/opencode/settings-helpers.test.js +109 -0
- package/server/lib/opencode/settings-normalization-runtime.js +428 -0
- package/server/lib/opencode/settings-runtime.js +826 -0
- package/server/lib/opencode/settings-runtime.test.js +85 -0
- package/server/lib/opencode/shared.js +615 -0
- package/server/lib/opencode/shutdown-runtime.js +139 -0
- package/server/lib/opencode/shutdown-runtime.test.js +58 -0
- package/server/lib/opencode/skill-routes.js +701 -0
- package/server/lib/opencode/skills.js +548 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
- package/server/lib/opencode/static-routes-runtime.js +65 -0
- package/server/lib/opencode/theme-runtime.js +167 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
- package/server/lib/opencode/vinci-routes.js +76 -0
- package/server/lib/opencode/watcher.js +115 -0
- package/server/lib/opencode/watcher.test.js +239 -0
- package/server/lib/preview/proxy-runtime.js +1333 -0
- package/server/lib/preview/proxy-runtime.test.js +144 -0
- package/server/lib/projects/project-config.js +567 -0
- package/server/lib/projects/project-config.test.js +175 -0
- package/server/lib/projects/project-id.js +13 -0
- package/server/lib/quota/DOCUMENTATION.md +58 -0
- package/server/lib/quota/index.js +25 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +168 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
- package/server/lib/quota/providers/zhipuai.js +114 -0
- package/server/lib/quota/routes.js +27 -0
- package/server/lib/quota/utils/auth.js +50 -0
- package/server/lib/quota/utils/formatters.js +85 -0
- package/server/lib/quota/utils/formatters.test.js +54 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
- package/server/lib/scheduled-tasks/routes.js +235 -0
- package/server/lib/scheduled-tasks/runtime.js +773 -0
- package/server/lib/scheduled-tasks/runtime.test.js +100 -0
- package/server/lib/security/request-security.js +115 -0
- package/server/lib/session-folders/routes.js +63 -0
- package/server/lib/session-folders/routes.test.js +102 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +87 -0
- package/server/lib/terminal/DOCUMENTATION.md +76 -0
- package/server/lib/terminal/index.js +31 -0
- package/server/lib/terminal/output-replay-buffer.js +78 -0
- package/server/lib/terminal/output-replay-buffer.test.js +75 -0
- package/server/lib/terminal/runtime.js +850 -0
- package/server/lib/terminal/runtime.test.js +96 -0
- package/server/lib/terminal/terminal-ws-protocol.js +68 -0
- package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
- package/server/lib/text/DOCUMENTATION.md +35 -0
- package/server/lib/text/summarization.js +138 -0
- package/server/lib/text/summarization.test.js +34 -0
- package/server/lib/tts/DOCUMENTATION.md +146 -0
- package/server/lib/tts/base-url.js +62 -0
- package/server/lib/tts/capability-runtime.js +31 -0
- package/server/lib/tts/index.js +19 -0
- package/server/lib/tts/routes.js +261 -0
- package/server/lib/tts/routes.test.js +53 -0
- package/server/lib/tts/service.js +178 -0
- package/server/lib/tts/stt.js +75 -0
- package/server/lib/tunnels/DOCUMENTATION.md +18 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/managed-config.js +201 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/routes.js +605 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
- package/server/lib/ui-auth/ui-auth.js +673 -0
- package/server/lib/ui-auth/ui-passkeys.js +545 -0
- package/server/opencode-proxy.test.js +151 -0
- package/server/proxy-headers.js +61 -0
- package/server/proxy-headers.test.js +58 -0
- package/server/sse-routes.test.js +152 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
|
|
6
|
+
import { parseSkillRepoSource } from './source.js';
|
|
7
|
+
|
|
8
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
9
|
+
|
|
10
|
+
function normalizeUserSkillDir(userSkillDir) {
|
|
11
|
+
if (!userSkillDir) return null;
|
|
12
|
+
const legacySkillDir = path.join(os.homedir(), '.vinci', '.opencode', 'skill');
|
|
13
|
+
const pluralSkillDir = path.join(os.homedir(), '.vinci', '.opencode', 'skills');
|
|
14
|
+
if (userSkillDir === legacySkillDir) {
|
|
15
|
+
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
|
|
16
|
+
return pluralSkillDir;
|
|
17
|
+
}
|
|
18
|
+
return userSkillDir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateSkillName(skillName) {
|
|
22
|
+
if (typeof skillName !== 'string') return false;
|
|
23
|
+
if (skillName.length < 1 || skillName.length > 64) return false;
|
|
24
|
+
return SKILL_NAME_PATTERN.test(skillName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function safeRm(dir) {
|
|
28
|
+
try {
|
|
29
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toFsPath(repoDir, repoRelPosixPath) {
|
|
36
|
+
const parts = String(repoRelPosixPath || '')
|
|
37
|
+
.split('/')
|
|
38
|
+
.map((p) => p.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
return path.join(repoDir, ...parts);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function ensureDir(dirPath) {
|
|
44
|
+
await fs.promises.mkdir(dirPath, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function copyDirectoryNoSymlinks(srcDir, dstDir) {
|
|
48
|
+
const srcReal = await fs.promises.realpath(srcDir);
|
|
49
|
+
await ensureDir(dstDir);
|
|
50
|
+
|
|
51
|
+
const walk = async (currentSrc, currentDst) => {
|
|
52
|
+
const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true });
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const nextSrc = path.join(currentSrc, entry.name);
|
|
55
|
+
const nextDst = path.join(currentDst, entry.name);
|
|
56
|
+
|
|
57
|
+
const stat = await fs.promises.lstat(nextSrc);
|
|
58
|
+
if (stat.isSymbolicLink()) {
|
|
59
|
+
throw new Error('Symlinks are not supported in skills');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Guard against traversal: ensure source is still under srcReal
|
|
63
|
+
const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc));
|
|
64
|
+
if (!nextRealParent.startsWith(srcReal)) {
|
|
65
|
+
throw new Error('Invalid source path traversal detected');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (stat.isDirectory()) {
|
|
69
|
+
await ensureDir(nextDst);
|
|
70
|
+
await walk(nextSrc, nextDst);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (stat.isFile()) {
|
|
75
|
+
await ensureDir(path.dirname(nextDst));
|
|
76
|
+
await fs.promises.copyFile(nextSrc, nextDst);
|
|
77
|
+
try {
|
|
78
|
+
await fs.promises.chmod(nextDst, stat.mode & 0o777);
|
|
79
|
+
} catch {
|
|
80
|
+
// best-effort
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Skip other types (sockets, devices, etc.)
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
await walk(srcDir, dstDir);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function cloneRepo({ cloneUrl, identity, tempDir }) {
|
|
93
|
+
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
|
|
94
|
+
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
|
|
95
|
+
|
|
96
|
+
const result = await runGit(preferred, { identity, timeoutMs: 90_000 });
|
|
97
|
+
if (result.ok) return { ok: true };
|
|
98
|
+
|
|
99
|
+
const fallbackResult = await runGit(fallback, { identity, timeoutMs: 90_000 });
|
|
100
|
+
if (fallbackResult.ok) return { ok: true };
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: fallbackResult,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
|
|
109
|
+
const source = targetSource === 'agents' ? 'agents' : 'opencode';
|
|
110
|
+
|
|
111
|
+
if (scope === 'user') {
|
|
112
|
+
if (source === 'agents') {
|
|
113
|
+
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
|
114
|
+
}
|
|
115
|
+
return path.join(userSkillDir, skillName);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!workingDirectory) {
|
|
119
|
+
throw new Error('workingDirectory is required for project installs');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (source === 'agents') {
|
|
123
|
+
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return path.join(workingDirectory, '.opencode', 'skills', skillName);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function installSkillsFromRepository({
|
|
130
|
+
source,
|
|
131
|
+
subpath,
|
|
132
|
+
defaultSubpath,
|
|
133
|
+
identity,
|
|
134
|
+
scope,
|
|
135
|
+
targetSource,
|
|
136
|
+
workingDirectory,
|
|
137
|
+
userSkillDir,
|
|
138
|
+
selections,
|
|
139
|
+
conflictPolicy,
|
|
140
|
+
conflictDecisions,
|
|
141
|
+
} = {}) {
|
|
142
|
+
const gitCheck = await assertGitAvailable();
|
|
143
|
+
if (!gitCheck.ok) {
|
|
144
|
+
return { ok: false, error: gitCheck.error };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
|
|
148
|
+
if (normalizedUserSkillDir) {
|
|
149
|
+
userSkillDir = normalizedUserSkillDir;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!userSkillDir) {
|
|
153
|
+
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (scope !== 'user' && scope !== 'project') {
|
|
157
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
|
|
161
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (scope === 'project' && !workingDirectory) {
|
|
165
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const parsed = parseSkillRepoSource(source, { subpath });
|
|
169
|
+
if (!parsed.ok) {
|
|
170
|
+
return { ok: false, error: parsed.error };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
|
|
174
|
+
void effectiveSubpath;
|
|
175
|
+
|
|
176
|
+
const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
|
|
177
|
+
|
|
178
|
+
const requestedDirs = Array.isArray(selections) ? selections.map((s) => String(s?.skillDir || '').trim()).filter(Boolean) : [];
|
|
179
|
+
if (requestedDirs.length === 0) {
|
|
180
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Validate names early and compute conflicts without mutating.
|
|
184
|
+
const skillPlans = requestedDirs.map((skillDirPosix) => {
|
|
185
|
+
const skillName = path.posix.basename(skillDirPosix);
|
|
186
|
+
return { skillDirPosix, skillName, installable: validateSkillName(skillName) };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const conflicts = [];
|
|
190
|
+
for (const plan of skillPlans) {
|
|
191
|
+
if (!plan.installable) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
|
|
196
|
+
if (fs.existsSync(targetDir)) {
|
|
197
|
+
const decision = conflictDecisions?.[plan.skillName];
|
|
198
|
+
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
|
|
199
|
+
if (!decision && !hasAutoPolicy) {
|
|
200
|
+
conflicts.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (conflicts.length > 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: {
|
|
209
|
+
kind: 'conflicts',
|
|
210
|
+
message: 'Some skills already exist in the selected scope',
|
|
211
|
+
conflicts,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'vinci-skills-install-'));
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
|
|
220
|
+
if (!cloned.ok) {
|
|
221
|
+
const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
|
|
222
|
+
if (looksLikeAuthError(msg)) {
|
|
223
|
+
return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
|
|
224
|
+
}
|
|
225
|
+
return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Selective checkout for only requested skill dirs.
|
|
229
|
+
await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { identity, timeoutMs: 15_000 });
|
|
230
|
+
const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { identity, timeoutMs: 30_000 });
|
|
231
|
+
if (!setResult.ok) {
|
|
232
|
+
return { ok: false, error: { kind: 'unknown', message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
|
|
236
|
+
if (!checkoutResult.ok) {
|
|
237
|
+
return { ok: false, error: { kind: 'unknown', message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const installed = [];
|
|
241
|
+
const skipped = [];
|
|
242
|
+
|
|
243
|
+
for (const plan of skillPlans) {
|
|
244
|
+
if (!plan.installable) {
|
|
245
|
+
skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const srcDir = toFsPath(tempBase, plan.skillDirPosix);
|
|
250
|
+
const skillMdPath = path.join(srcDir, 'SKILL.md');
|
|
251
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
252
|
+
skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
|
|
257
|
+
const exists = fs.existsSync(targetDir);
|
|
258
|
+
|
|
259
|
+
let decision = conflictDecisions?.[plan.skillName] || null;
|
|
260
|
+
if (!decision) {
|
|
261
|
+
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
|
|
262
|
+
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
|
263
|
+
if (!exists) decision = 'overwrite'; // no conflict, proceed
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (exists && decision === 'skip') {
|
|
267
|
+
skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (exists && decision === 'overwrite') {
|
|
272
|
+
await safeRm(targetDir);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Ensure project parent directories exist
|
|
276
|
+
await ensureDir(path.dirname(targetDir));
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
await copyDirectoryNoSymlinks(srcDir, targetDir);
|
|
280
|
+
installed.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await safeRm(targetDir);
|
|
283
|
+
skipped.push({
|
|
284
|
+
skillName: plan.skillName,
|
|
285
|
+
reason: error instanceof Error ? error.message : 'Failed to copy skill files',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { ok: true, installed, skipped };
|
|
291
|
+
} finally {
|
|
292
|
+
await safeRm(tempBase);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
|
|
6
|
+
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
|
|
7
|
+
import { parseSkillRepoSource } from './source.js';
|
|
8
|
+
|
|
9
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
10
|
+
|
|
11
|
+
function validateSkillName(skillName) {
|
|
12
|
+
if (typeof skillName !== 'string') return false;
|
|
13
|
+
if (skillName.length < 1 || skillName.length > 64) return false;
|
|
14
|
+
return SKILL_NAME_PATTERN.test(skillName);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseSkillMd(content) {
|
|
18
|
+
const text = typeof content === 'string' ? content : '';
|
|
19
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
frontmatter: {},
|
|
24
|
+
warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const frontmatter = yaml.parse(match[1]) || {};
|
|
30
|
+
return { ok: true, frontmatter, warnings: [] };
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
frontmatter: {},
|
|
35
|
+
warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function safeRm(dir) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function cloneRepo({ cloneUrl, identity, tempDir }) {
|
|
49
|
+
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
|
|
50
|
+
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
|
|
51
|
+
|
|
52
|
+
const result = await runGit(preferred, { identity, timeoutMs: 60_000 });
|
|
53
|
+
if (result.ok) return { ok: true };
|
|
54
|
+
|
|
55
|
+
const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 });
|
|
56
|
+
if (fallbackResult.ok) return { ok: true };
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: fallbackResult,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function scanSkillsRepository({
|
|
65
|
+
source,
|
|
66
|
+
subpath,
|
|
67
|
+
defaultSubpath,
|
|
68
|
+
identity,
|
|
69
|
+
} = {}) {
|
|
70
|
+
const gitCheck = await assertGitAvailable();
|
|
71
|
+
if (!gitCheck.ok) {
|
|
72
|
+
return { ok: false, error: gitCheck.error };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parsed = parseSkillRepoSource(source, { subpath });
|
|
76
|
+
if (!parsed.ok) {
|
|
77
|
+
return { ok: false, error: parsed.error };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
|
|
81
|
+
const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
|
|
82
|
+
|
|
83
|
+
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'vinci-skills-scan-'));
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
|
|
87
|
+
if (!cloned.ok) {
|
|
88
|
+
const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
|
|
89
|
+
if (looksLikeAuthError(msg)) {
|
|
90
|
+
return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const toFsPath = (posixPath) => path.join(tempBase, ...String(posixPath || '').split('/').filter(Boolean));
|
|
96
|
+
|
|
97
|
+
const patterns = effectiveSubpath
|
|
98
|
+
? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
|
|
99
|
+
: ['SKILL.md', '**/SKILL.md'];
|
|
100
|
+
|
|
101
|
+
let skillMdPaths = null;
|
|
102
|
+
|
|
103
|
+
// Fast path: sparse checkout only SKILL.md files, then parse from disk.
|
|
104
|
+
// This avoids one `git show` per skill.
|
|
105
|
+
const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 });
|
|
106
|
+
if (sparseInit.ok) {
|
|
107
|
+
const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 });
|
|
108
|
+
if (sparseSet.ok) {
|
|
109
|
+
const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
|
|
110
|
+
if (checkout.ok) {
|
|
111
|
+
const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 });
|
|
112
|
+
if (lsFiles.ok) {
|
|
113
|
+
skillMdPaths = lsFiles.stdout
|
|
114
|
+
.split(/\r?\n/)
|
|
115
|
+
.map((line) => line.trim())
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fallback: list tree and read SKILL.md blobs via git.
|
|
124
|
+
if (!Array.isArray(skillMdPaths)) {
|
|
125
|
+
const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
|
|
126
|
+
if (effectiveSubpath) {
|
|
127
|
+
listArgs.push('--', effectiveSubpath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 });
|
|
131
|
+
if (!listResult.ok) {
|
|
132
|
+
// If subpath doesn't exist, treat as empty scan.
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
normalizedRepo: parsed.normalizedRepo,
|
|
136
|
+
effectiveSubpath,
|
|
137
|
+
items: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
skillMdPaths = listResult.stdout
|
|
142
|
+
.split(/\r?\n/)
|
|
143
|
+
.map((line) => line.trim())
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Root-level SKILL.md doesn't map cleanly to OpenCode's "skill name == folder name" convention.
|
|
149
|
+
const uniqueSkillDirs = Array.from(
|
|
150
|
+
new Set(
|
|
151
|
+
skillMdPaths
|
|
152
|
+
.filter((p) => p !== 'SKILL.md')
|
|
153
|
+
.map((p) => path.posix.dirname(p))
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const items = [];
|
|
158
|
+
const maxParallel = 10;
|
|
159
|
+
let idx = 0;
|
|
160
|
+
|
|
161
|
+
const worker = async () => {
|
|
162
|
+
while (idx < uniqueSkillDirs.length) {
|
|
163
|
+
const skillDir = uniqueSkillDirs[idx++];
|
|
164
|
+
const skillName = path.posix.basename(skillDir);
|
|
165
|
+
const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
|
|
166
|
+
|
|
167
|
+
const warnings = [];
|
|
168
|
+
let skillMdContent = '';
|
|
169
|
+
|
|
170
|
+
// Prefer filesystem reads when sparse checkout succeeded.
|
|
171
|
+
const filePath = toFsPath(skillMdPath);
|
|
172
|
+
try {
|
|
173
|
+
skillMdContent = await fs.promises.readFile(filePath, 'utf8');
|
|
174
|
+
} catch {
|
|
175
|
+
const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 });
|
|
176
|
+
if (!showResult.ok) {
|
|
177
|
+
warnings.push('Failed to read SKILL.md');
|
|
178
|
+
} else {
|
|
179
|
+
skillMdContent = showResult.stdout;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsedMd = parseSkillMd(skillMdContent);
|
|
184
|
+
warnings.push(...(parsedMd.warnings || []));
|
|
185
|
+
|
|
186
|
+
const description = typeof parsedMd.frontmatter?.description === 'string' ? parsedMd.frontmatter.description : undefined;
|
|
187
|
+
const frontmatterName = typeof parsedMd.frontmatter?.name === 'string' ? parsedMd.frontmatter.name : undefined;
|
|
188
|
+
|
|
189
|
+
const installable = validateSkillName(skillName);
|
|
190
|
+
if (!installable) {
|
|
191
|
+
warnings.push('Skill directory name is not a valid OpenCode skill name');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
items.push({
|
|
195
|
+
repoSource: source,
|
|
196
|
+
repoSubpath: effectiveSubpath || undefined,
|
|
197
|
+
skillDir,
|
|
198
|
+
skillName,
|
|
199
|
+
frontmatterName,
|
|
200
|
+
description,
|
|
201
|
+
installable,
|
|
202
|
+
warnings: warnings.length ? warnings : undefined,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
await Promise.all(Array.from({ length: Math.min(maxParallel, uniqueSkillDirs.length || 1) }, () => worker()));
|
|
208
|
+
|
|
209
|
+
// Stable ordering for UX
|
|
210
|
+
items.sort((a, b) => a.skillName.localeCompare(b.skillName));
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
normalizedRepo: parsed.normalizedRepo,
|
|
215
|
+
effectiveSubpath,
|
|
216
|
+
items,
|
|
217
|
+
};
|
|
218
|
+
} finally {
|
|
219
|
+
await safeRm(tempBase);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const GITHUB_HOST = 'github.com';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function normalizeGitOwnerRepo(owner, repo) {
|
|
5
|
+
const normalizedOwner = String(owner || '').trim();
|
|
6
|
+
const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, '');
|
|
7
|
+
if (!normalizedOwner || !normalizedRepo) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
return { owner: normalizedOwner, repo: normalizedRepo };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export function parseSkillRepoSource(input, options = {}) {
|
|
15
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
16
|
+
if (!raw) {
|
|
17
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
|
|
18
|
+
}
|
|
19
|
+
const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null;
|
|
20
|
+
|
|
21
|
+
const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand';
|
|
22
|
+
const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null;
|
|
23
|
+
|
|
24
|
+
if (gitHost === null && urlFormat !== 'shorthand') {
|
|
25
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository URL format' } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pathSegments = urlFormat === 'https'
|
|
29
|
+
? raw.split('/').slice(3).filter(Boolean)
|
|
30
|
+
: urlFormat === 'ssh'
|
|
31
|
+
? (raw.split('@')[1].split(':')[1] ?? '').split('/').filter(Boolean)
|
|
32
|
+
: null;
|
|
33
|
+
|
|
34
|
+
const repoName = pathSegments && pathSegments.length > 0
|
|
35
|
+
? pathSegments[pathSegments.length - 1].replace(/\.git$/i, '')
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
const gitOwner = pathSegments && pathSegments.length > 1
|
|
39
|
+
? pathSegments.slice(0, -1).join('/')
|
|
40
|
+
: (pathSegments && pathSegments.length === 1 ? pathSegments[0] : null);
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
// SSH git@host:owner/repo(.git) or HTTPS https://host/owner/repo(.git)
|
|
44
|
+
if (urlFormat === 'ssh' || urlFormat === 'https') {
|
|
45
|
+
const parsed = normalizeGitOwnerRepo(gitOwner, repoName);
|
|
46
|
+
if (!parsed) {
|
|
47
|
+
return { ok: false, error: { kind: 'invalidSource', message: `Invalid ${urlFormat} repository URL` } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
host: gitHost,
|
|
53
|
+
owner: parsed.owner,
|
|
54
|
+
repo: parsed.repo,
|
|
55
|
+
cloneUrlSsh: `git@${gitHost}:${parsed.owner}/${parsed.repo}.git`,
|
|
56
|
+
cloneUrlHttps: `https://${gitHost}/${parsed.owner}/${parsed.repo}.git`,
|
|
57
|
+
// For SSH URLs, subpath is only accepted via options.subpath
|
|
58
|
+
effectiveSubpath: explicitSubpath,
|
|
59
|
+
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Shorthand: owner/repo[/subpath...]
|
|
64
|
+
const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
|
|
65
|
+
if (shorthandMatch) {
|
|
66
|
+
const parsed = normalizeGitOwnerRepo(shorthandMatch[1], shorthandMatch[2]);
|
|
67
|
+
if (!parsed) {
|
|
68
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null;
|
|
72
|
+
const effectiveSubpath = explicitSubpath || shorthandSubpath;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
host: GITHUB_HOST,
|
|
77
|
+
owner: parsed.owner,
|
|
78
|
+
repo: parsed.repo,
|
|
79
|
+
cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
|
|
80
|
+
cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
|
|
81
|
+
effectiveSubpath,
|
|
82
|
+
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
|
|
87
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Terminal Module Documentation
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
This module provides WebSocket transport utilities for terminal input and output in the web server runtime, including message normalization, control frame parsing, rate limiting, pathname resolution, and short-lived output replay buffering for terminal WebSocket connections.
|
|
5
|
+
|
|
6
|
+
## Entrypoints and structure
|
|
7
|
+
- `packages/web/server/lib/terminal/`: Terminal module directory.
|
|
8
|
+
- `index.js`: Stable module entrypoint that re-exports protocol helpers and replay-buffer helpers.
|
|
9
|
+
- `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration.
|
|
10
|
+
- `terminal-ws-protocol.js`: Single-file module containing terminal WebSocket protocol utilities.
|
|
11
|
+
- `output-replay-buffer.js`: Helper module for buffering recent terminal output so late subscribers can receive startup prompt data.
|
|
12
|
+
- `packages/web/server/lib/terminal/terminal-ws-protocol.test.js`: Test file for protocol utilities.
|
|
13
|
+
- `packages/web/server/lib/terminal/output-replay-buffer.test.js`: Test file for replay buffer helpers.
|
|
14
|
+
|
|
15
|
+
Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`.
|
|
16
|
+
|
|
17
|
+
## Public exports
|
|
18
|
+
|
|
19
|
+
### Constants
|
|
20
|
+
- `TERMINAL_WS_PATH`: Primary WebSocket endpoint path (`/api/terminal/ws`).
|
|
21
|
+
- `TERMINAL_WS_CONTROL_TAG_JSON`: Control frame tag byte (`0x01`) indicating JSON payload.
|
|
22
|
+
- `TERMINAL_WS_MAX_PAYLOAD_BYTES`: Maximum inbound WebSocket payload size (64KB).
|
|
23
|
+
- `TERMINAL_OUTPUT_REPLAY_MAX_BYTES`: Maximum buffered terminal output retained for replay (64KB).
|
|
24
|
+
|
|
25
|
+
### Request Parsing
|
|
26
|
+
- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
|
|
27
|
+
- `isTerminalWsPathname(pathname)`: Returns whether a pathname matches a supported terminal WebSocket route.
|
|
28
|
+
|
|
29
|
+
### Message Normalization
|
|
30
|
+
- `normalizeTerminalWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
|
|
31
|
+
- `normalizeTerminalWsMessageToText(rawData)`: Normalizes data to UTF-8 text string.
|
|
32
|
+
|
|
33
|
+
### Control Frame Handling
|
|
34
|
+
- `readTerminalWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid or malformed.
|
|
35
|
+
- `createTerminalWsControlFrame(payload)`: Creates a control frame with JSON payload and prepends the control tag byte.
|
|
36
|
+
|
|
37
|
+
### Replay Buffer Helpers
|
|
38
|
+
- `createTerminalOutputReplayBuffer()`: Creates mutable state for recent terminal output replay.
|
|
39
|
+
- `appendTerminalOutputReplayChunk(bufferState, data, maxBytes?)`: Appends a chunk, trimming older buffered data to stay within the configured byte budget.
|
|
40
|
+
- `listTerminalOutputReplayChunksSince(bufferState, lastSeenId)`: Returns buffered chunks newer than the provided replay cursor.
|
|
41
|
+
- `getLatestTerminalOutputReplayChunkId(bufferState)`: Returns the latest chunk id in the replay buffer, or `0` when empty.
|
|
42
|
+
|
|
43
|
+
### Rate Limiting
|
|
44
|
+
- `pruneRebindTimestamps(timestamps, now, windowMs)`: Filters timestamps to keep only those within the active time window.
|
|
45
|
+
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded the configured threshold.
|
|
46
|
+
|
|
47
|
+
## Usage in web server
|
|
48
|
+
The terminal helpers are used by `packages/web/server/index.js` for:
|
|
49
|
+
- WebSocket endpoint path definition and matching
|
|
50
|
+
- Message normalization for terminal input payloads
|
|
51
|
+
- Control frame parsing for session binding, keepalive, and exit signaling
|
|
52
|
+
- Rate limiting for session rebind operations
|
|
53
|
+
- Request pathname parsing for WebSocket routing
|
|
54
|
+
- Replaying startup output such as shell prompts when the client binds after the PTY already emitted data
|
|
55
|
+
|
|
56
|
+
The web server combines these utilities with `bun-pty` or `node-pty` to drive full-duplex PTY sessions.
|
|
57
|
+
|
|
58
|
+
## Notes for contributors
|
|
59
|
+
- Keep control frames backward-compatible when possible; use explicit `v` values for protocol changes.
|
|
60
|
+
- Always normalize incoming WebSocket messages before processing them.
|
|
61
|
+
- Keep replay buffering small and memory-only; it exists to cover startup races, not to implement persistent scrollback.
|
|
62
|
+
- Add tests for new control frame types, websocket path changes, malformed payload handling, and replay trimming semantics.
|
|
63
|
+
- Keep HTTP input and SSE output fallbacks functional unless the rollout explicitly removes them.
|
|
64
|
+
|
|
65
|
+
## Verification notes
|
|
66
|
+
### Manual verification
|
|
67
|
+
1. Start the web server and create a terminal session via `/api/terminal/create`.
|
|
68
|
+
2. Wait briefly before binding the client to ensure the shell emits its prompt first.
|
|
69
|
+
3. Connect to `/api/terminal/ws` WebSocket and bind to the session.
|
|
70
|
+
4. Verify the startup prompt and early shell output are replayed before interactive input begins.
|
|
71
|
+
5. Verify `/api/terminal/input-ws` is rejected with `404 Not Found` and `/api/terminal/:sessionId/stream` still works as a fallback path.
|
|
72
|
+
|
|
73
|
+
### Automated verification
|
|
74
|
+
- Run `bun test packages/web/server/lib/terminal/terminal-ws-protocol.test.js`
|
|
75
|
+
- Run `bun test packages/web/server/lib/terminal/output-replay-buffer.test.js`
|
|
76
|
+
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
|