magus-cli 0.0.0-placeholder → 7.1.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 +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +524 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +248 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +340 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +345 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +310 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1396 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1564 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +657 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +245 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +847 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +377 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +820 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +535 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1423 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +440 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +149 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import {
|
|
5
|
+
type RecommendedSkill,
|
|
6
|
+
classifyStarReliability,
|
|
7
|
+
} from "../data/skill-repos.js";
|
|
8
|
+
import type {
|
|
9
|
+
GitTreeResponse,
|
|
10
|
+
SkillFrontmatter,
|
|
11
|
+
SkillInfo,
|
|
12
|
+
SkillSource,
|
|
13
|
+
} from "../types/index.js";
|
|
14
|
+
import {
|
|
15
|
+
getMarketplaceClonePath,
|
|
16
|
+
scanMcpServers,
|
|
17
|
+
} from "./local-marketplace.js";
|
|
18
|
+
import { ensureCheckout } from "./plugin-checkout.js";
|
|
19
|
+
|
|
20
|
+
const SKILLS_API_BASE =
|
|
21
|
+
"https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
22
|
+
|
|
23
|
+
// ─── In-process cache ─────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface TreeCacheEntry {
|
|
26
|
+
etag: string;
|
|
27
|
+
tree: GitTreeResponse;
|
|
28
|
+
fetchedAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const treeCache = new Map<string, TreeCacheEntry>();
|
|
32
|
+
|
|
33
|
+
// ─── Path helpers ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function getUserSkillsDir(): string {
|
|
36
|
+
return path.join(os.homedir(), ".claude", "skills");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getProjectSkillsDir(projectPath?: string): string {
|
|
40
|
+
return path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── GitHub Tree API ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
|
|
46
|
+
const cached = treeCache.get(repo);
|
|
47
|
+
|
|
48
|
+
const url = `https://api.github.com/repos/${repo}/git/trees/HEAD?recursive=1`;
|
|
49
|
+
const headers: Record<string, string> = {
|
|
50
|
+
Accept: "application/vnd.github+json",
|
|
51
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (cached?.etag) {
|
|
55
|
+
headers["If-None-Match"] = cached.etag;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const token =
|
|
59
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
60
|
+
if (token) {
|
|
61
|
+
headers.Authorization = `Bearer ${token}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
headers,
|
|
66
|
+
signal: AbortSignal.timeout(10000),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (response.status === 304 && cached) {
|
|
70
|
+
return cached.tree;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (response.status === 403 || response.status === 429) {
|
|
74
|
+
const resetHeader = response.headers.get("X-RateLimit-Reset");
|
|
75
|
+
const resetTime = resetHeader
|
|
76
|
+
? new Date(Number(resetHeader) * 1000).toLocaleTimeString()
|
|
77
|
+
: "unknown";
|
|
78
|
+
throw new Error(
|
|
79
|
+
`GitHub API rate limit exceeded. Resets at ${resetTime}. Set GITHUB_TOKEN to increase limits.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const etag = response.headers.get("ETag") || "";
|
|
90
|
+
const tree = (await response.json()) as GitTreeResponse;
|
|
91
|
+
|
|
92
|
+
treeCache.set(repo, { etag, tree, fetchedAt: Date.now() });
|
|
93
|
+
return tree;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Skill Set fetching ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch all skills from a skill set repo using the GitHub Tree API.
|
|
100
|
+
* Returns SkillInfo[] with installed status marked via disk scan.
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchSkillSetSkills(
|
|
103
|
+
repo: string,
|
|
104
|
+
projectPath?: string,
|
|
105
|
+
): Promise<SkillInfo[]> {
|
|
106
|
+
const tree = await fetchGitTree(repo);
|
|
107
|
+
|
|
108
|
+
// Find all SKILL.md blobs
|
|
109
|
+
const skillBlobs = tree.tree.filter(
|
|
110
|
+
(entry) => entry.type === "blob" && entry.path.endsWith("/SKILL.md"),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const userInstalled = await getInstalledSkillNames("user");
|
|
114
|
+
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
115
|
+
|
|
116
|
+
const slugify = (name: string) =>
|
|
117
|
+
name
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
120
|
+
.replace(/^-|-$/g, "");
|
|
121
|
+
|
|
122
|
+
const source: SkillSource = {
|
|
123
|
+
label: repo,
|
|
124
|
+
repo,
|
|
125
|
+
skillsPath: "",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return skillBlobs.map((blob) => {
|
|
129
|
+
// Derive skill name from parent directory of SKILL.md
|
|
130
|
+
const parts = blob.path.split("/");
|
|
131
|
+
const name = parts[parts.length - 2]; // e.g. "huggingface-datasets"
|
|
132
|
+
const repoPath = blob.path; // e.g. "skills/huggingface-datasets/SKILL.md"
|
|
133
|
+
const slug = slugify(name);
|
|
134
|
+
|
|
135
|
+
const isUser = userInstalled.has(slug) || userInstalled.has(name);
|
|
136
|
+
const isProj = projectInstalled.has(slug) || projectInstalled.has(name);
|
|
137
|
+
const installed = isUser || isProj;
|
|
138
|
+
const installedScope: "user" | "project" | null = isProj
|
|
139
|
+
? "project"
|
|
140
|
+
: isUser
|
|
141
|
+
? "user"
|
|
142
|
+
: null;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
id: `${repo}/${blob.path.replace("/SKILL.md", "")}`,
|
|
146
|
+
name,
|
|
147
|
+
source,
|
|
148
|
+
repoPath,
|
|
149
|
+
gitBlobSha: blob.sha,
|
|
150
|
+
frontmatter: null,
|
|
151
|
+
installed,
|
|
152
|
+
installedScope,
|
|
153
|
+
hasUpdate: false,
|
|
154
|
+
description: "",
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Plugin contents listing ─────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/** The four things a Claude Code plugin can ship. */
|
|
162
|
+
export type PluginComponentKind = "skill" | "command" | "agent" | "mcp";
|
|
163
|
+
|
|
164
|
+
/** One component found inside a plugin, for read-only display. */
|
|
165
|
+
export interface PluginComponent {
|
|
166
|
+
kind: PluginComponentKind;
|
|
167
|
+
/** Skill directory, command/agent file stem, or MCP server key. */
|
|
168
|
+
name: string;
|
|
169
|
+
/**
|
|
170
|
+
* Path within the repo. The SKILL.md / command .md / agent .md, or the
|
|
171
|
+
* `.mcp.json` that declares the server.
|
|
172
|
+
*/
|
|
173
|
+
repoPath: string;
|
|
174
|
+
/**
|
|
175
|
+
* Sub-directory between the component root and the component itself, when
|
|
176
|
+
* the plugin groups them. "engineering" for skills/engineering/code-review.
|
|
177
|
+
*/
|
|
178
|
+
group?: string;
|
|
179
|
+
/** MCP only: the command the server runs, for the detail pane. */
|
|
180
|
+
mcpCommand?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Strip a marketplace `source` string down to a repo-relative directory.
|
|
185
|
+
* "./" and "" both mean the repo root.
|
|
186
|
+
*/
|
|
187
|
+
function normalizePluginSource(source?: string): string {
|
|
188
|
+
if (!source) return "";
|
|
189
|
+
return source.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Sort key: group the four kinds, then alphabetise within each. */
|
|
193
|
+
const KIND_ORDER: Record<PluginComponentKind, number> = {
|
|
194
|
+
skill: 0,
|
|
195
|
+
command: 1,
|
|
196
|
+
agent: 2,
|
|
197
|
+
mcp: 3,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Collect `<root>/**\/*.md` under one component directory.
|
|
202
|
+
*
|
|
203
|
+
* Commands and agents are flat `.md` files, sometimes one directory deep for
|
|
204
|
+
* grouping. README.md is excluded: several plugins document a directory from
|
|
205
|
+
* inside it, and listing that as a command invites the user to run it.
|
|
206
|
+
*/
|
|
207
|
+
function collectMarkdown(
|
|
208
|
+
paths: string[],
|
|
209
|
+
root: string,
|
|
210
|
+
kind: PluginComponentKind,
|
|
211
|
+
): PluginComponent[] {
|
|
212
|
+
const out: PluginComponent[] = [];
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
if (!path.startsWith(root) || !path.endsWith(".md")) continue;
|
|
215
|
+
const inner = path.slice(root.length, -".md".length).split("/");
|
|
216
|
+
const name = inner[inner.length - 1];
|
|
217
|
+
if (!name || name.toLowerCase() === "readme") continue;
|
|
218
|
+
out.push({
|
|
219
|
+
kind,
|
|
220
|
+
name,
|
|
221
|
+
repoPath: path,
|
|
222
|
+
group: inner.length > 1 ? inner.slice(0, -1).join("/") : undefined,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* List everything a plugin ships, from the repo tree.
|
|
230
|
+
*
|
|
231
|
+
* Skills, commands and agents come from one tree read. MCP servers cost a
|
|
232
|
+
* second request, made only when the plugin actually carries a `.mcp.json` —
|
|
233
|
+
* the file has to be opened to learn the server *names*, and a plugin whose
|
|
234
|
+
* only content is an MCP server otherwise renders as empty. mnemex is exactly
|
|
235
|
+
* that shape: no skills, no commands, no agents, one server, and it appeared
|
|
236
|
+
* to ship nothing at all.
|
|
237
|
+
*
|
|
238
|
+
* Skill discovery is scoped deliberately to `<source>/skills/**` plus a
|
|
239
|
+
* `<source>/SKILL.md` for a plugin that IS one skill. Repos in the wild mirror
|
|
240
|
+
* their skills into every harness directory they support and into per-language
|
|
241
|
+
* docs trees: everything-claude-code carries 898 SKILL.md files but only 286
|
|
242
|
+
* distinct skills, the rest living under `.kiro/`, `.agents/`, `.cursor/` and
|
|
243
|
+
* `docs/<lang>/`. An unscoped walk lists all 898 and reads as the plugin being
|
|
244
|
+
* three times its real size. ponytail is the same shape at small scale: 6
|
|
245
|
+
* skills, mirrored into `.openclaw/skills/`.
|
|
246
|
+
*/
|
|
247
|
+
export async function fetchPluginContents(
|
|
248
|
+
repo: string,
|
|
249
|
+
pluginSource?: string,
|
|
250
|
+
marketplaceName?: string,
|
|
251
|
+
): Promise<PluginComponent[]> {
|
|
252
|
+
// Three sources, cheapest first.
|
|
253
|
+
//
|
|
254
|
+
// 1. Claude Code's marketplace clone. Free and complete — but only exists if
|
|
255
|
+
// the marketplace was actually cloned. "✓ Added" does not imply that: the
|
|
256
|
+
// badge means the catalog resolved, and a catalog resolves over HTTP.
|
|
257
|
+
// The short repo name is what a root-level SKILL.md is called, whichever
|
|
258
|
+
// source answers.
|
|
259
|
+
const rootSkillName = repo.split("/")[1] || repo;
|
|
260
|
+
|
|
261
|
+
if (marketplaceName) {
|
|
262
|
+
const local = await readLocalPluginContents(
|
|
263
|
+
getMarketplaceClonePath(marketplaceName),
|
|
264
|
+
pluginSource,
|
|
265
|
+
rootSkillName,
|
|
266
|
+
);
|
|
267
|
+
if (local) return local;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Our own read-only checkout. Unlimited and offline after the first fetch,
|
|
271
|
+
// and explicitly not an install — see plugin-checkout.ts. This is what
|
|
272
|
+
// keeps browsing working once the API budget is gone.
|
|
273
|
+
const checkout = await ensureCheckout(repo);
|
|
274
|
+
if (checkout) {
|
|
275
|
+
const cloned = await readLocalPluginContents(
|
|
276
|
+
checkout,
|
|
277
|
+
pluginSource,
|
|
278
|
+
rootSkillName,
|
|
279
|
+
);
|
|
280
|
+
if (cloned) return cloned;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3. The GitHub Tree API. Capped at 60 requests an hour across every repo on
|
|
284
|
+
// screen combined, so it is the last resort rather than the first.
|
|
285
|
+
const tree = await fetchGitTree(repo);
|
|
286
|
+
const base = normalizePluginSource(pluginSource);
|
|
287
|
+
const prefix = base ? `${base}/` : "";
|
|
288
|
+
const skillsPrefix = `${prefix}skills/`;
|
|
289
|
+
|
|
290
|
+
const blobs = tree.tree
|
|
291
|
+
.filter((entry) => entry.type === "blob")
|
|
292
|
+
.map((entry) => entry.path);
|
|
293
|
+
|
|
294
|
+
const components: PluginComponent[] = [];
|
|
295
|
+
const seenSkills = new Set<string>();
|
|
296
|
+
|
|
297
|
+
for (const path of blobs) {
|
|
298
|
+
// A plugin that is itself a single skill (blader/humanizer): one
|
|
299
|
+
// SKILL.md at the plugin root and no skills/ directory at all.
|
|
300
|
+
if (path === `${prefix}SKILL.md`) {
|
|
301
|
+
const name = base ? base.split("/").pop() || repo : repo.split("/")[1];
|
|
302
|
+
if (!seenSkills.has(name)) {
|
|
303
|
+
seenSkills.add(name);
|
|
304
|
+
components.push({ kind: "skill", name, repoPath: path });
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!path.startsWith(skillsPrefix) || !path.endsWith("/SKILL.md")) continue;
|
|
310
|
+
|
|
311
|
+
// "skills/engineering/code-review/SKILL.md" → ["engineering", "code-review"]
|
|
312
|
+
const inner = path
|
|
313
|
+
.slice(skillsPrefix.length, -"/SKILL.md".length)
|
|
314
|
+
.split("/");
|
|
315
|
+
const name = inner[inner.length - 1];
|
|
316
|
+
if (!name || seenSkills.has(name)) continue;
|
|
317
|
+
seenSkills.add(name);
|
|
318
|
+
|
|
319
|
+
components.push({
|
|
320
|
+
kind: "skill",
|
|
321
|
+
name,
|
|
322
|
+
repoPath: path,
|
|
323
|
+
group: inner.length > 1 ? inner.slice(0, -1).join("/") : undefined,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
components.push(
|
|
328
|
+
...collectMarkdown(blobs, `${prefix}commands/`, "command"),
|
|
329
|
+
...collectMarkdown(blobs, `${prefix}agents/`, "agent"),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const mcpPath = `${prefix}.mcp.json`;
|
|
333
|
+
if (blobs.includes(mcpPath)) {
|
|
334
|
+
components.push(...(await fetchMcpServers(repo, mcpPath)));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
components.sort((a, b) => {
|
|
338
|
+
if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
339
|
+
const g = (a.group || "").localeCompare(b.group || "");
|
|
340
|
+
return g !== 0 ? g : a.name.localeCompare(b.name);
|
|
341
|
+
});
|
|
342
|
+
return components;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* List a plugin's contents from the marketplace clone on disk.
|
|
347
|
+
*
|
|
348
|
+
* Returns null when there is no clone — the marketplace was never added, which
|
|
349
|
+
* is the normal state for a `featured` entry the user is only browsing. That
|
|
350
|
+
* case falls through to the tree API.
|
|
351
|
+
*
|
|
352
|
+
* The shape must match `fetchPluginContents`'s network path exactly, groups
|
|
353
|
+
* included, or a plugin would render differently depending on whether it
|
|
354
|
+
* happened to be installed.
|
|
355
|
+
*/
|
|
356
|
+
export async function readLocalPluginContents(
|
|
357
|
+
repoRoot: string,
|
|
358
|
+
pluginSource?: string,
|
|
359
|
+
/**
|
|
360
|
+
* Name for a plugin that IS one skill at its root. Passed in rather than
|
|
361
|
+
* derived from `repoRoot`, whose basename differs per source: a marketplace
|
|
362
|
+
* clone is `magus`, a checkout is `blader__humanizer`. Deriving it made the
|
|
363
|
+
* same plugin render under two different names depending on which source
|
|
364
|
+
* answered.
|
|
365
|
+
*/
|
|
366
|
+
rootSkillName?: string,
|
|
367
|
+
): Promise<PluginComponent[] | null> {
|
|
368
|
+
const base = normalizePluginSource(pluginSource);
|
|
369
|
+
const pluginDir = path.join(repoRoot, ...(base ? base.split("/") : []));
|
|
370
|
+
if (!(await fs.pathExists(pluginDir))) return null;
|
|
371
|
+
|
|
372
|
+
const fallbackName = rootSkillName || path.basename(repoRoot);
|
|
373
|
+
const components: PluginComponent[] = [];
|
|
374
|
+
const rel = (abs: string) => {
|
|
375
|
+
const suffix = path.relative(repoRoot, abs);
|
|
376
|
+
return suffix.split(path.sep).join("/");
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// A plugin that is itself a single skill: one SKILL.md at its root.
|
|
380
|
+
const rootSkill = path.join(pluginDir, "SKILL.md");
|
|
381
|
+
if (await fs.pathExists(rootSkill)) {
|
|
382
|
+
components.push({
|
|
383
|
+
kind: "skill",
|
|
384
|
+
name: base ? base.split("/").pop() || fallbackName : fallbackName,
|
|
385
|
+
repoPath: rel(rootSkill),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const skill of await walkSkillDirs(path.join(pluginDir, "skills"))) {
|
|
390
|
+
components.push({
|
|
391
|
+
kind: "skill",
|
|
392
|
+
name: skill.name,
|
|
393
|
+
repoPath: rel(path.join(skill.dir, "SKILL.md")),
|
|
394
|
+
group: skill.group,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const [dir, kind] of [
|
|
399
|
+
["commands", "command"],
|
|
400
|
+
["agents", "agent"],
|
|
401
|
+
] as const) {
|
|
402
|
+
for (const file of await walkMarkdown(path.join(pluginDir, dir))) {
|
|
403
|
+
components.push({
|
|
404
|
+
kind,
|
|
405
|
+
name: file.name,
|
|
406
|
+
repoPath: rel(file.path),
|
|
407
|
+
group: file.group,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const name of await scanMcpServers(pluginDir)) {
|
|
413
|
+
components.push({
|
|
414
|
+
kind: "mcp",
|
|
415
|
+
name,
|
|
416
|
+
repoPath: rel(path.join(pluginDir, ".mcp.json")),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
components.sort((a, b) => {
|
|
421
|
+
if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
422
|
+
const g = (a.group || "").localeCompare(b.group || "");
|
|
423
|
+
return g !== 0 ? g : a.name.localeCompare(b.name);
|
|
424
|
+
});
|
|
425
|
+
return components;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Every directory under `root` that holds a SKILL.md, with its grouping path. */
|
|
429
|
+
export async function walkSkillDirs(
|
|
430
|
+
root: string,
|
|
431
|
+
group = "",
|
|
432
|
+
depth = 0,
|
|
433
|
+
): Promise<Array<{ name: string; dir: string; group?: string }>> {
|
|
434
|
+
if (depth > 3 || !(await fs.pathExists(root))) return [];
|
|
435
|
+
let entries: import("node:fs").Dirent[];
|
|
436
|
+
try {
|
|
437
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const found: Array<{ name: string; dir: string; group?: string }> = [];
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
if (!entry.isDirectory()) continue;
|
|
445
|
+
const child = path.join(root, entry.name);
|
|
446
|
+
if (await fs.pathExists(path.join(child, "SKILL.md"))) {
|
|
447
|
+
found.push({ name: entry.name, dir: child, group: group || undefined });
|
|
448
|
+
} else {
|
|
449
|
+
found.push(
|
|
450
|
+
...(await walkSkillDirs(
|
|
451
|
+
child,
|
|
452
|
+
group ? `${group}/${entry.name}` : entry.name,
|
|
453
|
+
depth + 1,
|
|
454
|
+
)),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return found;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Markdown files under `root`, one level of grouping deep. README excluded. */
|
|
462
|
+
export async function walkMarkdown(
|
|
463
|
+
root: string,
|
|
464
|
+
group = "",
|
|
465
|
+
depth = 0,
|
|
466
|
+
): Promise<Array<{ name: string; path: string; group?: string }>> {
|
|
467
|
+
if (depth > 2 || !(await fs.pathExists(root))) return [];
|
|
468
|
+
let entries: import("node:fs").Dirent[];
|
|
469
|
+
try {
|
|
470
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
471
|
+
} catch {
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const found: Array<{ name: string; path: string; group?: string }> = [];
|
|
476
|
+
for (const entry of entries) {
|
|
477
|
+
const child = path.join(root, entry.name);
|
|
478
|
+
if (entry.isDirectory()) {
|
|
479
|
+
found.push(
|
|
480
|
+
...(await walkMarkdown(
|
|
481
|
+
child,
|
|
482
|
+
group ? `${group}/${entry.name}` : entry.name,
|
|
483
|
+
depth + 1,
|
|
484
|
+
)),
|
|
485
|
+
);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
489
|
+
const name = entry.name.slice(0, -".md".length);
|
|
490
|
+
if (name.toLowerCase() === "readme") continue;
|
|
491
|
+
found.push({ name, path: child, group: group || undefined });
|
|
492
|
+
}
|
|
493
|
+
return found;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Read the server names out of a plugin's `.mcp.json`.
|
|
498
|
+
*
|
|
499
|
+
* Both shapes are in use: a bare `{ "<name>": {...} }` map, which mnemex
|
|
500
|
+
* writes, and the `{ "mcpServers": { "<name>": {...} } }` wrapper. A failure
|
|
501
|
+
* here degrades to a single unnamed entry rather than throwing — the plugin
|
|
502
|
+
* demonstrably has a server, and losing the whole listing over its name would
|
|
503
|
+
* be a worse answer than showing it without one.
|
|
504
|
+
*/
|
|
505
|
+
async function fetchMcpServers(
|
|
506
|
+
repo: string,
|
|
507
|
+
mcpPath: string,
|
|
508
|
+
): Promise<PluginComponent[]> {
|
|
509
|
+
try {
|
|
510
|
+
const headers: Record<string, string> = {
|
|
511
|
+
Accept: "application/vnd.github.raw",
|
|
512
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
513
|
+
};
|
|
514
|
+
const token =
|
|
515
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
516
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
517
|
+
|
|
518
|
+
const response = await fetch(
|
|
519
|
+
`https://api.github.com/repos/${repo}/contents/${mcpPath}`,
|
|
520
|
+
{ headers, signal: AbortSignal.timeout(10000) },
|
|
521
|
+
);
|
|
522
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
523
|
+
|
|
524
|
+
const raw = JSON.parse(await response.text()) as Record<string, unknown>;
|
|
525
|
+
const servers = (
|
|
526
|
+
raw.mcpServers && typeof raw.mcpServers === "object"
|
|
527
|
+
? raw.mcpServers
|
|
528
|
+
: raw
|
|
529
|
+
) as Record<string, { command?: string; args?: string[] }>;
|
|
530
|
+
|
|
531
|
+
const named = Object.entries(servers)
|
|
532
|
+
.filter(([, cfg]) => cfg && typeof cfg === "object")
|
|
533
|
+
.map(([name, cfg]) => ({
|
|
534
|
+
kind: "mcp" as const,
|
|
535
|
+
name,
|
|
536
|
+
repoPath: mcpPath,
|
|
537
|
+
mcpCommand: [cfg.command, ...(cfg.args ?? [])]
|
|
538
|
+
.filter(Boolean)
|
|
539
|
+
.join(" "),
|
|
540
|
+
}));
|
|
541
|
+
|
|
542
|
+
return named.length > 0
|
|
543
|
+
? named
|
|
544
|
+
: [{ kind: "mcp", name: "MCP server", repoPath: mcpPath }];
|
|
545
|
+
} catch {
|
|
546
|
+
return [{ kind: "mcp", name: "MCP server", repoPath: mcpPath }];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ─── Plugin skill content ────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
/** A skill's own SKILL.md, split into its frontmatter and its body. */
|
|
553
|
+
export interface PluginSkillDetail {
|
|
554
|
+
/** Every frontmatter key, in the order the file declares them. */
|
|
555
|
+
frontmatter: Array<{ key: string; value: string }>;
|
|
556
|
+
/** Markdown after the frontmatter block, trailing whitespace trimmed. */
|
|
557
|
+
body: string;
|
|
558
|
+
/** Total body lines, so a truncated render can say what it withheld. */
|
|
559
|
+
bodyLines: number;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Read one skill's SKILL.md.
|
|
564
|
+
*
|
|
565
|
+
* Served from the contents API rather than raw.githubusercontent.com, for two
|
|
566
|
+
* reasons: it is the host `fetchGitTree` already uses, so GITHUB_TOKEN raises
|
|
567
|
+
* the limit for both at once, and the raw host is the one a Tailscale MagicDNS
|
|
568
|
+
* search domain hijacks — which fails as an empty body rather than an error.
|
|
569
|
+
*/
|
|
570
|
+
export async function fetchPluginSkillDetail(
|
|
571
|
+
repo: string,
|
|
572
|
+
repoPath: string,
|
|
573
|
+
): Promise<PluginSkillDetail> {
|
|
574
|
+
const headers: Record<string, string> = {
|
|
575
|
+
Accept: "application/vnd.github.raw",
|
|
576
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
577
|
+
};
|
|
578
|
+
const token =
|
|
579
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
580
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
581
|
+
|
|
582
|
+
const response = await fetch(
|
|
583
|
+
`https://api.github.com/repos/${repo}/contents/${repoPath}`,
|
|
584
|
+
{ headers, signal: AbortSignal.timeout(10000) },
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (response.status === 403 || response.status === 429) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
"GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase limits.",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return splitSkillDocument(await response.text());
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Split a SKILL.md into ordered frontmatter pairs and its body.
|
|
603
|
+
*
|
|
604
|
+
* Ordered pairs rather than an object because the panel renders them as written
|
|
605
|
+
* — `name` and `description` first is the near-universal convention, and
|
|
606
|
+
* re-sorting them would bury the description under whatever keys sort earlier.
|
|
607
|
+
* List values are flattened to a comma-joined string for display.
|
|
608
|
+
*/
|
|
609
|
+
export function splitSkillDocument(content: string): PluginSkillDetail {
|
|
610
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
|
|
611
|
+
const frontmatter: Array<{ key: string; value: string }> = [];
|
|
612
|
+
|
|
613
|
+
if (match) {
|
|
614
|
+
let pendingKey: string | null = null;
|
|
615
|
+
for (const line of match[1].split("\n")) {
|
|
616
|
+
// A " - item" line continues the list opened by the previous key.
|
|
617
|
+
const listItem = line.match(/^\s*-\s+(.*)$/);
|
|
618
|
+
if (listItem && pendingKey) {
|
|
619
|
+
const last = frontmatter[frontmatter.length - 1];
|
|
620
|
+
last.value = last.value
|
|
621
|
+
? `${last.value}, ${stripQuotes(listItem[1])}`
|
|
622
|
+
: stripQuotes(listItem[1]);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const colonIdx = line.indexOf(":");
|
|
627
|
+
if (colonIdx === -1) continue;
|
|
628
|
+
const key = line.slice(0, colonIdx).trim();
|
|
629
|
+
if (!key || key.startsWith("#")) continue;
|
|
630
|
+
|
|
631
|
+
const raw = line.slice(colonIdx + 1).trim();
|
|
632
|
+
pendingKey = key;
|
|
633
|
+
frontmatter.push({
|
|
634
|
+
key,
|
|
635
|
+
value:
|
|
636
|
+
raw.startsWith("[") && raw.endsWith("]")
|
|
637
|
+
? raw
|
|
638
|
+
.slice(1, -1)
|
|
639
|
+
.split(",")
|
|
640
|
+
.map((s) => stripQuotes(s.trim()))
|
|
641
|
+
.filter(Boolean)
|
|
642
|
+
.join(", ")
|
|
643
|
+
: stripQuotes(raw),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const body = content.slice(match ? match[0].length : 0).trimEnd();
|
|
649
|
+
return {
|
|
650
|
+
frontmatter,
|
|
651
|
+
body,
|
|
652
|
+
bodyLines: body ? body.split("\n").length : 0,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function stripQuotes(value: string): string {
|
|
657
|
+
return value.replace(/^["']|["']$/g, "");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ─── Frontmatter parser ───────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
663
|
+
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
664
|
+
if (!frontmatterMatch) return {};
|
|
665
|
+
|
|
666
|
+
const yaml = frontmatterMatch[1];
|
|
667
|
+
const result: Record<string, unknown> = {};
|
|
668
|
+
|
|
669
|
+
for (const line of yaml.split("\n")) {
|
|
670
|
+
const colonIdx = line.indexOf(":");
|
|
671
|
+
if (colonIdx === -1) continue;
|
|
672
|
+
|
|
673
|
+
const key = line.slice(0, colonIdx).trim();
|
|
674
|
+
const rawValue = line.slice(colonIdx + 1).trim();
|
|
675
|
+
|
|
676
|
+
if (!key) continue;
|
|
677
|
+
|
|
678
|
+
// Handle arrays (simple inline: [a, b, c])
|
|
679
|
+
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
680
|
+
const items = rawValue
|
|
681
|
+
.slice(1, -1)
|
|
682
|
+
.split(",")
|
|
683
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
684
|
+
.filter(Boolean);
|
|
685
|
+
result[key] = items;
|
|
686
|
+
} else {
|
|
687
|
+
// Strip quotes
|
|
688
|
+
result[key] = rawValue.replace(/^["']|["']$/g, "");
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return result as Partial<SkillFrontmatter>;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export async function fetchSkillFrontmatter(
|
|
696
|
+
skill: SkillInfo,
|
|
697
|
+
): Promise<SkillFrontmatter> {
|
|
698
|
+
const url = `https://raw.githubusercontent.com/${skill.source.repo}/HEAD/${skill.repoPath}`;
|
|
699
|
+
|
|
700
|
+
const response = await fetch(url, {
|
|
701
|
+
signal: AbortSignal.timeout(10000),
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
if (!response.ok) {
|
|
705
|
+
return {
|
|
706
|
+
name: skill.name,
|
|
707
|
+
description: "(no description)",
|
|
708
|
+
category: "general",
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const content = await response.text();
|
|
713
|
+
const parsed = parseYamlFrontmatter(content);
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
name: parsed.name || skill.name,
|
|
717
|
+
description: parsed.description || "(no description)",
|
|
718
|
+
category: parsed.category,
|
|
719
|
+
author: parsed.author,
|
|
720
|
+
version: parsed.version,
|
|
721
|
+
tags: parsed.tags,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ─── Check installation ───────────────────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
export async function getInstalledSkillNames(
|
|
728
|
+
scope: "user" | "project",
|
|
729
|
+
projectPath?: string,
|
|
730
|
+
): Promise<Set<string>> {
|
|
731
|
+
const dir =
|
|
732
|
+
scope === "user" ? getUserSkillsDir() : getProjectSkillsDir(projectPath);
|
|
733
|
+
|
|
734
|
+
const installed = new Set<string>();
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
if (!(await fs.pathExists(dir))) return installed;
|
|
738
|
+
const entries = await fs.readdir(dir);
|
|
739
|
+
for (const entry of entries) {
|
|
740
|
+
const skillMd = path.join(dir, entry, "SKILL.md");
|
|
741
|
+
if (await fs.pathExists(skillMd)) {
|
|
742
|
+
installed.add(entry);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
} catch {
|
|
746
|
+
// ignore
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return installed;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ─── Firebase Skills API ──────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
function mapApiSkillToSkillInfo(entry: unknown): SkillInfo {
|
|
755
|
+
const raw: Record<string, unknown> =
|
|
756
|
+
typeof entry === "object" && entry !== null
|
|
757
|
+
? (entry as Record<string, unknown>)
|
|
758
|
+
: {};
|
|
759
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
760
|
+
const repo = str(raw.repo) || "unknown";
|
|
761
|
+
const skillPath = str(raw.skillPath) || str(raw.name) || "";
|
|
762
|
+
const source: SkillSource = {
|
|
763
|
+
label: repo,
|
|
764
|
+
repo,
|
|
765
|
+
skillsPath: "",
|
|
766
|
+
};
|
|
767
|
+
const stars = typeof raw.stars === "number" ? raw.stars : undefined;
|
|
768
|
+
return {
|
|
769
|
+
id: `${repo}/${skillPath}`,
|
|
770
|
+
name: str(raw.name) || skillPath,
|
|
771
|
+
description: str(raw.description),
|
|
772
|
+
source,
|
|
773
|
+
repoPath: skillPath ? `${skillPath}/SKILL.md` : "SKILL.md",
|
|
774
|
+
gitBlobSha: "",
|
|
775
|
+
frontmatter: null,
|
|
776
|
+
installed: false,
|
|
777
|
+
installedScope: null,
|
|
778
|
+
hasUpdate: false,
|
|
779
|
+
stars,
|
|
780
|
+
starReliability: classifyStarReliability(repo, stars),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export async function fetchPopularSkills(limit = 30): Promise<SkillInfo[]> {
|
|
785
|
+
try {
|
|
786
|
+
const res = await fetch(
|
|
787
|
+
`${SKILLS_API_BASE}/search?q=development&limit=${limit}&sortBy=stars`,
|
|
788
|
+
{ signal: AbortSignal.timeout(10000) },
|
|
789
|
+
);
|
|
790
|
+
if (!res.ok) return [];
|
|
791
|
+
const data = (await res.json()) as { skills?: unknown[] };
|
|
792
|
+
return (data.skills || []).map(mapApiSkillToSkillInfo);
|
|
793
|
+
} catch {
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ─── Fetch available skills ───────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
export async function fetchAvailableSkills(
|
|
801
|
+
_repos: SkillSource[],
|
|
802
|
+
projectPath?: string,
|
|
803
|
+
recommended: RecommendedSkill[] = [],
|
|
804
|
+
): Promise<SkillInfo[]> {
|
|
805
|
+
const userInstalled = await getInstalledSkillNames("user");
|
|
806
|
+
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
807
|
+
|
|
808
|
+
const slugify = (name: string) =>
|
|
809
|
+
name
|
|
810
|
+
.toLowerCase()
|
|
811
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
812
|
+
.replace(/^-|-$/g, "");
|
|
813
|
+
|
|
814
|
+
const markInstalled = (skill: SkillInfo): SkillInfo => {
|
|
815
|
+
const slug = slugify(skill.name);
|
|
816
|
+
const isUserInstalled =
|
|
817
|
+
userInstalled.has(slug) || userInstalled.has(skill.name);
|
|
818
|
+
const isProjInstalled =
|
|
819
|
+
projectInstalled.has(slug) || projectInstalled.has(skill.name);
|
|
820
|
+
const installed = isUserInstalled || isProjInstalled;
|
|
821
|
+
const installedScope: "user" | "project" | null = isProjInstalled
|
|
822
|
+
? "project"
|
|
823
|
+
: isUserInstalled
|
|
824
|
+
? "user"
|
|
825
|
+
: null;
|
|
826
|
+
return { ...skill, installed, installedScope };
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
// 1. Recommended skills from the versioned models-index catalog.
|
|
830
|
+
const recommendedSkills: SkillInfo[] = recommended.map((rec) => {
|
|
831
|
+
const source: SkillSource = {
|
|
832
|
+
label: rec.repo,
|
|
833
|
+
repo: rec.repo,
|
|
834
|
+
skillsPath: "",
|
|
835
|
+
};
|
|
836
|
+
const skill: SkillInfo = {
|
|
837
|
+
id: `${rec.repo}/${rec.skillPath}`,
|
|
838
|
+
name: rec.name,
|
|
839
|
+
description: rec.description,
|
|
840
|
+
source,
|
|
841
|
+
repoPath: `${rec.skillPath}/SKILL.md`,
|
|
842
|
+
gitBlobSha: "",
|
|
843
|
+
frontmatter: null,
|
|
844
|
+
installed: false,
|
|
845
|
+
installedScope: null,
|
|
846
|
+
hasUpdate: false,
|
|
847
|
+
isRecommended: true,
|
|
848
|
+
starReliability: classifyStarReliability(rec.repo, rec.stars),
|
|
849
|
+
};
|
|
850
|
+
return markInstalled(skill);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// 2. Fetch popular skills from Firebase API
|
|
854
|
+
const popular = await fetchPopularSkills(30);
|
|
855
|
+
const popularSkills = popular.map((s) =>
|
|
856
|
+
markInstalled({ ...s, isRecommended: false }),
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
// 3. Combine: recommended first, then popular (dedup by name)
|
|
860
|
+
const seen = new Set<string>(recommendedSkills.map((s) => s.name));
|
|
861
|
+
const deduped = popularSkills.filter((s) => !seen.has(s.name));
|
|
862
|
+
|
|
863
|
+
return [...recommendedSkills, ...deduped];
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ─── Install / Uninstall ──────────────────────────────────────────────────────
|
|
867
|
+
|
|
868
|
+
/** Hard ceilings so one pathological repo cannot fill a user's disk. */
|
|
869
|
+
const MAX_SKILL_FILES = 200;
|
|
870
|
+
const MAX_SKILL_BYTES = 20 * 1024 * 1024;
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Pick the files that belong to one skill out of a whole-repo tree listing.
|
|
874
|
+
*
|
|
875
|
+
* Returns paths relative to the skill directory, so `skills/x/refs/a.md`
|
|
876
|
+
* under `skills/x` becomes `refs/a.md`.
|
|
877
|
+
*
|
|
878
|
+
* The trailing slash on the prefix is load-bearing: without it `skills/audit`
|
|
879
|
+
* also matches `skills/audit-website/...`, and the user gets a second skill's
|
|
880
|
+
* files dumped into the first one's directory.
|
|
881
|
+
*/
|
|
882
|
+
export function selectSkillFiles(
|
|
883
|
+
treePaths: { path: string; type: string }[],
|
|
884
|
+
repoPath: string,
|
|
885
|
+
): string[] {
|
|
886
|
+
const prefix = `${repoPath.replace(/\/+$/, "")}/`;
|
|
887
|
+
return treePaths
|
|
888
|
+
.filter((e) => e.type === "blob" && e.path.startsWith(prefix))
|
|
889
|
+
.map((e) => e.path.slice(prefix.length))
|
|
890
|
+
.filter((rel) => rel.length > 0);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Resolve one relative path inside the install directory, or return null.
|
|
895
|
+
*
|
|
896
|
+
* These paths come from a remote repository, so they are untrusted input to a
|
|
897
|
+
* filesystem write. Anything that escapes the install directory — `..`
|
|
898
|
+
* segments, an absolute path — is dropped rather than written outside the
|
|
899
|
+
* skill folder.
|
|
900
|
+
*/
|
|
901
|
+
export function resolveSkillFilePath(
|
|
902
|
+
installDir: string,
|
|
903
|
+
relativePath: string,
|
|
904
|
+
): string | null {
|
|
905
|
+
if (!relativePath || path.isAbsolute(relativePath)) return null;
|
|
906
|
+
const resolvedDir = path.resolve(installDir);
|
|
907
|
+
const target = path.resolve(resolvedDir, relativePath);
|
|
908
|
+
if (target !== resolvedDir && !target.startsWith(resolvedDir + path.sep)) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
return target;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** What an install actually delivered — surfaced so the UI can be honest. */
|
|
915
|
+
export interface InstallResult {
|
|
916
|
+
/** Number of files written, always ≥ 1 (SKILL.md). */
|
|
917
|
+
fileCount: number;
|
|
918
|
+
/**
|
|
919
|
+
* Set when only SKILL.md could be written for a skill that has more files,
|
|
920
|
+
* e.g. the GitHub tree call was rate-limited. The skill is installed but
|
|
921
|
+
* incomplete, and the caller should say so.
|
|
922
|
+
*/
|
|
923
|
+
degraded?: string;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export async function installSkill(
|
|
927
|
+
skill: SkillInfo,
|
|
928
|
+
scope: "user" | "project",
|
|
929
|
+
projectPath?: string,
|
|
930
|
+
): Promise<InstallResult> {
|
|
931
|
+
// Try multiple URL patterns — repos structure SKILL.md differently
|
|
932
|
+
const repo = skill.source.repo;
|
|
933
|
+
const repoPath = skill.repoPath.replace(/\/SKILL\.md$/, "");
|
|
934
|
+
const candidates = [
|
|
935
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/SKILL.md`,
|
|
936
|
+
`https://raw.githubusercontent.com/${repo}/main/${repoPath}/SKILL.md`,
|
|
937
|
+
`https://raw.githubusercontent.com/${repo}/master/${repoPath}/SKILL.md`,
|
|
938
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/SKILL.md`,
|
|
939
|
+
`https://raw.githubusercontent.com/${repo}/main/SKILL.md`,
|
|
940
|
+
];
|
|
941
|
+
|
|
942
|
+
let content: string | null = null;
|
|
943
|
+
for (const url of candidates) {
|
|
944
|
+
try {
|
|
945
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
946
|
+
if (response.ok) {
|
|
947
|
+
content = await response.text();
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
} catch {}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (!content) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`Failed to fetch skill: SKILL.md not found in ${repo}/${repoPath}`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Use slug for directory name — display names can have slashes/spaces
|
|
960
|
+
const dirName = skill.name
|
|
961
|
+
.toLowerCase()
|
|
962
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
963
|
+
.replace(/^-|-$/g, "");
|
|
964
|
+
|
|
965
|
+
const installDir =
|
|
966
|
+
scope === "user"
|
|
967
|
+
? path.join(getUserSkillsDir(), dirName)
|
|
968
|
+
: path.join(getProjectSkillsDir(projectPath), dirName);
|
|
969
|
+
|
|
970
|
+
await fs.ensureDir(installDir);
|
|
971
|
+
await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
|
|
972
|
+
|
|
973
|
+
// SKILL.md alone is usually not a working skill. Skills ship scripts,
|
|
974
|
+
// references and assets beside it and link to them by relative path, so
|
|
975
|
+
// writing only SKILL.md leaves dangling links — measured: audit-website
|
|
976
|
+
// references `references/OUTPUT-FORMAT.md`, systematic-debugging ships
|
|
977
|
+
// `find-polluter.sh` and 9 more. Fetch the rest of the directory.
|
|
978
|
+
let extras: string[] = [];
|
|
979
|
+
try {
|
|
980
|
+
const tree = await fetchGitTree(repo);
|
|
981
|
+
extras = selectSkillFiles(tree.tree, repoPath).filter(
|
|
982
|
+
(rel) => rel !== "SKILL.md",
|
|
983
|
+
);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
// The tree call is the rate-limited one. A skill with only SKILL.md is
|
|
986
|
+
// still correct, so report the shortfall instead of failing the install.
|
|
987
|
+
return {
|
|
988
|
+
fileCount: 1,
|
|
989
|
+
degraded:
|
|
990
|
+
error instanceof Error && /rate limit/i.test(error.message)
|
|
991
|
+
? "GitHub rate limit — supporting files not fetched. Set GITHUB_TOKEN and reinstall."
|
|
992
|
+
: "supporting files could not be listed; SKILL.md only",
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (extras.length === 0) return { fileCount: 1 };
|
|
997
|
+
|
|
998
|
+
const truncated = extras.length > MAX_SKILL_FILES;
|
|
999
|
+
const selected = truncated ? extras.slice(0, MAX_SKILL_FILES) : extras;
|
|
1000
|
+
|
|
1001
|
+
let written = 1;
|
|
1002
|
+
let bytes = content.length;
|
|
1003
|
+
let budgetHit = false;
|
|
1004
|
+
|
|
1005
|
+
for (const rel of selected) {
|
|
1006
|
+
if (bytes >= MAX_SKILL_BYTES) {
|
|
1007
|
+
budgetHit = true;
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
const target = resolveSkillFilePath(installDir, rel);
|
|
1011
|
+
if (!target) continue; // escapes the install dir — never write it
|
|
1012
|
+
try {
|
|
1013
|
+
const res = await fetch(
|
|
1014
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/${rel}`,
|
|
1015
|
+
{ signal: AbortSignal.timeout(15000) },
|
|
1016
|
+
);
|
|
1017
|
+
if (!res.ok) continue;
|
|
1018
|
+
// Buffer, not text: skills ship binary assets (.png, .svg) and
|
|
1019
|
+
// decoding those as UTF-8 corrupts them.
|
|
1020
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1021
|
+
await fs.ensureDir(path.dirname(target));
|
|
1022
|
+
await fs.writeFile(target, buf);
|
|
1023
|
+
written++;
|
|
1024
|
+
bytes += buf.length;
|
|
1025
|
+
} catch {
|
|
1026
|
+
// One unreachable asset should not abandon a half-written skill.
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
let degraded: string | undefined;
|
|
1031
|
+
if (budgetHit) {
|
|
1032
|
+
degraded = `stopped at ${MAX_SKILL_BYTES / 1024 / 1024}MB — skill is larger than the install budget`;
|
|
1033
|
+
} else if (truncated) {
|
|
1034
|
+
degraded = `only the first ${MAX_SKILL_FILES} of ${extras.length + 1} files were installed`;
|
|
1035
|
+
} else if (written < selected.length + 1) {
|
|
1036
|
+
degraded = `${selected.length + 1 - written} of ${selected.length + 1} files could not be fetched`;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return { fileCount: written, degraded };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
export async function uninstallSkill(
|
|
1043
|
+
skillName: string,
|
|
1044
|
+
scope: "user" | "project",
|
|
1045
|
+
projectPath?: string,
|
|
1046
|
+
): Promise<void> {
|
|
1047
|
+
const dirName = skillName
|
|
1048
|
+
.toLowerCase()
|
|
1049
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1050
|
+
.replace(/^-|-$/g, "");
|
|
1051
|
+
|
|
1052
|
+
const installDir =
|
|
1053
|
+
scope === "user"
|
|
1054
|
+
? path.join(getUserSkillsDir(), dirName)
|
|
1055
|
+
: path.join(getProjectSkillsDir(projectPath), dirName);
|
|
1056
|
+
|
|
1057
|
+
const skillMdPath = path.join(installDir, "SKILL.md");
|
|
1058
|
+
|
|
1059
|
+
// Remove the whole skill directory, not just SKILL.md. Installs now write
|
|
1060
|
+
// supporting files (scripts, references, assets); deleting SKILL.md alone
|
|
1061
|
+
// left those orphaned, and the follow-up `rmdir` then failed silently on the
|
|
1062
|
+
// non-empty directory — so the skill vanished from the UI while its files
|
|
1063
|
+
// stayed on disk forever.
|
|
1064
|
+
//
|
|
1065
|
+
// Gated on SKILL.md existing so this only ever deletes a directory that is
|
|
1066
|
+
// actually an installed skill.
|
|
1067
|
+
if (await fs.pathExists(skillMdPath)) {
|
|
1068
|
+
await fs.remove(installDir);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// No SKILL.md: at most an empty leftover directory. Remove it only if empty.
|
|
1073
|
+
try {
|
|
1074
|
+
await fs.rmdir(installDir);
|
|
1075
|
+
} catch {
|
|
1076
|
+
// Ignore if not empty or doesn't exist
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ─── Check installed skills from file system ──────────────────────────────────
|
|
1081
|
+
|
|
1082
|
+
export async function getInstalledSkillsFromFs(
|
|
1083
|
+
projectPath?: string,
|
|
1084
|
+
): Promise<{ name: string; scope: "user" | "project" }[]> {
|
|
1085
|
+
const result: { name: string; scope: "user" | "project" }[] = [];
|
|
1086
|
+
|
|
1087
|
+
const userDir = getUserSkillsDir();
|
|
1088
|
+
const projDir = getProjectSkillsDir(projectPath);
|
|
1089
|
+
|
|
1090
|
+
const scopedDirs: Array<[string, "user" | "project"]> = [
|
|
1091
|
+
[userDir, "user"],
|
|
1092
|
+
[projDir, "project"],
|
|
1093
|
+
];
|
|
1094
|
+
|
|
1095
|
+
for (const [dir, scope] of scopedDirs) {
|
|
1096
|
+
try {
|
|
1097
|
+
if (!(await fs.pathExists(dir))) continue;
|
|
1098
|
+
const entries = await fs.readdir(dir);
|
|
1099
|
+
for (const entry of entries) {
|
|
1100
|
+
const skillMd = path.join(dir, entry, "SKILL.md");
|
|
1101
|
+
if (await fs.pathExists(skillMd)) {
|
|
1102
|
+
result.push({ name: entry, scope });
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
} catch {
|
|
1106
|
+
// ignore
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
return result;
|
|
1111
|
+
}
|