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
|
@@ -11,11 +11,16 @@
|
|
|
11
11
|
* public interface would be a privacy footgun.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
16
|
+
import { readFile, stat as statAsync } from "node:fs/promises";
|
|
15
17
|
import { homedir } from "node:os";
|
|
16
|
-
import { join, resolve } from "node:path";
|
|
18
|
+
import { join, resolve, sep } from "node:path";
|
|
17
19
|
|
|
18
|
-
import {
|
|
20
|
+
import { configDir } from "./config-paths";
|
|
21
|
+
import { computeStats, computeDailyActivity, sessionDurationSummary } from "./analytics";
|
|
22
|
+
import { discoverInstalledPlugins } from "./plugin-discovery";
|
|
23
|
+
import { listWorkflows, loadWorkflow, saveWorkflow } from "./workflow-store";
|
|
19
24
|
import { listActiveSessions, supportsProcScan } from "./active-sessions";
|
|
20
25
|
import { readGateStatus, readAllGateStatus } from "./gate-status";
|
|
21
26
|
import { computeAffinityMap, suggestionsByProfile } from "./pair-suggestions";
|
|
@@ -31,7 +36,11 @@ import {
|
|
|
31
36
|
type MergeMode,
|
|
32
37
|
} from "./profile-merge";
|
|
33
38
|
import { validateProfileName } from "./profile-generator";
|
|
34
|
-
import {
|
|
39
|
+
import { loadMcpCatalog, addMcpToProfile } from "./mcp-catalog";
|
|
40
|
+
import { aggregateProfileClis, type ProfileCli } from "./skill-clis";
|
|
41
|
+
import { collectPermissions } from "./permissions";
|
|
42
|
+
import { reposForProfile, resolveRepoStars } from "./repos";
|
|
43
|
+
import { parseSkillFromContent, parseSkillFromDir } from "./skill-router";
|
|
35
44
|
import { resolveLocalSkill } from "./resolver-local";
|
|
36
45
|
import { resolveProfileForCwd } from "./cwd-resolver";
|
|
37
46
|
import { quickDiagnose } from "../commands/status";
|
|
@@ -44,11 +53,6 @@ const WEB_DIST = join(REPO_ROOT, "web", "dist");
|
|
|
44
53
|
/** Standard envelope so the UI doesn't have to special-case per-endpoint shape. */
|
|
45
54
|
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
|
46
55
|
|
|
47
|
-
function configDir(): string {
|
|
48
|
-
return process.env.XDG_CONFIG_HOME
|
|
49
|
-
? join(process.env.XDG_CONFIG_HOME, "cue")
|
|
50
|
-
: join(homedir(), ".config", "cue");
|
|
51
|
-
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* Resolve a `?profile=...` query against precedence: explicit → cwd pin →
|
|
@@ -161,6 +165,7 @@ export async function handleStatus(): Promise<ApiResult<unknown>> {
|
|
|
161
165
|
: null,
|
|
162
166
|
totalProfiles: (await listProfiles()).length,
|
|
163
167
|
totalSessions: stats.reduce((a, s) => a + s.sessions, 0),
|
|
168
|
+
durations: sessionDurationSummary(),
|
|
164
169
|
telemetryEnabled: telemetryEnabled(),
|
|
165
170
|
},
|
|
166
171
|
};
|
|
@@ -185,6 +190,96 @@ function mergeProfilesDir(): string {
|
|
|
185
190
|
return process.env.CUE_PROFILES_DIR ?? join(REPO_ROOT, "profiles");
|
|
186
191
|
}
|
|
187
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Serve a profile's logo image (`profiles/<name>/<iconImage>`) as raw bytes.
|
|
195
|
+
* Read-only and path-traversal-safe: the profile name is restricted to a
|
|
196
|
+
* single dir segment, the iconImage must be a bare filename, and the resolved
|
|
197
|
+
* path must stay inside the profiles dir. 404 when the profile has no logo.
|
|
198
|
+
*/
|
|
199
|
+
async function serveProfileIcon(rawName: string | null): Promise<Response> {
|
|
200
|
+
if (!rawName) return new Response("missing profile", { status: 400 });
|
|
201
|
+
// Composite selectors (a+b) → use the first part's logo.
|
|
202
|
+
const name = rawName.split("+")[0]!.trim();
|
|
203
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes("..")) {
|
|
204
|
+
return new Response("bad profile", { status: 400 });
|
|
205
|
+
}
|
|
206
|
+
let iconImage: string | undefined;
|
|
207
|
+
try {
|
|
208
|
+
iconImage = (await loadProfile(name)).iconImage;
|
|
209
|
+
} catch {
|
|
210
|
+
return new Response("not found", { status: 404 });
|
|
211
|
+
}
|
|
212
|
+
if (!iconImage || iconImage.includes("/") || iconImage.includes("\\") || iconImage.includes("..")) {
|
|
213
|
+
return new Response("no icon", { status: 404 });
|
|
214
|
+
}
|
|
215
|
+
const dir = mergeProfilesDir();
|
|
216
|
+
const file = resolve(join(dir, name, iconImage));
|
|
217
|
+
if (!file.startsWith(resolve(dir))) return new Response("forbidden", { status: 403 });
|
|
218
|
+
if (!existsSync(file) || !statSync(file).isFile()) return new Response("not found", { status: 404 });
|
|
219
|
+
return new Response(readFileSync(file), {
|
|
220
|
+
headers: { "Content-Type": contentTypeFor(file), "Cache-Control": "max-age=3600" },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Directory holding generated plugin logos, keyed by `<plugin-name>.png`. */
|
|
225
|
+
function pluginLogosDir(): string {
|
|
226
|
+
return process.env.CUE_PLUGIN_LOGOS_DIR ?? join(REPO_ROOT, "resources", "plugin-logos");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Directory holding playbook markdown docs, keyed by `<slug>.md`. */
|
|
230
|
+
function playbooksDir(): string {
|
|
231
|
+
return process.env.CUE_PLAYBOOKS_DIR ?? join(REPO_ROOT, "resources", "playbooks");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Directory holding slash-command markdown docs, keyed by `<ref>.md`. */
|
|
235
|
+
function commandsDir(): string {
|
|
236
|
+
return process.env.CUE_COMMANDS_DIR ?? join(REPO_ROOT, "resources", "commands");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Serve a plugin's logo image as raw bytes. Two sources, tried in order:
|
|
241
|
+
* 1. Reuse — a cue profile sharing the plugin's bare name that ships its own
|
|
242
|
+
* logo (the `resend` / `vercel` / `stripe` plugins map straight onto
|
|
243
|
+
* profiles/<name>/<iconImage>).
|
|
244
|
+
* 2. Generated — a PNG under the plugin-logos dir keyed by the plugin name.
|
|
245
|
+
* Path-traversal-safe exactly like serveProfileIcon. 404 when neither exists.
|
|
246
|
+
*/
|
|
247
|
+
async function servePluginIcon(rawId: string | null): Promise<Response> {
|
|
248
|
+
if (!rawId) return new Response("missing plugin", { status: 400 });
|
|
249
|
+
// Plugin ids are "name@marketplace" — the logo keys off the bare name.
|
|
250
|
+
const name = rawId.split("@")[0]!.trim();
|
|
251
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes("..")) {
|
|
252
|
+
return new Response("bad plugin", { status: 400 });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 1. Reuse a same-named profile's logo when it ships one.
|
|
256
|
+
try {
|
|
257
|
+
const iconImage = (await loadProfile(name)).iconImage;
|
|
258
|
+
if (iconImage && !iconImage.includes("/") && !iconImage.includes("\\") && !iconImage.includes("..")) {
|
|
259
|
+
const pdir = mergeProfilesDir();
|
|
260
|
+
const pfile = resolve(join(pdir, name, iconImage));
|
|
261
|
+
if (pfile.startsWith(resolve(pdir)) && existsSync(pfile) && statSync(pfile).isFile()) {
|
|
262
|
+
return new Response(readFileSync(pfile), {
|
|
263
|
+
headers: { "Content-Type": contentTypeFor(pfile), "Cache-Control": "max-age=3600" },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// No same-named profile — fall through to generated art.
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. Generated logo under the plugin-logos dir.
|
|
272
|
+
const gdir = pluginLogosDir();
|
|
273
|
+
const gfile = resolve(join(gdir, name + ".png"));
|
|
274
|
+
if (!gfile.startsWith(resolve(gdir))) return new Response("forbidden", { status: 403 });
|
|
275
|
+
if (existsSync(gfile) && statSync(gfile).isFile()) {
|
|
276
|
+
return new Response(readFileSync(gfile), {
|
|
277
|
+
headers: { "Content-Type": "image/png", "Cache-Control": "max-age=3600" },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return new Response("not found", { status: 404 });
|
|
281
|
+
}
|
|
282
|
+
|
|
188
283
|
/**
|
|
189
284
|
* Full profile inventory for the Merge Studio source list: every profile's
|
|
190
285
|
* resolved skill/MCP/plugin counts plus its `bundles`/`conflicts` hints.
|
|
@@ -200,24 +295,620 @@ export async function handleProfilesFull(): Promise<ApiResult<unknown>> {
|
|
|
200
295
|
return {
|
|
201
296
|
name,
|
|
202
297
|
icon: p.icon ?? null,
|
|
298
|
+
// Filename (relative to the profile dir) of a real logo, when set —
|
|
299
|
+
// served by GET /api/v1/profile-icon?profile=<name>. null = emoji only.
|
|
300
|
+
// iconImage is inheritable, so a base like core would otherwise leak
|
|
301
|
+
// its logo to every child; only report it when the file actually
|
|
302
|
+
// exists in THIS profile's own dir (also hides dangling refs).
|
|
303
|
+
iconImage:
|
|
304
|
+
p.iconImage && existsSync(join(mergeProfilesDir(), name, p.iconImage))
|
|
305
|
+
? p.iconImage
|
|
306
|
+
: null,
|
|
203
307
|
description: p.description,
|
|
204
308
|
skills: p.skills.local.length,
|
|
205
309
|
npx: p.skills.npx.length,
|
|
206
310
|
mcps: p.mcps.length,
|
|
207
311
|
plugins: p.plugins.length,
|
|
312
|
+
subagents: p.subagents?.length ?? 0,
|
|
208
313
|
bundles: p.bundles ?? [],
|
|
209
314
|
conflicts: p.conflicts ?? [],
|
|
210
315
|
inheritsCore: p.inheritanceChain.includes("core"),
|
|
211
316
|
error: null as string | null,
|
|
212
317
|
};
|
|
213
318
|
} catch (err) {
|
|
214
|
-
|
|
319
|
+
// Degraded row: keep the full ProfileRow shape so consumers can rely
|
|
320
|
+
// on array fields (e.g. conflicts.some(...)) without guarding.
|
|
321
|
+
return {
|
|
322
|
+
name,
|
|
323
|
+
icon: null,
|
|
324
|
+
iconImage: null as string | null,
|
|
325
|
+
description: "",
|
|
326
|
+
skills: 0,
|
|
327
|
+
npx: 0,
|
|
328
|
+
mcps: 0,
|
|
329
|
+
plugins: 0,
|
|
330
|
+
subagents: 0,
|
|
331
|
+
bundles: [] as string[],
|
|
332
|
+
conflicts: [] as string[],
|
|
333
|
+
inheritsCore: false,
|
|
334
|
+
error: (err as Error).message,
|
|
335
|
+
};
|
|
215
336
|
}
|
|
216
337
|
}),
|
|
217
338
|
);
|
|
218
339
|
return { ok: true, data: rows };
|
|
219
340
|
}
|
|
220
341
|
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Profile detail — the explorer/search/mcps data source for cue studio.
|
|
344
|
+
//
|
|
345
|
+
// Returns a profile's full skill catalogue grouped by namespace, each skill's
|
|
346
|
+
// SKILL.md body + byte size + connected-MCP hints, plus the profile's MCP,
|
|
347
|
+
// plugin, and command refs. Reuses the same loader + resolver + parser the CLI
|
|
348
|
+
// uses so the studio reads real on-disk data, never a mock.
|
|
349
|
+
//
|
|
350
|
+
// Reading 50+ SKILL.md files per request is the heaviest read on the server,
|
|
351
|
+
// so the result is cached briefly (keyed by profile selector).
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
interface StudioSkill {
|
|
355
|
+
id: string;
|
|
356
|
+
ns: string;
|
|
357
|
+
name: string;
|
|
358
|
+
desc: string;
|
|
359
|
+
sizeK: number;
|
|
360
|
+
body: string;
|
|
361
|
+
uses: string[];
|
|
362
|
+
missing: boolean;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** One `##` heading of a playbook, rendered as a step chip in the studio. */
|
|
366
|
+
interface PlaybookStep {
|
|
367
|
+
name: string;
|
|
368
|
+
detail: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A profile's playbook, shaped for the studio Workflows page. */
|
|
372
|
+
interface PlaybookWorkflow {
|
|
373
|
+
id: string;
|
|
374
|
+
name: string;
|
|
375
|
+
title: string;
|
|
376
|
+
emoji: string;
|
|
377
|
+
trigger: string;
|
|
378
|
+
est: string;
|
|
379
|
+
desc: string;
|
|
380
|
+
steps: PlaybookStep[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** A delegatable subagent ref, split into its division + slug for grouping. */
|
|
384
|
+
interface SubagentRef {
|
|
385
|
+
/** The raw ref, e.g. "design/design-ui-designer". */
|
|
386
|
+
id: string;
|
|
387
|
+
/** First path segment — the agency division (design, finance, sales…). */
|
|
388
|
+
division: string;
|
|
389
|
+
/** Trailing slug — the agent name. */
|
|
390
|
+
name: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A profile slash-command, resolved from its on-disk markdown source. */
|
|
394
|
+
interface StudioCommand {
|
|
395
|
+
/** Display name with the leading slash, e.g. "/goal". */
|
|
396
|
+
name: string;
|
|
397
|
+
/** Bare ref / file stem used to resolve the source, e.g. "goal". */
|
|
398
|
+
ref: string;
|
|
399
|
+
/** One-line `description:` from frontmatter ("" when absent / unresolved). */
|
|
400
|
+
desc: string;
|
|
401
|
+
/** Optional `argument-hint:` from frontmatter. */
|
|
402
|
+
argHint: string | null;
|
|
403
|
+
/** Full markdown body, rendered in the studio editor preview. */
|
|
404
|
+
body: string;
|
|
405
|
+
/** KB size of the source file (0 when unresolved). */
|
|
406
|
+
sizeK: number;
|
|
407
|
+
/** True when no source .md resolved (built-in / plugin-provided command). */
|
|
408
|
+
missing: boolean;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface ProfileDetail {
|
|
412
|
+
profile: string;
|
|
413
|
+
parts: string[];
|
|
414
|
+
counts: { skills: number; mcps: number; plugins: number; commands: number; subagents: number; clis: number };
|
|
415
|
+
skills: StudioSkill[];
|
|
416
|
+
mcps: { id: string; status: string }[];
|
|
417
|
+
plugins: { id: string; name: string; marketplace: string; status: string }[];
|
|
418
|
+
commands: StudioCommand[];
|
|
419
|
+
/** Real on-disk workflows: the playbooks this profile declares, parsed. */
|
|
420
|
+
playbooks: PlaybookWorkflow[];
|
|
421
|
+
/** Delegatable specialists this profile wires into `.claude/agents/`. */
|
|
422
|
+
subagents: SubagentRef[];
|
|
423
|
+
/** External CLI tools the profile's skills declare (frontmatter Bash refs). */
|
|
424
|
+
clis: ProfileCli[];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Pull a `uses:` (or `mcps:`) frontmatter list out of a SKILL.md, if present. */
|
|
428
|
+
function parseUsesFromFrontmatter(content: string): string[] {
|
|
429
|
+
const lines = content.split("\n");
|
|
430
|
+
if (lines[0] !== "---") return [];
|
|
431
|
+
for (let i = 1; i < lines.length; i++) {
|
|
432
|
+
if (lines[i] === "---") break;
|
|
433
|
+
const m = lines[i]!.match(/^(?:uses|mcps):\s*\[([^\]]*)\]/);
|
|
434
|
+
if (m) {
|
|
435
|
+
return m[1]!
|
|
436
|
+
.split(",")
|
|
437
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
438
|
+
.filter(Boolean);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Strip a single matched pair of wrapping quotes — never an unbalanced lone quote. */
|
|
445
|
+
function stripWrappingQuotes(s: string): string {
|
|
446
|
+
return s.replace(/^(['"])([\s\S]*)\1$/, "$2");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Pull `description:` and `argument-hint:` out of a command markdown's frontmatter. */
|
|
450
|
+
function parseCommandFrontmatter(content: string): { desc: string; argHint: string | null } {
|
|
451
|
+
// Normalize CRLF so an externally-authored (Windows) command file's "---\r"
|
|
452
|
+
// fence still matches the delimiter check below.
|
|
453
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
454
|
+
if (lines[0] !== "---") return { desc: "", argHint: null };
|
|
455
|
+
let desc = "";
|
|
456
|
+
let argHint: string | null = null;
|
|
457
|
+
for (let i = 1; i < lines.length; i++) {
|
|
458
|
+
if (lines[i] === "---") break;
|
|
459
|
+
const d = lines[i]!.match(/^description:\s*(.+)$/);
|
|
460
|
+
if (d) desc = stripWrappingQuotes(d[1]!.trim());
|
|
461
|
+
const a = lines[i]!.match(/^argument-hint:\s*(.+)$/);
|
|
462
|
+
if (a) argHint = stripWrappingQuotes(a[1]!.trim());
|
|
463
|
+
}
|
|
464
|
+
return { desc, argHint };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Placeholder body for a command with no on-disk source (plugin / built-in). */
|
|
468
|
+
function stubCommandBody(name: string): string {
|
|
469
|
+
return `---
|
|
470
|
+
command: ${name}
|
|
471
|
+
source: plugin or built-in
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
# ${name}
|
|
475
|
+
|
|
476
|
+
This command has no markdown source in \`resources/commands/\` — it is contributed by an installed plugin or built in to the agent, so its body isn't stored locally. Invoke it by typing \`${name}\` in the prompt.`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Map a playbook slug/title to a stable emoji for its workflow card. */
|
|
480
|
+
function playbookEmoji(hint: string): string {
|
|
481
|
+
const s = hint.toLowerCase();
|
|
482
|
+
// Specific themes first — generic "ship/deploy" verbs appear in many titles.
|
|
483
|
+
if (/bug|triage|debug/.test(s)) return "🐛";
|
|
484
|
+
if (/sprint/.test(s)) return "🏃";
|
|
485
|
+
if (/improve|clean|refactor|health/.test(s)) return "🔧";
|
|
486
|
+
if (/skill/.test(s)) return "🧪";
|
|
487
|
+
if (/research|analyze|investigate/.test(s)) return "🔍";
|
|
488
|
+
if (/security|secops|cso/.test(s)) return "🛡";
|
|
489
|
+
if (/doc|write/.test(s)) return "📝";
|
|
490
|
+
if (/growth|market/.test(s)) return "📈";
|
|
491
|
+
if (/vite|web|frontend|design/.test(s)) return "🎨";
|
|
492
|
+
if (/medusa|shop|commerce/.test(s)) return "🛒";
|
|
493
|
+
if (/ship|deploy|release|land|canary/.test(s)) return "🚀";
|
|
494
|
+
return "📋";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Rough token estimate (~4 chars/token) → compact "~1.2K" label. */
|
|
498
|
+
function estTokensLabel(chars: number): string {
|
|
499
|
+
const t = Math.max(1, Math.round(chars / 4));
|
|
500
|
+
if (t < 1000) return `~${t}`;
|
|
501
|
+
return `~${(t / 1000).toFixed(1).replace(/\.0$/, "")}K`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Collapse whitespace and cap a string with an ellipsis. */
|
|
505
|
+
function collapse(s: string, cap = 160): string {
|
|
506
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
507
|
+
return t.length > cap ? t.slice(0, cap - 1).trimEnd() + "…" : t;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Strip leading markdown markers (bullets, numbers, bold) from a line. */
|
|
511
|
+
function cleanMdLine(s: string): string {
|
|
512
|
+
return s.replace(/^[-*]\s+/, "").replace(/^\d+\.\s+/, "").replace(/\*\*/g, "").trim();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Parse a playbook markdown doc into a workflow-card model:
|
|
517
|
+
* - `title` from the `# Playbook: …` H1 (falls back to the prettified slug),
|
|
518
|
+
* - `desc` from the first "Use when …" paragraph (falls back to the first
|
|
519
|
+
* non-heading paragraph),
|
|
520
|
+
* - one `step` per `##` heading, numbered prefix stripped, with a one-line
|
|
521
|
+
* `detail` pulled from that section's first body line.
|
|
522
|
+
*/
|
|
523
|
+
function parsePlaybook(slug: string, content: string): PlaybookWorkflow {
|
|
524
|
+
const lines = content.split("\n");
|
|
525
|
+
|
|
526
|
+
let title = slug.replace(/[-_]/g, " ");
|
|
527
|
+
const h1Idx = lines.findIndex((l) => /^#\s+/.test(l));
|
|
528
|
+
if (h1Idx >= 0) {
|
|
529
|
+
const m = lines[h1Idx]!.match(/^#\s+(?:Playbook:\s*)?(.+)$/);
|
|
530
|
+
if (m) title = m[1]!.trim();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let desc = "";
|
|
534
|
+
const gatherParagraph = (start: number): string => {
|
|
535
|
+
const para: string[] = [];
|
|
536
|
+
for (let i = start; i < lines.length && lines[i]!.trim() !== ""; i++) para.push(lines[i]!);
|
|
537
|
+
return collapse(para.join(" "));
|
|
538
|
+
};
|
|
539
|
+
const useIdx = lines.findIndex((l) => /use when/i.test(l));
|
|
540
|
+
if (useIdx >= 0) {
|
|
541
|
+
desc = gatherParagraph(useIdx);
|
|
542
|
+
} else {
|
|
543
|
+
for (let i = h1Idx + 1; i < lines.length; i++) {
|
|
544
|
+
const l = lines[i]!.trim();
|
|
545
|
+
if (!l || l.startsWith("#")) continue;
|
|
546
|
+
desc = gatherParagraph(i);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const steps: PlaybookStep[] = [];
|
|
552
|
+
for (let i = 0; i < lines.length; i++) {
|
|
553
|
+
const m = lines[i]!.match(/^##\s+(.+)$/);
|
|
554
|
+
if (!m) continue;
|
|
555
|
+
const name = collapse(m[1]!.replace(/^\d+\.\s*/, ""), 60);
|
|
556
|
+
let detail = "";
|
|
557
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
558
|
+
if (/^##\s+/.test(lines[j]!)) break;
|
|
559
|
+
const c = cleanMdLine(lines[j]!);
|
|
560
|
+
if (c) { detail = collapse(c, 140); break; }
|
|
561
|
+
}
|
|
562
|
+
steps.push({ name, detail });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
id: slug,
|
|
567
|
+
name: slug,
|
|
568
|
+
title,
|
|
569
|
+
emoji: playbookEmoji(`${slug} ${title}`),
|
|
570
|
+
trigger: "playbook",
|
|
571
|
+
est: estTokensLabel(content.length),
|
|
572
|
+
desc: desc || `Playbook: ${title}`,
|
|
573
|
+
steps,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const profileDetailCache = new Map<string, { ts: number; data: ProfileDetail }>();
|
|
578
|
+
const PROFILE_DETAIL_TTL_MS = 60_000;
|
|
579
|
+
|
|
580
|
+
export async function handleProfileDetail(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
581
|
+
// Default to the profile resolved for the server's cwd (same precedence as
|
|
582
|
+
// /status), so the studio opens on the active profile with no query param.
|
|
583
|
+
let name = resolveProfileQuery(params.get("profile"));
|
|
584
|
+
if (!name) {
|
|
585
|
+
const resolved = await resolveProfileForCwd({
|
|
586
|
+
cwd: process.cwd(),
|
|
587
|
+
homeDir: homedir(),
|
|
588
|
+
configDir: configDir(),
|
|
589
|
+
});
|
|
590
|
+
if (resolved.source !== "none") name = (resolved as { profile: string }).profile;
|
|
591
|
+
}
|
|
592
|
+
if (!name) return { ok: false, error: "no-profile" };
|
|
593
|
+
|
|
594
|
+
const cached = profileDetailCache.get(name);
|
|
595
|
+
if (cached && Date.now() - cached.ts < PROFILE_DETAIL_TTL_MS) {
|
|
596
|
+
return { ok: true, data: cached.data };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let profile;
|
|
600
|
+
try {
|
|
601
|
+
profile = await loadProfile(name);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
return { ok: false, error: (err as Error).message };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Resolve + read each local skill. Failures degrade to a stub entry (kept in
|
|
607
|
+
// the list so tree counts stay honest) rather than failing the whole call.
|
|
608
|
+
const localSkills: StudioSkill[] = await Promise.all(
|
|
609
|
+
profile.skills.local
|
|
610
|
+
.map((s) => s.id)
|
|
611
|
+
.filter((id) => !id.includes("*"))
|
|
612
|
+
.map(async (id): Promise<StudioSkill> => {
|
|
613
|
+
const ns = id.includes("/") ? id.split("/")[0]! : "skills";
|
|
614
|
+
const slug = id.slice(id.lastIndexOf("/") + 1);
|
|
615
|
+
try {
|
|
616
|
+
const dir = await resolveLocalSkill(id);
|
|
617
|
+
const path = join(dir, "SKILL.md");
|
|
618
|
+
const content = await readFile(path, "utf8");
|
|
619
|
+
const sizeK = +((await statAsync(path)).size / 1024).toFixed(1);
|
|
620
|
+
const parsed = parseSkillFromContent(id, content, slug);
|
|
621
|
+
const desc = parsed.capability || parsed.rawDescription || "";
|
|
622
|
+
return { id, ns, name: parsed.name || slug, desc, sizeK, body: content, uses: parseUsesFromFrontmatter(content), missing: false };
|
|
623
|
+
} catch {
|
|
624
|
+
return { id, ns, name: slug, desc: "(SKILL.md could not be read)", sizeK: 0, body: `---\nname: ${slug}\n---\n\n# ${slug}\n\nThis skill could not be resolved on disk.`, uses: [], missing: true };
|
|
625
|
+
}
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
// npx skills: referenced by repo + slug; bodies live in a remote package, so
|
|
630
|
+
// surface them as catalogue entries under the "npx" namespace with a stub body.
|
|
631
|
+
const npxSkills: StudioSkill[] = profile.skills.npx.flatMap((ref) =>
|
|
632
|
+
(ref.skills ?? []).map((slug): StudioSkill => {
|
|
633
|
+
const id = `${ref.repo}#${slug}`;
|
|
634
|
+
const body = `---\nname: ${slug}\nnamespace: npx\nrepo: ${ref.repo}\n---\n\n# ${slug}\n\nProvided on demand via \`npx ${ref.repo}\`. The body is fetched at activation time, so it is not stored locally.`;
|
|
635
|
+
return { id, ns: "npx", name: slug, desc: `npx skill from ${ref.repo}`, sizeK: 0, body, uses: [], missing: false };
|
|
636
|
+
}),
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const skills = [...localSkills, ...npxSkills];
|
|
640
|
+
|
|
641
|
+
const plugins = profile.plugins.map((p) => {
|
|
642
|
+
const at = p.id.lastIndexOf("@");
|
|
643
|
+
const pname = at > 0 ? p.id.slice(0, at) : p.id;
|
|
644
|
+
const marketplace = at > 0 ? p.id.slice(at + 1) : "";
|
|
645
|
+
return { id: p.id, name: pname, marketplace, status: "loaded" };
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Playbooks the profile declares → real on-disk workflows for the studio's
|
|
649
|
+
// Workflows page. Read + parse each; unreadable / oddly-named ones are skipped
|
|
650
|
+
// so one bad file never fails the whole call.
|
|
651
|
+
const pbDir = playbooksDir();
|
|
652
|
+
const playbooks: PlaybookWorkflow[] = (
|
|
653
|
+
await Promise.all(
|
|
654
|
+
(profile.playbooks ?? []).map(async (slug): Promise<PlaybookWorkflow | null> => {
|
|
655
|
+
if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
|
|
656
|
+
try {
|
|
657
|
+
const content = await readFile(join(pbDir, `${slug}.md`), "utf8");
|
|
658
|
+
return parsePlaybook(slug, content);
|
|
659
|
+
} catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}),
|
|
663
|
+
)
|
|
664
|
+
).filter((p): p is PlaybookWorkflow => p !== null);
|
|
665
|
+
|
|
666
|
+
// Subagents — the delegatable specialists the profile wires into agents/.
|
|
667
|
+
// Refs are "<division>/<slug>"; split for grouped display in the studio.
|
|
668
|
+
const subagents: SubagentRef[] = (profile.subagents ?? []).map((ref) => {
|
|
669
|
+
const slash = ref.indexOf("/");
|
|
670
|
+
const division = slash > 0 ? ref.slice(0, slash) : "other";
|
|
671
|
+
const sname = ref.slice(ref.lastIndexOf("/") + 1);
|
|
672
|
+
return { id: ref, division, name: sname };
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// CLIs the profile's skills shell out to — parsed from the skill bodies we
|
|
676
|
+
// already loaded above (no extra disk reads), enriched from cli-recipes.json.
|
|
677
|
+
const clis = aggregateProfileClis(skills);
|
|
678
|
+
|
|
679
|
+
// Commands — resolve each profile-declared slash command to its on-disk
|
|
680
|
+
// markdown (resources/commands/<ref>.md), reading the frontmatter description
|
|
681
|
+
// + argument-hint and the body for the studio's command preview. Refs come
|
|
682
|
+
// from the profile definition, but the stem is validated (no separators, no
|
|
683
|
+
// `..`) so a malformed ref can never read outside the commands dir. Built-in
|
|
684
|
+
// or plugin-provided commands with no source .md degrade to a stub entry
|
|
685
|
+
// (kept in the list so tree counts stay honest).
|
|
686
|
+
const cmdDir = commandsDir();
|
|
687
|
+
const commands: StudioCommand[] = await Promise.all(
|
|
688
|
+
profile.commands.map(async (raw): Promise<StudioCommand> => {
|
|
689
|
+
const ref = raw.replace(/^\//, "").replace(/\.md$/, "");
|
|
690
|
+
const name = `/${ref}`;
|
|
691
|
+
if (!/^[A-Za-z0-9._-]+$/.test(ref) || ref.includes("..")) {
|
|
692
|
+
return { name, ref, desc: "", argHint: null, body: stubCommandBody(name), sizeK: 0, missing: true };
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const path = join(cmdDir, `${ref}.md`);
|
|
696
|
+
// Belt-and-suspenders containment, mirroring serveProfileIcon: the ref
|
|
697
|
+
// regex already bars separators, but assert the resolved path stays
|
|
698
|
+
// inside the commands dir so a loosened guard can never read outside it.
|
|
699
|
+
if (!resolve(path).startsWith(resolve(cmdDir) + sep)) {
|
|
700
|
+
return { name, ref, desc: "", argHint: null, body: stubCommandBody(name), sizeK: 0, missing: true };
|
|
701
|
+
}
|
|
702
|
+
const content = await readFile(path, "utf8");
|
|
703
|
+
const sizeK = +((await statAsync(path)).size / 1024).toFixed(1);
|
|
704
|
+
const { desc, argHint } = parseCommandFrontmatter(content);
|
|
705
|
+
return { name, ref, desc, argHint, body: content, sizeK, missing: false };
|
|
706
|
+
} catch {
|
|
707
|
+
return { name, ref, desc: "", argHint: null, body: stubCommandBody(name), sizeK: 0, missing: true };
|
|
708
|
+
}
|
|
709
|
+
}),
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
const data: ProfileDetail = {
|
|
713
|
+
profile: name,
|
|
714
|
+
parts: name.split("+").map((s) => s.trim()).filter(Boolean),
|
|
715
|
+
counts: {
|
|
716
|
+
skills: skills.length,
|
|
717
|
+
mcps: profile.mcps.length,
|
|
718
|
+
plugins: profile.plugins.length,
|
|
719
|
+
commands: profile.commands.length,
|
|
720
|
+
subagents: subagents.length,
|
|
721
|
+
clis: clis.length,
|
|
722
|
+
},
|
|
723
|
+
skills,
|
|
724
|
+
mcps: profile.mcps.map((m) => ({ id: m.id, status: "connected" })),
|
|
725
|
+
plugins,
|
|
726
|
+
commands,
|
|
727
|
+
playbooks,
|
|
728
|
+
subagents,
|
|
729
|
+
clis,
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
profileDetailCache.set(name, { ts: Date.now(), data });
|
|
733
|
+
return { ok: true, data };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
// Hooks — the active profile's real Claude Code hooks, read from the settings
|
|
738
|
+
// files that actually drive them: the cue-materialized runtime settings.json
|
|
739
|
+
// (per profile) plus the user's global ~/.claude/settings.json. Flattened and
|
|
740
|
+
// grouped by lifecycle event for the studio Hooks view. Read-only.
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
interface FlatHook {
|
|
744
|
+
event: string;
|
|
745
|
+
matcher: string;
|
|
746
|
+
command: string;
|
|
747
|
+
description: string;
|
|
748
|
+
id: string;
|
|
749
|
+
source: "profile" | "global";
|
|
750
|
+
/** Absolute path of the script the command runs, if it resolves to one. */
|
|
751
|
+
scriptPath: string | null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
interface SettingsHookEntry {
|
|
755
|
+
matcher?: string;
|
|
756
|
+
hooks?: { type?: string; command?: string; description?: string; id?: string }[];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** The Claude config dir a hook's `${CLAUDE_CONFIG_DIR}` expands to, by source. */
|
|
760
|
+
function claudeDirForSource(source: FlatHook["source"], profileName: string | null): string | null {
|
|
761
|
+
if (source === "global") return join(homedir(), ".claude");
|
|
762
|
+
return profileName ? join(configDir(), "runtime", profileName, "claude") : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Resolve the script file a hook command runs, if any. Expands the env vars cue
|
|
767
|
+
* bakes into materialized hook commands (`${CLAUDE_CONFIG_DIR}` and `~`) and
|
|
768
|
+
* returns an absolute path — or null when the command isn't a script invocation
|
|
769
|
+
* or the path can't be fully resolved (so the UI hides the "view source" link).
|
|
770
|
+
*/
|
|
771
|
+
function resolveHookScript(command: string, source: FlatHook["source"], profileName: string | null): string | null {
|
|
772
|
+
const m = command.match(/(\S+\.(?:sh|bash|zsh|js|mjs|cjs|ts|py|rb|pl))\b/);
|
|
773
|
+
if (!m) return null;
|
|
774
|
+
const claudeDir = claudeDirForSource(source, profileName);
|
|
775
|
+
let p = m[1]!
|
|
776
|
+
.replace(/\$\{CLAUDE_CONFIG_DIR\}|\$CLAUDE_CONFIG_DIR/g, claudeDir ?? "\0")
|
|
777
|
+
.replace(/^~(?=\/)/, homedir());
|
|
778
|
+
if (p.includes("\0") || p.includes("$")) return null; // unresolved variable
|
|
779
|
+
if (!p.startsWith("/")) return null; // only absolute paths
|
|
780
|
+
return resolve(p);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Extension → human language label for the source viewer's badge + highlighter. */
|
|
784
|
+
function hookLanguage(path: string): string {
|
|
785
|
+
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
786
|
+
const map: Record<string, string> = {
|
|
787
|
+
sh: "bash", bash: "bash", zsh: "bash",
|
|
788
|
+
js: "javascript", mjs: "javascript", cjs: "javascript",
|
|
789
|
+
ts: "typescript", py: "python", rb: "ruby", pl: "perl",
|
|
790
|
+
};
|
|
791
|
+
return map[ext] ?? ext ?? "text";
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Parse the `hooks` map out of one settings.json, tagging each with `source`. */
|
|
795
|
+
function readHooksFile(path: string, source: FlatHook["source"], profileName: string | null): FlatHook[] {
|
|
796
|
+
if (!existsSync(path)) return [];
|
|
797
|
+
let parsed: { hooks?: Record<string, SettingsHookEntry[]> };
|
|
798
|
+
try {
|
|
799
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
800
|
+
} catch {
|
|
801
|
+
return [];
|
|
802
|
+
}
|
|
803
|
+
const hooks = parsed.hooks;
|
|
804
|
+
if (!hooks || typeof hooks !== "object") return [];
|
|
805
|
+
const out: FlatHook[] = [];
|
|
806
|
+
for (const event of Object.keys(hooks)) {
|
|
807
|
+
const groups = hooks[event];
|
|
808
|
+
if (!Array.isArray(groups)) continue;
|
|
809
|
+
for (const g of groups) {
|
|
810
|
+
const matcher = g.matcher && g.matcher.length > 0 ? g.matcher : "*";
|
|
811
|
+
for (const h of g.hooks ?? []) {
|
|
812
|
+
if (!h.command) continue;
|
|
813
|
+
out.push({
|
|
814
|
+
event,
|
|
815
|
+
matcher,
|
|
816
|
+
command: h.command,
|
|
817
|
+
description: h.description ?? "",
|
|
818
|
+
id: h.id ?? `${event}:${matcher}:${h.command}`,
|
|
819
|
+
source,
|
|
820
|
+
scriptPath: resolveHookScript(h.command, source, profileName),
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Resolve the profile a hooks query targets — explicit param, else cwd. */
|
|
829
|
+
async function resolveHooksProfile(profileParam: string | null): Promise<string | null> {
|
|
830
|
+
const explicit = resolveProfileQuery(profileParam);
|
|
831
|
+
if (explicit) return explicit;
|
|
832
|
+
const resolved = await resolveProfileForCwd({ cwd: process.cwd(), homeDir: homedir(), configDir: configDir() });
|
|
833
|
+
return resolved.source !== "none" ? (resolved as { profile: string }).profile : null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** All hooks (global + this profile's runtime), deduped by id — the shared
|
|
837
|
+
* source of truth for both the hooks list and the source-viewer allowlist. */
|
|
838
|
+
function enumerateHooks(profileName: string | null): FlatHook[] {
|
|
839
|
+
const globalPath = join(homedir(), ".claude", "settings.json");
|
|
840
|
+
const runtimePath = profileName ? join(configDir(), "runtime", profileName, "claude", "settings.json") : null;
|
|
841
|
+
const flat: FlatHook[] = [
|
|
842
|
+
...readHooksFile(globalPath, "global", null),
|
|
843
|
+
...(runtimePath ? readHooksFile(runtimePath, "profile", profileName) : []),
|
|
844
|
+
];
|
|
845
|
+
// Dedup by id (a profile hook overriding a global one wins — it appears later).
|
|
846
|
+
const byId = new Map<string, FlatHook>();
|
|
847
|
+
for (const h of flat) byId.set(h.id, h);
|
|
848
|
+
return [...byId.values()];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export async function handleHooks(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
852
|
+
const name = await resolveHooksProfile(params.get("profile"));
|
|
853
|
+
const deduped = enumerateHooks(name);
|
|
854
|
+
|
|
855
|
+
// Group by event in a stable lifecycle order; unknown events sort last.
|
|
856
|
+
const EVENT_ORDER = [
|
|
857
|
+
"PreToolUse", "PostToolUse", "UserPromptSubmit", "SessionStart",
|
|
858
|
+
"SessionEnd", "Stop", "SubagentStop", "PreCompact", "Notification",
|
|
859
|
+
];
|
|
860
|
+
const byEvent = new Map<string, FlatHook[]>();
|
|
861
|
+
for (const h of deduped) {
|
|
862
|
+
if (!byEvent.has(h.event)) byEvent.set(h.event, []);
|
|
863
|
+
byEvent.get(h.event)!.push(h);
|
|
864
|
+
}
|
|
865
|
+
const events = [...byEvent.keys()]
|
|
866
|
+
.sort((a, b) => {
|
|
867
|
+
const ia = EVENT_ORDER.indexOf(a), ib = EVENT_ORDER.indexOf(b);
|
|
868
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b);
|
|
869
|
+
})
|
|
870
|
+
.map((event) => ({ event, hooks: byEvent.get(event)! }));
|
|
871
|
+
|
|
872
|
+
return { ok: true, data: { profile: name, total: deduped.length, events } };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const HOOK_SOURCE_MAX_BYTES = 256 * 1024;
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Read one hook's script source for the studio's source viewer. Security: the
|
|
879
|
+
* requested path must be one of the *enumerated* hook script paths for this
|
|
880
|
+
* profile (an allowlist rebuilt per request) — an arbitrary path, or any
|
|
881
|
+
* `../` traversal, fails the membership check before any file is read.
|
|
882
|
+
*/
|
|
883
|
+
export async function handleHookSource(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
884
|
+
const requested = params.get("path");
|
|
885
|
+
if (!requested) return { ok: false, error: "missing-path" };
|
|
886
|
+
|
|
887
|
+
const name = await resolveHooksProfile(params.get("profile"));
|
|
888
|
+
const allow = new Set(
|
|
889
|
+
enumerateHooks(name).map((h) => h.scriptPath).filter((p): p is string => !!p),
|
|
890
|
+
);
|
|
891
|
+
const abs = resolve(requested);
|
|
892
|
+
if (!allow.has(abs)) return { ok: false, error: "not-a-hook-script" };
|
|
893
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return { ok: false, error: "not-found" };
|
|
894
|
+
if (statSync(abs).size > HOOK_SOURCE_MAX_BYTES) return { ok: false, error: "too-large" };
|
|
895
|
+
|
|
896
|
+
const home = homedir();
|
|
897
|
+
const displayPath = abs.startsWith(home) ? "~" + abs.slice(home.length) : abs;
|
|
898
|
+
const slash = displayPath.lastIndexOf("/");
|
|
899
|
+
return {
|
|
900
|
+
ok: true,
|
|
901
|
+
data: {
|
|
902
|
+
path: abs,
|
|
903
|
+
displayPath,
|
|
904
|
+
filename: displayPath.slice(slash + 1),
|
|
905
|
+
dir: displayPath.slice(0, slash + 1),
|
|
906
|
+
language: hookLanguage(abs),
|
|
907
|
+
content: readFileSync(abs, "utf8"),
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
221
912
|
interface MergeRequest {
|
|
222
913
|
names?: string[];
|
|
223
914
|
name?: string;
|
|
@@ -277,6 +968,69 @@ export async function handleMergeSave(body: MergeRequest | null): Promise<ApiRes
|
|
|
277
968
|
}
|
|
278
969
|
}
|
|
279
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Full MCP catalog — every server cue can wire into a profile, with inferred
|
|
973
|
+
* transport + install hint. Drives the studio's "Available in cue" section.
|
|
974
|
+
* Read-only; the client diffs this against the active profile's `mcps` to show
|
|
975
|
+
* only the not-yet-connected entries.
|
|
976
|
+
*/
|
|
977
|
+
let mcpCatalogCache: { ts: number; data: unknown[] } | null = null;
|
|
978
|
+
const MCP_CATALOG_TTL_MS = 60_000;
|
|
979
|
+
|
|
980
|
+
export async function handleMcpCatalog(): Promise<ApiResult<unknown>> {
|
|
981
|
+
// The usedBy map below scans every profile via loadProfile; cache the built
|
|
982
|
+
// catalog briefly so a hard refresh / multiple clients don't re-scan all
|
|
983
|
+
// ~77 profiles on every hit. Global data, so a single-slot TTL cache fits.
|
|
984
|
+
if (mcpCatalogCache && Date.now() - mcpCatalogCache.ts < MCP_CATALOG_TTL_MS) {
|
|
985
|
+
return { ok: true, data: mcpCatalogCache.data };
|
|
986
|
+
}
|
|
987
|
+
// Map each MCP id → the profiles that wire it (resolved, so bundle- and
|
|
988
|
+
// core-inherited mcps count), each with its icon — lets the studio show
|
|
989
|
+
// "used by <profile icons>" next to a catalog entry's add button.
|
|
990
|
+
const profilesDir = mergeProfilesDir();
|
|
991
|
+
const usedBy = new Map<
|
|
992
|
+
string,
|
|
993
|
+
{ name: string; icon: string | null; iconImage: string | null }[]
|
|
994
|
+
>();
|
|
995
|
+
for (const name of await listProfiles()) {
|
|
996
|
+
try {
|
|
997
|
+
const p = await loadProfile(name);
|
|
998
|
+
const iconImage =
|
|
999
|
+
p.iconImage && existsSync(join(profilesDir, name, p.iconImage)) ? p.iconImage : null;
|
|
1000
|
+
for (const m of p.mcps) {
|
|
1001
|
+
const list = usedBy.get(m.id) ?? [];
|
|
1002
|
+
list.push({ name, icon: p.icon ?? null, iconImage });
|
|
1003
|
+
usedBy.set(m.id, list);
|
|
1004
|
+
}
|
|
1005
|
+
} catch {
|
|
1006
|
+
/* skip a profile that won't resolve */
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const data = loadMcpCatalog().map((e) => ({ ...e, usedBy: usedBy.get(e.id) ?? [] }));
|
|
1010
|
+
mcpCatalogCache = { ts: Date.now(), data };
|
|
1011
|
+
return { ok: true, data };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Add a catalog MCP to a single physical profile's profile.yaml. The client
|
|
1016
|
+
* passes the chosen part-profile (composite runtime profiles have no file to
|
|
1017
|
+
* write), validated here against path traversal + catalog membership.
|
|
1018
|
+
*/
|
|
1019
|
+
export async function handleMcpAdd(
|
|
1020
|
+
body: { id?: unknown; profile?: unknown } | null,
|
|
1021
|
+
): Promise<ApiResult<unknown>> {
|
|
1022
|
+
const id = typeof body?.id === "string" ? body.id : "";
|
|
1023
|
+
const profile = typeof body?.profile === "string" ? body.profile : "";
|
|
1024
|
+
if (!id) return { ok: false, error: "missing-id" };
|
|
1025
|
+
if (!profile) return { ok: false, error: "missing-profile" };
|
|
1026
|
+
try {
|
|
1027
|
+
const result = await addMcpToProfile(id, profile);
|
|
1028
|
+
return { ok: true, data: result };
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
return { ok: false, error: (err as Error).message };
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
280
1034
|
export async function handleSkillReport(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
281
1035
|
if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
|
|
282
1036
|
const name = resolveProfileQuery(params.get("profile"));
|
|
@@ -312,11 +1066,23 @@ export async function handleGates(params: URLSearchParams): Promise<ApiResult<un
|
|
|
312
1066
|
return { ok: true, data: readGateStatus(name) };
|
|
313
1067
|
}
|
|
314
1068
|
|
|
1069
|
+
// Trigger-gaps is the dashboard's most expensive endpoint (it reads recent
|
|
1070
|
+
// transcripts and runs skills × prompts matching). Cache the result briefly so
|
|
1071
|
+
// a page load + auto-refetches don't recompute it back-to-back and stall the
|
|
1072
|
+
// single-threaded server. Keyed by profile + window; 90s TTL.
|
|
1073
|
+
const triggerGapsCache = new Map<string, { ts: number; data: unknown }>();
|
|
1074
|
+
const TRIGGER_GAPS_TTL_MS = 90_000;
|
|
1075
|
+
|
|
315
1076
|
export async function handleTriggerGaps(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
316
1077
|
if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
|
|
317
1078
|
const name = resolveProfileQuery(params.get("profile"));
|
|
318
1079
|
if (!name) return { ok: false, error: "no-profile" };
|
|
319
1080
|
const sinceDays = parseSinceDays(params.get("since"));
|
|
1081
|
+
const cacheKey = `${name}:${sinceDays}`;
|
|
1082
|
+
const cached = triggerGapsCache.get(cacheKey);
|
|
1083
|
+
if (cached && Date.now() - cached.ts < TRIGGER_GAPS_TTL_MS) {
|
|
1084
|
+
return { ok: true, data: cached.data };
|
|
1085
|
+
}
|
|
320
1086
|
try {
|
|
321
1087
|
const profile = await loadProfile(name);
|
|
322
1088
|
const skills = [];
|
|
@@ -334,10 +1100,9 @@ export async function handleTriggerGaps(params: URLSearchParams): Promise<ApiRes
|
|
|
334
1100
|
const hits = new Map<string, number>();
|
|
335
1101
|
for (const u of usage) hits.set(u.id, u.hits);
|
|
336
1102
|
const rows = computeTriggerGaps({ skills, userPrompts, hits });
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
};
|
|
1103
|
+
const data = { profile: name, windowDays: sinceDays, promptsScanned: userPrompts.length, rows };
|
|
1104
|
+
triggerGapsCache.set(cacheKey, { ts: Date.now(), data });
|
|
1105
|
+
return { ok: true, data };
|
|
341
1106
|
} catch (err) {
|
|
342
1107
|
return { ok: false, error: (err as Error).message };
|
|
343
1108
|
}
|
|
@@ -382,36 +1147,675 @@ export async function handleKillSession(
|
|
|
382
1147
|
}
|
|
383
1148
|
}
|
|
384
1149
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
1150
|
+
/**
|
|
1151
|
+
* The activity-chart payload (gap-filled sessions-per-day + per-profile counts)
|
|
1152
|
+
* for a window. Shared by the polling GET and the SSE stream so both emit the
|
|
1153
|
+
* exact same shape — the stream is just this, pushed on change.
|
|
1154
|
+
*/
|
|
1155
|
+
export function buildTimeline(sinceDays: number): {
|
|
1156
|
+
windowDays: number;
|
|
1157
|
+
daily: ReturnType<typeof computeDailyActivity>;
|
|
1158
|
+
profiles: { profile: string; sessions: number; lastUsed: string | null }[];
|
|
1159
|
+
} {
|
|
388
1160
|
const cutoff = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
|
|
389
1161
|
const events = computeStats({ since: cutoff });
|
|
390
|
-
// Bucket sessions per profile per day. Computed from session counts since
|
|
391
|
-
// we don't have per-day rollups; close enough for a sparkline.
|
|
392
1162
|
return {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1163
|
+
windowDays: sinceDays,
|
|
1164
|
+
daily: computeDailyActivity(sinceDays),
|
|
1165
|
+
profiles: events.map((e) => ({
|
|
1166
|
+
profile: e.profile,
|
|
1167
|
+
sessions: e.sessions,
|
|
1168
|
+
lastUsed: e.last_used,
|
|
1169
|
+
})),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export async function handleTelemetryTimeline(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
1174
|
+
if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
|
|
1175
|
+
return { ok: true, data: buildTimeline(parseSinceDays(params.get("since"))) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// SSE response headers — no caching, and X-Accel-Buffering: no so any reverse
|
|
1179
|
+
// proxy (or vite's dev proxy) forwards each event instead of buffering it.
|
|
1180
|
+
const SSE_HEADERS = {
|
|
1181
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1182
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1183
|
+
"Connection": "keep-alive",
|
|
1184
|
+
"X-Accel-Buffering": "no",
|
|
1185
|
+
} as const;
|
|
1186
|
+
const STREAM_TICK_MS = 3000;
|
|
1187
|
+
const STREAM_HEARTBEAT_MS = 20000;
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Live activity stream (Server-Sent Events). Sends the timeline payload on
|
|
1191
|
+
* connect, then re-sends it whenever it changes (recompute + diff every few
|
|
1192
|
+
* seconds; identical payloads are suppressed). A periodic comment heartbeat
|
|
1193
|
+
* keeps the connection from going idle. One-way + read-only; the browser's
|
|
1194
|
+
* EventSource handles reconnection. Returns a streaming Response —
|
|
1195
|
+
* `writeWebResponse` pipes text/event-stream bodies instead of buffering them.
|
|
1196
|
+
*/
|
|
1197
|
+
export function handleTelemetryStream(params: URLSearchParams): Response {
|
|
1198
|
+
const sinceDays = parseSinceDays(params.get("since"));
|
|
1199
|
+
const enabled = telemetryEnabled();
|
|
1200
|
+
const enc = new TextEncoder();
|
|
1201
|
+
let tick: ReturnType<typeof setInterval> | null = null;
|
|
1202
|
+
let beat: ReturnType<typeof setInterval> | null = null;
|
|
1203
|
+
let lastSent = "";
|
|
1204
|
+
|
|
1205
|
+
const stop = () => {
|
|
1206
|
+
if (tick) { clearInterval(tick); tick = null; }
|
|
1207
|
+
if (beat) { clearInterval(beat); beat = null; }
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1211
|
+
start(controller) {
|
|
1212
|
+
const emit = (frame: string) => {
|
|
1213
|
+
try { controller.enqueue(enc.encode(frame)); } catch { stop(); }
|
|
1214
|
+
};
|
|
1215
|
+
if (!enabled) {
|
|
1216
|
+
// Keep the connection open (heartbeat only) so EventSource doesn't
|
|
1217
|
+
// reconnect-spam; the polling GET surfaces "telemetry-disabled" itself.
|
|
1218
|
+
emit("event: error\ndata: telemetry-disabled\n\n");
|
|
1219
|
+
} else {
|
|
1220
|
+
const send = () => {
|
|
1221
|
+
let json: string;
|
|
1222
|
+
try { json = JSON.stringify(buildTimeline(sinceDays)); } catch { return; }
|
|
1223
|
+
if (json === lastSent) return; // suppress unchanged payloads
|
|
1224
|
+
lastSent = json;
|
|
1225
|
+
emit(`event: timeline\ndata: ${json}\n\n`);
|
|
1226
|
+
};
|
|
1227
|
+
send(); // initial snapshot, immediately
|
|
1228
|
+
tick = setInterval(send, STREAM_TICK_MS);
|
|
1229
|
+
}
|
|
1230
|
+
beat = setInterval(() => emit(": ping\n\n"), STREAM_HEARTBEAT_MS);
|
|
401
1231
|
},
|
|
1232
|
+
cancel() { stop(); },
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Every Claude Code plugin installed on this machine (enabled or not), read
|
|
1240
|
+
* from Claude Code's real store — a superset of the active profile's declared
|
|
1241
|
+
* `plugins:`. Powers the studio Plugins page's auto-discovery view.
|
|
1242
|
+
*/
|
|
1243
|
+
export async function handleDiscoveredPlugins(): Promise<ApiResult<unknown>> {
|
|
1244
|
+
return { ok: true, data: { plugins: discoverInstalledPlugins() } };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ── version / update banner ────────────────────────────────────────────────
|
|
1248
|
+
// The studio's "update available" pill + maintainer broadcast. This is the one
|
|
1249
|
+
// outbound call the dashboard makes — a GET of cue-ai's public version doc on
|
|
1250
|
+
// the npm registry. No user data leaves the box; it's cached ~1h and fail-soft
|
|
1251
|
+
// (any error → no banner), mirroring the CLI's existing 24h update check.
|
|
1252
|
+
|
|
1253
|
+
/** Maintainer-authored broadcast, baked into the published `package.json`. */
|
|
1254
|
+
interface NoticePayload { message?: string; command?: string }
|
|
1255
|
+
interface VersionInfo {
|
|
1256
|
+
current: string;
|
|
1257
|
+
latest: string | null;
|
|
1258
|
+
updateAvailable: boolean;
|
|
1259
|
+
notice: NoticePayload | null;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const NPM_LATEST_URL = "https://registry.npmjs.org/cue-ai/latest";
|
|
1263
|
+
const VERSION_TTL_MS = 60 * 60 * 1000; // 1h — matches the answer's "cached ~1h".
|
|
1264
|
+
let versionCache: { ts: number; data: VersionInfo } | null = null;
|
|
1265
|
+
|
|
1266
|
+
/** Installed cue-ai version, read from this package's own package.json. */
|
|
1267
|
+
function localVersion(): string {
|
|
1268
|
+
try {
|
|
1269
|
+
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as { version?: string };
|
|
1270
|
+
return pkg.version ?? "0.0.0";
|
|
1271
|
+
} catch {
|
|
1272
|
+
return "0.0.0";
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** True when semver `a` is strictly newer than `b` (major.minor.patch only). */
|
|
1277
|
+
export function semverGt(a: string, b: string): boolean {
|
|
1278
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1279
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1280
|
+
for (let i = 0; i < 3; i++) {
|
|
1281
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
1282
|
+
if (x !== y) return x > y;
|
|
1283
|
+
}
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Pure shaping of the version banner from the local version + the registry's
|
|
1289
|
+
* `latest` doc (its published package.json). Split out so it's unit-testable
|
|
1290
|
+
* without a network round-trip. `doc` is null when the fetch failed.
|
|
1291
|
+
*/
|
|
1292
|
+
export function computeVersionInfo(
|
|
1293
|
+
current: string,
|
|
1294
|
+
doc: { version?: string; cue?: { notice?: NoticePayload } } | null,
|
|
1295
|
+
): VersionInfo {
|
|
1296
|
+
const latest = doc?.version ?? null;
|
|
1297
|
+
const n = doc?.cue?.notice;
|
|
1298
|
+
const notice = n && (n.message || n.command) ? { message: n.message, command: n.command } : null;
|
|
1299
|
+
return { current, latest, updateAvailable: !!latest && semverGt(latest, current), notice };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
export async function handleVersion(): Promise<ApiResult<unknown>> {
|
|
1303
|
+
const current = localVersion();
|
|
1304
|
+
// Serve cached registry data but always refresh `current` from disk (cheap,
|
|
1305
|
+
// and stays correct across an in-place update without a server restart).
|
|
1306
|
+
if (versionCache && Date.now() - versionCache.ts < VERSION_TTL_MS) {
|
|
1307
|
+
return { ok: true, data: computeVersionInfo(current, { version: versionCache.data.latest ?? undefined, cue: versionCache.data.notice ? { notice: versionCache.data.notice } : undefined }) };
|
|
1308
|
+
}
|
|
1309
|
+
let doc: { version?: string; cue?: { notice?: NoticePayload } } | null = null;
|
|
1310
|
+
try {
|
|
1311
|
+
const res = await fetch(NPM_LATEST_URL, { signal: AbortSignal.timeout(3000) });
|
|
1312
|
+
if (res.ok) doc = (await res.json()) as typeof doc;
|
|
1313
|
+
} catch {
|
|
1314
|
+
// Offline / timeout / blocked → fail-soft: no banner this cycle.
|
|
1315
|
+
}
|
|
1316
|
+
const data = computeVersionInfo(current, doc);
|
|
1317
|
+
versionCache = { ts: Date.now(), data };
|
|
1318
|
+
return { ok: true, data };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ── marketplace ─────────────────────────────────────────────────────────────
|
|
1322
|
+
// One unified feed of everything addable to a profile: shared hosted-registry
|
|
1323
|
+
// skills/mcps plus this checkout's local library (profiles, mcp catalog, CLIs,
|
|
1324
|
+
// playbooks, plugins). Normalized into a single MarketItem shape the studio's
|
|
1325
|
+
// Market page renders as one searchable grid. Read-only; cached 60s like the
|
|
1326
|
+
// profile-detail cache since it scans the registry + every playbook + plugins.
|
|
1327
|
+
|
|
1328
|
+
interface MarketItem {
|
|
1329
|
+
id: string;
|
|
1330
|
+
type: "profile" | "workflow" | "skill" | "cli" | "mcp" | "plugin";
|
|
1331
|
+
name: string;
|
|
1332
|
+
author: string;
|
|
1333
|
+
handle: string;
|
|
1334
|
+
stars: number;
|
|
1335
|
+
installs: string;
|
|
1336
|
+
when: string;
|
|
1337
|
+
featured: boolean;
|
|
1338
|
+
desc: string;
|
|
1339
|
+
tags: string[];
|
|
1340
|
+
source: "registry" | "local";
|
|
1341
|
+
add: string;
|
|
1342
|
+
addKind: "mcp" | "skill" | "profile" | "cli" | "workflow" | "plugin";
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
interface MarketRegistrySkill {
|
|
1346
|
+
id?: string; name?: string; description?: string;
|
|
1347
|
+
repo?: string; tags?: string[]; stars?: number; installs?: string; featured?: boolean;
|
|
1348
|
+
}
|
|
1349
|
+
interface MarketRegistryMcp {
|
|
1350
|
+
id?: string; name?: string; description?: string;
|
|
1351
|
+
repo?: string; tags?: string[]; stars?: number; installs?: string; featured?: boolean;
|
|
1352
|
+
}
|
|
1353
|
+
interface MarketRegistry {
|
|
1354
|
+
skills?: MarketRegistrySkill[];
|
|
1355
|
+
mcps?: MarketRegistryMcp[];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Path to the shared registry doc in this checkout (may not exist). */
|
|
1359
|
+
function registryIndexPath(): string {
|
|
1360
|
+
return join(REPO_ROOT, "docs", "registry", "index.json");
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const REGISTRY_URL = "https://opencue.github.io/cue/registry/index.json";
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Load the shared registry — local doc first, then a short-timeout curl fetch,
|
|
1367
|
+
* mirroring `loadRegistry` in src/commands/marketplace.ts. Any failure is
|
|
1368
|
+
* tolerated (returns null) so the local-library part of the feed still renders
|
|
1369
|
+
* offline.
|
|
1370
|
+
*/
|
|
1371
|
+
function loadMarketRegistry(): MarketRegistry | null {
|
|
1372
|
+
const path = registryIndexPath();
|
|
1373
|
+
if (existsSync(path)) {
|
|
1374
|
+
try { return JSON.parse(readFileSync(path, "utf8")) as MarketRegistry; } catch { /* fall through */ }
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
const res = spawnSync("curl", ["-sfL", "--max-time", "5", REGISTRY_URL], { encoding: "utf8", timeout: 8000 });
|
|
1378
|
+
if (res.status === 0 && res.stdout) return JSON.parse(res.stdout) as MarketRegistry;
|
|
1379
|
+
} catch { /* offline / blocked */ }
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** "owner/name" → display author. "" when the repo field is absent. */
|
|
1384
|
+
function authorFromRepo(repo: string | undefined): string {
|
|
1385
|
+
if (!repo) return "";
|
|
1386
|
+
return repo.split("/")[0]!.trim();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** Registry skills + mcps → MarketItem[] (source:"registry"). */
|
|
1390
|
+
function registryItems(reg: MarketRegistry | null): MarketItem[] {
|
|
1391
|
+
if (!reg) return [];
|
|
1392
|
+
const items: MarketItem[] = [];
|
|
1393
|
+
for (const s of reg.skills ?? []) {
|
|
1394
|
+
if (!s.id || !s.repo) continue;
|
|
1395
|
+
const handle = authorFromRepo(s.repo);
|
|
1396
|
+
items.push({
|
|
1397
|
+
id: `skill:${s.id}`,
|
|
1398
|
+
type: "skill",
|
|
1399
|
+
name: s.name || s.id,
|
|
1400
|
+
author: handle || "cue",
|
|
1401
|
+
handle: handle || "cue",
|
|
1402
|
+
stars: typeof s.stars === "number" ? s.stars : 0,
|
|
1403
|
+
installs: s.installs ?? "",
|
|
1404
|
+
when: "",
|
|
1405
|
+
featured: s.featured === true,
|
|
1406
|
+
desc: s.description ?? "",
|
|
1407
|
+
tags: Array.isArray(s.tags) ? s.tags : [],
|
|
1408
|
+
source: "registry",
|
|
1409
|
+
add: `cue marketplace install-skill ${s.repo}`,
|
|
1410
|
+
addKind: "skill",
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
for (const m of reg.mcps ?? []) {
|
|
1414
|
+
if (!m.id) continue;
|
|
1415
|
+
const handle = authorFromRepo(m.repo);
|
|
1416
|
+
items.push({
|
|
1417
|
+
id: `mcp:${m.id}`,
|
|
1418
|
+
type: "mcp",
|
|
1419
|
+
name: m.name || m.id,
|
|
1420
|
+
author: handle || "cue",
|
|
1421
|
+
handle: handle || "cue",
|
|
1422
|
+
stars: typeof m.stars === "number" ? m.stars : 0,
|
|
1423
|
+
installs: m.installs ?? "",
|
|
1424
|
+
when: "",
|
|
1425
|
+
featured: m.featured === true,
|
|
1426
|
+
desc: m.description ?? "",
|
|
1427
|
+
tags: Array.isArray(m.tags) ? m.tags : [],
|
|
1428
|
+
source: "registry",
|
|
1429
|
+
add: `cue marketplace install-mcp ${m.id}`,
|
|
1430
|
+
addKind: "mcp",
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
return items;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Every parseable file in resources/playbooks → workflow MarketItem[]. */
|
|
1437
|
+
function playbookMarketItems(): MarketItem[] {
|
|
1438
|
+
const dir = playbooksDir();
|
|
1439
|
+
let files: string[];
|
|
1440
|
+
try {
|
|
1441
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
1442
|
+
} catch {
|
|
1443
|
+
return [];
|
|
1444
|
+
}
|
|
1445
|
+
const items: MarketItem[] = [];
|
|
1446
|
+
for (const file of files) {
|
|
1447
|
+
const slug = file.replace(/\.md$/, "");
|
|
1448
|
+
if (!/^[A-Za-z0-9._-]+$/.test(slug)) continue;
|
|
1449
|
+
try {
|
|
1450
|
+
const content = readFileSync(join(dir, file), "utf8");
|
|
1451
|
+
const pb = parsePlaybook(slug, content);
|
|
1452
|
+
items.push({
|
|
1453
|
+
id: `workflow:${slug}`,
|
|
1454
|
+
type: "workflow",
|
|
1455
|
+
name: pb.title,
|
|
1456
|
+
author: "cue",
|
|
1457
|
+
handle: "cue",
|
|
1458
|
+
stars: 0,
|
|
1459
|
+
installs: "",
|
|
1460
|
+
when: "",
|
|
1461
|
+
featured: false,
|
|
1462
|
+
desc: pb.desc,
|
|
1463
|
+
tags: [],
|
|
1464
|
+
source: "local",
|
|
1465
|
+
add: "(playbook)",
|
|
1466
|
+
addKind: "workflow",
|
|
1467
|
+
});
|
|
1468
|
+
} catch { /* skip unreadable */ }
|
|
1469
|
+
}
|
|
1470
|
+
return items;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Local profiles → profile MarketItem[]. */
|
|
1474
|
+
async function profileMarketItems(): Promise<MarketItem[]> {
|
|
1475
|
+
const names = await listProfiles();
|
|
1476
|
+
const items: MarketItem[] = [];
|
|
1477
|
+
for (const name of names) {
|
|
1478
|
+
let desc = "";
|
|
1479
|
+
let tags: string[] = [];
|
|
1480
|
+
try {
|
|
1481
|
+
const p = await loadProfile(name);
|
|
1482
|
+
desc = p.description ?? "";
|
|
1483
|
+
tags = p.bundles ?? [];
|
|
1484
|
+
} catch { /* keep a bare row */ }
|
|
1485
|
+
items.push({
|
|
1486
|
+
id: `profile:${name}`,
|
|
1487
|
+
type: "profile",
|
|
1488
|
+
name,
|
|
1489
|
+
author: "cue",
|
|
1490
|
+
handle: "cue",
|
|
1491
|
+
stars: 0,
|
|
1492
|
+
installs: "",
|
|
1493
|
+
when: "",
|
|
1494
|
+
featured: false,
|
|
1495
|
+
desc,
|
|
1496
|
+
tags,
|
|
1497
|
+
source: "local",
|
|
1498
|
+
add: `cue use ${name}`,
|
|
1499
|
+
addKind: "profile",
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
return items;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Local MCP catalog → mcp MarketItem[], minus ids already in `registryMcpIds`. */
|
|
1506
|
+
function localMcpMarketItems(registryMcpIds: Set<string>): MarketItem[] {
|
|
1507
|
+
return loadMcpCatalog()
|
|
1508
|
+
.filter((e) => !registryMcpIds.has(e.id))
|
|
1509
|
+
.map((e) => ({
|
|
1510
|
+
id: `mcp:${e.id}`,
|
|
1511
|
+
type: "mcp" as const,
|
|
1512
|
+
name: e.id,
|
|
1513
|
+
author: "cue",
|
|
1514
|
+
handle: "cue",
|
|
1515
|
+
stars: 0,
|
|
1516
|
+
installs: "",
|
|
1517
|
+
when: "",
|
|
1518
|
+
featured: false,
|
|
1519
|
+
desc: e.description ?? "",
|
|
1520
|
+
tags: [],
|
|
1521
|
+
source: "local" as const,
|
|
1522
|
+
add: `cue marketplace install-mcp ${e.id}`,
|
|
1523
|
+
addKind: "mcp" as const,
|
|
1524
|
+
}));
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** resources/cli-recipes.json → cli MarketItem[]. */
|
|
1528
|
+
function cliMarketItems(): MarketItem[] {
|
|
1529
|
+
let recipes: Record<string, unknown>;
|
|
1530
|
+
try {
|
|
1531
|
+
recipes = JSON.parse(readFileSync(join(REPO_ROOT, "resources", "cli-recipes.json"), "utf8")) as Record<string, unknown>;
|
|
1532
|
+
} catch {
|
|
1533
|
+
return [];
|
|
1534
|
+
}
|
|
1535
|
+
const items: MarketItem[] = [];
|
|
1536
|
+
for (const [name, recipe] of Object.entries(recipes)) {
|
|
1537
|
+
// Skip the schema-doc key and any non-object entries.
|
|
1538
|
+
if (name.startsWith("$") || typeof recipe !== "object" || recipe === null) continue;
|
|
1539
|
+
const r = recipe as { needs?: unknown; apt?: unknown; brew?: unknown };
|
|
1540
|
+
const desc = typeof r.needs === "string" ? r.needs : "";
|
|
1541
|
+
items.push({
|
|
1542
|
+
id: `cli:${name}`,
|
|
1543
|
+
type: "cli",
|
|
1544
|
+
name,
|
|
1545
|
+
author: "cue",
|
|
1546
|
+
handle: "cue",
|
|
1547
|
+
stars: 0,
|
|
1548
|
+
installs: "",
|
|
1549
|
+
when: "",
|
|
1550
|
+
featured: false,
|
|
1551
|
+
desc,
|
|
1552
|
+
tags: [],
|
|
1553
|
+
source: "local",
|
|
1554
|
+
add: "(see recipe)",
|
|
1555
|
+
addKind: "cli",
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
return items;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** Installed Claude Code plugins → plugin MarketItem[]. */
|
|
1562
|
+
function pluginMarketItems(): MarketItem[] {
|
|
1563
|
+
return discoverInstalledPlugins().map((p) => ({
|
|
1564
|
+
id: `plugin:${p.id}`,
|
|
1565
|
+
type: "plugin" as const,
|
|
1566
|
+
name: p.name,
|
|
1567
|
+
author: "cue",
|
|
1568
|
+
handle: "cue",
|
|
1569
|
+
stars: 0,
|
|
1570
|
+
installs: "",
|
|
1571
|
+
when: relativeAge(p.installedAt),
|
|
1572
|
+
featured: false,
|
|
1573
|
+
desc: p.description ?? "",
|
|
1574
|
+
tags: [],
|
|
1575
|
+
source: "local" as const,
|
|
1576
|
+
add: `cue marketplace install-plugin ${p.id}`,
|
|
1577
|
+
addKind: "plugin" as const,
|
|
1578
|
+
}));
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** ISO timestamp → compact relative age ("2d","1w","now"); "" when absent. */
|
|
1582
|
+
function relativeAge(iso: string | null): string {
|
|
1583
|
+
if (!iso) return "";
|
|
1584
|
+
const then = Date.parse(iso);
|
|
1585
|
+
if (Number.isNaN(then)) return "";
|
|
1586
|
+
const secs = Math.max(0, (Date.now() - then) / 1000);
|
|
1587
|
+
if (secs < 60) return "now";
|
|
1588
|
+
const mins = Math.floor(secs / 60);
|
|
1589
|
+
if (mins < 60) return `${mins}m`;
|
|
1590
|
+
const hours = Math.floor(mins / 60);
|
|
1591
|
+
if (hours < 24) return `${hours}h`;
|
|
1592
|
+
const days = Math.floor(hours / 24);
|
|
1593
|
+
if (days < 7) return `${days}d`;
|
|
1594
|
+
const weeks = Math.floor(days / 7);
|
|
1595
|
+
if (weeks < 5) return `${weeks}w`;
|
|
1596
|
+
const months = Math.floor(days / 30);
|
|
1597
|
+
if (months < 12) return `${months}mo`;
|
|
1598
|
+
return `${Math.floor(days / 365)}y`;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
interface MarketData { items: MarketItem[]; counts: Record<string, number> }
|
|
1602
|
+
let marketCache: { ts: number; data: MarketData } | null = null;
|
|
1603
|
+
const MARKET_TTL_MS = 60_000;
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Unified marketplace feed: shared-registry skills/mcps + the local library
|
|
1607
|
+
* (profiles, mcp catalog, CLIs, playbooks, plugins), normalized into one
|
|
1608
|
+
* MarketItem[] with per-type counts. Registry fetch failures degrade to the
|
|
1609
|
+
* local-only feed. Cached 60s. featured = any registry item flagged featured,
|
|
1610
|
+
* else the top 3 items by stars.
|
|
1611
|
+
*/
|
|
1612
|
+
export async function handleMarket(): Promise<ApiResult<unknown>> {
|
|
1613
|
+
if (marketCache && Date.now() - marketCache.ts < MARKET_TTL_MS) {
|
|
1614
|
+
return { ok: true, data: marketCache.data };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const reg = loadMarketRegistry();
|
|
1618
|
+
const regItems = registryItems(reg);
|
|
1619
|
+
const registryMcpIds = new Set(
|
|
1620
|
+
regItems.filter((i) => i.type === "mcp").map((i) => i.id.slice("mcp:".length)),
|
|
1621
|
+
);
|
|
1622
|
+
|
|
1623
|
+
const items: MarketItem[] = [
|
|
1624
|
+
...regItems,
|
|
1625
|
+
...(await profileMarketItems()),
|
|
1626
|
+
...localMcpMarketItems(registryMcpIds),
|
|
1627
|
+
...cliMarketItems(),
|
|
1628
|
+
...playbookMarketItems(),
|
|
1629
|
+
...pluginMarketItems(),
|
|
1630
|
+
];
|
|
1631
|
+
|
|
1632
|
+
// Featured: honor any registry-flagged item; otherwise spotlight the top 3
|
|
1633
|
+
// by stars (only registry items carry stars, so this picks the most-starred
|
|
1634
|
+
// registry entries when nothing is explicitly flagged).
|
|
1635
|
+
const anyFlagged = items.some((i) => i.featured);
|
|
1636
|
+
if (!anyFlagged) {
|
|
1637
|
+
const top3 = [...items].sort((a, b) => b.stars - a.stars).slice(0, 3);
|
|
1638
|
+
const top3Ids = new Set(top3.map((i) => i.id));
|
|
1639
|
+
for (const i of items) i.featured = top3Ids.has(i.id) && i.stars > 0;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const counts: Record<string, number> = {
|
|
1643
|
+
all: items.length,
|
|
1644
|
+
profile: 0, workflow: 0, skill: 0, cli: 0, mcp: 0, plugin: 0,
|
|
402
1645
|
};
|
|
1646
|
+
for (const i of items) counts[i.type] = (counts[i.type] ?? 0) + 1;
|
|
1647
|
+
|
|
1648
|
+
const data: MarketData = { items, counts };
|
|
1649
|
+
marketCache = { ts: Date.now(), data };
|
|
1650
|
+
return { ok: true, data };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// ── Workflows: the n8n-style canvas's saved DAGs (resources/workflows/*.json) ──
|
|
1654
|
+
export async function handleWorkflows(): Promise<ApiResult<unknown>> {
|
|
1655
|
+
return { ok: true, data: listWorkflows() };
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
export async function handleWorkflow(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
1659
|
+
const name = params.get("name");
|
|
1660
|
+
if (!name) return { ok: false, error: "missing-name" };
|
|
1661
|
+
try {
|
|
1662
|
+
const wf = loadWorkflow(name);
|
|
1663
|
+
return wf ? { ok: true, data: wf } : { ok: false, error: "not-found" };
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
return { ok: false, error: (err as Error).message };
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
export async function handleWorkflowSave(body: unknown): Promise<ApiResult<unknown>> {
|
|
1670
|
+
try {
|
|
1671
|
+
return { ok: true, data: saveWorkflow(body, new Date().toISOString()) };
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
return { ok: false, error: (err as Error).message };
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// ════════ ENV VARIABLES ════════
|
|
1678
|
+
// Per-folder .env viewer. Reads real `<folder>/.env` from an allowlist of
|
|
1679
|
+
// project folders, parses KEY=value, classifies each var, and MASKS secret
|
|
1680
|
+
// values server-side — a raw secret value is returned only when the request
|
|
1681
|
+
// names that key in `reveal`. Loopback-only trust boundary (see binding note).
|
|
1682
|
+
|
|
1683
|
+
/** Project folders whose `.env` the studio may read. `~` expands to $HOME.
|
|
1684
|
+
* Mirrors the design's folder list; the canonical home for the user's repos. */
|
|
1685
|
+
const ENV_FOLDER_DEFS: ReadonlyArray<{ path: string; tag: string }> = [
|
|
1686
|
+
{ path: "~/Documents/cue", tag: "cue" },
|
|
1687
|
+
{ path: "~/Documents/recodee/authmux", tag: "authmux" },
|
|
1688
|
+
{ path: "~/Documents/x-growth-bot", tag: "x-growth-bot" },
|
|
1689
|
+
{ path: "~/work/medusa", tag: "medusa" },
|
|
1690
|
+
{ path: "~/Documents/medusa-shops/marva", tag: "marva" },
|
|
1691
|
+
{ path: "~/Documents/gitguardex", tag: "gitguardex" },
|
|
1692
|
+
];
|
|
1693
|
+
|
|
1694
|
+
const expandHome = (p: string): string => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
|
|
1695
|
+
|
|
1696
|
+
const ENV_SECRET_RE = /(SECRET|_KEY|TOKEN|PASSWORD|PASSWD|_PASS|CREDENTIAL|DATABASE_URL|_DSN|PRIVATE)/i;
|
|
1697
|
+
type EnvKind = "secret" | "url" | "bool" | "num" | "plain";
|
|
1698
|
+
function classifyEnvVar(key: string, val: string): EnvKind {
|
|
1699
|
+
if (ENV_SECRET_RE.test(key)) return "secret";
|
|
1700
|
+
if (/^https?:\/\//.test(val)) return "url";
|
|
1701
|
+
if (/^(true|false)$/i.test(val)) return "bool";
|
|
1702
|
+
if (/^\d+$/.test(val)) return "num";
|
|
1703
|
+
return "plain";
|
|
1704
|
+
}
|
|
1705
|
+
interface EnvVarRow { key: string; value: string; kind: EnvKind; masked: boolean }
|
|
1706
|
+
/** Mask all but the first/last 3 chars (mirrors the design's mask()). */
|
|
1707
|
+
function maskSecretValue(v: string): string {
|
|
1708
|
+
return v.length <= 8 ? "•".repeat(v.length) : v.slice(0, 3) + "•".repeat(Math.min(18, v.length - 6)) + v.slice(-3);
|
|
1709
|
+
}
|
|
1710
|
+
function parseEnvText(raw: string, revealKey: string): EnvVarRow[] {
|
|
1711
|
+
const out: EnvVarRow[] = [];
|
|
1712
|
+
for (const line of raw.split("\n")) {
|
|
1713
|
+
const t = line.trim();
|
|
1714
|
+
if (!t || t.startsWith("#")) continue;
|
|
1715
|
+
const eq = line.indexOf("=");
|
|
1716
|
+
if (eq < 0) continue;
|
|
1717
|
+
const key = line.slice(0, eq).trim();
|
|
1718
|
+
let value = line.slice(eq + 1).trim();
|
|
1719
|
+
// Strip a single layer of surrounding quotes, like dotenv does.
|
|
1720
|
+
if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
|
|
1721
|
+
value = value.slice(1, -1);
|
|
1722
|
+
}
|
|
1723
|
+
const kind = classifyEnvVar(key, value);
|
|
1724
|
+
const isSecret = kind === "secret";
|
|
1725
|
+
// `reveal` names a single key, or "*" to reveal every secret at once.
|
|
1726
|
+
const reveal = isSecret && (revealKey === "*" || revealKey === key);
|
|
1727
|
+
out.push({ key, value: isSecret && !reveal ? maskSecretValue(value) : value, kind, masked: isSecret && !reveal });
|
|
1728
|
+
}
|
|
1729
|
+
return out;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/** GET /api/v1/env/folders → the allowlisted project folders + their var counts. */
|
|
1733
|
+
export async function handleEnvFolders(): Promise<ApiResult<unknown>> {
|
|
1734
|
+
const folders = ENV_FOLDER_DEFS.map((f) => {
|
|
1735
|
+
const abs = join(expandHome(f.path), ".env");
|
|
1736
|
+
let count = 0;
|
|
1737
|
+
try { if (existsSync(abs)) count = parseEnvText(readFileSync(abs, "utf8"), "").length; } catch { /* unreadable → 0 */ }
|
|
1738
|
+
return { path: f.path, tag: f.tag, count };
|
|
1739
|
+
});
|
|
1740
|
+
return { ok: true, data: { folders } };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** GET /api/v1/env?folder=<path>[&reveal=<KEY>] → parsed, masked vars for one
|
|
1744
|
+
* allowlisted folder. `reveal` returns the raw value for that single key. */
|
|
1745
|
+
export async function handleEnv(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
1746
|
+
const folder = params.get("folder") ?? "";
|
|
1747
|
+
const reveal = params.get("reveal") ?? "";
|
|
1748
|
+
// Allowlist: the requested folder must be one we offer — no arbitrary paths.
|
|
1749
|
+
const def = ENV_FOLDER_DEFS.find((f) => f.path === folder);
|
|
1750
|
+
if (!def) return { ok: false, error: "unknown-folder" };
|
|
1751
|
+
const abs = join(expandHome(def.path), ".env");
|
|
1752
|
+
if (!existsSync(abs)) return { ok: true, data: { folder: def.path, tag: def.tag, exists: false, vars: [] } };
|
|
1753
|
+
let raw = "";
|
|
1754
|
+
try { raw = readFileSync(abs, "utf8"); } catch (err) { return { ok: false, error: `read failed: ${(err as Error).message}` }; }
|
|
1755
|
+
return { ok: true, data: { folder: def.path, tag: def.tag, exists: true, vars: parseEnvText(raw, reveal) } };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* GitHub source repos a profile's skills / MCPs / plugins originate from, with
|
|
1760
|
+
* live star counts. Derives the profile's namespace / MCP / plugin sets from
|
|
1761
|
+
* the resolved profile, filters the curated catalog to what it actually
|
|
1762
|
+
* contains, then resolves each repo's stargazers (cached 6h, fail-soft).
|
|
1763
|
+
*/
|
|
1764
|
+
export async function handleRepos(params: URLSearchParams): Promise<ApiResult<unknown>> {
|
|
1765
|
+
let name = resolveProfileQuery(params.get("profile"));
|
|
1766
|
+
if (!name) {
|
|
1767
|
+
const resolved = await resolveProfileForCwd({
|
|
1768
|
+
cwd: process.cwd(),
|
|
1769
|
+
homeDir: homedir(),
|
|
1770
|
+
configDir: configDir(),
|
|
1771
|
+
});
|
|
1772
|
+
if (resolved.source !== "none") name = (resolved as { profile: string }).profile;
|
|
1773
|
+
}
|
|
1774
|
+
if (!name) return { ok: false, error: "no-profile" };
|
|
1775
|
+
|
|
1776
|
+
let profile;
|
|
1777
|
+
try {
|
|
1778
|
+
profile = await loadProfile(name);
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
return { ok: false, error: (err as Error).message };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const namespaces = new Set<string>();
|
|
1784
|
+
for (const s of profile.skills.local) {
|
|
1785
|
+
namespaces.add(s.id.includes("/") ? s.id.split("/")[0]! : "skills");
|
|
1786
|
+
}
|
|
1787
|
+
if (profile.skills.npx.length) namespaces.add("npx");
|
|
1788
|
+
const mcpIds = profile.mcps.map((m) => m.id);
|
|
1789
|
+
const pluginIds = profile.plugins.map((p) => p.id);
|
|
1790
|
+
|
|
1791
|
+
const matched = reposForProfile({ namespaces, mcpIds, pluginIds });
|
|
1792
|
+
const repos = await resolveRepoStars(matched, Date.now());
|
|
1793
|
+
return { ok: true, data: { profile: name, repos } };
|
|
403
1794
|
}
|
|
404
1795
|
|
|
405
1796
|
const ROUTES: Record<string, (params: URLSearchParams) => Promise<ApiResult<unknown>>> = {
|
|
406
1797
|
"/api/v1/status": () => handleStatus(),
|
|
1798
|
+
"/api/v1/env/folders": () => handleEnvFolders(),
|
|
1799
|
+
"/api/v1/env": (p) => handleEnv(p),
|
|
1800
|
+
"/api/v1/plugins/discovered": () => handleDiscoveredPlugins(),
|
|
1801
|
+
"/api/v1/workflows": () => handleWorkflows(),
|
|
1802
|
+
"/api/v1/workflow": (p) => handleWorkflow(p),
|
|
407
1803
|
"/api/v1/profiles": () => handleProfiles(),
|
|
408
1804
|
"/api/v1/profiles/full": () => handleProfilesFull(),
|
|
1805
|
+
"/api/v1/profile-detail": (p) => handleProfileDetail(p),
|
|
1806
|
+
"/api/v1/hooks": (p) => handleHooks(p),
|
|
1807
|
+
"/api/v1/hook-source": (p) => handleHookSource(p),
|
|
409
1808
|
"/api/v1/skill-report": (p) => handleSkillReport(p),
|
|
410
1809
|
"/api/v1/pairs": (p) => handlePairs(p),
|
|
411
1810
|
"/api/v1/gates": (p) => handleGates(p),
|
|
412
1811
|
"/api/v1/trigger-gaps": (p) => handleTriggerGaps(p),
|
|
413
1812
|
"/api/v1/active-sessions": () => handleActiveSessions(),
|
|
414
1813
|
"/api/v1/telemetry/timeline": (p) => handleTelemetryTimeline(p),
|
|
1814
|
+
"/api/v1/mcps/catalog": () => handleMcpCatalog(),
|
|
1815
|
+
"/api/v1/market": () => handleMarket(),
|
|
1816
|
+
"/api/v1/version": () => handleVersion(),
|
|
1817
|
+
"/api/v1/permissions": () => Promise.resolve({ ok: true, data: collectPermissions() }),
|
|
1818
|
+
"/api/v1/repos": (p) => handleRepos(p),
|
|
415
1819
|
};
|
|
416
1820
|
|
|
417
1821
|
function contentTypeFor(path: string): string {
|
|
@@ -457,7 +1861,52 @@ export function createHandler(): (req: Request) => Promise<Response> {
|
|
|
457
1861
|
return Response.json(result, { status: result.ok ? 200 : 400 });
|
|
458
1862
|
}
|
|
459
1863
|
|
|
1864
|
+
if (req.method === "POST" && url.pathname === "/api/v1/mcps/add") {
|
|
1865
|
+
let body: unknown = null;
|
|
1866
|
+
try { body = await req.json(); } catch { /* malformed */ }
|
|
1867
|
+
const result = await handleMcpAdd(body as Parameters<typeof handleMcpAdd>[0]);
|
|
1868
|
+
return Response.json(result, { status: result.ok ? 200 : 400 });
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (req.method === "POST" && url.pathname === "/api/v1/workflows/save") {
|
|
1872
|
+
let body: unknown = null;
|
|
1873
|
+
try { body = await req.json(); } catch { /* malformed */ }
|
|
1874
|
+
const result = await handleWorkflowSave(body);
|
|
1875
|
+
return Response.json(result, { status: result.ok ? 200 : 400 });
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Live activity stream (Server-Sent Events, not JSON): pushes the timeline
|
|
1879
|
+
// payload on change so the dashboard chart auto-updates without polling.
|
|
1880
|
+
if (req.method === "GET" && url.pathname === "/api/v1/telemetry/stream") {
|
|
1881
|
+
return handleTelemetryStream(url.searchParams);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Profile logo bytes (not JSON): GET /api/v1/profile-icon?profile=<name>.
|
|
1885
|
+
// Serves profiles/<name>/<iconImage> for profiles that set one.
|
|
1886
|
+
if (req.method === "GET" && url.pathname === "/api/v1/profile-icon") {
|
|
1887
|
+
return serveProfileIcon(url.searchParams.get("profile"));
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Plugin logo bytes (not JSON): GET /api/v1/plugin-icon?plugin=<id>.
|
|
1891
|
+
// Reuses a same-named profile's logo, else a generated PNG.
|
|
1892
|
+
if (req.method === "GET" && url.pathname === "/api/v1/plugin-icon") {
|
|
1893
|
+
return servePluginIcon(url.searchParams.get("plugin"));
|
|
1894
|
+
}
|
|
1895
|
+
|
|
460
1896
|
if (url.pathname.startsWith("/api/v1/")) {
|
|
1897
|
+
// Defense-in-depth for the secret-revealing path: a `reveal` request to
|
|
1898
|
+
// /api/v1/env returns raw .env secrets. Even though the server has no
|
|
1899
|
+
// permissive CORS (so a cross-origin tab can't read the response) and
|
|
1900
|
+
// binds loopback by default, refuse a reveal whose Sec-Fetch-Site marks
|
|
1901
|
+
// it cross-origin — so a drive-by tab can't pull plaintext even if CORS
|
|
1902
|
+
// or the bind host were ever loosened. Same-origin (the studio SPA),
|
|
1903
|
+
// `none` (address-bar), and header-absent (curl / tests) are allowed.
|
|
1904
|
+
if (url.pathname === "/api/v1/env" && url.searchParams.get("reveal")) {
|
|
1905
|
+
const site = req.headers.get("sec-fetch-site");
|
|
1906
|
+
if (site === "cross-site" || site === "same-site") {
|
|
1907
|
+
return Response.json({ ok: false, error: "reveal-cross-origin-blocked" }, { status: 403 });
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
461
1910
|
const handler = ROUTES[url.pathname];
|
|
462
1911
|
if (!handler) {
|
|
463
1912
|
return Response.json({ ok: false, error: "not-found" }, { status: 404 });
|