From f063ddc1b9fe610d8049118e64237cedbb4dce1b Mon Sep 17 00:00:00 2001 From: KingEmma Date: Sat, 1 Aug 2026 10:09:57 +0000 Subject: [PATCH 1/2] Add deterministic project detection --- .github/workflows/ci.yml | 4 +- AGENTS.md | 2 +- CHANGELOG.md | 21 ++- CONTRIBUTING.md | 3 +- README.md | 38 +++- RELEASE_CHECKLIST.md | 7 +- docs/decision-log.md | 21 +++ docs/quality-rubric.md | 4 +- docs/repo-memory.md | 9 +- package.json | 2 + scripts/detect.mjs | 232 +++++++++++++++++++++++++ scripts/init.mjs | 57 +++++- scripts/smoke-test.mjs | 121 ++++++++++++- template/prompts/README.md | 9 +- template/prompts/localize-cursor-os.md | 2 +- 15 files changed, 503 insertions(+), 29 deletions(-) create mode 100644 scripts/detect.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e48f1a..c269bca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: - name: Syntax-check scripts run: | node --check scripts/init.mjs + node --check scripts/detect.mjs node --check scripts/smoke-test.mjs - name: Validate package.json parses run: node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))" @@ -31,10 +32,11 @@ jobs: npm install "$GITHUB_WORKSPACE"/cursor-os-*.tgz ./node_modules/.bin/cursor-os init --target ./proj ./node_modules/.bin/cursor-os doctor --target ./proj + ./node_modules/.bin/cursor-os detect --target ./proj --format json | grep -q '"schemaVersion": 1' # bare invocation must print help and write nothing ./node_modules/.bin/cursor-os | grep -q "Usage:" # programmatic import must resolve - node -e "import('cursor-os').then(m => { if (typeof m.install !== 'function') process.exit(1); }).catch(() => process.exit(1))" + node -e "import('cursor-os').then(m => { if (typeof m.install !== 'function' || typeof m.doctor !== 'function' || typeof m.detect !== 'function') process.exit(1); }).catch(() => process.exit(1))" smoke-test: strategy: diff --git a/AGENTS.md b/AGENTS.md index 31de595..85e9d3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This repository ships Cursor OS — an installable operating layer that makes Cu ## Repository layout - `template/` — the kit that gets copied into other projects. Never assume a specific stack here. -- `scripts/` — the installer (`init.mjs`) and its smoke test. Node built-ins only; no dependencies. +- `scripts/` — the CLI and install/doctor APIs (`init.mjs`), read-only project detection (`detect.mjs`), and smoke test. Node built-ins only; no dependencies. - `examples/` — concrete examples showing what localization looks like. - `docs/` — this repo's own decision log (not installed into user projects). - `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md` — public-facing docs. diff --git a/CHANGELOG.md b/CHANGELOG.md index f1062f5..f8885a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - +### Added + +- Read-only `detect` command with deterministic text and versioned JSON output. +- Evidence-backed detection for languages, JavaScript frameworks, package managers, + monorepo indicators, package scripts, common tooling, and Next.js/Supabase/Vercel + localization presets. +- Programmatic `detect({ target })` export alongside `install` and `doctor`. +- Post-install project-signal summary that grounds the localization next step without + modifying user files. +- Detection warnings for malformed `package.json` and competing package-manager + lockfiles; neither condition prevents a partial report. +- Smoke coverage for programmatic and CLI detection, JSON parsing, read-only behavior, + invalid formats, stack presets, malformed manifests, and post-install signals (155 + checks total). + +### Changed + +- `prompts/localize-cursor-os.md` now consumes `cursor-os detect --format json` as an + optional evidence source, while requiring every signal to be verified against the + repository before localization edits. ## [0.2.0] — 2026-06-10 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5450ee..a7e0a1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,9 +41,10 @@ Prompts live in `template/prompts/` and use descriptive command-style names (e.g ## Development ```bash -npm test # run installer, doctor, and CLI smoke tests +npm test # run installer, doctor, detector, and CLI smoke tests node scripts/init.mjs init --dry-run # preview an install into the current dir node scripts/init.mjs doctor # check install state of current dir +node scripts/init.mjs detect --format json # read-only project signals npm run pack:dry-run # preview npm package contents ``` diff --git a/README.md b/README.md index e4eb70e..90d7296 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,17 @@ prompts/ The installer copies these files. The localization prompt fills them in for your project. +Before localization, the read-only detector can extract evidence-backed stack signals +from root manifests and configuration files: + +```bash +npx cursor-os detect --format json +``` + +It reports languages, frameworks, services, tooling, package scripts, workspace shape, +and applicable localization presets. It never edits the project, and its output is +guidance—not a replacement for inspecting the actual code. + ## The two-step setup **Step 1 — Install the base OS** (the installer does this): @@ -117,6 +128,8 @@ Target: /path/to/your-project (Abbreviated — `doctor` lists every installed file; the version shown matches your checkout. The `note:` lines flag the unfilled TODO placeholders in `AGENTS.md` and `docs/repo-memory.md` that localization resolves. Once localization fills them, `doctor` reports "installed and localized". If the install came from an older Cursor OS version, `doctor` also notes the drift so you can re-run `init` to pick up new files.) `init` runs this same health check automatically after installing, so you always see the placeholder count and the next step without a separate command. +When root manifests expose recognizable tooling, it also prints a concise set of +detected project signals to ground the localization step. ## What Cursor loads automatically vs. what you paste @@ -134,6 +147,9 @@ localize-cursor-os.md (once after install) plan-feature.md → implement-change.md → verify-work.md → review-pr.md ``` +For an evidence-first setup, run `npx cursor-os detect --format json` immediately +before `prompts/localize-cursor-os.md`. + See the [prompts guide](template/prompts/README.md) (installs as `prompts/README.md`) for when to use each prompt. ## When not to use the full workflow @@ -166,15 +182,32 @@ npx cursor-os [target] [options] Commands: init Install Cursor OS into the target directory doctor Check whether Cursor OS is installed in the target directory + detect Report project stack signals without modifying files Options: -n, --dry-run Preview changes without writing anything (init only) -t, --target DIR Use DIR as the target directory + --format TYPE Output text or json (detect only; default: text) -v, --version Print version and exit -h, --help Show this help ``` -A command is required: bare invocation (`npx cursor-os` with no arguments) prints help and never writes files. For a target directory named `init` or `doctor`, or one whose name starts with `-`, use the explicit form `init --target `. Requires Node.js 20 or newer. +A command is required: bare invocation (`npx cursor-os` with no arguments) prints help and never writes files. For a target directory named `init`, `doctor`, or `detect`, or one whose name starts with `-`, use the intended command with `--target `. Requires Node.js 20 or newer. + +`detect` reads only root manifests, lockfiles, dependency names, and well-known config +markers. JSON output uses a versioned schema and includes evidence for each signal plus +non-fatal warnings for malformed manifests or competing lockfiles. + +## Programmatic API + +```js +import { detect, doctor, install } from "cursor-os"; + +const profile = detect({ target: process.cwd() }); +``` + +All three APIs are dependency-free. `detect` and `doctor` are read-only; `install` +preserves the no-overwrite contract. ## What gets installed @@ -220,7 +253,8 @@ prompts/ - `v0.1` — installable operating layer: contract, rules, skills, verifier, docs, prompts, installer, doctor command. ✅ - `v0.2` — npm publishing (`npx cursor-os init`), safer CLI defaults, post-install health check, version-drift detection. ✅ -- Next — interactive setup with project detection, stack presets (Next.js, Supabase, Vercel). +- `v0.3` — read-only project detection, deterministic JSON, and localization preset signals for Next.js, Supabase, and Vercel. 🚧 Unreleased +- Next — opt-in interactive localization using the detected profile, with explicit review before edits. ## Contributing diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 594275c..3d4ca5c 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -21,13 +21,14 @@ Use this checklist before tagging a public release or publishing Cursor OS to np ## Installer and CLI -- [ ] `node scripts/init.mjs --help` shows `init`, `doctor`, `--target`, `--dry-run`, and `--version`. +- [ ] `node scripts/init.mjs --help` shows `init`, `doctor`, `detect`, `--target`, `--format`, `--dry-run`, and `--version`. - [ ] `node scripts/init.mjs` with no arguments prints help and writes nothing. - [ ] `node scripts/init.mjs --version` matches `package.json`. - [ ] `node scripts/init.mjs init --dry-run --target ` writes nothing. - [ ] `node scripts/init.mjs init --target ` installs the expected file set. - [ ] `node scripts/init.mjs doctor --target ` exits 0. - [ ] `node scripts/init.mjs doctor --target ` exits non-zero and lists missing files. +- [ ] `node scripts/init.mjs detect --target --format json` emits parseable, evidence-backed JSON and writes nothing. - [ ] Invalid commands and invalid `--target` usage exit non-zero without writing files. ## Template quality @@ -48,8 +49,8 @@ Use this checklist before tagging a public release or publishing Cursor OS to np ## Packaging integrity -- [ ] `npm pack`, install the tarball into a scratch project, and run the bin: `init`, `doctor`, and bare invocation all behave. (CI runs this on every push.) -- [ ] `import('cursor-os')` resolves and exposes `install` and `doctor`. +- [ ] `npm pack`, install the tarball into a scratch project, and run the bin: `init`, `doctor`, `detect`, and bare invocation all behave. (CI runs this on every push.) +- [ ] `import('cursor-os')` resolves and exposes `install`, `doctor`, and `detect`. - [ ] `head -1 scripts/init.mjs` is exactly `#!/usr/bin/env node` (no CRLF, no BOM). - [ ] No stray `*.tgz` files tracked in git. diff --git a/docs/decision-log.md b/docs/decision-log.md index 5c5f746..ca73ce3 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -2,6 +2,27 @@ Append-only record of notable decisions for the Cursor OS project itself. Newest first. (Not part of the installable template — `template/docs/decision-log.md` is what gets installed into user projects.) +## 2026-08-01 — Project detection is read-only, evidence-backed, and advisory + +- **Decision:** Add a `detect` command and programmatic API that inspect root manifests, + lockfiles, dependency names, and well-known config markers. It emits deterministic + text or versioned JSON, including evidence and warnings, but never writes a generated + profile into the project. Next.js, Supabase, and Vercel are emitted as localization + preset signals rather than stack-specific template files. +- **Context:** The v0.2 workflow asks an agent to rediscover basic stack facts during + every localization. The roadmap called for project detection and presets, but the + base template must remain framework-neutral and the installer must not silently + rewrite user content. +- **Alternatives:** Automatically localize files during `init` — rejected because it + spends tokens and produces unreviewed edits. Persist `.cursor/project-profile.json` + — rejected for now because generated state can become stale and would create another + overwrite exception. Recursively scan all source files — rejected because it is + slower, noisier, and unnecessary for the first evidence pass. +- **Consequences:** Detection is safe to run at any time and straightforward to consume + in automation. Localization still verifies signals against source and remains an + explicit, reviewable step. Richer opt-in interactive localization can build on the + versioned report later. + ## 2026-06-10 — v0.2.0 publishes manually; CI publishing with provenance deferred - **Decision:** The first npm release is published manually by the maintainer (`npm publish` from a tagged, CI-green commit). Automated publishing from GitHub Actions with npm provenance/trusted publishing is deferred. diff --git a/docs/quality-rubric.md b/docs/quality-rubric.md index bfc260e..9b64e38 100644 --- a/docs/quality-rubric.md +++ b/docs/quality-rubric.md @@ -28,8 +28,8 @@ Gate to check before claiming any change is done. All items must pass with evide ## Verification (project-specific) -- [ ] `npm test` passes — `node scripts/smoke-test.mjs`, 126 checks minimum. -- [ ] `node --check scripts/init.mjs && node --check scripts/smoke-test.mjs` clean. +- [ ] `npm test` passes — `node scripts/smoke-test.mjs`, 155 checks minimum. +- [ ] `node --check scripts/init.mjs && node --check scripts/detect.mjs && node --check scripts/smoke-test.mjs` clean. - [ ] `npm run pack:dry-run` exits 0 and file list matches `package.json#files`. - [ ] `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))"` succeeds. - [ ] If `template/` files changed: `EXPECTED` in `smoke-test.mjs`, `README.md`, and `CHANGELOG.md` updated together. diff --git a/docs/repo-memory.md b/docs/repo-memory.md index 24bd5ea..f18d434 100644 --- a/docs/repo-memory.md +++ b/docs/repo-memory.md @@ -10,14 +10,14 @@ Cursor OS: an installable operating layer that makes Cursor project-aware. Devel - **Language:** Node.js ESM (`.mjs`), zero runtime or dev dependencies by design - **Minimum Node:** 20 (enforced in `package.json` `engines`) -- **Product:** `template/` (the installable kit) + `scripts/` (installer CLI) +- **Product:** `template/` (the installable kit) + `scripts/` (installer, doctor, and read-only detector CLI) - **No build step:** scripts run directly with `node` ## How it's organised ``` template/ The kit installed into user projects (AGENTS.md, rules, skills, agents, docs, prompts) -scripts/ init.mjs (installer + library) and smoke-test.mjs (installer smoke test suite) +scripts/ init.mjs (CLI + install/doctor APIs), detect.mjs (read-only project profile), smoke-test.mjs examples/ Before/after localization walkthrough docs/ This repo's own decision log (not installed into user projects) .cursor/ cursor-os installed on itself (dogfooded) @@ -27,10 +27,11 @@ docs/ This repo's own decision log (not installed into user project - Install: none (zero dependencies) - Test: `npm test` -- Lint / syntax-check: `node --check scripts/init.mjs && node --check scripts/smoke-test.mjs` +- Lint / syntax-check: `node --check scripts/init.mjs && node --check scripts/detect.mjs && node --check scripts/smoke-test.mjs` - Pack preview: `npm run pack:dry-run` - Dry-run install: `node scripts/init.mjs init --dry-run` - Health check: `node scripts/init.mjs doctor` +- Project detection: `node scripts/init.mjs detect --format json` - Build: none (no compile step) ## Conventions and gotchas @@ -38,6 +39,7 @@ docs/ This repo's own decision log (not installed into user project - **Zero deps, always.** The installer must stay dependency-free (Node built-ins only). No devDependencies without a very strong reason. - **Template files are framework-neutral.** No stack presets in `template/`; all project-specific content is generated during localization. - **No-clobber invariant.** `install()` never overwrites existing files. Every test that touches this path must preserve it. +- **Detection is advisory and read-only.** `detect()` uses root evidence only, returns a versioned report, and never replaces source inspection during localization. - **Idempotency.** Re-running `init` is always safe. Smoke tests verify second-run behaviour. - **EXPECTED list in smoke-test.mjs must stay in sync with template/.** When you add or remove a template file, update `EXPECTED` in `scripts/smoke-test.mjs`, `README.md`, and `CHANGELOG.md` together. - **Prompt files referenced by full path, never by number.** e.g. `prompts/plan-feature.md` not "prompt 1". @@ -57,3 +59,4 @@ docs/ This repo's own decision log (not installed into user project - 2026-06-10 — `doctor`'s success state required rewriting template instruction notes so placeholder markers only appear as real placeholders. Fixed in audit remediation; the smoke test pins this. - 2026-06-10 — `listFiles` used `statSync` (follows symlinks); swapped to `lstatSync` to prevent cycle risk. - 2026-06-10 — CLI previously defaulted to `init`; now bare invocation prints help (breaking pre-publish, safe decision). +- 2026-08-01 — `detect` adds evidence-backed stack and preset signals without persisting generated state or adding dependencies. diff --git a/package.json b/package.json index d94cb74..7265009 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "scripts": { "init": "node scripts/init.mjs init", "doctor": "node scripts/init.mjs doctor", + "detect": "node scripts/init.mjs detect", "pack:dry-run": "npm pack --dry-run", "test": "node scripts/smoke-test.mjs" }, "files": [ "template", "scripts/init.mjs", + "scripts/detect.mjs", "examples", "README.md", "LICENSE", diff --git a/scripts/detect.mjs b/scripts/detect.mjs new file mode 100644 index 0000000..a8780b6 --- /dev/null +++ b/scripts/detect.mjs @@ -0,0 +1,232 @@ +// Read-only project detection for Cursor OS. +// Uses root manifests and configuration markers only: deterministic, fast, +// dependency-free, and safe to run before localization. + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +const PACKAGE_MANAGER_MARKERS = [ + ["pnpm", "pnpm-lock.yaml"], + ["yarn", "yarn.lock"], + ["npm", "package-lock.json"], + ["bun", "bun.lock"], + ["bun", "bun.lockb"], +]; + +function isFile(target, rel) { + try { + return statSync(join(target, rel)).isFile(); + } catch { + return false; + } +} + +function readJson(target, rel, warnings) { + if (!isFile(target, rel)) return null; + try { + return JSON.parse(readFileSync(join(target, rel), "utf8")); + } catch (error) { + warnings.push(`${rel}: ${error.message}`); + return null; + } +} + +function packageManagerName(value) { + if (typeof value !== "string" || value.length === 0) return null; + return value.split("@")[0] || null; +} + +function sortedObject(value) { + return Object.fromEntries( + Object.entries(value ?? {}) + .filter(([, item]) => typeof item === "string") + .sort(([left], [right]) => left.localeCompare(right)), + ); +} + +/** + * Inspect a project without modifying it. + * + * The report is intentionally evidence-backed: signals come from root manifests, + * dependency names, lockfiles and well-known configuration markers. Detection is + * guidance for localization, not a substitute for reading the repository. + */ +export function detect({ target } = {}) { + if (!target) throw new Error("detect() requires a target directory"); + const resolvedTarget = resolve(target); + if (!existsSync(resolvedTarget)) { + throw new Error(`target directory does not exist: ${resolvedTarget}`); + } + if (!statSync(resolvedTarget).isDirectory()) { + throw new Error(`target is not a directory: ${resolvedTarget}`); + } + + const warnings = []; + const evidence = []; + const packageJson = readJson(resolvedTarget, "package.json", warnings); + const dependencies = new Set( + [ + packageJson?.dependencies, + packageJson?.devDependencies, + packageJson?.peerDependencies, + packageJson?.optionalDependencies, + ].flatMap((group) => Object.keys(group ?? {})), + ); + + const addSignal = (list, value, source) => { + if (!list.includes(value)) list.push(value); + evidence.push({ signal: value, source }); + }; + + const languages = []; + const frameworks = []; + const services = []; + const tooling = []; + const presets = []; + + if (packageJson) addSignal(languages, "JavaScript", "package.json"); + if (isFile(resolvedTarget, "tsconfig.json") || dependencies.has("typescript")) { + addSignal(languages, "TypeScript", isFile(resolvedTarget, "tsconfig.json") ? "tsconfig.json" : "package.json:typescript"); + } + if (isFile(resolvedTarget, "pyproject.toml") || isFile(resolvedTarget, "requirements.txt")) { + addSignal(languages, "Python", isFile(resolvedTarget, "pyproject.toml") ? "pyproject.toml" : "requirements.txt"); + } + if (isFile(resolvedTarget, "Cargo.toml")) addSignal(languages, "Rust", "Cargo.toml"); + if (isFile(resolvedTarget, "go.mod")) addSignal(languages, "Go", "go.mod"); + + const dependencySignals = [ + ["next", frameworks, "Next.js", presets, "nextjs"], + ["react", frameworks, "React"], + ["vue", frameworks, "Vue"], + ["nuxt", frameworks, "Nuxt"], + ["svelte", frameworks, "Svelte"], + ["@sveltejs/kit", frameworks, "SvelteKit"], + ["astro", frameworks, "Astro"], + ["@angular/core", frameworks, "Angular"], + ["@supabase/supabase-js", services, "Supabase", presets, "supabase"], + ["@supabase/ssr", services, "Supabase", presets, "supabase"], + ["stripe", services, "Stripe"], + ["@sentry/node", services, "Sentry"], + ["@sentry/nextjs", services, "Sentry"], + ["typescript", tooling, "TypeScript"], + ["tailwindcss", tooling, "Tailwind CSS"], + ["prisma", tooling, "Prisma"], + ["drizzle-orm", tooling, "Drizzle ORM"], + ["turbo", tooling, "Turborepo"], + ["nx", tooling, "Nx"], + ["storybook", tooling, "Storybook"], + ["@storybook/react", tooling, "Storybook"], + ["vitest", tooling, "Vitest"], + ["jest", tooling, "Jest"], + ["eslint", tooling, "ESLint"], + ["@playwright/test", tooling, "Playwright"], + ["cypress", tooling, "Cypress"], + ]; + + for (const [dependency, bucket, label, presetBucket, preset] of dependencySignals) { + if (!dependencies.has(dependency)) continue; + addSignal(bucket, label, `package.json:${dependency}`); + if (presetBucket && preset && !presetBucket.includes(preset)) presetBucket.push(preset); + } + + if (isFile(resolvedTarget, "supabase/config.toml")) { + addSignal(services, "Supabase", "supabase/config.toml"); + if (!presets.includes("supabase")) presets.push("supabase"); + } + if ( + isFile(resolvedTarget, "vercel.json") || + isFile(resolvedTarget, ".vercel/project.json") || + dependencies.has("vercel") + ) { + const source = isFile(resolvedTarget, "vercel.json") + ? "vercel.json" + : isFile(resolvedTarget, ".vercel/project.json") + ? ".vercel/project.json" + : "package.json:vercel"; + addSignal(services, "Vercel", source); + if (!presets.includes("vercel")) presets.push("vercel"); + } + + const lockfiles = PACKAGE_MANAGER_MARKERS.filter(([, rel]) => isFile(resolvedTarget, rel)); + const declaredPackageManager = packageManagerName(packageJson?.packageManager); + const packageManager = declaredPackageManager ?? lockfiles[0]?.[0] ?? null; + if (declaredPackageManager) { + evidence.push({ signal: `package-manager:${declaredPackageManager}`, source: "package.json:packageManager" }); + } else if (lockfiles[0]) { + evidence.push({ signal: `package-manager:${lockfiles[0][0]}`, source: lockfiles[0][1] }); + } + if (lockfiles.length > 1) { + warnings.push(`multiple package-manager lockfiles found: ${lockfiles.map(([, rel]) => rel).join(", ")}`); + } + + const workspaceIndicators = []; + const workspaces = packageJson?.workspaces; + if ( + (Array.isArray(workspaces) && workspaces.length > 0) || + (workspaces && typeof workspaces === "object" && Object.keys(workspaces).length > 0) + ) { + workspaceIndicators.push("package.json:workspaces"); + } + for (const rel of ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"]) { + if (isFile(resolvedTarget, rel)) workspaceIndicators.push(rel); + } + + for (const values of [languages, frameworks, services, tooling, presets, workspaceIndicators]) { + values.sort((left, right) => left.localeCompare(right)); + } + evidence.sort((left, right) => + `${left.signal}\0${left.source}`.localeCompare(`${right.signal}\0${right.source}`), + ); + warnings.sort((left, right) => left.localeCompare(right)); + + return { + schemaVersion: 1, + target: resolvedTarget, + project: { + name: typeof packageJson?.name === "string" ? packageJson.name : basename(resolvedTarget), + private: typeof packageJson?.private === "boolean" ? packageJson.private : null, + packageManager, + }, + languages, + frameworks, + services, + tooling, + presets, + workspace: { + monorepo: workspaceIndicators.length > 0, + indicators: workspaceIndicators, + }, + packageScripts: sortedObject(packageJson?.scripts), + evidence, + warnings, + }; +} + +export function formatDetectionText(report) { + const line = (label, values) => `${label}: ${values.length ? values.join(", ") : "none detected"}`; + const output = [ + "Cursor OS — detect", + `Target: ${report.target}`, + `Project: ${report.project.name}`, + `Package manager: ${report.project.packageManager ?? "not detected"}`, + line("Languages", report.languages), + line("Frameworks", report.frameworks), + line("Services", report.services), + line("Tooling", report.tooling), + line("Localization presets", report.presets), + `Workspace: ${report.workspace.monorepo ? `monorepo (${report.workspace.indicators.join(", ")})` : "single project or not detected"}`, + ]; + + const scripts = Object.entries(report.packageScripts); + output.push("Package scripts:"); + if (scripts.length === 0) output.push(" none detected"); + for (const [name, command] of scripts) output.push(` ${name}: ${command}`); + + if (report.warnings.length) { + output.push("Warnings:"); + for (const warning of report.warnings) output.push(` ${warning}`); + } + + output.push("Read-only detection: no files were modified."); + return output.join("\n"); +} diff --git a/scripts/init.mjs b/scripts/init.mjs index 94b14fe..30670ab 100755 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -16,6 +16,9 @@ import { } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, relative, resolve } from "node:path"; +import { detect, formatDetectionText } from "./detect.mjs"; + +export { detect } from "./detect.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, ".."); @@ -37,12 +40,13 @@ function readVersion() { // ── Argument parsing ────────────────────────────────────────────────────────── /** - * Parse argv into { command, dryRun, target, help, version }. - * command: "init" | "doctor" | null + * Parse argv into { command, dryRun, target, format, help, version }. + * command: "init" | "doctor" | "detect" | null * * Supported forms: * node init.mjs init [target] [--dry-run] [--target DIR] * node init.mjs doctor [target] [--target DIR] + * node init.mjs detect [target] [--target DIR] [--format text|json] * node init.mjs --help | -h * node init.mjs --version | -v * @@ -53,6 +57,7 @@ function parseArgs(argv) { const args = { command: null, dryRun: false, + format: "text", target: process.cwd(), help: false, version: false, @@ -60,12 +65,26 @@ function parseArgs(argv) { errors: [], }; let targetSet = false; + let formatSet = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--help" || a === "-h") { args.help = true; } else if (a === "--version" || a === "-v") { args.version = true; } else if (a === "--dry-run" || a === "-n") { args.dryRun = true; } + else if (a === "--format") { + const value = argv[i + 1]; + if (!value || value.startsWith("-")) { + args.errors.push("--format requires text or json"); + } else if (value !== "text" && value !== "json") { + args.errors.push(`unsupported format: ${value}`); + i++; + } else { + args.format = value; + formatSet = true; + i++; + } + } else if (a === "--target" || a === "-t") { const value = argv[i + 1]; if (!value || value.startsWith("-")) { @@ -76,7 +95,7 @@ function parseArgs(argv) { i++; } } - else if ((a === "init" || a === "doctor") && args.command === null) { + else if ((a === "init" || a === "doctor" || a === "detect") && args.command === null) { // Subcommand recognized regardless of whether --target has already been set args.command = a; } @@ -96,13 +115,17 @@ function parseArgs(argv) { // A command is required whenever arguments are given. Bare invocation // (no args at all) falls through to help so `npx cursor-os` is read-only. if (args.command === null && !args.help && !args.version && !args.bare) { - args.errors.push("missing command: specify 'init' or 'doctor'"); + args.errors.push("missing command: specify 'init', 'doctor' or 'detect'"); } - if (args.command === "doctor" && args.dryRun) { + if (args.command !== "init" && args.dryRun) { args.errors.push("--dry-run is only valid with init"); } + if (args.command !== "detect" && formatSet) { + args.errors.push("--format is only valid with detect"); + } + return args; } @@ -115,6 +138,7 @@ Usage: Commands: init Install Cursor OS into the target directory doctor Check whether Cursor OS is installed in the target directory + detect Read project manifests and report stack signals (never writes) Arguments: target Directory to operate on (default: current directory) @@ -122,6 +146,7 @@ Arguments: Options: -n, --dry-run Preview changes without writing anything (init only) -t, --target DIR Use DIR as the target directory + --format TYPE Output text or json (detect only; default: text) -v, --version Print version and exit -h, --help Show this help @@ -131,11 +156,13 @@ Examples: cursor-os init --target ./my-project cursor-os doctor cursor-os doctor --target ./my-project + cursor-os detect + cursor-os detect --target ./my-project --format json Notes: A command is required; bare invocation prints this help and writes nothing. - For a target directory named "init" or "doctor", or one starting with "-", - use the explicit form: init --target . + For a target directory named "init", "doctor" or "detect", or one starting with "-", + use the intended command with the explicit form: --target . When running from a local checkout: node scripts/init.mjs The installer copies AGENTS.md, .cursor/, docs/, and prompts/ into the target. @@ -313,12 +340,26 @@ function runInit(args) { } if (health.todoCount > 0) { + const project = detect({ target: args.target }); + const signals = [...project.frameworks, ...project.services, ...project.tooling].slice(0, 8); + if (signals.length > 0) { + console.log(`\nDetected project signals: ${signals.join(", ")}`); + } console.log(`\nNext: open Cursor in ${where} and run prompts/localize-cursor-os.md to adapt the OS to your project.`); console.log('Tip: with the Cursor CLI installed you can run it directly:'); console.log(' cursor-agent -p "$(cat prompts/localize-cursor-os.md)"'); } } +function runDetect(args) { + const result = detect({ target: args.target }); + if (args.format === "json") { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatDetectionText(result)); + } +} + function runDoctor(args) { const result = doctor({ target: args.target }); const version = readVersion(); @@ -385,6 +426,8 @@ function main() { try { if (args.command === "doctor") { runDoctor(args); + } else if (args.command === "detect") { + runDetect(args); } else { runInit(args); } diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index decbebc..6edc69f 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ // doctor passes on an installed dir, and doctor reports missing files otherwise. // Node built-ins only. -import { install, doctor } from "./init.mjs"; +import { install, doctor, detect } from "./init.mjs"; import { mkdtempSync, mkdirSync, @@ -307,12 +307,84 @@ withTempDir((dir) => { ); }); -// 9. CLI entry point coverage. +// 9. detect — reports evidence-backed stack signals and never writes. +console.log("\ndetect (project signals):"); +withTempDir((dir) => { + writeFileSync( + join(dir, "package.json"), + JSON.stringify( + { + name: "detected-app", + private: true, + packageManager: "pnpm@9.15.0", + workspaces: ["apps/*"], + scripts: { + build: "next build", + dev: "next dev", + test: "vitest run", + }, + dependencies: { + "@supabase/supabase-js": "^2.0.0", + next: "^15.0.0", + react: "^19.0.0", + }, + devDependencies: { + tailwindcss: "^4.0.0", + typescript: "^5.0.0", + vitest: "^3.0.0", + }, + }, + null, + 2, + ), + "utf8", + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); + writeFileSync(join(dir, "tsconfig.json"), "{}\n", "utf8"); + writeFileSync(join(dir, "vercel.json"), "{}\n", "utf8"); + writeFileSync(join(dir, "turbo.json"), "{}\n", "utf8"); + const before = listAll(dir); + + const result = detect({ target: dir }); + check("detect reads project name", result.project.name === "detected-app"); + check("detect reads package manager", result.project.packageManager === "pnpm"); + check("detect finds TypeScript", result.languages.includes("TypeScript")); + check("detect finds Next.js", result.frameworks.includes("Next.js")); + check("detect finds React", result.frameworks.includes("React")); + check("detect finds Supabase", result.services.includes("Supabase")); + check("detect finds Vercel", result.services.includes("Vercel")); + check("detect finds tooling", result.tooling.includes("Tailwind CSS") && result.tooling.includes("Vitest")); + check( + "detect emits stack presets", + ["nextjs", "supabase", "vercel"].every((preset) => result.presets.includes(preset)), + ); + check("detect identifies a monorepo", result.workspace.monorepo === true); + check("detect preserves package scripts", result.packageScripts.test === "vitest run"); + check("detect includes signal evidence", result.evidence.some((item) => item.source === "package.json:next")); + check("detect writes no files", JSON.stringify(listAll(dir)) === JSON.stringify(before)); +}); + +console.log("\ndetect (malformed manifests):"); +withTempDir((dir) => { + writeFileSync(join(dir, "package.json"), "{not json", "utf8"); + writeFileSync(join(dir, "package-lock.json"), "{}\n", "utf8"); + writeFileSync(join(dir, "yarn.lock"), "# lock\n", "utf8"); + + const result = detect({ target: dir }); + check("detect reports malformed package.json as a warning", result.warnings.some((warning) => warning.startsWith("package.json:"))); + check("detect reports multiple lockfiles", result.warnings.some((warning) => warning.includes("multiple package-manager lockfiles"))); + check("detect still identifies a package manager", result.project.packageManager === "yarn"); +}); + +// 10. CLI entry point coverage. console.log("\nCLI:"); withTempDir((dir) => { const help = runCli(["--help"]); check("--help exits 0", help.status === 0); - check("--help prints command list", help.stdout.includes("Commands:") && help.stdout.includes("doctor")); + check( + "--help prints command list", + help.stdout.includes("Commands:") && help.stdout.includes("doctor") && help.stdout.includes("detect"), + ); const version = runCli(["--version"]); check("--version exits 0", version.status === 0); @@ -336,6 +408,39 @@ withTempDir((dir) => { check("CLI doctor installed dir reports installed", doctorInstalled.stdout.includes("Cursor OS is installed")); }); +withTempDir((dir) => { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "cli-detect", dependencies: { next: "^15.0.0" } }), + "utf8", + ); + writeFileSync(join(dir, "vercel.json"), "{}\n", "utf8"); + const before = listAll(dir); + + const text = runCli(["detect", "--target", dir]); + check("CLI detect text exits 0", text.status === 0); + check("CLI detect text reports project", text.stdout.includes("Project: cli-detect")); + check("CLI detect text reports stack signals", text.stdout.includes("Next.js") && text.stdout.includes("Vercel")); + + const json = runCli(["detect", "--target", dir, "--format", "json"]); + const parsed = json.status === 0 ? JSON.parse(json.stdout) : null; + check("CLI detect JSON exits 0", json.status === 0); + check("CLI detect JSON is parseable", parsed?.schemaVersion === 1); + check("CLI detect JSON includes evidence", parsed?.evidence?.length > 0); + check("CLI detect remains read-only", JSON.stringify(listAll(dir)) === JSON.stringify(before)); +}); + +withTempDir((dir) => { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ dependencies: { next: "^15.0.0", "@supabase/supabase-js": "^2.0.0" } }), + "utf8", + ); + const initDetected = runCli(["init", "--target", dir]); + check("CLI init with detectable project exits 0", initDetected.status === 0); + check("CLI init prints detected project signals", initDetected.stdout.includes("Detected project signals: Next.js, Supabase")); +}); + withTempDir((dir) => { const dryRun = runCli(["init", "--dry-run", "--target", dir]); check("CLI init --dry-run exits 0", dryRun.status === 0); @@ -415,6 +520,16 @@ withTempDir((dir) => { check("CLI doctor --dry-run prints error", dryRunDoctor.stderr.includes("--dry-run is only valid with init")); }); +withTempDir((dir) => { + const invalidFormat = runCli(["detect", "--format", "yaml"], { cwd: dir }); + check("CLI detect rejects unsupported format", invalidFormat.status === 1); + check("CLI detect unsupported format prints error", invalidFormat.stderr.includes("unsupported format: yaml")); + + const dryRunDetect = runCli(["detect", "--dry-run"], { cwd: dir }); + check("CLI detect --dry-run exits non-zero", dryRunDetect.status === 1); + check("CLI detect --dry-run prints error", dryRunDetect.stderr.includes("--dry-run is only valid with init")); +}); + console.log(`\n${passed} checks passed, ${failures.length} failed.`); if (failures.length) { console.error("\nFailures:"); diff --git a/template/prompts/README.md b/template/prompts/README.md index e9ec363..8d1a94c 100644 --- a/template/prompts/README.md +++ b/template/prompts/README.md @@ -16,10 +16,11 @@ These prompts are **not** loaded automatically by Cursor. Paste the relevant pro ## How to use a prompt -1. Open Cursor chat (Agent mode). -2. Copy the contents of the prompt file. -3. Paste into the chat and fill in the placeholder (e.g., ``). -4. Send. +1. For localization, optionally run `npx cursor-os detect --format json` first to expose evidence-backed stack signals. +2. Open Cursor chat (Agent mode). +3. Copy the contents of the prompt file. +4. Paste into the chat and fill in the placeholder (e.g., ``). +5. Send. ## Typical flow for a new feature diff --git a/template/prompts/localize-cursor-os.md b/template/prompts/localize-cursor-os.md index 30dab3d..17f7f07 100644 --- a/template/prompts/localize-cursor-os.md +++ b/template/prompts/localize-cursor-os.md @@ -30,7 +30,7 @@ Report any other file you believe should change, but do not change it. ## Steps -1. **Inspect.** Detect the language(s), framework(s), package manager, data layer, and deployment target from manifests and config (e.g. `package.json`, lockfiles, `pyproject.toml`, `go.mod`, Dockerfiles, CI). Read the README and any existing docs. Map the top-level directory structure and identify where the real logic lives. +1. **Inspect.** If the Cursor OS CLI is available, first run `npx cursor-os detect --target . --format json` and use its evidence-backed signals as a starting point. Verify every signal against the actual manifests and config; detection is advisory and may be incomplete. Then inspect anything the detector does not cover (e.g. `pyproject.toml`, `go.mod`, Dockerfiles, CI, deployment config), read the README and existing docs, map the top-level structure, and identify where the real logic lives. 2. **Extract the real commands.** Find the actual install / dev / test / lint / typecheck / build commands from scripts and CI. Use these verbatim — do not assume conventional names. From 11ed5e8994ab7836b3a097025f74bad6a8d0ec17 Mon Sep 17 00:00:00 2001 From: KingEmma Date: Sat, 1 Aug 2026 13:46:53 +0000 Subject: [PATCH 2/2] Avoid task-runner monorepo false positives --- scripts/detect.mjs | 8 +++++++- scripts/smoke-test.mjs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/detect.mjs b/scripts/detect.mjs index a8780b6..16b44b6 100644 --- a/scripts/detect.mjs +++ b/scripts/detect.mjs @@ -146,6 +146,12 @@ export function detect({ target } = {}) { addSignal(services, "Vercel", source); if (!presets.includes("vercel")) presets.push("vercel"); } + for (const [rel, label] of [ + ["turbo.json", "Turborepo"], + ["nx.json", "Nx"], + ]) { + if (isFile(resolvedTarget, rel)) addSignal(tooling, label, rel); + } const lockfiles = PACKAGE_MANAGER_MARKERS.filter(([, rel]) => isFile(resolvedTarget, rel)); const declaredPackageManager = packageManagerName(packageJson?.packageManager); @@ -167,7 +173,7 @@ export function detect({ target } = {}) { ) { workspaceIndicators.push("package.json:workspaces"); } - for (const rel of ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"]) { + for (const rel of ["pnpm-workspace.yaml", "lerna.json"]) { if (isFile(resolvedTarget, rel)) workspaceIndicators.push(rel); } diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 6edc69f..df73872 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -354,6 +354,7 @@ withTempDir((dir) => { check("detect finds Supabase", result.services.includes("Supabase")); check("detect finds Vercel", result.services.includes("Vercel")); check("detect finds tooling", result.tooling.includes("Tailwind CSS") && result.tooling.includes("Vitest")); + check("detect identifies Turborepo tooling from its config", result.tooling.includes("Turborepo")); check( "detect emits stack presets", ["nextjs", "supabase", "vercel"].every((preset) => result.presets.includes(preset)), @@ -364,6 +365,21 @@ withTempDir((dir) => { check("detect writes no files", JSON.stringify(listAll(dir)) === JSON.stringify(before)); }); +console.log("\ndetect (task runner without workspace):"); +withTempDir((dir) => { + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "single-package" }), "utf8"); + writeFileSync(join(dir, "turbo.json"), "{}\n", "utf8"); + writeFileSync(join(dir, "nx.json"), "{}\n", "utf8"); + + const result = detect({ target: dir }); + check("task-runner configs do not imply a monorepo", result.workspace.monorepo === false); + check("task-runner configs are not workspace indicators", result.workspace.indicators.length === 0); + check( + "task-runner configs still identify tooling", + result.tooling.includes("Turborepo") && result.tooling.includes("Nx"), + ); +}); + console.log("\ndetect (malformed manifests):"); withTempDir((dir) => { writeFileSync(join(dir, "package.json"), "{not json", "utf8");