Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/defect.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ labels: []
## Environment

- Operating system and architecture:
- `<tool>-setup-system --version`:
- `cursor-setup-system --version`:
- Product version being configured:

## Target state, if relevant
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
contents: read
uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-ci.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29
with:
toolchain: '1.98.0'
toolchain: '1.98.1'
# The three-OS matrix is the evidence ADR-0113 asks for, and standard
# hosted runners are free with unlimited minutes on a public repository.
test_matrix_os: '["ubuntu-latest", "macos-latest", "windows-latest"]'
Expand Down Expand Up @@ -99,7 +99,7 @@ jobs:
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
toolchain: '1.98.0'
toolchain: '1.98.1'
# The same reason the release job gives. This job's whole subject is
# the bytes that come out of the build, so restoring someone else's
# `target/` and then reading its import table would answer a question
Expand Down Expand Up @@ -181,9 +181,13 @@ jobs:
# bypassed, or read by someone with no reason to trust it.
#
# Measured on Linux across all seven binaries, then on macOS by
# `0.0.8`'s own run, and the two agree exactly: the six that declare
# `launch` import `execvp` and nothing else; antigravity, which
# declares none, imports no spawn symbol at all, on either platform.
# `0.0.8`'s own run, and the two agree exactly: every build that
# declares `launch` imports `execvp` and nothing else. `0.0.8` still
# carried the era when antigravity declared none and imported no
# spawn symbol at all -- all seven declare it now (antigravity's is
# a documented-home binding), so today the negative arm is enforced
# on nobody, and stays because the day a launch slips out of a
# declaration is the day the spawn symbol must fail here.
# So the linker drops what nothing calls and the absence is real
# rather than incidental -- which is what had to be true before this
# could be enforced anywhere.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
toolchain: '1.98.0'
toolchain: '1.98.1'

- name: Prove this is the native architecture, not emulation
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
toolchain: '1.98.0'
toolchain: '1.98.1'
# This action caches by default, and a release must not build on top
# of a cache. A run with weaker trust than this one can write the
# cache a release would restore, so a poisoned entry would be baked
Expand Down
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,23 @@ instructions, skills, commands, hooks, MCP entries, plugins and settings togethe
> verifies and installs with the network gone.
>
> `launch` starts the exact executable that install placed, never a name
> found on `PATH`, and points the product at the target through the
> environment variable its own documentation names.
> found on `PATH`, and runs it under a copied process home: the surfaces
> this product resolves from `HOME` itself are overlaid out of the target,
> so the session it assembles is the target's and not the caller's.

## Using it

```bash
cursor-setup-system list
cursor-setup-system install baseline --target ~/.tool-config
cursor-setup-system status --target ~/.tool-config
cursor-setup-system select full-auto --target ~/.tool-config
cursor-setup-system diff --target ~/.tool-config
cursor-setup-system reinstall --target ~/.tool-config
cursor-setup-system backups --target ~/.tool-config
cursor-setup-system hold --backup slot-000000000001 --reason "before the experiment" --target ~/.tool-config
cursor-setup-system restore --backup slot-000000000001 --target ~/.tool-config
cursor-setup-system remove --target ~/.tool-config
cursor-setup-system install baseline --target ~/.cursor
cursor-setup-system status --target ~/.cursor
cursor-setup-system select full-auto --target ~/.cursor
cursor-setup-system diff --target ~/.cursor
cursor-setup-system reinstall --target ~/.cursor
cursor-setup-system backups --target ~/.cursor
cursor-setup-system hold --backup slot-000000000001 --reason "before the experiment" --target ~/.cursor
cursor-setup-system restore --backup slot-000000000001 --target ~/.cursor
cursor-setup-system remove --target ~/.cursor
```

Every command takes an explicit `--target`. There is no default and no fallback
Expand All @@ -47,8 +48,8 @@ seven setup systems, expressed in each product's own format:

| | |
| --- | --- |
| `baseline` | a working floor: instructions plus a conservative configuration |
| `minimal` | the product's own defaults, and the state a restore proves it can reach |
| `baseline` | a working floor: instructions plus the shared autonomous posture |
| `minimal` | instructions plus the shared autonomous posture, and nothing else |
| `full-auto` | nothing asked and nothing sandboxed, in this product's own keys |
| `nddev-builder` | the full-auto posture plus the product-native NDDev authoring toolkit |

Expand Down
29 changes: 12 additions & 17 deletions SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,18 @@ A provider that advertised an operation it cannot perform would let a caller ask
for something that cannot be honoured, which is worse than not offering it.

All five core operations do work: `backup`, `restore`, `remove`, `install` and
`replace`, both from the local setup catalog and from an `ai-stp-bundle/1`
`replace`, both from the local setup catalog and from an `ai-stp-bundle/2`
arriving over the wire.

## Using this against a home you already have

**An owned namespace is removed whole.** The table below says what this build
owns; `remove` deletes each of those paths entirely, and a backup slot holds
what was there first. That includes content this build never wrote -- if the
product itself put a key in a configuration file this provider owns, `remove`
takes the file, not the keys this provider added to it.
**Removal follows receipts, not namespaces.** The table below says what this
build owns; `remove` withdraws the files this provider recorded writing, and
in a JSON file it owns it strips the keys it added rather than taking the
file. Anything under those paths this build never wrote stays. Emptying every
owned namespace is a separate, explicitly named operation: `reset`.

Measured, with the real product: launching Codex through `launch` and running
`mcp add` writes `~/.codex/config.toml` with an `[mcp_servers.*]` entry; a
later `install` captures that file into a slot and replaces it; a later
`remove` deletes it. The entry is not lost -- `backups` lists the slot as
*before install, setup none*, and restoring it returns the file byte for byte
-- but it is not in the target either.
No credential-free command is measured writing this product's home -- the dated measurement lives in `references/` and the absence is recorded, not assumed. The receipt discipline is the same for whatever arrives later: a file this provider wrote is captured into a slot before the next `install`, withdrawn by `remove`, and returned byte for byte by `restore`.

So: point `--target` at a home you are willing to have managed. `backups
--target <dir>` names every earlier state and which setup each preceded, and
Expand Down Expand Up @@ -229,19 +224,19 @@ So the page documents a directory the current product does not read. Recorded at

**Re-measured 2026-09-02 when the pin moved to 2026.08.31-4057e58**, this time across every JavaScript member of the package: `computeAgentsDirs()` is unchanged -- the workspace join and, under third-party extensibility, the workspace's `.claude/agents` -- and a home-joined form is still absent while the invented control is absent and the home joins for `commands`, `hooks.json`, `mcp.json` and `rules` are present. The consumer's cursor#94 asked for this directory at the home on the strength of the `**/.cursor/agents` globs, which are workspace-index rules. The answer at the home stays no; the workspace surface is declared under the `project` scope, where the product actually reads it. ([source](https://cursor.com/docs/subagents))

**`hooks`** -- **Corrected 2026-08-28: a user-level file exists.** This row read "a plugin manifest key, not a directory under the config home". The product resolves `userConfigPath: join(homedir(), ".cursor", "hooks.json")`, alongside an enterprise path and the manifest key. Not owned, for the same reason as `rules` and `commands`. Raised. ([source](https://cursor.com/docs/hooks) -- measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)
**`hooks`** -- **Corrected 2026-09-29: the comparison this ended on no longer holds.** The product resolves `userConfigPath: join(homedir(), ".cursor", "hooks.json")`, and the file form `hooks.json` is owned -- as are `rules` and `commands`, added 2026-08-28. What stays declined is the *directory* `hooks` this row names: ownership is by exact surface, and the directory form the manifest key implies is not a path this provider writes. ([source](https://cursor.com/docs/hooks) -- measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)

**`NDDEV-CURSOR-PROVIDER.json`** -- This provider's own state file: which setup is applied, the identity it recorded, and which slot reverses the last operation. Written by every operation and excluded from target identity, because counting it would leave a target different from the identity the operation just wrote. Not a projection surface and never ownable as one. (this provider's own contract; no vendor page is involved)

**`.cursor-setup-system`** -- This provider's own control directory: the target lock, the backup slots and their payloads. Kept out of the declaration for the same reason as the state file, and recorded here because the declined list is where a reader looks before opening a file to find out what it is. (this provider's own contract; no vendor page is involved)

**`plugins/cache`** -- The product's own plugin cache, a sibling of the owned `plugins/local`. Named in the same joins. It matters because this provider owns the parent `plugins` during the transition window, so a `remove` takes this with it. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)
**`plugins/cache`** -- The product's own plugin cache, a sibling of the owned `plugins/local`. Corrected 2026-09-29: the parent `plugins` is no longer owned -- owning it made `remove` take the product's own siblings, which is the cost that removed the declaration -- so a `remove` leaves this where it is. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)

**`plugins/marketplaces`** -- Where the product records the marketplaces a person added, sibling to `plugins/local`. Taken by a `remove` of the owned parent, which is the concrete cost of the transition window. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)
**`plugins/marketplaces`** -- Where the product records the marketplaces a person added, sibling to `plugins/local`. Corrected 2026-09-29: the parent `plugins` is no longer owned, so `remove` no longer takes this with it. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)

**`plugins/local-marketplaces.json`** -- The product's record of locally added marketplaces. Same sibling relationship and the same consequence. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)
**`plugins/local-marketplaces.json`** -- The product's record of locally added marketplaces. Same sibling relationship and, after 2026-09-29, the same standing: the parent is not owned, so `remove` leaves it alone. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)

**`plugins/installed_plugins.json`** -- The product's own record of what it installed. A `remove` of the owned parent takes it, and the product then no longer knows about plugins a person installed by hand. Recoverable from the capture that runs first, and the sharpest single reason the window should close when the consumer's corpus objects naming `plugins` retire. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)
**`plugins/installed_plugins.json`** -- The product's own record of what it installed. Corrected 2026-09-29: the parent `plugins` is no longer owned, so `remove` leaves this record -- and the product's knowledge of hand-installed plugins -- alone. (measured in the 2026.08.25-3e8eec8 linux/x86_64 bytes (sha256:7a212e5a...), digest verified before reading)

**`cli-runtime-state`** -- One row for the subtree a run leaves behind: `agent-cli-state.json`, `ai-tracking/ai-code-tracking.db`, `cli-workspaces.json`, `ide_state.json`, `plans`, `projects`. The product's own lifetime. (measured from the 2026.08.25-3e8eec8 bundle)

Expand Down
12 changes: 6 additions & 6 deletions crates/cursor-setup-system/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ pub const CURSOR: Harness = Harness {
documented_config_home: "~/.cursor",
config_home_env: "CURSOR_CONFIG_DIR",
// **Partial, and this is the one the inference got wrong.** This baseline's
// own note has said since 2026-08-28 that `cli-config.json` is *"one of the
// eight this build owns"* that follows the variable: `rules`, `commands`,
// own note has said since 2026-08-28 that `cli-config.json` is the one
// owned surface that follows the variable: `rules`, `commands`,
// `hooks.json`, `mcp.json` and the plugin pair are built from a literal
// join to the process home in `cursor-config/dist/paths.js` and reach no
// resolver at all. The declaration said launch anyway, because the rule
Expand Down Expand Up @@ -76,7 +76,7 @@ pub const CURSOR: Harness = Harness {
// `cursor` -- not `.cursor` -- then falls back to the home. The data
// root reads `CURSOR_DATA_DIR` and is not XDG-aware.
//
// **Of the eight namespaces this build owns, exactly one goes through
// **Of the seven namespaces this build owns, exactly one goes through
// either.** `cli-config.json` is `join(configRoot(), "cli-config.json")`.
// `commands`, `rules`, `hooks.json`, `mcp.json` and the `plugins` pair
// are built from a literal `join(homedir(), ".cursor", ...)` and go
Expand All @@ -85,8 +85,8 @@ pub const CURSOR: Harness = Harness {
// `permissions.json` and `statsig-cache.json` -- none of them ours.
//
// The first note said the product reads `$XDG_CONFIG_HOME/cursor`
// without qualification. True of the resolver, false of seven of the
// eight paths this provider writes: a measurement of one thing stated
// without qualification. True of the resolver, false of six of the
// seven paths this provider writes: a measurement of one thing stated
// as a fact about another.
config_home_note: "XDG_CONFIG_HOME moves cli-config.json to $XDG_CONFIG_HOME/cursor and moves nothing else this build owns",
control_directory: ".cursor-setup-system",
Expand Down Expand Up @@ -666,7 +666,7 @@ mod tests {
}
/// Three postures, on every one of the seven.
///
/// `baseline` is a working floor, `minimal` is the product's own defaults,
/// `baseline` is a working floor, `minimal` is the shared autonomous posture and nothing else,
/// and `full-auto` asks nothing and sandboxes nothing. A caller who learns
/// them on one product knows them on all seven, which is the whole reason
/// the names are the estate's rather than each harness's.
Expand Down
42 changes: 37 additions & 5 deletions crates/harness-runtime/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ pub struct Examined {
/// half is the part that took the measuring:
///
/// * `SKILL.md`, and a file directly under `agents/`, are entry points. Cursor's
/// own generator writes `{name, description}` for exactly these.
/// own generator writes `{name, description}` for exactly these. A codex
/// `agents/<name>.toml` is the same obligation in TOML: the product refuses
/// the file by name when `name` or `description` is absent, so the check
/// reads the keys instead of frontmatter.
/// * A file under `references/` is **not** -- it is a document a skill links to,
/// and requiring frontmatter there would be inventing a rule.
/// * A file under `commands/` is **not**. Cursor's loader builds
Expand Down Expand Up @@ -357,10 +360,18 @@ pub fn undescribed(setups: &[Setup]) -> Examined {
found.push(format!("{} cannot read {name:?}", setup.manifest.id));
continue;
};
let named = if std::path::Path::new(&name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
{
toml_names
} else {
frontmatter_names
};
for key in ["name", "description"] {
if !frontmatter_names(&text, key) {
if !named(&text, key) {
found.push(format!(
"{} ships {name:?} with no `{key}` in its frontmatter, and a component \
"{} ships {name:?} with no `{key}` the product reads, and a component \
the product cannot describe is one the model cannot choose",
setup.manifest.id
));
Expand Down Expand Up @@ -767,13 +778,34 @@ fn is_entry_point(relative: &str) -> bool {
if leaf.eq_ignore_ascii_case("SKILL.md") {
return true;
}
// `agents/<name>.md`, and only directly under it.
// `agents/<name>.md` and `agents/<name>.toml`, and only directly under it.
// The `.toml` form is how codex declares a role file; it is measured there
// to carry the same two keys in TOML spelling.
parts.len() >= 2
&& parts[parts.len() - 2] == "agents"
&& std::path::Path::new(leaf)
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("toml")
})
}

/// Whether a TOML entry point assigns `key` at the top level.
///
/// Read by structure rather than by searching the whole file: only lines
/// before the first `[section]` header count, because a `description` three
/// tables down belongs to that table and not to the agent the file names.
fn toml_names(text: &str, key: &str) -> bool {
text.lines()
.take_while(|line| !line.trim_start().starts_with('['))
.any(|line| {
let line = line.trim_start();
line.starts_with(key)
&& line[key.len()..]
.trim_start_matches([' ', '\t'])
.starts_with('=')
})
}

/// Whether a file opens with YAML frontmatter naming `key`.
Expand Down
Loading
Loading