diff --git a/.cursor/rules/no-auto-commit.mdc b/.cursor/rules/no-auto-commit.mdc new file mode 100644 index 0000000..ec6ab3d --- /dev/null +++ b/.cursor/rules/no-auto-commit.mdc @@ -0,0 +1,14 @@ +--- +description: Never commit or push without explicit user instruction. +alwaysApply: true +--- + +# No auto-commit + +Never run `git commit`, `git push`, `git tag`, or `npm publish` unless the user explicitly asks (e.g. "commit this", "push", "publish"). + +- Show the user what changed and let them decide. +- Use `git diff` or `git status` output to summarise changes in your response instead. +- If you are about to perform a git write operation, stop and confirm first. + +This applies regardless of how small, clean, or obviously-correct the change appears. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d5cc121 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Normalize line endings to LF everywhere. A CRLF shebang in scripts/init.mjs +# would break the published bin on Unix, so this is a correctness guard, not style. +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a907ed..8e48f1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,46 @@ on: branches: [main] jobs: - smoke-test: + checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + - name: Syntax-check scripts + run: | + node --check scripts/init.mjs + node --check scripts/smoke-test.mjs + - name: Validate package.json parses + run: node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))" + - name: Preview npm package contents + run: npm run pack:dry-run + + - name: E2E — install from tarball and run the bin + run: | + npm pack + mkdir -p /tmp/e2e/proj + cd /tmp/e2e + npm init -y > /dev/null + npm install "$GITHUB_WORKSPACE"/cursor-os-*.tgz + ./node_modules/.bin/cursor-os init --target ./proj + ./node_modules/.bin/cursor-os doctor --target ./proj + # 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))" + + smoke-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node: [20, 22] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} - run: npm test diff --git a/.gitignore b/.gitignore index fe32485..8a3c2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,15 @@ Thumbs.db .tmp/ tmp/ *.local + +# npm pack output +*.tgz + +# cursor-os installed on itself (dogfooded). The files below are byte-for-byte +.cursor/agents/ +.cursor/skills/ +.cursor/.cursor-os-version +.cursor/rules/core.mdc +.cursor/rules/debugging.mdc +.cursor/rules/frontend.mdc +prompts/ diff --git a/AGENTS.md b/AGENTS.md index 37d42ec..31de595 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This repository ships Cursor OS — an installable operating layer that makes Cu - `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. - `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. ## Working agreements diff --git a/CHANGELOG.md b/CHANGELOG.md index a41cd0d..f1062f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,37 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + + +## [0.2.0] — 2026-06-10 + +First npm release: `npx cursor-os init`. + ### Added +- `init` now runs a post-install health check automatically: it reports missing files, the remaining placeholder count, and the exact next step (including a Cursor CLI one-liner for running localization). +- `doctor` now reports version drift between the installed `.cursor/.cursor-os-version` marker and the current Cursor OS version, and suggests re-running `init` to pick up new files. +- Smoke tests for the localized success path ("installed and localized"), version-drift reporting, the post-install check, and the new bare-invocation behavior (126 checks). +- CI hardening: smoke tests run on Node 20 and 22 across Ubuntu and Windows; a separate job syntax-checks the scripts, validates `package.json` parses, and previews the npm package contents. +- `docs/decision-log.md` recording the CLI default, upgrade-semantics, Node-version, and localization-detection decisions. +- Programmatic API: `import { install, doctor } from "cursor-os"` now resolves via the package `exports` field. +- Runtime Node version guard: the CLI fails fast with a clear message on Node older than 20 (the `engines` field is advisory only). +- CI end-to-end packaging test: packs the tarball, installs it into a scratch project, and runs the `cursor-os` bin (`init`, `doctor`, bare help, and programmatic import). +- `.gitattributes` enforcing LF line endings — a CRLF shebang would break the published bin on Unix. +- `.gitignore` entry for `*.tgz` (local `npm pack` output). + +### Changed (breaking, pre-publish) + +- A command (`init` or `doctor`) is now required whenever arguments are given. Bare invocation with no arguments prints help and writes nothing — `npx cursor-os` will never modify the filesystem by accident. The previous default-to-`init` behavior (including `node scripts/init.mjs `) is removed; use `init ` or `init --target `. +- Minimum supported Node.js version raised from 18 (end-of-life) to 20 (`engines` field). + +### Fixed + +- `doctor` could never report "installed and localized": the install-time instruction notes in `template/AGENTS.md` and `template/docs/repo-memory.md` contained the literal word "TODO", so the placeholder count never reached zero. The notes no longer use the word, and the localization prompt now instructs deleting them when done. Fresh-install placeholder counts changed from 5/12 to 4/10. +- CLI direct-invocation guard now realpath-normalizes both paths, so the CLI runs correctly when invoked through a symlinked path (e.g. macOS `/tmp` → `/private/tmp`). +- `listFiles` uses `lstat` so a symlinked directory inside `template/` can no longer cause infinite recursion. +- Removed an unused variable in the smoke test; hoisted the doctor placeholder-file list to a module constant. + - GitHub Actions smoke-test workflow. - Pull request and issue templates for public contributions. - README badges and unofficial-project disclaimer. @@ -31,10 +60,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `03-debug-regression.md` → `prompts/debug-regression.md` - `04-pr-review.md` → `prompts/review-pr.md` - All template references to numbered prompt filenames updated. -- CLI restructured with explicit `init` and `doctor` subcommands; bare invocation still defaults to `init`. +- CLI restructured with explicit `init` and `doctor` subcommands. - CLI argument parsing hardened: unknown commands, unknown options, invalid `--target`, and `doctor --dry-run` now fail without writing files. - `doctor` now checks the full installed template file set plus the version marker, not just core files. -- Smoke test updated for new prompt file names and extended with doctor and subprocess CLI tests (112 checks). +- Smoke test updated for new prompt file names and extended with doctor and subprocess CLI tests. - `package.json` description updated; `examples/` and public-release docs added to published files list. - `CONTRIBUTING.md` updated with prompt naming convention and test-sync guidance. - `AGENTS.md` updated with `examples/` in the layout and prompt naming rule. @@ -42,7 +71,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Not done yet -- npm publishing (`npx cursor-os init`) — planned for `v0.2`. +- Interactive setup with project detection and stack presets (Next.js, Supabase, Vercel) — planned for a future release. ## [0.1.0] — 2026-06-02 diff --git a/README.md b/README.md index 9575266..e4eb70e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ An installable operating layer that makes Cursor project-aware. [![CI](https://github.com/KingEmma7/cursor-os/actions/workflows/ci.yml/badge.svg)](https://github.com/KingEmma7/cursor-os/actions/workflows/ci.yml) -[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](package.json) +[![Version](https://img.shields.io/github/package-json/v/KingEmma7/cursor-os)](package.json) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) > **Unofficial project.** Cursor OS is a community-maintained installable layer for Cursor. It is not affiliated with, endorsed by, or maintained by Cursor or Anysphere. @@ -50,9 +50,8 @@ The installer copies these files. The localization prompt fills them in for your **Step 1 — Install the base OS** (the installer does this): ```bash -git clone https://github.com/KingEmma7/cursor-os.git ~/cursor-os cd /path/to/your-project -node ~/cursor-os/scripts/init.mjs init +npx cursor-os init ``` This gives you the structure. The files contain TODO placeholders — Cursor knows to use them, but they don't yet describe your project. @@ -71,13 +70,13 @@ Cursor OS is designed to drop into any project at any stage — greenfield or ma ```bash # From the root of any existing project -node /path/to/cursor-os/scripts/init.mjs init +npx cursor-os init # Preview what would be installed first -node /path/to/cursor-os/scripts/init.mjs init --dry-run +npx cursor-os init --dry-run # Check if Cursor OS is already installed -node /path/to/cursor-os/scripts/init.mjs doctor +npx cursor-os doctor ``` For new repos, create your project normally first, then run the installer from the project root. This repository's root is the Cursor OS source project, not the installed project layout. @@ -91,31 +90,33 @@ After localization: - When you paste `prompts/plan-feature.md` into Cursor and describe a feature, the plan references your actual architecture and patterns without you explaining them. - When you paste `prompts/implement-change.md`, Cursor follows your conventions without being told. -Use the Cursor OS checkout to check the installation state: +Check the installation state of any project: ```bash -node ~/cursor-os/scripts/init.mjs doctor --target /path/to/your-project +npx cursor-os doctor --target /path/to/your-project ``` Example output: ``` -Cursor OS v0.1.0 — doctor +Cursor OS vX.Y.Z — doctor Target: /path/to/your-project ok .cursor/agents/verifier.md ok .cursor/rules/core.mdc ok .cursor/skills/implementation-loop/SKILL.md ok AGENTS.md - note: 5 TODO placeholder(s) remain — run prompts/localize-cursor-os.md + note: 4 TODO placeholder(s) remain — run prompts/localize-cursor-os.md ok docs/quality-rubric.md ok docs/repo-memory.md - note: 12 TODO placeholder(s) remain — run prompts/localize-cursor-os.md + note: 10 TODO placeholder(s) remain — run prompts/localize-cursor-os.md ok prompts/localize-cursor-os.md ok .cursor/.cursor-os-version ``` -(Abbreviated — `doctor` lists every installed file. The `note:` lines flag the unfilled TODO placeholders in `AGENTS.md` and `docs/repo-memory.md` that localization resolves.) +(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. ## What Cursor loads automatically vs. what you paste @@ -145,9 +146,9 @@ See the [prompts guide](template/prompts/README.md) (installs as `prompts/README Custom instruction sets or system-prompt files (sometimes called "behavioral guideline packs") tell Cursor how to behave generically. Cursor OS does something different: it tells Cursor about *this* project specifically. The two are complementary. Cursor OS files live in the repo, travel with the code, and get updated as the project evolves. -## Current status +## Installing from a checkout -Cursor OS is **not published to npm yet**. For v0.1, clone the repo and run the installer script directly. +If you prefer not to use npm, clone the repo and run the installer script directly — it behaves identically: ```bash git clone https://github.com/KingEmma7/cursor-os.git ~/cursor-os @@ -155,17 +156,15 @@ cd your-project node ~/cursor-os/scripts/init.mjs init ``` -`npx cursor-os init` is planned for a future release. - -Before publishing, run through [`RELEASE_CHECKLIST.md`](RELEASE_CHECKLIST.md). The checklist covers release readiness, package metadata, installer checks, template quality, and `npm pack --dry-run`. +Maintainers: before tagging a release, run through [`RELEASE_CHECKLIST.md`](RELEASE_CHECKLIST.md). ## CLI reference ``` -node scripts/init.mjs [command] [target] [options] +npx cursor-os [target] [options] Commands: - init Install Cursor OS into the target directory (default) + init Install Cursor OS into the target directory doctor Check whether Cursor OS is installed in the target directory Options: @@ -175,6 +174,8 @@ Options: -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. + ## What gets installed ``` @@ -217,8 +218,9 @@ prompts/ ## Roadmap -- `v0.1` — installable operating layer: contract, rules, skills, verifier, docs, prompts, installer, doctor command. -- `v0.2` — npm publishing (`npx cursor-os init`), interactive setup with project detection, stack presets (Next.js, Supabase, Vercel). +- `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). ## Contributing diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 0813fef..594275c 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -22,6 +22,7 @@ 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` 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. @@ -41,16 +42,26 @@ Use this checklist before tagging a public release or publishing Cursor OS to np - [ ] `npm test` passes. - [ ] `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))"` succeeds. +- [ ] No hardcoded version strings in `README.md` or `examples/` (the badge is dynamic; example outputs use `vX.Y.Z`). Versions appear only in `package.json` and `CHANGELOG.md`. - [ ] Final old-branding search has no matches outside historical changelog context. - [ ] Final search has no numbered prompt references outside historical changelog context. +## 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`. +- [ ] `head -1 scripts/init.mjs` is exactly `#!/usr/bin/env node` (no CRLF, no BOM). +- [ ] No stray `*.tgz` files tracked in git. + ## npm publishing Only after all previous sections pass: +- [ ] npm account has 2FA enabled (at minimum for writes). - [ ] Confirm the intended version. - [ ] Update `CHANGELOG.md` with release date. - [ ] Create a git tag for the release. - [ ] Run `npm publish --dry-run`. - [ ] Run `npm publish` only when intentionally publishing. -- [ ] After publish, update README install examples from local script usage to `npx cursor-os init`. +- [ ] README install examples use the `npx cursor-os` form (done in the release commit, not after). +- [ ] After publish, verify with `npm view cursor-os` and `npx cursor-os@latest init --dry-run --target `. diff --git a/docs/decision-log.md b/docs/decision-log.md new file mode 100644 index 0000000..5c5f746 --- /dev/null +++ b/docs/decision-log.md @@ -0,0 +1,45 @@ +# Decision Log — cursor-os (this repo) + +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-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. +- **Context:** Provenance attestation requires publishing from CI with OIDC; setting that up needs npm-side trusted-publisher configuration that doesn't exist yet. Shipping v0.2.0 should not block on it. +- **Alternatives:** A release workflow gated on tags with `NPM_TOKEN` — rejected for now: a half-configured workflow that fails on first use is worse than a documented manual process. CI already verifies the exact tarball end-to-end on every push. +- **Consequences:** No provenance badge on the first release. Revisit before v0.3: configure npm trusted publishing and add a tag-triggered release workflow with `--provenance`. + +## 2026-06-10 — Localization detection stays a placeholder-marker heuristic + +- **Decision:** `doctor` detects unfinished localization by counting `\bTODO\b` matches in `AGENTS.md` and `docs/repo-memory.md`. Instructional prose in those template files must not contain the literal word "TODO", and the localization prompt instructs deleting the install-time notes when done. +- **Context:** The previous instructional text contained "TODO", making the "installed and localized" state unreachable. +- **Alternatives:** A structured flag (e.g. `localized: true` in the version marker) — rejected for now: it adds write-state the agent must remember to set, while the heuristic is self-healing and matches what users see in the files. +- **Consequences:** Template prose edits must avoid the bare word "TODO" outside real placeholders. The localized-success smoke test guards this. + +## 2026-06-10 — Bare invocation prints help; a command is required + +- **Decision:** `node scripts/init.mjs` (and post-publish `npx cursor-os`) with no arguments prints help and writes nothing. `init` must be explicit. Supplying arguments without a command is an error. +- **Context:** The CLI previously defaulted to `init` into the current directory, so an exploratory bare run wrote 19 files wherever the user happened to be. +- **Alternatives:** Keep default-to-init (friendlier for the documented happy path) — rejected: a default action that mutates the filesystem is the wrong failure mode for a tool about to be published to npm. +- **Consequences:** Breaking pre-publish CLI change; README, help text, smoke tests, and CHANGELOG updated together. `init` compensates with an automatic post-install health check so the explicit command costs nothing in guidance. + +## 2026-06-10 — Upgrade semantics: skip-existing stays; doctor reports drift + +- **Decision:** Re-running `init` over an existing install continues to only add missing files and refresh the version marker — never overwrite. `doctor` compares the marker version to the current package version and notes drift, recommending a re-run of `init`. +- **Context:** v0.2 will introduce new/changed template files; users need a signal without risking their localized content. +- **Alternatives:** A diff/merge upgrade mode — deferred: high complexity, and localized files cannot be machine-merged safely. May revisit with a three-way-merge or `--force-file` flag if real demand appears. +- **Consequences:** Users on old templates must consciously re-run `init`; changed (not just added) template files won't propagate. Documented in README. + +## 2026-06-10 — Node.js floor raised to 20; CI tests 20/22 on Ubuntu and Windows + +- **Decision:** `engines.node >= 20`. CI matrix: Node 20 and 22 × ubuntu-latest and windows-latest, plus a checks job (`node --check`, `package.json` parse, `npm pack --dry-run`). +- **Context:** Node 18 is end-of-life; CI previously tested only Node 20 on Ubuntu while claiming `>=18` support, and the release checklist's machine-checkable items weren't enforced. +- **Alternatives:** Keep `>=18` for reach — rejected: claiming support for an EOL version CI doesn't test is a promise the project can't keep. +- **Consequences:** Slightly narrower install base; honest support claims; checklist items can no longer be skipped by a human. + +## 2026-06-10 — Localization is not auto-executed by the installer + +- **Decision:** The installer never invokes an AI to localize. Instead, `init` ends with an automatic doctor pass (placeholder count + next step) and prints a Cursor CLI one-liner (`cursor-agent -p "$(cat prompts/localize-cursor-os.md)"`) as an opt-in tip. +- **Context:** Proposal to run localization automatically as part of `npx cursor-os init`. +- **Alternatives:** Shell out to `cursor-agent` when detected — rejected for v0.x: spends the user's tokens without consent, produces unreviewed edits, and adds an external dependency to a deliberately dependency-free script. +- **Consequences:** Setup remains two steps, but the second step is now printed at the exact moment it's needed. Revisit as an explicit `--localize` opt-in flag for v0.2+. diff --git a/docs/quality-rubric.md b/docs/quality-rubric.md new file mode 100644 index 0000000..bfc260e --- /dev/null +++ b/docs/quality-rubric.md @@ -0,0 +1,43 @@ +# Quality Rubric + +Gate to check before claiming any change is done. All items must pass with evidence. + +## Correctness + +- [ ] Meets the stated acceptance criteria — the actual request, not an adjacent one. +- [ ] Edge cases and invalid input handled, not just the happy path. +- [ ] Error and empty states are handled deliberately, not left to crash or swallow silently. + +## Fit + +- [ ] Reuses existing patterns and utilities; no parallel abstractions invented. +- [ ] Changes are surgical and scoped to the task; no unrelated refactors mixed in. +- [ ] Zero new dependencies added without explicit justification (this repo is zero-dep by design). + +## Maintainability + +- [ ] The next developer can understand where the change lives and why. +- [ ] New abstractions named clearly with one responsibility. +- [ ] Complex decisions recorded in `docs/decision-log.md`. + +## Safety + +- [ ] No-clobber invariant preserved: `install()` never overwrites existing user files. +- [ ] `template/` remains framework-neutral; no stack-specific content added. +- [ ] Template prose contains no bare "TODO" outside real placeholder markers. + +## 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 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. +- [ ] If new template file added: `doctor` output reflects it (derived from `template/` at runtime). + +## Communication + +- [ ] Report covers what changed, how it was verified, and remaining risks. +- [ ] `docs/repo-memory.md` updated if a durable fact changed. +- [ ] `docs/decision-log.md` appended if a notable decision was made. +- [ ] `CHANGELOG.md` updated for any user-visible change. diff --git a/docs/repo-memory.md b/docs/repo-memory.md new file mode 100644 index 0000000..24bd5ea --- /dev/null +++ b/docs/repo-memory.md @@ -0,0 +1,59 @@ +# Repo Memory + +Durable facts about this repository. Keep it short, current, and true — stale memory is worse than none. + +## What this project is + +Cursor OS: an installable operating layer that makes Cursor project-aware. Developers run the installer into any repo, then run the localization prompt once in Cursor to fill in project-specific context. Published to npm as `cursor-os`. + +## Stack and key dependencies + +- **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) +- **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) +examples/ Before/after localization walkthrough +docs/ This repo's own decision log (not installed into user projects) +.cursor/ cursor-os installed on itself (dogfooded) +``` + +## Commands that matter + +- Install: none (zero dependencies) +- Test: `npm test` +- Lint / syntax-check: `node --check scripts/init.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` +- Build: none (no compile step) + +## Conventions and gotchas + +- **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. +- **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". +- **Template prose must not contain the bare placeholder marker word outside real placeholders.** Instructional notes use alternative phrasing. This is what lets `doctor` reach its "installed and localized" success state. +- **`docs/decision-log.md` at repo root** is this project's own decision record and is NOT the same as `template/docs/decision-log.md` (the blank one that gets installed into user projects). + +## Known constraints + +- Installer must remain dependency-free (`SECURITY.md:41`, root `AGENTS.md`) +- Must never overwrite user files (core safety promise; most-tested behaviour in smoke suite) +- Template must remain framework-neutral (no stack presets) +- Bare `npx cursor-os` must never write files — command required +- Node ≥ 20 required; no Node 18 support (EOL, decision logged 2026-06-10) + +## Recently learned facts + +- 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). diff --git a/examples/localization-example.md b/examples/localization-example.md index 24db889..cadf6c7 100644 --- a/examples/localization-example.md +++ b/examples/localization-example.md @@ -21,7 +21,7 @@ Cursor guesses at the routing style, invents an ORM method that doesn't match wh You run: ```bash -node ../cursor-os/scripts/init.mjs init +npx cursor-os init ``` The following files are now in the project root: @@ -48,19 +48,19 @@ prompts/update-repo-memory.md At this point, Cursor sees the files, but `AGENTS.md` and `docs/repo-memory.md` contain only TODO markers. The OS is installed but not yet project-aware. -Running `node scripts/init.mjs doctor` confirms: +Running `npx cursor-os doctor` confirms: ``` -Cursor OS v0.1.0 — doctor +Cursor OS vX.Y.Z — doctor Target: /home/user/task-api ok AGENTS.md - note: 5 TODO placeholder(s) remain — run prompts/localize-cursor-os.md + note: 4 TODO placeholder(s) remain — run prompts/localize-cursor-os.md ok .cursor/rules/core.mdc ok .cursor/skills/implementation-loop/SKILL.md ok .cursor/agents/verifier.md ok docs/repo-memory.md - note: 12 TODO placeholder(s) remain — run prompts/localize-cursor-os.md + note: 10 TODO placeholder(s) remain — run prompts/localize-cursor-os.md ok docs/quality-rubric.md ok prompts/localize-cursor-os.md ok .cursor/.cursor-os-version @@ -68,6 +68,12 @@ Target: /home/user/task-api Cursor OS is installed. Run prompts/localize-cursor-os.md to complete setup. ``` +After localization fills the placeholders (and removes the install-time notes), the same command reports: + +``` +Cursor OS appears installed and localized. +``` + --- ## Stage 3: After running the localization prompt diff --git a/package.json b/package.json index 6a6075d..b1b3ffa 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "cursor-os", - "version": "0.1.0", + "version": "0.2.0", "description": "An installable operating layer that makes Cursor project-aware: a portable engineering contract, rules, memory docs, and prompts that adapt to any repository.", "type": "module", "bin": { "cursor-os": "./scripts/init.mjs" }, + "exports": { + ".": "./scripts/init.mjs" + }, "scripts": { "init": "node scripts/init.mjs init", "doctor": "node scripts/init.mjs doctor", @@ -14,7 +17,7 @@ }, "files": [ "template", - "scripts", + "scripts/init.mjs", "examples", "README.md", "LICENSE", @@ -38,7 +41,10 @@ "prompt-engineering", "software-engineering" ], - "author": "Emmanuel Tagbor", + "author": { + "name": "Emmanuel Tagbor", + "url": "https://github.com/KingEmma7" + }, "license": "MIT", "repository": { "type": "git", @@ -49,6 +55,6 @@ }, "homepage": "https://github.com/KingEmma7/cursor-os#readme", "engines": { - "node": ">=18" + "node": ">=20" } } diff --git a/scripts/init.mjs b/scripts/init.mjs index 4f913ba..94b14fe 100755 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -11,16 +11,20 @@ import { mkdirSync, copyFileSync, readdirSync, - statSync, + lstatSync, + realpathSync, } from "node:fs"; import { fileURLToPath } from "node:url"; -import { dirname, join, relative } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, ".."); const templateDir = join(repoRoot, "template"); const MARKER_REL = join(".cursor", ".cursor-os-version"); +// Prose files doctor scans for unfilled placeholder markers. +const TODO_FILES = ["AGENTS.md", join("docs", "repo-memory.md")]; + function readVersion() { try { const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")); @@ -37,10 +41,13 @@ function readVersion() { * command: "init" | "doctor" | null * * Supported forms: - * node init.mjs [init] [target] [--dry-run] [--target DIR] + * node init.mjs init [target] [--dry-run] [--target DIR] * node init.mjs doctor [target] [--target DIR] * node init.mjs --help | -h * node init.mjs --version | -v + * + * A command is required when any other argument is given. A bare invocation + * with no arguments prints help — it never writes files. */ function parseArgs(argv) { const args = { @@ -49,6 +56,7 @@ function parseArgs(argv) { target: process.cwd(), help: false, version: false, + bare: argv.length === 0, errors: [], }; let targetSet = false; @@ -76,9 +84,8 @@ function parseArgs(argv) { args.errors.push(`unknown option: ${a}`); } else if (!targetSet) { - // Any bare, non-flag, non-subcommand word is a target path. - // This preserves backwards-compat for `node init.mjs my-project` as well - // as absolute or slash-prefixed paths. + // Any bare, non-flag, non-subcommand word is a target path + // (absolute, relative, or a plain directory name). args.target = a; targetSet = true; } else { @@ -86,9 +93,10 @@ function parseArgs(argv) { } } - // Default command - if (args.command === null && !args.help && !args.version) { - args.command = "init"; + // 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'"); } if (args.command === "doctor" && args.dryRun) { @@ -102,10 +110,10 @@ function parseArgs(argv) { const HELP = `Cursor OS — installer Usage: - node scripts/init.mjs [command] [target] [options] + cursor-os [target] [options] Commands: - init Install Cursor OS into the target directory (default) + init Install Cursor OS into the target directory doctor Check whether Cursor OS is installed in the target directory Arguments: @@ -118,12 +126,17 @@ Options: -h, --help Show this help Examples: - node scripts/init.mjs - node scripts/init.mjs init - node scripts/init.mjs init --dry-run - node scripts/init.mjs init --target ./my-project - node scripts/init.mjs doctor - node scripts/init.mjs doctor --target ./my-project + cursor-os init + cursor-os init --dry-run + cursor-os init --target ./my-project + cursor-os doctor + cursor-os doctor --target ./my-project + +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 . + When running from a local checkout: node scripts/init.mjs The installer copies AGENTS.md, .cursor/, docs/, and prompts/ into the target. It never overwrites existing user files — it skips them and reports. @@ -131,12 +144,17 @@ After installing, open Cursor and run prompts/localize-cursor-os.md.`; // ── File helpers ────────────────────────────────────────────────────────────── -/** Recursively collect files under dir as paths relative to dir. */ +/** + * Recursively collect files under dir as paths relative to dir. + * Uses lstat so symlinked directories are not recursed into (prevents cycles). + * Symlinks to files are included and copied as their target's content by + * copyFileSync; symlinks to directories are skipped (not recursed, not copied). + */ function listFiles(dir) { const out = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); - if (statSync(full).isDirectory()) { + if (lstatSync(full).isDirectory()) { for (const child of listFiles(full)) out.push(join(entry, child)); } else { out.push(entry); @@ -207,9 +225,19 @@ function doctorChecks() { ]; } +/** Read the installed version from the marker file, or null if unreadable. */ +function readMarkerVersion(target) { + try { + const content = readFileSync(join(target, MARKER_REL), "utf8"); + return content.match(/cursor-os (\S+)/)?.[1] ?? null; + } catch { + return null; + } +} + /** * Check whether Cursor OS appears installed in target. - * Returns { checks: [{label, present, note}], todoCount, target }. + * Returns { checks: [{label, present, note}], todoCount, markerVersion, target }. * Never modifies files. */ export function doctor({ target } = {}) { @@ -221,17 +249,14 @@ export function doctor({ target } = {}) { let note = null; let todoCount = 0; - if (present) { - // Flag unfilled TODO placeholders in key prose files - const TODO_FILES = ["AGENTS.md", join("docs", "repo-memory.md")]; - if (TODO_FILES.includes(rel)) { - try { - const content = readFileSync(fullPath, "utf8"); - todoCount = (content.match(/\bTODO\b/g) ?? []).length; - if (todoCount > 0) note = `${todoCount} TODO placeholder(s) remain — run prompts/localize-cursor-os.md`; - } catch { - // ignore read errors - } + // Flag unfilled TODO placeholders in key prose files + if (present && TODO_FILES.includes(rel)) { + try { + const content = readFileSync(fullPath, "utf8"); + todoCount = (content.match(/\bTODO\b/g) ?? []).length; + if (todoCount > 0) note = `${todoCount} TODO placeholder(s) remain — run prompts/localize-cursor-os.md`; + } catch { + // ignore read errors } } @@ -239,7 +264,7 @@ export function doctor({ target } = {}) { }); const todoCount = checks.reduce((n, c) => n + c.todoCount, 0); - return { checks, todoCount, target }; + return { checks, todoCount, markerVersion: readMarkerVersion(target), target }; } // ── CLI entry point ─────────────────────────────────────────────────────────── @@ -266,9 +291,31 @@ function runInit(args) { if (args.dryRun) { console.log("\nDry run complete — no files were written."); + return; + } + + // Post-install health check: confirm the install and surface what + // localization still needs to fill in, so the next step is unmissable. + const health = doctor({ target: args.target }); + const missing = health.checks.filter((c) => !c.present).length; + // Show a relative path only when the target is inside this checkout. + const rel = relative(repoRoot, args.target); + let where = rel || "this repo"; + if (rel.startsWith("..")) where = resolve(args.target); + + console.log("\nPost-install check:"); + if (missing > 0) { + console.log(` ${missing} expected file(s) missing — run: cursor-os doctor --target ${args.target}`); + } else if (health.todoCount > 0) { + console.log(` All files installed. ${health.todoCount} placeholder(s) await localization.`); } else { - const where = relative(repoRoot, args.target) || "this repo"; - console.log(`\nDone. Next: open Cursor and run prompts/localize-cursor-os.md to adapt the OS to ${where}.`); + console.log(" All files installed and localized."); + } + + if (health.todoCount > 0) { + 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)"'); } } @@ -286,18 +333,36 @@ function runDoctor(args) { if (!present) allPresent = false; } + if (result.markerVersion && result.markerVersion !== version) { + console.log(`\n note: installed from cursor-os ${result.markerVersion}; current is ${version}.`); + console.log(" Re-run init to add any files introduced since (existing files are never overwritten)."); + } + console.log(""); if (allPresent && result.todoCount === 0) { console.log("Cursor OS appears installed and localized."); } else if (allPresent) { console.log("Cursor OS is installed. Run prompts/localize-cursor-os.md to complete setup."); } else { - console.log("Cursor OS is not fully installed. Run: node scripts/init.mjs init"); + console.log("Cursor OS is not fully installed. Run: cursor-os init"); process.exitCode = 1; } } +// Minimum supported Node major version. Keep in sync with package.json engines. +const MIN_NODE_MAJOR = 20; + function main() { + // engines in package.json is advisory only — fail fast with a clear message. + const nodeMajor = Number(process.versions.node.split(".")[0]); + if (nodeMajor < MIN_NODE_MAJOR) { + console.error( + `Error: cursor-os requires Node.js ${MIN_NODE_MAJOR} or newer (you are running ${process.versions.node}).`, + ); + process.exitCode = 1; + return; + } + const args = parseArgs(process.argv.slice(2)); if (args.errors.length) { @@ -312,7 +377,7 @@ function main() { return; } - if (args.help) { + if (args.help || args.bare) { console.log(HELP); return; } @@ -330,6 +395,19 @@ function main() { } // Only run main when invoked directly, not when imported by the smoke test. -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { +// realpathSync normalizes symlinks (e.g. macOS /tmp → /private/tmp). +function isDirectInvocation() { + if (!process.argv[1]) return false; + try { + return ( + realpathSync(fileURLToPath(import.meta.url)) === + realpathSync(process.argv[1]) + ); + } catch { + return fileURLToPath(import.meta.url) === process.argv[1]; + } +} + +if (isDirectInvocation()) { main(); } diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 800d998..decbebc 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -240,6 +240,73 @@ withTempDir((dir) => { check("doctor reports actual TODO placeholder count", result.todoCount > 0); }); +// 8b. doctor — a fully localized install reaches the success state. +console.log("\ndoctor (localized install):"); +withTempDir((dir) => { + install({ target: dir }); + // Simulate localization: real content, no placeholder markers left. + writeFileSync( + join(dir, "AGENTS.md"), + [ + "# AGENTS.md", + "", + "## Project context", + "", + "- **What this is:** Internal task API for ops teams.", + "- **Stack:** Node.js 22, Fastify, PostgreSQL 16, Fly.io.", + "- **How it's organized:** See docs/architecture.md.", + "- **Build / test / run commands:** npm install / npm test / npm run dev.", + "", + ].join("\n"), + "utf8", + ); + writeFileSync( + join(dir, "docs", "repo-memory.md"), + [ + "# Repo Memory", + "", + "## What this project is", + "", + "Internal task-management REST API for operations teams.", + "", + "## Commands that matter", + "", + "- Install: npm install", + "- Test: npm test", + "", + ].join("\n"), + "utf8", + ); + + const result = doctor({ target: dir }); + check("doctor reports zero placeholders on a localized install", result.todoCount === 0); + check("doctor still reports all files present", result.checks.every((c) => c.present)); + + const cli = runCli(["doctor", "--target", dir]); + check("CLI doctor localized install exits 0", cli.status === 0); + check( + "CLI doctor localized install reports installed and localized", + cli.stdout.includes("installed and localized"), + ); +}); + +// 8c. doctor — reports version drift between the marker and current package. +console.log("\ndoctor (version drift):"); +withTempDir((dir) => { + install({ target: dir }); + writeFileSync(join(dir, ".cursor", ".cursor-os-version"), "cursor-os 0.0.1\n", "utf8"); + + const result = doctor({ target: dir }); + check("doctor exposes the installed marker version", result.markerVersion === "0.0.1"); + + const cli = runCli(["doctor", "--target", dir]); + check("CLI doctor exits 0 despite version drift", cli.status === 0); + check( + "CLI doctor notes the version drift", + cli.stdout.includes("installed from cursor-os 0.0.1"), + ); +}); + // 9. CLI entry point coverage. console.log("\nCLI:"); withTempDir((dir) => { @@ -254,6 +321,15 @@ withTempDir((dir) => { const initDefault = runCli(["init", "--target", dir]); check("CLI init --target exits 0", initDefault.status === 0); check("CLI init --target creates AGENTS.md", existsSync(join(dir, "AGENTS.md"))); + check( + "CLI init prints a post-install check with placeholder count", + initDefault.stdout.includes("Post-install check:") && + initDefault.stdout.includes("await localization"), + ); + check( + "CLI init points at the localization prompt", + initDefault.stdout.includes("prompts/localize-cursor-os.md"), + ); const doctorInstalled = runCli(["doctor", "--target", dir]); check("CLI doctor installed dir exits 0", doctorInstalled.status === 0); @@ -268,9 +344,11 @@ withTempDir((dir) => { }); withTempDir((dir) => { + // Flags without a command must fail instead of silently defaulting to init. const directDryRun = runCli(["--dry-run"], { cwd: dir }); - check("CLI direct --dry-run exits 0", directDryRun.status === 0); - check("CLI direct --dry-run writes nothing", listAll(dir).length === 0); + check("CLI --dry-run without command exits non-zero", directDryRun.status === 1); + check("CLI --dry-run without command prints error", directDryRun.stderr.includes("missing command")); + check("CLI --dry-run without command writes nothing", listAll(dir).length === 0); }); withTempDir((dir) => { @@ -280,9 +358,19 @@ withTempDir((dir) => { }); withTempDir((dir) => { + // Bare invocation is read-only: prints help, writes nothing. + const bare = runCli([], { cwd: dir }); + check("CLI bare invocation exits 0", bare.status === 0); + check("CLI bare invocation prints help", bare.stdout.includes("Usage:") && bare.stdout.includes("doctor")); + check("CLI bare invocation writes nothing", listAll(dir).length === 0); +}); + +withTempDir((dir) => { + // A bare path without a command must fail instead of installing. const legacyPath = runCli([dir]); - check("CLI bare path target exits 0 for backwards compatibility", legacyPath.status === 0); - check("CLI bare path target creates AGENTS.md", existsSync(join(dir, "AGENTS.md"))); + check("CLI bare path without command exits non-zero", legacyPath.status === 1); + check("CLI bare path without command prints error", legacyPath.stderr.includes("missing command")); + check("CLI bare path without command writes nothing", listAll(dir).length === 0); }); withTempDir((dir) => { @@ -292,19 +380,18 @@ withTempDir((dir) => { }); withTempDir((dir) => { - // Bare relative name with no slash must be treated as a target, not an error - const bareRelative = runCli(["my-project"], { cwd: dir }); - check("CLI bare relative name treated as target, exits 0", bareRelative.status === 0); - check("CLI bare relative name creates AGENTS.md inside it", existsSync(join(dir, "my-project", "AGENTS.md"))); + // Bare relative name still resolves as a target when init is explicit. + const bareRelative = runCli(["init", "my-project"], { cwd: dir }); + check("CLI init with bare relative name exits 0", bareRelative.status === 0); + check("CLI init with bare relative name creates AGENTS.md inside it", existsSync(join(dir, "my-project", "AGENTS.md"))); }); withTempDir((dir) => { // Options before subcommand must be allowed: --target DIR doctor - const targetBeforeCmd = runCli(["--target", dir, "doctor"]); install({ target: dir }); - const targetBeforeCmd2 = runCli(["--target", dir, "doctor"]); - check("CLI --target before subcommand is accepted", targetBeforeCmd2.status === 0); - check("CLI --target before subcommand runs doctor", targetBeforeCmd2.stdout.includes("doctor")); + const targetBeforeCmd = runCli(["--target", dir, "doctor"]); + check("CLI --target before subcommand is accepted", targetBeforeCmd.status === 0); + check("CLI --target before subcommand runs doctor", targetBeforeCmd.stdout.includes("doctor")); }); withTempDir((dir) => { diff --git a/template/AGENTS.md b/template/AGENTS.md index c47f044..63d1820 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -2,7 +2,7 @@ This file is the engineering contract for this repository. Cursor reads it automatically; other tools can use it as plain markdown project guidance. Keep it accurate as the project's source of truth for *how* work gets done here. -> After installing Cursor OS, run the localization prompt (`prompts/localize-cursor-os.md`) so the sections below describe *this* project specifically. Replace the TODO markers with real details. +> After installing Cursor OS, run the localization prompt (`prompts/localize-cursor-os.md`) so the sections below describe *this* project specifically. Replace the placeholder markers with real details, then delete this note. ## Project context diff --git a/template/docs/repo-memory.md b/template/docs/repo-memory.md index 2fb1ab6..bdf999a 100644 --- a/template/docs/repo-memory.md +++ b/template/docs/repo-memory.md @@ -2,7 +2,7 @@ Durable facts about this repository that an agent should know before working in it. Keep it short, current, and true — stale memory is worse than none. Update it whenever a fact here changes or you learn something the next session would need. -> Most of this is filled in by the localization prompt (`prompts/localize-cursor-os.md`). Replace TODO markers with real details; delete sections that don't apply. Never invent facts — leave a TODO if unsure. +> Most of this is filled in by the localization prompt (`prompts/localize-cursor-os.md`). Replace placeholder markers with real details; delete sections that don't apply. Never invent facts — leave a placeholder marker if unsure. Delete this note once localization is done. ## What this project is diff --git a/template/prompts/localize-cursor-os.md b/template/prompts/localize-cursor-os.md index 8b6b17d..30dab3d 100644 --- a/template/prompts/localize-cursor-os.md +++ b/template/prompts/localize-cursor-os.md @@ -34,9 +34,9 @@ Report any other file you believe should change, but do not change it. 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. -3. **Fill `docs/repo-memory.md`.** Replace every TODO you can verify: what the project is, stack, organization, commands, conventions, and constraints. Leave TODOs only where the repo genuinely doesn't answer the question. +3. **Fill `docs/repo-memory.md`.** Replace every TODO you can verify: what the project is, stack, organization, commands, conventions, and constraints. Leave TODOs only where the repo genuinely doesn't answer the question. When done, delete the install-time blockquote note near the top of the file. -4. **Fill `AGENTS.md` project-context section.** Keep it to a few true sentences; point to `docs/architecture.md` for detail rather than duplicating it. +4. **Fill `AGENTS.md` project-context section.** Keep it to a few true sentences; point to `docs/architecture.md` for detail rather than duplicating it. When done, delete the install-time blockquote note near the top of the file. 5. **Create `docs/architecture.md`** if the project is non-trivial: the main modules/services, how data flows, key boundaries, and important patterns — all grounded in the code. Skip it for a tiny repo and say why.