cue-ai 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +82 -33
- package/bin/cue-review-progress +107 -0
- package/bin/cue-review-watch +98 -0
- package/dist/cue.js +7352 -3744
- package/package.json +16 -5
- package/profiles/_types.ts +9 -0
- package/profiles/backend/profile.yaml +2 -0
- package/profiles/blog-writer/profile.yaml +10 -0
- package/profiles/browser/profile.yaml +9 -2
- package/profiles/builder/profile.yaml +3 -6
- package/profiles/career/profile.yaml +13 -2
- package/profiles/claude-api/profile.yaml +1 -1
- package/profiles/commerce/profile.yaml +27 -3
- package/profiles/core/logo.png +0 -0
- package/profiles/core/profile.yaml +62 -2
- package/profiles/dash-merge-test/profile.yaml +109 -0
- package/profiles/designer/profile.yaml +2 -0
- package/profiles/designer-medusa-next/profile.yaml +4 -1
- package/profiles/designer-medusa-vite/profile.yaml +4 -1
- package/profiles/docs-writer/profile.yaml +3 -1
- package/profiles/eu-tender-research/README.md +48 -0
- package/profiles/eu-tender-research/logo.png +0 -0
- package/profiles/eu-tender-research/profile.yaml +108 -0
- package/profiles/finance/logo.png +0 -0
- package/profiles/finance/profile.yaml +46 -0
- package/profiles/frontend/profile.yaml +5 -9
- package/profiles/growth/profile.yaml +2 -3
- package/profiles/gstack/profile.yaml +15 -0
- package/profiles/higgsfield/profile.yaml +3 -0
- package/profiles/hyperframes/logo.png +0 -0
- package/profiles/hyperframes/profile.yaml +59 -0
- package/profiles/improver/profile.yaml +88 -0
- package/profiles/marketing/profile.yaml +0 -3
- package/profiles/medusa-dev/profile.yaml +2 -0
- package/profiles/medusa-next/profile.yaml +2 -3
- package/profiles/medusa-vite/profile.yaml +2 -3
- package/profiles/n8n/logo.png +0 -0
- package/profiles/n8n/profile.yaml +50 -0
- package/profiles/nextjs/profile.yaml +2 -3
- package/profiles/ops/profile.yaml +2 -0
- package/profiles/postizz/profile.yaml +13 -3
- package/profiles/python/profile.yaml +3 -0
- package/profiles/research/profile.yaml +3 -1
- package/profiles/schema.json +10 -0
- package/profiles/secops/profile.yaml +2 -0
- package/profiles/seo/profile.yaml +56 -0
- package/profiles/skill-writer/profile.yaml +8 -0
- package/profiles/ssh/profile.yaml +32 -0
- package/profiles/strapi/logo.png +0 -0
- package/profiles/strapi/profile.yaml +45 -0
- package/profiles/stripe/logo.png +0 -0
- package/profiles/stripe/profile.yaml +1 -0
- package/profiles/supabase/logo.png +0 -0
- package/profiles/supabase/profile.yaml +85 -0
- package/profiles/vercel/logo.png +0 -0
- package/profiles/vercel/profile.yaml +25 -1
- package/profiles/vite/profile.yaml +4 -3
- package/profiles/web-frontend-base/profile.yaml +5 -4
- package/profiles/webshop/profile.yaml +23 -5
- package/profiles/x-growth-bot/profile.yaml +44 -0
- package/resources/icons/generate-icons.py +128 -2
- package/resources/mcps/configs/claude.sanitized.json +42 -0
- package/resources/mcps/configs/codex.sanitized.json +7 -0
- package/resources/skills/skills/career/resume-version-manager/SKILL.md +351 -0
- package/resources/skills/skills/career/salary-negotiation-prep/SKILL.md +378 -0
- package/resources/skills/skills/content/pdf/SKILL.md +2 -0
- package/resources/skills/skills/content/postiz-cards/SKILL.md +48 -0
- package/resources/skills/skills/content/postiz-cards/scripts/analytics.sh +38 -0
- package/resources/skills/skills/content/postiz-cards/scripts/card.sh +42 -0
- package/resources/skills/skills/content/postiz-cards/scripts/lint.py +38 -0
- package/resources/skills/skills/design/headless-gif-demo/SKILL.md +1 -1
- package/resources/skills/skills/design/readme-svg-design/SKILL.md +1 -1
- package/resources/skills/skills/eu-funding/grant-outreach/SKILL.md +70 -0
- package/resources/skills/skills/eu-funding/hu-grant-finder/SKILL.md +114 -0
- package/resources/skills/skills/eu-funding/hu-grant-finder/evals.md +26 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/SKILL.md +80 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/evals.md +26 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/scripts/ted-search.sh +46 -0
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +1 -1
- package/resources/skills/skills/github/gx-agents/SKILL.md +96 -0
- package/resources/skills/skills/gstack/design-shotgun/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ab-test-analyzer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ab-test-setup-and-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/account-structure-review/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-copy-variant-generator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-extension-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-spend-allocator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/anomaly-detection/SKILL.md +1 -1
- package/resources/skills/skills/marketing/attribution-model-comparison/SKILL.md +1 -1
- package/resources/skills/skills/marketing/audience-overlap-analysis/SKILL.md +7 -1
- package/resources/skills/skills/marketing/bid-strategy-recommendations/SKILL.md +7 -1
- package/resources/skills/skills/marketing/budget-scenario-planner/SKILL.md +6 -1
- package/resources/skills/skills/marketing/campaign-naming-convention-builder/SKILL.md +7 -1
- package/resources/skills/skills/marketing/channel-mix-optimizer/SKILL.md +7 -1
- package/resources/skills/skills/marketing/client-report-narratives/SKILL.md +6 -1
- package/resources/skills/skills/marketing/competitor-creative-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/competitor-teardown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/content-repurposer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/conversion-path-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/cpa-diagnostics/SKILL.md +1 -1
- package/resources/skills/skills/marketing/creative-fatigue-detection/SKILL.md +1 -1
- package/resources/skills/skills/marketing/day-hour-performance-breakdown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/device-performance-split/SKILL.md +1 -1
- package/resources/skills/skills/marketing/e2e-seo-assistant/SKILL.md +1 -1
- package/resources/skills/skills/marketing/email-sequence-writer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/frequency-cap-recommendations/SKILL.md +1 -1
- package/resources/skills/skills/marketing/geo-performance-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/google-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/icp-research-assistant/SKILL.md +1 -1
- package/resources/skills/skills/marketing/keyword-cannibalization-check/SKILL.md +1 -1
- package/resources/skills/skills/marketing/landing-page-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/landing-page-audit-quick/SKILL.md +1 -1
- package/resources/skills/skills/marketing/linkedin-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/meta-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/pacing-monitor/SKILL.md +1 -1
- package/resources/skills/skills/marketing/performance-benchmarking/SKILL.md +1 -1
- package/resources/skills/skills/marketing/programmatic-seo-builder/SKILL.md +1 -1
- package/resources/skills/skills/marketing/quality-score-breakdown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/reddit-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/retargeting-window-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/roas-forecasting/SKILL.md +1 -1
- package/resources/skills/skills/marketing/search-term-mining/SKILL.md +1 -1
- package/resources/skills/skills/marketing/utm-tracking-generator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/wasted-spend-finder/SKILL.md +1 -1
- package/resources/skills/skills/marketing/weekly-account-summary/SKILL.md +1 -1
- package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +4 -4
- package/resources/skills/skills/meta/cue-dashboard/SKILL.md +109 -0
- package/resources/skills/skills/meta/cue-developer/SKILL.md +161 -0
- package/resources/skills/skills/meta/cue-developer/evals/evals.json +57 -0
- package/resources/skills/skills/meta/cue-developer/references/architecture.md +65 -0
- package/resources/skills/skills/meta/cue-developer/references/build_and_test.md +72 -0
- package/resources/skills/skills/meta/cue-developer/references/contributing.md +75 -0
- package/resources/skills/skills/meta/cue-developer/references/conventions.md +57 -0
- package/resources/skills/skills/meta/cue-developer/references/first_time_setup.md +51 -0
- package/resources/skills/skills/meta/cue-developer/references/skill_and_mcp_authoring.md +84 -0
- package/resources/skills/skills/meta/cue-developer/references/troubleshooting.md +42 -0
- package/resources/skills/skills/meta/delegation-check/SKILL.md +148 -0
- package/resources/skills/skills/meta/delegation-check/specs/scan-algorithm.md +125 -0
- package/resources/skills/skills/meta/delegation-check/specs/separation-rules.md +190 -0
- package/resources/skills/skills/meta/focus/SKILL.md +62 -0
- package/resources/skills/skills/meta/help/SKILL.md +1 -1
- package/resources/skills/skills/meta/integrity-tags/SKILL.md +2 -0
- package/resources/skills/skills/meta/next-steps/SKILL.md +124 -0
- package/resources/skills/skills/meta/next-steps/evals/eval-set.json +92 -0
- package/resources/skills/skills/meta/profile-from-docs/SKILL.md +141 -0
- package/resources/skills/skills/meta/ralph-loop/SKILL.md +83 -0
- package/resources/skills/skills/meta/ralph-loop/scripts/loop.sh +73 -0
- package/resources/skills/skills/meta/skill-simplify/SKILL.md +136 -0
- package/resources/skills/skills/meta/skill-simplify/phases/01-analysis.md +173 -0
- package/resources/skills/skills/meta/skill-simplify/phases/02-optimize.md +104 -0
- package/resources/skills/skills/meta/skill-simplify/phases/03-check.md +145 -0
- package/resources/skills/skills/meta/smart-loader/scripts/smart-lookup.sh +13 -4
- package/resources/skills/skills/meta/verify-council/SKILL.md +182 -0
- package/resources/skills/skills/meta/verify-council/references/lane-prompts.md +103 -0
- package/resources/skills/skills/meta/verify-council/references/workflow.js +217 -0
- package/resources/skills/skills/nvidia/aiq-research/SKILL.md +1 -1
- package/resources/skills/skills/nvidia/cuopt-developer/SKILL.md +16 -1
- package/resources/skills/skills/nvidia/cuopt-developer/resources/contributing.md +2 -2
- package/resources/skills/skills/nvidia/cuopt-developer/resources/numerical_debugging.md +128 -0
- package/resources/skills/skills/nvidia/cuopt-developer/resources/python_bindings.md +2 -9
- package/resources/skills/skills/nvidia/cuopt-developer/resources/vrp_skills.md +166 -0
- package/resources/skills/skills/nvidia/cuopt-install/SKILL.md +2 -10
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/SKILL.md +3 -23
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/resources/examples.md +40 -20
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-python/SKILL.md +5 -1
- package/resources/skills/skills/nvidia/skill-evolution/SKILL.md +4 -5
- package/resources/skills/skills/research/trendradar/SKILL.md +1 -1
- package/resources/skills/skills/ssh/ssh-config/SKILL.md +94 -0
- package/resources/skills/skills/ssh/ssh-copy/SKILL.md +92 -0
- package/resources/skills/skills/ssh/ssh-harden/SKILL.md +108 -0
- package/resources/skills/skills/ssh/ssh-keys/SKILL.md +82 -0
- package/resources/skills/skills/ssh/ssh-paste-image/LICENSE +28 -0
- package/resources/skills/skills/ssh/ssh-paste-image/SKILL.md +149 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/build.sh +29 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/go.mod +3 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/main.go +79 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/ccimgd.service +12 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/com.ccimgd.plist +20 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/go.mod +3 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/main.go +98 -0
- package/resources/skills/skills/ssh/ssh-tunnel/SKILL.md +96 -0
- package/resources/skills/skills/strapi/building-with-strapi/SKILL.md +112 -0
- package/resources/skills/skills/strapi/strapi-cli/SKILL.md +93 -0
- package/resources/skills/skills/strapi/strapi-content-api/SKILL.md +115 -0
- package/resources/skills/skills/strapi/strapi-deploy/SKILL.md +89 -0
- package/resources/skills/skills/strapi/strapi-mcp-setup/SKILL.md +101 -0
- package/resources/skills/skills/strapi/strapi-plugins/SKILL.md +97 -0
- package/resources/skills/skills/tools/context7/SKILL.md +101 -0
- package/resources/skills/skills/tools/opensrc/SKILL.md +1 -1
- package/resources/skills/skills/tools/portless/SKILL.md +186 -0
- package/resources/skills/skills/xbot/operate/SKILL.md +229 -0
- package/src/commands/_index.ts +8 -0
- package/src/commands/ai-score.e2e.test.ts +11 -4
- package/src/commands/ai.ts +3 -4
- package/src/commands/auto-detect.ts +1 -1
- package/src/commands/cli.test.ts +1 -2
- package/src/commands/cli.ts +1 -1
- package/src/commands/cloud.ts +1 -1
- package/src/commands/current.ts +1 -4
- package/src/commands/dash.test.ts +110 -0
- package/src/commands/dash.ts +194 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/diff.ts +1 -1
- package/src/commands/discover.test.ts +1 -1
- package/src/commands/discover.ts +90 -40
- package/src/commands/doctor.test.ts +58 -0
- package/src/commands/doctor.ts +79 -3
- package/src/commands/eval-behavior.ts +1 -1
- package/src/commands/eval.ts +2 -2
- package/src/commands/evolve.ts +4 -3
- package/src/commands/failures.test.ts +1 -1
- package/src/commands/features-batch1.test.ts +6 -1
- package/src/commands/icon.ts +1 -5
- package/src/commands/import-profile.ts +1 -1
- package/src/commands/init.ts +50 -7
- package/src/commands/install-sh.e2e.test.ts +65 -0
- package/src/commands/launch-handoff.e2e.test.ts +88 -0
- package/src/commands/launch.e2e.test.ts +8 -1
- package/src/commands/launch.test.ts +29 -0
- package/src/commands/launch.ts +185 -131
- package/src/commands/lock.ts +0 -1
- package/src/commands/marketplace.ts +0 -4
- package/src/commands/materialize.ts +1 -1
- package/src/commands/mem.ts +341 -0
- package/src/commands/optimizer.ts +0 -3
- package/src/commands/playground.ts +1 -2
- package/src/commands/profile-draft-skill.ts +1 -1
- package/src/commands/replay-whatif.ts +1 -6
- package/src/commands/score.ts +2 -2
- package/src/commands/security.test.ts +88 -0
- package/src/commands/security.ts +74 -28
- package/src/commands/shell.test.ts +65 -4
- package/src/commands/shell.ts +67 -7
- package/src/commands/skills-test.ts +0 -1
- package/src/commands/skills.ts +28 -2
- package/src/commands/sources.ts +1 -2
- package/src/commands/status.ts +2 -6
- package/src/commands/submit-profile.ts +1 -1
- package/src/commands/suggest.ts +35 -10
- package/src/commands/trigger-gaps.test.ts +50 -0
- package/src/commands/trigger-gaps.ts +63 -29
- package/src/commands/update.ts +1 -1
- package/src/commands/validate.ts +16 -4
- package/src/commands/watch-live.ts +1 -1
- package/src/commands/workspace.ts +1 -1
- package/src/index.ts +26 -10
- package/src/lib/active-sessions.ts +1 -1
- package/src/lib/agent-adapters.test.ts +100 -0
- package/src/lib/agent-adapters.ts +2 -2
- package/src/lib/analytics.test.ts +88 -0
- package/src/lib/analytics.ts +82 -1
- package/src/lib/auto-detect.test.ts +10 -4
- package/src/lib/auto-detect.ts +19 -23
- package/src/lib/brand-icons.ts +0 -1
- package/src/lib/cache.ts +2 -3
- package/src/lib/claude-mem-env.test.ts +148 -0
- package/src/lib/claude-mem-env.ts +172 -0
- package/src/lib/combo-history.test.ts +53 -0
- package/src/lib/combo-history.ts +83 -0
- package/src/lib/companion-detect.test.ts +108 -0
- package/src/lib/companion-detect.ts +140 -0
- package/src/lib/companion-fetch.ts +4 -6
- package/src/lib/conditional-skills.test.ts +1 -1
- package/src/lib/config-paths.test.ts +53 -0
- package/src/lib/config-paths.ts +33 -0
- package/src/lib/dashboard-server.test.ts +351 -0
- package/src/lib/dashboard-server.ts +1476 -27
- package/src/lib/debug-log.test.ts +66 -0
- package/src/lib/debug-log.ts +45 -0
- package/src/lib/mcp-catalog.test.ts +102 -0
- package/src/lib/mcp-catalog.ts +193 -0
- package/src/lib/pair-suggestions.test.ts +111 -0
- package/src/lib/pair-suggestions.ts +98 -5
- package/src/lib/permissions.test.ts +76 -0
- package/src/lib/permissions.ts +125 -0
- package/src/lib/picker.test.ts +1106 -1
- package/src/lib/picker.ts +1230 -142
- package/src/lib/plugin-discovery.ts +126 -0
- package/src/lib/pr-poster.ts +1 -1
- package/src/lib/pr-throttle.ts +2 -6
- package/src/lib/profile-linter.test.ts +67 -1
- package/src/lib/profile-linter.ts +59 -14
- package/src/lib/profile-loader.test.ts +21 -0
- package/src/lib/profile-loader.ts +22 -3
- package/src/lib/profile-metrics.ts +2 -6
- package/src/lib/profile-names.test.ts +58 -0
- package/src/lib/repos.test.ts +57 -0
- package/src/lib/repos.ts +167 -0
- package/src/lib/resolver-npx.ts +10 -1
- package/src/lib/runtime-materializer.test.ts +200 -3
- package/src/lib/runtime-materializer.ts +129 -20
- package/src/lib/shared-profiles.ts +2 -3
- package/src/lib/skill-clis.test.ts +113 -0
- package/src/lib/skill-clis.ts +232 -0
- package/src/lib/skill-dependencies.ts +9 -1
- package/src/lib/skill-deps.ts +1 -1
- package/src/lib/skill-linter.ts +1 -1
- package/src/lib/skill-quality.ts +0 -1
- package/src/lib/skill-sandbox.test.ts +1 -1
- package/src/lib/skills-lock.test.ts +1 -1
- package/src/lib/telemetry-consent.ts +3 -5
- package/src/lib/telemetry-report.test.ts +2 -2
- package/src/lib/token-budget.ts +111 -0
- package/src/lib/trigger-gaps.test.ts +70 -0
- package/src/lib/trigger-gaps.ts +48 -6
- package/src/lib/tui/data.ts +1 -5
- package/src/lib/workflow-store.ts +150 -0
- package/src/lib/workspace-secrets.ts +0 -4
- package/src/lib/workspaces.ts +1 -1
package/src/lib/repos.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-repo provenance for the studio Profiles "Repos" tab — the GitHub
|
|
3
|
+
* repositories a profile's skills, MCPs, plugins and workflows originate from,
|
|
4
|
+
* with live star counts.
|
|
5
|
+
*
|
|
6
|
+
* cue has no machine-readable repo field per component, so the mapping is a
|
|
7
|
+
* curated catalog: each entry declares what it `provides` (namespaces / MCP ids
|
|
8
|
+
* / plugin names), and `reposForProfile` keeps only the entries a given profile
|
|
9
|
+
* actually contains. Every repo is a real GitHub repo so its star count is a
|
|
10
|
+
* live fetch (cached + fail-soft), satisfying the "auto-update the stars" ask.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type RepoKind = "profile" | "skill" | "mcp" | "plugin" | "workflow" | "cli";
|
|
14
|
+
|
|
15
|
+
interface RepoProvides {
|
|
16
|
+
/** Skill namespaces (first path segment) this repo is the source of. */
|
|
17
|
+
ns?: string[];
|
|
18
|
+
/** MCP server ids this repo ships. */
|
|
19
|
+
mcps?: string[];
|
|
20
|
+
/** Plugin names (bare, no @marketplace) this repo ships. */
|
|
21
|
+
plugins?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RepoCatalogEntry {
|
|
25
|
+
/** "owner/name" — the GitHub slug. */
|
|
26
|
+
repo: string;
|
|
27
|
+
desc: string;
|
|
28
|
+
provides: RepoProvides;
|
|
29
|
+
kinds: RepoKind[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RepoEntry {
|
|
33
|
+
repo: string;
|
|
34
|
+
url: string;
|
|
35
|
+
desc: string;
|
|
36
|
+
kinds: RepoKind[];
|
|
37
|
+
/** Live GitHub stargazer count, or null when it couldn't be fetched. */
|
|
38
|
+
stars: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Curated provenance catalog. Every `repo` is a real GitHub slug so the star
|
|
43
|
+
* fetch resolves; entries the active profile doesn't contain are filtered out.
|
|
44
|
+
*/
|
|
45
|
+
export const REPO_CATALOG: RepoCatalogEntry[] = [
|
|
46
|
+
{
|
|
47
|
+
repo: "opencue/cuecards",
|
|
48
|
+
desc: "The cue runtime — profiles, the meta toolkit and smart-loader.",
|
|
49
|
+
provides: { ns: ["meta", "caveman", "plan", "review", "gstack", "github", "design"] },
|
|
50
|
+
kinds: ["profile", "skill"],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
repo: "anthropics/skills",
|
|
54
|
+
desc: "npx skill bundle — pdf, docx and pptx authoring.",
|
|
55
|
+
provides: { ns: ["npx"] },
|
|
56
|
+
kinds: ["skill"],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
repo: "lightpanda-io/browser",
|
|
60
|
+
desc: "Fast headless browser, exposed as an MCP server.",
|
|
61
|
+
provides: { ns: ["browser"], mcps: ["lightpanda"] },
|
|
62
|
+
kinds: ["mcp", "skill"],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
repo: "upstash/context7",
|
|
66
|
+
desc: "Up-to-date, version-specific library docs as an MCP server.",
|
|
67
|
+
provides: { ns: ["tools"], mcps: ["context7"] },
|
|
68
|
+
kinds: ["mcp", "skill"],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
repo: "thedotmack/claude-mem",
|
|
72
|
+
desc: "Persistent memory plugin — skills under the plugin/ namespace.",
|
|
73
|
+
provides: { plugins: ["claude-mem"] },
|
|
74
|
+
kinds: ["plugin"],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
repo: "coollabsio/coolify",
|
|
78
|
+
desc: "Self-hosted deploy platform — the upstream behind cue's coolify MCP.",
|
|
79
|
+
provides: { mcps: ["coolify"] },
|
|
80
|
+
kinds: ["mcp", "skill"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
repo: "supabase/supabase",
|
|
84
|
+
desc: "Open-source Postgres backend — exposed as an MCP server.",
|
|
85
|
+
provides: { mcps: ["supabase"] },
|
|
86
|
+
kinds: ["mcp", "skill"],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
repo: "vercel/vercel",
|
|
90
|
+
desc: "Deploy + host web apps — the vercel Claude Code plugin's upstream.",
|
|
91
|
+
provides: { plugins: ["vercel"] },
|
|
92
|
+
kinds: ["plugin"],
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Strip a plugin id's `@marketplace` suffix → its bare name. */
|
|
97
|
+
function pluginName(id: string): string {
|
|
98
|
+
const at = id.indexOf("@");
|
|
99
|
+
return at > 0 ? id.slice(0, at) : id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Keep only catalog entries the profile actually contains: a repo matches when
|
|
104
|
+
* it provides a namespace the profile has, an MCP it connects, or a plugin it
|
|
105
|
+
* wires. Pure + exported so it's unit-testable without a network round-trip.
|
|
106
|
+
*/
|
|
107
|
+
export function reposForProfile(opts: {
|
|
108
|
+
namespaces: Iterable<string>;
|
|
109
|
+
mcpIds: Iterable<string>;
|
|
110
|
+
pluginIds: Iterable<string>;
|
|
111
|
+
}): RepoCatalogEntry[] {
|
|
112
|
+
const ns = new Set(opts.namespaces);
|
|
113
|
+
const mcps = new Set(opts.mcpIds);
|
|
114
|
+
const plugins = new Set([...opts.pluginIds].map(pluginName));
|
|
115
|
+
return REPO_CATALOG.filter((r) => {
|
|
116
|
+
const p = r.provides;
|
|
117
|
+
if (p.ns?.some((n) => ns.has(n))) return true;
|
|
118
|
+
if (p.mcps?.some((m) => mcps.has(m))) return true;
|
|
119
|
+
if (p.plugins?.some((pl) => plugins.has(pl))) return true;
|
|
120
|
+
return false;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── live star counts ────────────────────────────────────────────────────────
|
|
125
|
+
// One GitHub API GET per repo, cached 6h, fail-soft. The unauthenticated rate
|
|
126
|
+
// limit (60/hr) is ample for a handful of repos behind the cache.
|
|
127
|
+
|
|
128
|
+
const STAR_TTL_MS = 6 * 60 * 60 * 1000;
|
|
129
|
+
const starCache = new Map<string, { ts: number; stars: number | null }>();
|
|
130
|
+
|
|
131
|
+
/** Fetch one repo's stargazer count, cached + fail-soft (null on any error). */
|
|
132
|
+
export async function fetchStars(repo: string, now: number): Promise<number | null> {
|
|
133
|
+
const cached = starCache.get(repo);
|
|
134
|
+
if (cached && now - cached.ts < STAR_TTL_MS) return cached.stars;
|
|
135
|
+
let stars: number | null = null;
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`https://api.github.com/repos/${repo}`, {
|
|
138
|
+
headers: { "User-Agent": "cue-studio", Accept: "application/vnd.github+json" },
|
|
139
|
+
signal: AbortSignal.timeout(3000),
|
|
140
|
+
});
|
|
141
|
+
if (res.ok) {
|
|
142
|
+
const body = (await res.json()) as { stargazers_count?: number };
|
|
143
|
+
if (typeof body.stargazers_count === "number") stars = body.stargazers_count;
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// offline / timeout / rate-limited → null this cycle. Keep any prior value
|
|
147
|
+
// so a transient failure doesn't blank a previously-known count.
|
|
148
|
+
if (cached) return cached.stars;
|
|
149
|
+
}
|
|
150
|
+
starCache.set(repo, { ts: now, stars });
|
|
151
|
+
return stars;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolve a matched catalog into display rows with live star counts. */
|
|
155
|
+
export async function resolveRepoStars(matched: RepoCatalogEntry[], now: number): Promise<RepoEntry[]> {
|
|
156
|
+
const settled = await Promise.allSettled(matched.map((r) => fetchStars(r.repo, now)));
|
|
157
|
+
return matched.map((r, i) => {
|
|
158
|
+
const s = settled[i];
|
|
159
|
+
return {
|
|
160
|
+
repo: r.repo,
|
|
161
|
+
url: `https://github.com/${r.repo}`,
|
|
162
|
+
desc: r.desc,
|
|
163
|
+
kinds: r.kinds,
|
|
164
|
+
stars: s && s.status === "fulfilled" ? s.value : null,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
package/src/lib/resolver-npx.ts
CHANGED
|
@@ -29,7 +29,6 @@ import { ProfileError } from "../../profiles/_types";
|
|
|
29
29
|
import {
|
|
30
30
|
cacheChildren,
|
|
31
31
|
cacheHit,
|
|
32
|
-
cachePath,
|
|
33
32
|
cachePut,
|
|
34
33
|
cacheSkillPath,
|
|
35
34
|
type CacheLayout,
|
|
@@ -125,10 +124,20 @@ export const npxFetch: NpxFetchFn = async (repo, pin, skill, destDir) => {
|
|
|
125
124
|
const ref = pin.replace(/^git@/, "").replace(/^tag@/, "");
|
|
126
125
|
args.push("--ref", ref);
|
|
127
126
|
}
|
|
127
|
+
// Defense-in-depth: a single wedged `npx skills add` must never hang the
|
|
128
|
+
// whole run. On timeout spawnSync sets res.error (ETIMEDOUT), which maps to
|
|
129
|
+
// NpxFetchFailed below. CUE_NPX_TIMEOUT_MS overrides; a non-positive or
|
|
130
|
+
// non-numeric value (incl. "" → 0, which would DISABLE the timeout) falls
|
|
131
|
+
// back to the 45s default rather than silently defeating the guard.
|
|
132
|
+
const envTimeout = Number(process.env.CUE_NPX_TIMEOUT_MS);
|
|
133
|
+
const npxTimeoutMs = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 45000;
|
|
128
134
|
const res = spawnSync("npx", args, {
|
|
129
135
|
cwd: destDir,
|
|
130
136
|
stdio: ["ignore", "pipe", "pipe"],
|
|
131
137
|
encoding: "utf8",
|
|
138
|
+
timeout: npxTimeoutMs,
|
|
139
|
+
killSignal: "SIGKILL",
|
|
140
|
+
windowsHide: true,
|
|
132
141
|
});
|
|
133
142
|
if (res.error) {
|
|
134
143
|
throw new NpxFetchFailed(repo, res.error.message, res.error);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtemp, mkdir, writeFile, readFile, stat, lstat, rm, readlink } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, readFile, stat, lstat, rm, readlink, symlink } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
@@ -96,10 +96,54 @@ describe("materializeRuntime", () => {
|
|
|
96
96
|
mcpRegistry: {},
|
|
97
97
|
userClaudeMd: "",
|
|
98
98
|
});
|
|
99
|
-
|
|
99
|
+
// Flat layout (skills/<slug>) so Claude Code's one-level discovery finds it.
|
|
100
|
+
const link = await readlink(join(out.runtimeDir, "skills", "ui-ux-pro-max"));
|
|
100
101
|
expect(link).toBe("/fake/source/design/ui-ux-pro-max");
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
test("writes a .cue-skills manifest of <category>/<slug> ids for smart-loader", async () => {
|
|
105
|
+
const out = await materializeRuntime({
|
|
106
|
+
profile: sampleProfile,
|
|
107
|
+
agent: "claude-code",
|
|
108
|
+
runtimeRoot: join(root, "runtime"),
|
|
109
|
+
skillSourceLookup: async (id) => `/fake/source/${id}`,
|
|
110
|
+
mcpRegistry: {},
|
|
111
|
+
userClaudeMd: "",
|
|
112
|
+
});
|
|
113
|
+
const manifest = await readFile(join(out.runtimeDir, ".cue-skills"), "utf8");
|
|
114
|
+
expect(manifest.split("\n").filter(Boolean)).toContain("design/ui-ux-pro-max");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("slug collisions resolve last-wins; both ids stay in the manifest", async () => {
|
|
118
|
+
const collide: ResolvedProfile = {
|
|
119
|
+
...sampleProfile,
|
|
120
|
+
skills: {
|
|
121
|
+
...sampleProfile.skills,
|
|
122
|
+
local: [
|
|
123
|
+
{ id: "plan/investigate" },
|
|
124
|
+
{ id: "gstack/investigate" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const out = await materializeRuntime({
|
|
129
|
+
profile: collide,
|
|
130
|
+
agent: "claude-code",
|
|
131
|
+
runtimeRoot: join(root, "runtime"),
|
|
132
|
+
skillSourceLookup: async (id) => `/fake/source/${id}`,
|
|
133
|
+
mcpRegistry: {},
|
|
134
|
+
userClaudeMd: "",
|
|
135
|
+
});
|
|
136
|
+
// The later entry (gstack/investigate) wins the flat /investigate link.
|
|
137
|
+
const link = await readlink(join(out.runtimeDir, "skills", "investigate"));
|
|
138
|
+
expect(link).toBe("/fake/source/gstack/investigate");
|
|
139
|
+
// Both remain in the manifest so smart-loader knows the lean one is loaded too.
|
|
140
|
+
const manifest = (await readFile(join(out.runtimeDir, ".cue-skills"), "utf8"))
|
|
141
|
+
.split("\n")
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
expect(manifest).toContain("plan/investigate");
|
|
144
|
+
expect(manifest).toContain("gstack/investigate");
|
|
145
|
+
});
|
|
146
|
+
|
|
103
147
|
test("excludes resources whose agents list does not include current agent", async () => {
|
|
104
148
|
const filtered: ResolvedProfile = {
|
|
105
149
|
...sampleProfile,
|
|
@@ -238,7 +282,7 @@ describe("materializeRuntime", () => {
|
|
|
238
282
|
test("credentialsSource: refreshes settings on cache hit + repoints symlinks on account switch", async () => {
|
|
239
283
|
const credSrcA = join(root, "credsA");
|
|
240
284
|
const credSrcB = join(root, "credsB");
|
|
241
|
-
const { mkdir, writeFile
|
|
285
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
242
286
|
await mkdir(credSrcA, { recursive: true });
|
|
243
287
|
await mkdir(credSrcB, { recursive: true });
|
|
244
288
|
await writeFile(join(credSrcA, ".credentials.json"), '{"token":"A"}');
|
|
@@ -280,6 +324,78 @@ describe("materializeRuntime", () => {
|
|
|
280
324
|
expect(s2.permissions.allow).toEqual(["B"]);
|
|
281
325
|
});
|
|
282
326
|
|
|
327
|
+
test("credentialsSource: rebuild keeps the freshest token (no logged-out-after-relaunch)", async () => {
|
|
328
|
+
// Regression: Anthropic rotates the refresh token on every refresh. The old
|
|
329
|
+
// preserve step blindly resurrected the runtime's own .credentials.json on a
|
|
330
|
+
// rebuild — even when it held a dead, rotated token while the freshly-synced
|
|
331
|
+
// source had the live one — booting the relaunched profile into a logged-out
|
|
332
|
+
// state. The preserve step must keep whichever token has the higher expiresAt.
|
|
333
|
+
const stale = join(root, "src-stale");
|
|
334
|
+
const fresh = join(root, "src-fresh");
|
|
335
|
+
await mkdir(stale, { recursive: true });
|
|
336
|
+
await mkdir(fresh, { recursive: true });
|
|
337
|
+
const STALE = 1_000;
|
|
338
|
+
const FRESH = 9_000_000_000_000;
|
|
339
|
+
await writeFile(join(stale, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: STALE, refreshToken: "dead" } }));
|
|
340
|
+
await writeFile(join(fresh, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: FRESH, refreshToken: "live" } }));
|
|
341
|
+
|
|
342
|
+
const base = {
|
|
343
|
+
agent: "claude-code" as const,
|
|
344
|
+
runtimeRoot: join(root, "runtime"),
|
|
345
|
+
skillSourceLookup: async (id: string) => `/fake/source/${id}`,
|
|
346
|
+
mcpRegistry: { "claude-mem": { command: "claude-mem" } },
|
|
347
|
+
userClaudeMd: "",
|
|
348
|
+
};
|
|
349
|
+
const p1: ResolvedProfile = { ...sampleProfile, name: "rotate" };
|
|
350
|
+
// Extra skill → different hash → forces a REBUILD (exercises the preserve step).
|
|
351
|
+
const p2: ResolvedProfile = { ...sampleProfile, name: "rotate", skills: { local: [{ id: "design/ui-ux-pro-max" }, { id: "design/extra" }], npx: [] } };
|
|
352
|
+
|
|
353
|
+
// First launch: runtime ends up with the (then-current) STALE source token.
|
|
354
|
+
const first = await materializeRuntime({ ...base, profile: p1, credentialsSource: stale });
|
|
355
|
+
expect(first.rebuilt).toBe(true);
|
|
356
|
+
|
|
357
|
+
// syncFreshestToSource has since healed source to the live token. Relaunch
|
|
358
|
+
// with a changed profile → rebuild path runs the preserve step.
|
|
359
|
+
const second = await materializeRuntime({ ...base, profile: p2, credentialsSource: fresh });
|
|
360
|
+
expect(second.rebuilt).toBe(true);
|
|
361
|
+
|
|
362
|
+
const creds = JSON.parse(await readFile(join(second.runtimeDir, ".credentials.json"), "utf8"));
|
|
363
|
+
expect(creds.claudeAiOauth.expiresAt).toBe(FRESH);
|
|
364
|
+
expect(creds.claudeAiOauth.refreshToken).toBe("live");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("credentialsSource: rebuild keeps the runtime token when source is half-logged-out", async () => {
|
|
368
|
+
// Inverse guard: if SOURCE is stale/half-logged-out (no/low expiresAt) but the
|
|
369
|
+
// runtime is logged in (fresh token), a rebuild must NOT clobber the live
|
|
370
|
+
// runtime token with the dead source one. Preserves the original intent.
|
|
371
|
+
const fresh = join(root, "src-fresh");
|
|
372
|
+
const loggedOut = join(root, "src-loggedout");
|
|
373
|
+
await mkdir(fresh, { recursive: true });
|
|
374
|
+
await mkdir(loggedOut, { recursive: true });
|
|
375
|
+
const FRESH = 9_000_000_000_000;
|
|
376
|
+
await writeFile(join(fresh, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: FRESH, refreshToken: "live" } }));
|
|
377
|
+
await writeFile(join(loggedOut, ".credentials.json"), JSON.stringify({ claudeAiOauth: { refreshToken: "" } }));
|
|
378
|
+
|
|
379
|
+
const base = {
|
|
380
|
+
agent: "claude-code" as const,
|
|
381
|
+
runtimeRoot: join(root, "runtime"),
|
|
382
|
+
skillSourceLookup: async (id: string) => `/fake/source/${id}`,
|
|
383
|
+
mcpRegistry: { "claude-mem": { command: "claude-mem" } },
|
|
384
|
+
userClaudeMd: "",
|
|
385
|
+
};
|
|
386
|
+
const p1: ResolvedProfile = { ...sampleProfile, name: "rotate2" };
|
|
387
|
+
const p2: ResolvedProfile = { ...sampleProfile, name: "rotate2", skills: { local: [{ id: "design/ui-ux-pro-max" }, { id: "design/extra" }], npx: [] } };
|
|
388
|
+
|
|
389
|
+
const first = await materializeRuntime({ ...base, profile: p1, credentialsSource: fresh });
|
|
390
|
+
expect(first.rebuilt).toBe(true);
|
|
391
|
+
const second = await materializeRuntime({ ...base, profile: p2, credentialsSource: loggedOut });
|
|
392
|
+
expect(second.rebuilt).toBe(true);
|
|
393
|
+
|
|
394
|
+
const creds = JSON.parse(await readFile(join(second.runtimeDir, ".credentials.json"), "utf8"));
|
|
395
|
+
expect(creds.claudeAiOauth.expiresAt).toBe(FRESH);
|
|
396
|
+
expect(creds.claudeAiOauth.refreshToken).toBe("live");
|
|
397
|
+
});
|
|
398
|
+
|
|
283
399
|
test("CLAUDE.md stamp uses real ISO timestamp, not literal $(date)", async () => {
|
|
284
400
|
const out = await materializeRuntime({
|
|
285
401
|
profile: sampleProfile,
|
|
@@ -421,6 +537,31 @@ describe("materializeRuntime", () => {
|
|
|
421
537
|
expect(link).toContain("resources/hooks/bash-quality-preflight.json");
|
|
422
538
|
});
|
|
423
539
|
|
|
540
|
+
test("hooks: auto-review Stop hook + its .sh companion both land in the runtime", async () => {
|
|
541
|
+
const profile: ResolvedProfile = {
|
|
542
|
+
...sampleProfile,
|
|
543
|
+
name: "test-auto-review",
|
|
544
|
+
inheritanceChain: ["test-auto-review"],
|
|
545
|
+
rules: [], commands: [],
|
|
546
|
+
hooks: ["auto-review.json"],
|
|
547
|
+
};
|
|
548
|
+
const out = await materializeRuntime({
|
|
549
|
+
profile, agent: "claude-code",
|
|
550
|
+
runtimeRoot: join(root, "runtime"),
|
|
551
|
+
skillSourceLookup: async (id) => `/fake/source/${id}`,
|
|
552
|
+
mcpRegistry: {},
|
|
553
|
+
userClaudeMd: "",
|
|
554
|
+
});
|
|
555
|
+
const settings = JSON.parse(await readFile(join(out.runtimeDir, "settings.json"), "utf8"));
|
|
556
|
+
expect(settings.hooks.Stop).toBeArray();
|
|
557
|
+
expect(settings.hooks.Stop[0].hooks[0].id).toBe("cue:stop:auto-review");
|
|
558
|
+
expect(settings.hooks.Stop[0].hooks[0].command).toContain("auto-review.sh");
|
|
559
|
+
// The reviewer script companion must be symlinked too, else the hook fires
|
|
560
|
+
// `bash $CLAUDE_CONFIG_DIR/hooks/auto-review.sh` against a missing file.
|
|
561
|
+
const script = await readlink(join(out.runtimeDir, "hooks", "auto-review.sh"));
|
|
562
|
+
expect(script).toContain("resources/hooks/auto-review.sh");
|
|
563
|
+
});
|
|
564
|
+
|
|
424
565
|
// Claude Code reads MCP servers from .claude.json (top-level `mcpServers`),
|
|
425
566
|
// NOT from settings.json. The materializer must therefore merge profile MCPs
|
|
426
567
|
// into .claude.json — and copy (not symlink) it so mutations don't leak back
|
|
@@ -675,6 +816,62 @@ describe("isRuntimeStale", () => {
|
|
|
675
816
|
await writeFile(join(profilesRoot, "p3", "profile.yaml"), "name: x\n");
|
|
676
817
|
expect(await isRuntimeStale("p3", "claude-code", runtimeRoot)).toBe(false);
|
|
677
818
|
});
|
|
819
|
+
|
|
820
|
+
async function writeRuntimeSkill(name: string, runtimeRoot: string, slug: string): Promise<string> {
|
|
821
|
+
const skillDir = join(runtimeRoot, name, "claude", "skills", slug);
|
|
822
|
+
await mkdir(skillDir, { recursive: true });
|
|
823
|
+
const md = join(skillDir, "SKILL.md");
|
|
824
|
+
await writeFile(md, `# ${slug}\n`);
|
|
825
|
+
return md;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
test("returns true when a resolved SKILL.md is newer than .cue-hash (yaml older)", async () => {
|
|
829
|
+
const runtimeRoot = join(root, "runtime");
|
|
830
|
+
const { yamlPath, hashPath } = await setup("p4", runtimeRoot);
|
|
831
|
+
const mdPath = await writeRuntimeSkill("p4", runtimeRoot, "alpha");
|
|
832
|
+
await utimes(yamlPath, new Date(Date.now() - 120_000), new Date(Date.now() - 120_000));
|
|
833
|
+
await utimes(hashPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
|
|
834
|
+
await utimes(mdPath, new Date(), new Date());
|
|
835
|
+
expect(await isRuntimeStale("p4", "claude-code", runtimeRoot)).toBe(true);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("returns false when every SKILL.md is older than .cue-hash", async () => {
|
|
839
|
+
const runtimeRoot = join(root, "runtime");
|
|
840
|
+
const { yamlPath, hashPath } = await setup("p5", runtimeRoot);
|
|
841
|
+
const mdPath = await writeRuntimeSkill("p5", runtimeRoot, "alpha");
|
|
842
|
+
const old = new Date(Date.now() - 60_000);
|
|
843
|
+
await utimes(yamlPath, old, old);
|
|
844
|
+
await utimes(mdPath, old, old);
|
|
845
|
+
await utimes(hashPath, new Date(), new Date());
|
|
846
|
+
expect(await isRuntimeStale("p5", "claude-code", runtimeRoot)).toBe(false);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("skips a slug dir with no SKILL.md (broken symlink is non-fatal)", async () => {
|
|
850
|
+
const runtimeRoot = join(root, "runtime");
|
|
851
|
+
const { yamlPath, hashPath } = await setup("p6", runtimeRoot);
|
|
852
|
+
await mkdir(join(runtimeRoot, "p6", "claude", "skills", "broken"), { recursive: true });
|
|
853
|
+
await utimes(yamlPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
|
|
854
|
+
await utimes(hashPath, new Date(), new Date());
|
|
855
|
+
expect(await isRuntimeStale("p6", "claude-code", runtimeRoot)).toBe(false);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("detects a newer SKILL.md through a symlinked skill dir (production layout)", async () => {
|
|
859
|
+
const runtimeRoot = join(root, "runtime");
|
|
860
|
+
const { yamlPath, hashPath } = await setup("p7", runtimeRoot);
|
|
861
|
+
// Production materialize symlinks skills/<slug> → the source skill dir; the
|
|
862
|
+
// SKILL.md inside is a real file, so lstat resolves through to its mtime.
|
|
863
|
+
const src = join(profilesRoot, "src-skill-p7");
|
|
864
|
+
await mkdir(src, { recursive: true });
|
|
865
|
+
const srcMd = join(src, "SKILL.md");
|
|
866
|
+
await writeFile(srcMd, "# s\n");
|
|
867
|
+
const skillsDir = join(runtimeRoot, "p7", "claude", "skills");
|
|
868
|
+
await mkdir(skillsDir, { recursive: true });
|
|
869
|
+
await symlink(src, join(skillsDir, "s"));
|
|
870
|
+
await utimes(yamlPath, new Date(Date.now() - 120_000), new Date(Date.now() - 120_000));
|
|
871
|
+
await utimes(hashPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
|
|
872
|
+
await utimes(srcMd, new Date(), new Date());
|
|
873
|
+
expect(await isRuntimeStale("p7", "claude-code", runtimeRoot)).toBe(true);
|
|
874
|
+
});
|
|
678
875
|
});
|
|
679
876
|
|
|
680
877
|
describe("linkPluginCache", () => {
|
|
@@ -74,25 +74,58 @@ function profilesDir(): string {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Staleness predicate shared with `cue doctor`'s D5 check: a materialized
|
|
77
|
-
* runtime is stale when the profile's source `profile.yaml`
|
|
78
|
-
* recently than the stored `.cue-hash`.
|
|
79
|
-
* it, launch acts on it (auto-rebuild).
|
|
80
|
-
*
|
|
81
|
-
*
|
|
77
|
+
* runtime is stale when the profile's source `profile.yaml` OR any resolved
|
|
78
|
+
* skill's `SKILL.md` was modified more recently than the stored `.cue-hash`.
|
|
79
|
+
* Mirror, not duplicate — doctor reports it, launch acts on it (auto-rebuild).
|
|
80
|
+
*
|
|
81
|
+
* The SKILL.md check makes "edit a skill → relaunch → see the change" work:
|
|
82
|
+
* the materialized runtime's own `skills/<slug>` entries are symlinks to the
|
|
83
|
+
* source skill dirs, so lstat'ing `skills/<slug>/SKILL.md` resolves through to
|
|
84
|
+
* the real source file's mtime (only the final path component is treated
|
|
85
|
+
* specially by lstat, and SKILL.md is never itself a symlink). This is
|
|
86
|
+
* automatically scoped to the agent and to any conditional/subset pruning.
|
|
87
|
+
*
|
|
88
|
+
* Returns false when there's no runtime yet (no `.cue-hash`): the content-hash
|
|
89
|
+
* path in materializeRuntime handles a fresh build. Fail-open per entry — a
|
|
90
|
+
* deleted skill source (broken symlink) is skipped, not fatal (the profile.yaml
|
|
91
|
+
* edit that removed it already trips the yaml branch).
|
|
82
92
|
*/
|
|
83
93
|
export async function isRuntimeStale(
|
|
84
94
|
profileName: string,
|
|
85
95
|
agent: AgentKind,
|
|
86
96
|
runtimeRoot: string,
|
|
87
97
|
): Promise<boolean> {
|
|
88
|
-
const
|
|
89
|
-
const
|
|
98
|
+
const runtimeDir = join(runtimeRoot, profileName, agentSubdir(agent));
|
|
99
|
+
const hashFile = join(runtimeDir, ".cue-hash");
|
|
100
|
+
let hashMtime: number;
|
|
90
101
|
try {
|
|
91
|
-
|
|
92
|
-
return yamlStat.mtimeMs > hashStat.mtimeMs;
|
|
102
|
+
hashMtime = (await lstat(hashFile)).mtimeMs;
|
|
93
103
|
} catch {
|
|
94
|
-
return false;
|
|
104
|
+
return false; // no runtime yet — materializeRuntime's content hash handles it
|
|
95
105
|
}
|
|
106
|
+
|
|
107
|
+
// (a) Source profile.yaml newer than the hash. Its own try/catch so a missing
|
|
108
|
+
// yaml doesn't short-circuit the skill check below.
|
|
109
|
+
try {
|
|
110
|
+
if ((await lstat(join(profilesDir(), profileName, "profile.yaml"))).mtimeMs > hashMtime) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
} catch { /* no source yaml — fall through to the skill check */ }
|
|
114
|
+
|
|
115
|
+
// (b) Any resolved SKILL.md newer than the hash.
|
|
116
|
+
const skillsDir = join(runtimeDir, "skills");
|
|
117
|
+
let slugs: string[];
|
|
118
|
+
try {
|
|
119
|
+
slugs = await readdir(skillsDir);
|
|
120
|
+
} catch {
|
|
121
|
+
return false; // no skills/ dir → nothing more to compare
|
|
122
|
+
}
|
|
123
|
+
for (const slug of slugs) {
|
|
124
|
+
try {
|
|
125
|
+
if ((await lstat(join(skillsDir, slug, "SKILL.md"))).mtimeMs > hashMtime) return true;
|
|
126
|
+
} catch { /* broken symlink / no SKILL.md under this slug — skip */ }
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
96
129
|
}
|
|
97
130
|
|
|
98
131
|
function appliesToAgent(scoped: { agents?: AgentKind[] }, agent: AgentKind): boolean {
|
|
@@ -108,8 +141,15 @@ function sortedJson(value: unknown): string {
|
|
|
108
141
|
return "{" + keys.map((k) => JSON.stringify(k) + ":" + sortedJson(obj[k])).join(",") + "}";
|
|
109
142
|
}
|
|
110
143
|
|
|
144
|
+
// Bump when the on-disk runtime layout changes in a way the profile content
|
|
145
|
+
// doesn't capture (e.g. flat vs nested skills, new manifest files). Folding it
|
|
146
|
+
// into the hash forces every profile to rebuild once on its next launch, so
|
|
147
|
+
// layout fixes roll out without a manual `--rematerialize` per profile.
|
|
148
|
+
// v2: flat skill layout + .cue-skills manifest (was nested <category>/<slug>)
|
|
149
|
+
const MATERIALIZER_VERSION = 2;
|
|
150
|
+
|
|
111
151
|
function computeHash(profile: ResolvedProfile, agent: AgentKind): string {
|
|
112
|
-
const canonical = sortedJson({ agent, profile });
|
|
152
|
+
const canonical = sortedJson({ v: MATERIALIZER_VERSION, agent, profile });
|
|
113
153
|
return createHash("sha256").update(canonical).digest("hex");
|
|
114
154
|
}
|
|
115
155
|
|
|
@@ -175,19 +215,58 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
175
215
|
await mkdir(skillsDir, { recursive: true });
|
|
176
216
|
const skippedSkills: string[] = [];
|
|
177
217
|
let attemptedSkills = 0;
|
|
218
|
+
// Skills are linked FLAT — skills/<slug>, not skills/<category>/<slug>.
|
|
219
|
+
// Claude Code (and Codex) only register a personal skill one level deep, by
|
|
220
|
+
// its directory name (skills/<name>/SKILL.md → /<name>); a nested category
|
|
221
|
+
// dir is invisible to that scan. Flattening matches `activate-profile.sh`'s
|
|
222
|
+
// manual installer and lets every profile skill be invoked via the Skill
|
|
223
|
+
// tool — including the slug==category cases (caveman/caveman, github/github,
|
|
224
|
+
// colony/colony) that a nested layout can't expose. smart-loader's dedup no
|
|
225
|
+
// longer reads the dir tree; it reads the `.cue-skills` manifest written
|
|
226
|
+
// below, which preserves the <category>/<slug> identity.
|
|
227
|
+
const loadedSkillIds: string[] = [];
|
|
228
|
+
// Resolve first, link second, so slug collisions resolve by LAST-WINS: when
|
|
229
|
+
// two skills share a slug (e.g. plan/investigate lean vs gstack/investigate
|
|
230
|
+
// full), the later entry wins the flat /<slug> name. Skill lists merge
|
|
231
|
+
// parent→child (core first, profile last), so last-wins = the more-specific
|
|
232
|
+
// profile's choice overrides the inherited one — the standard override rule.
|
|
233
|
+
// The loser is still in the manifest, so smart-loader can surface it.
|
|
234
|
+
const slugToSrc = new Map<string, string>();
|
|
235
|
+
const slugToId = new Map<string, string>();
|
|
236
|
+
const overridden: string[] = [];
|
|
178
237
|
for (const skill of profile.skills.local) {
|
|
179
238
|
if (!appliesToAgent(skill, agent)) continue;
|
|
180
239
|
if (skill.when && !evaluateCondition(skill.when, process.cwd())) continue;
|
|
181
240
|
attemptedSkills++;
|
|
182
241
|
try {
|
|
183
242
|
const src = await input.skillSourceLookup(skill.id);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
243
|
+
loadedSkillIds.push(skill.id);
|
|
244
|
+
const slug = basename(skill.id);
|
|
245
|
+
const prevId = slugToId.get(slug);
|
|
246
|
+
if (prevId !== undefined && prevId !== skill.id) {
|
|
247
|
+
overridden.push(`${slug}: ${prevId} → ${skill.id}`);
|
|
248
|
+
}
|
|
249
|
+
slugToSrc.set(slug, src);
|
|
250
|
+
slugToId.set(slug, skill.id);
|
|
187
251
|
} catch (err) {
|
|
188
252
|
skippedSkills.push(skill.id);
|
|
189
253
|
}
|
|
190
254
|
}
|
|
255
|
+
for (const [slug, src] of slugToSrc) {
|
|
256
|
+
await symlink(src, join(skillsDir, slug));
|
|
257
|
+
}
|
|
258
|
+
if (overridden.length > 0) {
|
|
259
|
+
process.stderr.write(
|
|
260
|
+
`[cue] ${overridden.length} skill slug collision(s) resolved last-wins ` +
|
|
261
|
+
`(loser still smart-loadable): ${overridden.join("; ")}\n`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
// Manifest for smart-loader's --exclude-loaded: the resolved <category>/<slug>
|
|
265
|
+
// ids, decoupled from the (now flat) on-disk layout.
|
|
266
|
+
await writeFile(
|
|
267
|
+
join(tmpDir, ".cue-skills"),
|
|
268
|
+
loadedSkillIds.length > 0 ? `${loadedSkillIds.join("\n")}\n` : "",
|
|
269
|
+
);
|
|
191
270
|
if (skippedSkills.length > 0) {
|
|
192
271
|
process.stderr.write(
|
|
193
272
|
`[cue] skipped ${skippedSkills.length} missing skill(s): ${skippedSkills.slice(0, 5).join(", ")}` +
|
|
@@ -643,12 +722,26 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
643
722
|
const newPath = join(tmpDir, name);
|
|
644
723
|
try {
|
|
645
724
|
const st = await lstat(oldPath);
|
|
646
|
-
if (st.isFile() || st.isDirectory())
|
|
647
|
-
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
725
|
+
if (!(st.isFile() || st.isDirectory())) continue;
|
|
726
|
+
if (name === ".credentials.json") {
|
|
727
|
+
// Freshness guard — fixes "logged-out after relaunch". Anthropic rotates
|
|
728
|
+
// the refresh token on every refresh, so only the copy with the highest
|
|
729
|
+
// expiresAt still holds a live refresh token. Step 5's overlay already
|
|
730
|
+
// placed the freshly-synced SOURCE creds in tmpDir (and
|
|
731
|
+
// resolveClaudeCredentialsSource healed source from the freshest sibling
|
|
732
|
+
// runtime first). Resurrect the OLD runtime's creds ONLY when they are
|
|
733
|
+
// strictly newer than source — otherwise keep source, so a rebuild can't
|
|
734
|
+
// drag a dead, rotated token back into the runtime. When source is
|
|
735
|
+
// half-logged-out its expiresAt is 0/old, so a logged-in runtime still
|
|
736
|
+
// wins and stays logged in (the original intent of this preserve step).
|
|
737
|
+
const oldExp = await credentialsExpiresAt(oldPath);
|
|
738
|
+
const newExp = await credentialsExpiresAt(newPath);
|
|
739
|
+
if (oldExp <= newExp) continue; // source as-fresh-or-fresher → keep it
|
|
651
740
|
}
|
|
741
|
+
// Remove whatever overlay put here (likely a symlink for .claude.json
|
|
742
|
+
// or a copy for .credentials.json) so rename can replace it cleanly.
|
|
743
|
+
await rm(newPath, { force: true, recursive: true });
|
|
744
|
+
await rename(oldPath, newPath);
|
|
652
745
|
} catch { /* doesn't exist — skip */ }
|
|
653
746
|
}
|
|
654
747
|
await rm(runtimeDir, { recursive: true, force: true });
|
|
@@ -661,6 +754,22 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
661
754
|
return { runtimeDir, rebuilt: true, hash };
|
|
662
755
|
}
|
|
663
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Read `claudeAiOauth.expiresAt` (ms epoch) from a `.credentials.json`. Returns
|
|
759
|
+
* 0 when the file is missing, unparseable, or carries no expiry — so anything
|
|
760
|
+
* with a real token sorts as fresher in the rebuild preserve comparison.
|
|
761
|
+
*/
|
|
762
|
+
async function credentialsExpiresAt(path: string): Promise<number> {
|
|
763
|
+
try {
|
|
764
|
+
const raw = await readFile(path, "utf8");
|
|
765
|
+
const parsed = JSON.parse(raw) as { claudeAiOauth?: { expiresAt?: number } };
|
|
766
|
+
const exp = parsed?.claudeAiOauth?.expiresAt;
|
|
767
|
+
return typeof exp === "number" ? exp : 0;
|
|
768
|
+
} catch {
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
664
773
|
function collectProfileMcps(
|
|
665
774
|
profile: ResolvedProfile,
|
|
666
775
|
agent: AgentKind,
|
|
@@ -970,7 +1079,7 @@ function tomlRender(obj: { mcp_servers: Record<string, unknown> }): string {
|
|
|
970
1079
|
// ---------------------------------------------------------------------------
|
|
971
1080
|
|
|
972
1081
|
import { homedir } from "node:os";
|
|
973
|
-
import { readdirSync,
|
|
1082
|
+
import { readdirSync, existsSync, statSync } from "node:fs";
|
|
974
1083
|
import { spawnSync } from "node:child_process";
|
|
975
1084
|
|
|
976
1085
|
async function getLastSessionSummary(profileName: string): Promise<string | null> {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
|
+
import { cacheDir } from "./config-paths";
|
|
19
20
|
|
|
20
21
|
export interface SharedRef {
|
|
21
22
|
/** Originating GitHub user / org. */
|
|
@@ -279,9 +280,7 @@ export function registryIndexUrl(): string {
|
|
|
279
280
|
|
|
280
281
|
/** Path of the cached index.json on disk. */
|
|
281
282
|
export function indexCachePath(): string {
|
|
282
|
-
|
|
283
|
-
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".cache");
|
|
284
|
-
return join(base, "cue", "registry-index.json");
|
|
283
|
+
return join(cacheDir(), "registry-index.json");
|
|
285
284
|
}
|
|
286
285
|
|
|
287
286
|
export interface IndexCacheEntry {
|