Skip to content
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ Two separate key systems coexist:
- `context.secrets.onDidChange` listener keeps status bar live without reload
- After key validation in the webview (`serviceId === "recost"`), `recost.keyOnline` context is also updated so the status bar stays in sync

**Project ID (for remote scan submission)** — projects are created **only** in the web dashboard; the extension and CLI never auto-create them. Remote enrichment requires a user-supplied Project ID:
- In the extension: paste the dashboard's Project ID into the Keys tab. It is stored per-workspace in `workspaceState` (`recost.manualProjectId:<workspace-scope>`) and validated against `GET /projects/{id}` (key-fingerprinted validation snapshots).
- `resolveScanProjectTarget()` in `webview-provider.ts` returns `{ projectId, source: "manual" }` when a Project ID is set, otherwise `null`. A `null` target means the scan runs **local-only** and posts a `scanNotification` nudging the user to add a Project ID.
- In the CLI (`src/cli/scan.ts`): supply the Project ID via `--project-id <id>` or the `RECOST_PROJECT_ID` env var (flag wins). Without it, the CLI runs local-only and prints a stderr nudge.
- There is no auto-created `recost.projectId` globalState key and no `createProject`/`findProjectByName` in `api-client.ts` anymore.

**Chat provider keys** (OpenAI, Anthropic, etc.) — managed in `webview-provider.ts` + `chat/provider-registry.ts`:
- Stored per-provider in `context.secrets` under provider-specific keys (e.g., `eco.providerApiKey.openai`)
- Resolved via env var → SecretStorage fallback in `resolveProviderAuth()`
Expand Down
442 changes: 442 additions & 0 deletions docs/superpowers/plans/2026-06-10-dashboard-only-project-id.md

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions docs/superpowers/specs/2026-06-09-dashboard-only-project-id-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Dashboard-only project creation; manual Project ID as the sole remote path

**Issue:** #45 (Extension: Opt-in Project ID Persistence) — reframed.
**Date:** 2026-06-09
**Status:** Approved design, pending implementation plan.

## Background

Issue #45 originally asked for *opt-in* persistence of an auto-created project ID, on
the premise that "the extension creates a new project on every scan." That premise is
already stale: the current code persists an auto-created project ID across scans
(`getOrCreateProject()` in `webview-provider.ts`, stored in `globalState` under
`recost.projectId`), and a full bring-your-own-ID flow already exists (workspace-scoped
manual Project ID in the Keys tab, validated against `GET /projects/{id}`).

Rather than build the obsolete opt-in toggle or a telemetry-handoff panel, the team
decided to **invert the model**:

> Projects are created **only** in the web dashboard. The extension and CLI never create
> projects. The single way to get remote enrichment is to supply a Project ID (obtained
> from the dashboard) — in the extension via the Keys tab, in the CLI via flag/env.

This is mostly a **removal** of code (auto-creation) plus a small rewire, and it makes
the manually-entered Project ID the single source of truth for remote scans.

## Principle

- Project creation happens in the dashboard, never in the extension or CLI.
- Remote enrichment runs only when **both** a ReCost API key **and** a user-supplied
Project ID are present (and the project is valid for that key).
- Any other state — no key, no Project ID, or an invalid Project ID — degrades
gracefully to **local-only** results plus a nudge telling the user how to connect.

## Scope

Four areas change. Persistence storage, validation, and all local analysis are untouched.

### A. Extension scan flow

Files: `src/webview-provider.ts`, `src/webview/scan-publishing-handler.ts`.

- **Delete** `getOrCreateProject()` and its calls to `createProject` / `findProjectByName`.
- **Delete** the 404 → auto-recreate recovery block in `scan-publishing-handler.ts`
(currently `if (status === 404 && projectTarget.source === "auto") { createProject... }`).
A 404 from `submitScan` now means the supplied Project ID is invalid for this key.
- `resolveScanProjectTarget(rcApiKey)` collapses to:
- manual ID present → `{ projectId: <manualId>, source: "manual" }`
- otherwise → `null` (no remote target).
- `handleStartScan`: remote submit runs only when a key is present **and**
`resolveScanProjectTarget` returns non-null. Otherwise call `publishLocalOnlyResults`.
The local-only `projectId` argument becomes `manualProjectId ?? "local"` — the former
`getProjectId() ?? "local"` fallback is removed.
- **Retire** the `recost.projectId` `globalState` key and the `this.projectId` field on
`ReCostSidebarProvider` (no longer written by any flow).

### B. Chat context

Files: `src/webview-provider.ts`, `src/webview/chat-handler.ts` (consumer, unchanged shape).

- The `getProjectId()` callback passed to `ChatHandler` now returns `getManualProjectId()`
instead of the retired `this.projectId`. The chat fallback chain
`lastEndpoints[0]?.projectId ?? providerProjectId ?? "local"` is unchanged.

### C. CLI scan

File: `src/cli/scan.ts`.

- Add `projectId?: string` to `CliOptions`, resolved from a `--project-id` flag (via the
existing `getFlag` helper) **or** the `RECOST_PROJECT_ID` env var. Flag takes precedence
over env.
- **Delete** the `createProject(path.basename(...))` call.
- Remote enrichment runs only when `rcApiKey && projectId && remoteApiCalls.length > 0`.
Absent any of these → local-only (the existing default path; `projectId` stays `"local"`).
- When a key is present but no Project ID is supplied, write a stderr nudge:
`Set RECOST_PROJECT_ID (or --project-id) to sync scans remotely.`
- Update `printHelp()` to document the flag and env var.

### D. Keys tab UX

File: `webview/src/components/KeysPage.tsx`.

- Keep the existing Project ID input as the single remote control. There is no auto-created
ID to surface anymore.
- Add a one-line hint with a dashboard link near the input:
*"Create a project in the dashboard, then paste its ID here to sync scans."* The link
targets the dashboard base URL (`RECOST_DASHBOARD_BASE_URL` via existing config).

### E. API client cleanup

File: `src/api-client.ts`.

- **Delete** `createProject` and `findProjectByName` (no remaining callers after A and C).
- Keep `validateProjectId` (powers Keys-tab validation) and `submitScan` /
`getAllEndpoints` / `getAllSuggestions`.

## No-ID / failure behavior (graceful degradation)

| State | Behavior |
|-------|----------|
| No API key | Local-only + existing "no key" notification. |
| Key, no Project ID | Local-only + nudge: "Add a Project ID from your dashboard in the Keys tab to sync remotely." |
| Key + invalid Project ID (404) | Local-only; keep the saved manual ID; notify "Project ID … was not found." (existing manual-404 message path). |
| Key + valid Project ID | Remote submit to that project (existing path). |

## What stays untouched

- Workspace-scoped manual Project ID storage (`recost.manualProjectId:<scope>`).
- The validate → valid/invalid snapshot flow, key-fingerprinted.
- `submitScan`, `getAllEndpoints`, `getAllSuggestions`, `validateProjectId`.
- All local scanning, waste detection, simulator, and intelligence layers.

## Testing

- `resolveScanProjectTarget`: returns `null` with no manual ID; returns
`{ projectId, source: "manual" }` when a manual ID is set.
- Extension scan, key present, no manual ID → local-only results + nudge notification;
assert neither `createProject` nor `submitScan` is called.
- Extension scan, key + valid manual ID → `submitScan` called with that ID; remote-enriched
results published.
- Extension scan, key + manual ID that 404s → local-only; saved manual ID retained;
not-found notification.
- CLI: `--project-id` and `RECOST_PROJECT_ID` each drive remote submit to that ID; flag
beats env; absent → local-only with no project creation.
- Regression: no remaining references to `getOrCreateProject`, `createProject`,
`findProjectByName`, or `recost.projectId` in the codebase.

## Out of scope

- The opt-in `recost.persistProjectId` setting from the original #45 spec (obsolete under
this model).
- Any telemetry-handoff `.env` snippet panel.
- Dashboard-side project-creation UX (already exists; not part of this repo's change).
18 changes: 0 additions & 18 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,6 @@ async function apiFetchWith<T>(
return res.json() as Promise<T>;
}

export async function createProject(name: string, rcApiKey?: string): Promise<string> {
const { data } = await apiFetch<{ data: { id: string } }>("/projects", {
method: "POST",
body: JSON.stringify({ name }),
}, rcApiKey);
return data.id;
}

export async function findProjectByName(name: string, rcApiKey?: string): Promise<string | null> {
const encoded = encodeURIComponent(name);
const { data } = await apiFetch<{ data: { id: string; name: string }[] }>(
`/projects?name=${encoded}&limit=1`,
undefined,
rcApiKey
);
return data[0]?.id ?? null;
}

export async function validateRcApiKey(rcApiKey: string): Promise<void> {
if (!rcApiKey.startsWith("rc-")) {
const err = new Error("Invalid ReCost API key — keys must start with rc-") as Error & { status: number };
Expand Down
26 changes: 21 additions & 5 deletions src/cli/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as path from "path";
import { newLocalScanId } from "../scan-id";
import { createFilesystemScanAccess } from "./filesystem-adapter";
import { detectLocalWastePatternsInFiles, scanFiles } from "../scanner/core-scanner";
import { createProject, getAllEndpoints, getAllSuggestions, submitScan } from "../api-client";
import { getAllEndpoints, getAllSuggestions, submitScan } from "../api-client";
import { buildLocalScanResults, buildRemoteScanResults, shouldSubmitRemote, type FinalScanResults } from "../scan-results";
import { buildSnapshot } from "../intelligence/builder";
import { scoreSnapshot } from "../intelligence/scorer";
Expand All @@ -14,6 +14,7 @@ import { buildExportContext, formatAsJSON, formatAsMarkdown } from "../intellige
interface CliOptions {
target: string;
format: "json" | "summary" | "context";
projectId?: string;
}

interface CliResult {
Expand Down Expand Up @@ -53,9 +54,13 @@ function getFlag(args: string[], flag: string): string | null {
function printHelp(): void {
process.stdout.write(
[
"Usage: node dist/cli/scan.js <file-or-directory> [--format json|summary|context]",
"Usage: node dist/cli/scan.js <file-or-directory> [--format json|summary|context] [--project-id <id>]",
" node dist/cli/scan.js pack <directory> [--format markdown|json] [--output <file>] [--append-claude-md]",
"",
"Options:",
" --format <fmt> Output format: json (default), summary, context",
" --project-id <id> Dashboard project ID for remote sync (or RECOST_PROJECT_ID env var)",
"",
"Formats:",
" json Full scan results as JSON (default)",
" summary Human-readable summary of endpoints and issues",
Expand Down Expand Up @@ -83,6 +88,7 @@ function parseArgs(argv: string[]): CliOptions | null {
const args = [...argv];
let target = "";
let format: CliOptions["format"] = "json";
let projectId: string | undefined;

while (args.length > 0) {
const arg = args.shift();
Expand All @@ -96,6 +102,12 @@ function parseArgs(argv: string[]): CliOptions | null {
}
throw new Error(`Unsupported format: ${value ?? "(missing value)"}`);
}
if (arg === "--project-id") {
const value = args.shift();
if (!value) throw new Error("--project-id requires a value");
projectId = value;
continue;
}
if (!target) {
target = arg;
continue;
Expand All @@ -104,7 +116,7 @@ function parseArgs(argv: string[]): CliOptions | null {
}

if (!target) return null;
return { target, format };
return { target, format, projectId: (projectId ?? process.env.RECOST_PROJECT_ID?.trim()) || undefined };
}

function writeSummary(result: CliResult): void {
Expand Down Expand Up @@ -233,9 +245,9 @@ async function main(): Promise<void> {
const rcApiKey = resolveRcApiKey();
const remoteApiCalls = apiCalls.filter(shouldSubmitRemote);
let remoteResult: CliResult["remote"] = null;
if (rcApiKey && remoteApiCalls.length > 0) {
if (rcApiKey && options.projectId && remoteApiCalls.length > 0) {
try {
projectId = await createProject(path.basename(path.resolve(options.target)), rcApiKey);
projectId = options.projectId;
const remoteScan = await submitScan(projectId, remoteApiCalls, rcApiKey);
scanId = remoteScan.scanId;
const [remoteEndpoints, remoteSuggestions] = await Promise.all([
Expand Down Expand Up @@ -265,6 +277,10 @@ async function main(): Promise<void> {
}
}

if (rcApiKey && !options.projectId && remoteApiCalls.length > 0) {
process.stderr.write("Set RECOST_PROJECT_ID (or --project-id) to sync scans remotely. Showing local-only results.\n");
}

const result: CliResult = {
target: path.resolve(options.target),
scannedFileCount: access.files.length,
Expand Down
43 changes: 39 additions & 4 deletions src/test/scan-publishing-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ require.cache[require.resolve("../api-client")] = {
filename: require.resolve("../api-client"),
loaded: true,
exports: {
createProject: async () => "proj-stub",
submitScan: async () => {
if (nextScanError) throw nextScanError;
return { scanId: "scan-stub", summary: { totalEndpoints: 0, redundantCalls: 0, n1Suspects: 0, batchOpportunities: 0, cacheOpportunities: 0 } };
Expand All @@ -67,11 +66,9 @@ function makeCtx(posted: HostMessage[]): ScanPublishingHandlerContext {
setLastSummary: () => {},
setLastApiCalls: () => {},
setLastFindings: () => {},
setProjectId: () => {},
getProjectId: () => null,
getManualProjectId: () => null,
getRcApiKey: async () => "rc-good",
resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "auto" }),
resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "manual" as const }),
getWorkspaceName: () => "ws",
openKeys: () => {},
setRecostValidationState: noop,
Expand Down Expand Up @@ -150,6 +147,44 @@ async function runTests() {
assert.equal(refreshedAfterUpdate, true, "refreshStatusBar must be called after sendRecostKeyStatusUpdate");
}

// 5. No project target (no manual ID) → local-only + nudge, never calls submitScan
{
const posted: HostMessage[] = [];
nextScanError = null;
let submitCalled = false;
const api = require.cache[require.resolve("../api-client")]!.exports as { submitScan: (...a: unknown[]) => Promise<unknown> };
const realSubmit = api.submitScan;
api.submitScan = async (...a: unknown[]) => { submitCalled = true; return realSubmit(...a); };
const ctx: ScanPublishingHandlerContext = {
...makeCtx(posted),
resolveScanProjectTarget: async () => null,
};
const handler = new ScanPublishingHandler(ctx);
await handler.handleStartScan();
api.submitScan = realSubmit;
assert.equal(submitCalled, false, "submitScan must not be called without a project target");
const nudge = posted.find((m) => m.type === "scanNotification" && /Project ID/i.test((m as { message: string }).message));
assert.ok(nudge, "expected a nudge to add a Project ID");
}

// 6. Manual project target → submitScan IS called with that project id
{
const posted: HostMessage[] = [];
nextScanError = null;
let submittedProjectId: string | null = null;
const api = require.cache[require.resolve("../api-client")]!.exports as { submitScan: (projectId: string, ...a: unknown[]) => Promise<unknown> };
const realSubmit = api.submitScan;
api.submitScan = async (projectId: string, ...a: unknown[]) => { submittedProjectId = projectId; return realSubmit(projectId, ...a); };
const ctx: ScanPublishingHandlerContext = {
...makeCtx(posted),
resolveScanProjectTarget: async () => ({ projectId: "proj-manual", source: "manual" as const }),
};
const handler = new ScanPublishingHandler(ctx);
await handler.handleStartScan();
api.submitScan = realSubmit;
assert.equal(submittedProjectId, "proj-manual");
}

console.log("PASS scan-publishing-handler");
}

Expand Down
Loading
Loading