claude-dev-env 2.14.1 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/AGENTS.md +6 -4
- package/bin/install-constants.mjs +51 -0
- package/bin/install.codex-rules.test.mjs +173 -0
- package/bin/install.cursor-rules.test.mjs +103 -0
- package/bin/install.mjs +130 -19
- package/bin/install.profile-root.test.mjs +8 -0
- package/bin/install.prune.test.mjs +2 -1
- package/bin/install.transaction.test.mjs +1 -0
- package/bin/install.uninstall-transaction.test.mjs +1 -0
- package/bin/resolve-install-root.mjs +43 -10
- package/codex-rules/claude-dev-env.rules +12 -0
- package/package.json +2 -1
- package/scripts/sync_to_cursor/AGENTS.md +3 -3
- package/scripts/sync_to_cursor/canonical_docs.py +11 -11
- package/scripts/sync_to_cursor/config/__init__.py +8 -0
- package/scripts/sync_to_cursor/engine.py +26 -1
- package/scripts/sync_to_cursor/rules.py +76 -5
- package/scripts/tests/AGENTS.md +2 -0
- package/scripts/tests/test_engine.py +102 -0
- package/scripts/tests/test_rules.py +79 -0
- package/scripts/sync_to_cursor/config.py +0 -5
package/bin/AGENTS.md
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
# bin
|
|
2
2
|
|
|
3
|
-
The installer and its companion modules. Running `npx claude-dev-env` (or `node bin/install.mjs`) copies package files into the managed root (`~/.claude/` by default; `CLAUDE_CONFIG_DIR` or `--target` selects another), merges hook entries into that root's `settings.json`, installs Git hooks,
|
|
3
|
+
The installer and its companion modules. Running `npx claude-dev-env` (or `node bin/install.mjs`) copies package files into the managed root (`~/.claude/` by default; `CLAUDE_CONFIG_DIR` or `--target` selects another), merges hook entries into that root's `settings.json`, installs Git hooks, writes `~/.mypy.ini` under the process home, and copies Codex exec-policy files into `~/.codex/rules` (`CODEX_HOME/rules` when that variable is set), and generates Cursor `.mdc` files into `~/.cursor/rules` from the installed Claude rules.
|
|
4
4
|
|
|
5
5
|
## Files
|
|
6
6
|
|
|
7
7
|
| File | Purpose |
|
|
8
8
|
|---|---|
|
|
9
9
|
| `install.mjs` | Main installer: builds a read-only plan via `install-plan.mjs`, then runs mutations inside `install-transaction.mjs` recovery (copy content directories, merge hooks, install skills, prune, git hooks, mypy.ini); routes `CLAUDE_HOME`, the manifest path, and `~/.mypy.ini` through `resolve-install-root.mjs`; resolves single or multi-profile targets before mutation and writes one ownership manifest per target |
|
|
10
|
-
| `resolve-install-root.mjs` | Pure install-root resolver: precedence `--target` > `CLAUDE_CONFIG_DIR` > `~/.claude`, separator-boundary containment, and the declared external allowlist for `~/.mypy.ini` |
|
|
10
|
+
| `resolve-install-root.mjs` | Pure install-root resolver: precedence `--target` > `CLAUDE_CONFIG_DIR` > `~/.claude`, separator-boundary containment, and the declared external allowlist for `~/.mypy.ini` plus files under the Codex rules directory and the Cursor home |
|
|
11
11
|
| `select-install-targets.mjs` | Pure target selection for main-default, explicit `--target`, and `--profile`/`--profiles`; rejects ambiguous or duplicate targets; builds per-target manifest records with `targetIdentity` and `managedRoot` |
|
|
12
12
|
| `install-plan.mjs` | Read-only install and uninstall plans: install preflight (managed root, source conflicts, Python, settings when hooks install) and uninstall preflight (settings JSON before removal, removable vs skipped manifest records), freezes plans E2/F execute |
|
|
13
13
|
| `install-transaction.mjs` | Install, update, and uninstall transaction journal: captures prior settings, manifest, managed files, and `core.hooksPath`, restores them on failure, and supports fault injection phases for recovery tests |
|
|
14
14
|
| `install.transaction.test.mjs` | Unit and sandbox installer tests for snapshot/restore and fault phases (`after_file_staging`, `after_settings_write`, `after_git_config`, `after_manifest_write`) |
|
|
15
15
|
| `install.uninstall-transaction.test.mjs` | Uninstall plan preflight and recovery: malformed/non-object settings fail before removal, each fault phase restores files/settings/manifest/`core.hooksPath`, retry succeeds, selected-root containment |
|
|
16
16
|
| `install.profile-root.test.mjs` | Contract tests for the install-root resolver: precedence, containment boundary, external allowlist, and the install.mjs import smoke check |
|
|
17
|
-
| `install-
|
|
17
|
+
| `install.codex-rules.test.mjs` | Tests that Codex exec-policy files copy to `~/.codex/rules`, honor `CODEX_HOME`, skip `--only journal`, and uninstall without touching `default.rules` |
|
|
18
|
+
| `install.cursor-rules.test.mjs` | Tests that Cursor `.mdc` files generate into `~/.cursor/rules` from Claude rules, skip `--only journal`, and leave a local extra `.mdc` in place |
|
|
19
|
+
| `install-constants.mjs` | The named values `install.mjs` reads: `SKIPPED_SOURCE_ENTRY_NAMES` and `SKIPPED_SOURCE_FILE_EXTENSIONS` for the build artifacts the source walk leaves behind, `RUN_BACKUP_DIRECTORY_NAME_PATTERN` for the timestamp shape a run backup directory carries, `MANAGED_SKILLS_DIRECTORY_NAME` and `MANAGED_HOOKS_DIRECTORY_NAME` for the directory name each of those trees carries in a package source and under `~/.claude` — read by the copy loops, the hooks.json reads, the git-hook shims, the mypy configuration, and the prunes alike — `SETTINGS_FILE_NAME` for the settings file the merge, the retired-hook prune, and the uninstall purge share, and `MYPY_INI_FILE_NAME` for the home-directory file `install_mypy_ini.mjs` writes, plus the Codex home and rules directory names `resolve-install-root.mjs` uses |
|
|
18
20
|
| `ever-shipped-skills.mjs` | Static `EVER_SHIPPED_SKILL_NAMES` set of every top-level skill directory name the package has shipped; the installer subtracts the current skill set from it to prune retired skills left under `~/.claude/skills` |
|
|
19
21
|
| `expand_home_directory_tokens.mjs` | Expands residual `$HOME` / `${HOME}` / `~/` tokens in settings.json hook and statusLine commands to absolute home paths at install time (literal-safe for homes that contain `$`) |
|
|
20
22
|
| `git_hooks_installer.mjs` | Installs or updates the `pre-commit`, `pre-push`, and `post-commit` Git hooks in the user's git config; writes hook scripts that delegate to the installed Python hooks |
|
|
@@ -78,7 +80,7 @@ A run that moves nothing sweeps nothing, so every recovery point the user holds
|
|
|
78
80
|
|
|
79
81
|
`--uninstall` builds a read-only uninstall plan, captures a recovery snapshot, then removes each file the plan lists.
|
|
80
82
|
|
|
81
|
-
Settings JSON is validated before any removal. A malformed or non-object `settings.json` fails closed with the managed files still on disk. Each manifest record passes a containment guard: the path resolves under `~/.claude`, or it names the `~/.mypy.ini` the install writes in the home directory. Every other record is skipped with a warning and counted. Skipping keeps one malformed record — hand-edited, or written by an installer that ran against a different home — from stranding the user with a half-removed install. The purge removes every legitimate record, clears the manifest, and reports the skipped count.
|
|
83
|
+
Settings JSON is validated before any removal. A malformed or non-object `settings.json` fails closed with the managed files still on disk. Each manifest record passes a containment guard: the path resolves under `~/.claude`, or it names the `~/.mypy.ini` the install writes in the home directory, or it sits under the Codex rules directory. Every other record is skipped with a warning and counted. Skipping keeps one malformed record — hand-edited, or written by an installer that ran against a different home — from stranding the user with a half-removed install. The purge removes every legitimate record, clears the manifest, and reports the skipped count.
|
|
82
84
|
|
|
83
85
|
The uninstall runs inside the same snapshot/restore journal as install: prior settings, manifest, managed files, and `core.hooksPath` restore when a later phase fails, so a retry starts from a complete ownership record. The journal is discarded only after a successful commit.
|
|
84
86
|
|
|
@@ -85,3 +85,54 @@ export const SETTINGS_FILE_NAME = 'settings.json';
|
|
|
85
85
|
* removes the file the install created.
|
|
86
86
|
*/
|
|
87
87
|
export const MYPY_INI_FILE_NAME = '.mypy.ini';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Environment variable Codex uses for its config home. When unset, Codex reads
|
|
91
|
+
* `~/.codex`. The installer copies shipped exec-policy files into
|
|
92
|
+
* `<that home>/rules`.
|
|
93
|
+
*/
|
|
94
|
+
export const CODEX_HOME_ENVIRONMENT_VARIABLE = 'CODEX_HOME';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Directory name Codex uses under the user home when `CODEX_HOME` is unset.
|
|
98
|
+
*/
|
|
99
|
+
export const DEFAULT_CODEX_DIRECTORY_NAME = '.codex';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Directory name under the Codex home that holds `*.rules` exec-policy files.
|
|
103
|
+
* Codex loads every file in that directory; see `load_exec_policy` in Codex.
|
|
104
|
+
*/
|
|
105
|
+
export const CODEX_RULES_DIRECTORY_NAME = 'rules';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Package subdirectory that holds the shipped Codex exec-policy files. The
|
|
109
|
+
* installer copies this tree into the Codex rules directory, not into
|
|
110
|
+
* `~/.claude/`.
|
|
111
|
+
*/
|
|
112
|
+
export const CODEX_RULES_PACKAGE_DIRECTORY_NAME = 'codex-rules';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Shipped exec-policy file name. A distinct name keeps a local `default.rules`
|
|
116
|
+
* file in place.
|
|
117
|
+
*/
|
|
118
|
+
export const CODEX_RULES_SHIPPED_FILE_NAME = 'claude-dev-env.rules';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Directory name Cursor uses under the user home for editor config.
|
|
122
|
+
*/
|
|
123
|
+
export const DEFAULT_CURSOR_DIRECTORY_NAME = '.cursor';
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Directory name under the Cursor home that holds generated `.mdc` rule files.
|
|
127
|
+
*/
|
|
128
|
+
export const CURSOR_RULES_DIRECTORY_NAME = 'rules';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Installed script that writes Cursor `.mdc` files from Claude rules.
|
|
132
|
+
*/
|
|
133
|
+
export const CURSOR_SYNC_SCRIPT_FILE_NAME = 'sync_to_cursor.py';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Windows Python launcher command the installer may bake into hook settings.
|
|
137
|
+
*/
|
|
138
|
+
export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import {
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import {
|
|
16
|
+
resolveInstallRoot,
|
|
17
|
+
isAllowedInstallDestination,
|
|
18
|
+
} from './resolve-install-root.mjs';
|
|
19
|
+
import {
|
|
20
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
21
|
+
CODEX_RULES_DIRECTORY_NAME,
|
|
22
|
+
CODEX_RULES_PACKAGE_DIRECTORY_NAME,
|
|
23
|
+
CODEX_RULES_SHIPPED_FILE_NAME,
|
|
24
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
25
|
+
} from './install-constants.mjs';
|
|
26
|
+
import { CONTENT_DIRECTORIES, INSTALL_GROUPS } from './install.mjs';
|
|
27
|
+
|
|
28
|
+
const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
|
|
30
|
+
const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
|
|
31
|
+
const SHIPPED_RULES_SOURCE_PATH = join(
|
|
32
|
+
PACKAGE_DIRECTORY,
|
|
33
|
+
CODEX_RULES_PACKAGE_DIRECTORY_NAME,
|
|
34
|
+
CODEX_RULES_SHIPPED_FILE_NAME,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function runInstaller(homeDirectory, extraArguments) {
|
|
38
|
+
return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
|
|
39
|
+
cwd: PACKAGE_DIRECTORY,
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
HOME: homeDirectory,
|
|
44
|
+
USERPROFILE: homeDirectory,
|
|
45
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
46
|
+
[CODEX_HOME_ENVIRONMENT_VARIABLE]: join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('CONTENT_DIRECTORIES omits codex-rules because that tree installs to the Codex home', () => {
|
|
52
|
+
assert.equal(CONTENT_DIRECTORIES.includes(CODEX_RULES_PACKAGE_DIRECTORY_NAME), false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the core group installs Codex exec-policy files', () => {
|
|
56
|
+
assert.equal(INSTALL_GROUPS.core.includeCodexRules, true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('resolveInstallRoot names ~/.codex/rules and allows files under it', () => {
|
|
60
|
+
const homeDirectory = join(tmpdir(), 'cdev-codex-rules-home');
|
|
61
|
+
const resolution = resolveInstallRoot({
|
|
62
|
+
homeDirectory,
|
|
63
|
+
environment: {},
|
|
64
|
+
explicitTarget: null,
|
|
65
|
+
});
|
|
66
|
+
const expectedDirectory = join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME, CODEX_RULES_DIRECTORY_NAME);
|
|
67
|
+
assert.equal(resolution.codexRulesInstallDirectory, expectedDirectory);
|
|
68
|
+
assert.equal(
|
|
69
|
+
isAllowedInstallDestination(join(expectedDirectory, CODEX_RULES_SHIPPED_FILE_NAME), resolution),
|
|
70
|
+
true,
|
|
71
|
+
);
|
|
72
|
+
assert.equal(
|
|
73
|
+
isAllowedInstallDestination(join(homeDirectory, '.ssh', 'id_rsa'), resolution),
|
|
74
|
+
false,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('CODEX_HOME relocates the Codex rules destination', () => {
|
|
79
|
+
const homeDirectory = join(tmpdir(), 'cdev-codex-home-default');
|
|
80
|
+
const relocatedHome = join(tmpdir(), 'cdev-codex-home-relocated');
|
|
81
|
+
const resolution = resolveInstallRoot({
|
|
82
|
+
homeDirectory,
|
|
83
|
+
environment: { [CODEX_HOME_ENVIRONMENT_VARIABLE]: relocatedHome },
|
|
84
|
+
explicitTarget: null,
|
|
85
|
+
});
|
|
86
|
+
assert.equal(
|
|
87
|
+
resolution.codexRulesInstallDirectory,
|
|
88
|
+
join(relocatedHome, CODEX_RULES_DIRECTORY_NAME),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a full install copies shipped Codex rules and leaves a local default.rules in place', () => {
|
|
93
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-codex-install-'));
|
|
94
|
+
try {
|
|
95
|
+
const userRulesPath = join(
|
|
96
|
+
homeDirectory,
|
|
97
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
98
|
+
CODEX_RULES_DIRECTORY_NAME,
|
|
99
|
+
'default.rules',
|
|
100
|
+
);
|
|
101
|
+
mkdirSync(dirname(userRulesPath), { recursive: true });
|
|
102
|
+
writeFileSync(userRulesPath, 'prefix_rule(pattern=["echo", "hi"], decision="allow")\n');
|
|
103
|
+
|
|
104
|
+
runInstaller(homeDirectory, []);
|
|
105
|
+
|
|
106
|
+
const installedRulesPath = join(
|
|
107
|
+
homeDirectory,
|
|
108
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
109
|
+
CODEX_RULES_DIRECTORY_NAME,
|
|
110
|
+
CODEX_RULES_SHIPPED_FILE_NAME,
|
|
111
|
+
);
|
|
112
|
+
assert.equal(existsSync(installedRulesPath), true);
|
|
113
|
+
assert.equal(
|
|
114
|
+
readFileSync(installedRulesPath, 'utf8'),
|
|
115
|
+
readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8'),
|
|
116
|
+
);
|
|
117
|
+
assert.equal(
|
|
118
|
+
readFileSync(userRulesPath, 'utf8'),
|
|
119
|
+
'prefix_rule(pattern=["echo", "hi"], decision="allow")\n',
|
|
120
|
+
);
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a full install Codex rules file contains no personal home path', () => {
|
|
127
|
+
const shippedText = readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8');
|
|
128
|
+
assert.equal(shippedText.includes('Users\\jon'), false);
|
|
129
|
+
assert.equal(shippedText.includes('Users/jon'), false);
|
|
130
|
+
assert.equal(shippedText.includes('JonEcho'), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('--only journal skips Codex rules; --only core copies them', () => {
|
|
134
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-codex-groups-'));
|
|
135
|
+
try {
|
|
136
|
+
const installedRulesPath = join(
|
|
137
|
+
homeDirectory,
|
|
138
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
139
|
+
CODEX_RULES_DIRECTORY_NAME,
|
|
140
|
+
CODEX_RULES_SHIPPED_FILE_NAME,
|
|
141
|
+
);
|
|
142
|
+
runInstaller(homeDirectory, ['--only', 'journal']);
|
|
143
|
+
assert.equal(existsSync(installedRulesPath), false);
|
|
144
|
+
|
|
145
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
146
|
+
assert.equal(existsSync(installedRulesPath), true);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('uninstall removes the shipped Codex rules file and leaves default.rules', () => {
|
|
153
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-codex-uninstall-'));
|
|
154
|
+
try {
|
|
155
|
+
const rulesDirectory = join(
|
|
156
|
+
homeDirectory,
|
|
157
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
158
|
+
CODEX_RULES_DIRECTORY_NAME,
|
|
159
|
+
);
|
|
160
|
+
const userRulesPath = join(rulesDirectory, 'default.rules');
|
|
161
|
+
mkdirSync(rulesDirectory, { recursive: true });
|
|
162
|
+
writeFileSync(userRulesPath, 'keep-me\n');
|
|
163
|
+
|
|
164
|
+
runInstaller(homeDirectory, []);
|
|
165
|
+
runInstaller(homeDirectory, ['--uninstall']);
|
|
166
|
+
|
|
167
|
+
const installedRulesPath = join(rulesDirectory, CODEX_RULES_SHIPPED_FILE_NAME);
|
|
168
|
+
assert.equal(existsSync(installedRulesPath), false);
|
|
169
|
+
assert.equal(readFileSync(userRulesPath, 'utf8'), 'keep-me\n');
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import {
|
|
9
|
+
resolveInstallRoot,
|
|
10
|
+
isAllowedInstallDestination,
|
|
11
|
+
} from './resolve-install-root.mjs';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_CURSOR_DIRECTORY_NAME,
|
|
14
|
+
CURSOR_RULES_DIRECTORY_NAME,
|
|
15
|
+
} from './install-constants.mjs';
|
|
16
|
+
|
|
17
|
+
const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
|
|
19
|
+
const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
|
|
20
|
+
|
|
21
|
+
function runInstaller(homeDirectory, extraArguments) {
|
|
22
|
+
return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
|
|
23
|
+
cwd: PACKAGE_DIRECTORY,
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
env: {
|
|
26
|
+
...process.env,
|
|
27
|
+
HOME: homeDirectory,
|
|
28
|
+
USERPROFILE: homeDirectory,
|
|
29
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('resolveInstallRoot names ~/.cursor/rules and allows generated mdc files under it', () => {
|
|
35
|
+
const homeDirectory = join(tmpdir(), 'cdev-cursor-rules-home');
|
|
36
|
+
const resolution = resolveInstallRoot({
|
|
37
|
+
homeDirectory,
|
|
38
|
+
environment: {},
|
|
39
|
+
explicitTarget: null,
|
|
40
|
+
});
|
|
41
|
+
const expectedDirectory = join(
|
|
42
|
+
homeDirectory,
|
|
43
|
+
DEFAULT_CURSOR_DIRECTORY_NAME,
|
|
44
|
+
CURSOR_RULES_DIRECTORY_NAME,
|
|
45
|
+
);
|
|
46
|
+
assert.equal(resolution.cursorRulesInstallDirectory, expectedDirectory);
|
|
47
|
+
assert.equal(
|
|
48
|
+
isAllowedInstallDestination(join(expectedDirectory, 'plain-language.mdc'), resolution),
|
|
49
|
+
true,
|
|
50
|
+
);
|
|
51
|
+
assert.equal(
|
|
52
|
+
isAllowedInstallDestination(join(homeDirectory, '.ssh', 'id_rsa'), resolution),
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a full install writes stem-named Cursor rules and leaves a local extra mdc in place', () => {
|
|
58
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-install-'));
|
|
59
|
+
try {
|
|
60
|
+
const extraRulePath = join(
|
|
61
|
+
homeDirectory,
|
|
62
|
+
DEFAULT_CURSOR_DIRECTORY_NAME,
|
|
63
|
+
CURSOR_RULES_DIRECTORY_NAME,
|
|
64
|
+
'user-local.mdc',
|
|
65
|
+
);
|
|
66
|
+
mkdirSync(dirname(extraRulePath), { recursive: true });
|
|
67
|
+
writeFileSync(extraRulePath, 'keep-me\n');
|
|
68
|
+
|
|
69
|
+
runInstaller(homeDirectory, []);
|
|
70
|
+
|
|
71
|
+
const generatedPath = join(
|
|
72
|
+
homeDirectory,
|
|
73
|
+
DEFAULT_CURSOR_DIRECTORY_NAME,
|
|
74
|
+
CURSOR_RULES_DIRECTORY_NAME,
|
|
75
|
+
'plain-language.mdc',
|
|
76
|
+
);
|
|
77
|
+
assert.equal(existsSync(generatedPath), true);
|
|
78
|
+
const generatedText = readFileSync(generatedPath, 'utf8');
|
|
79
|
+
assert.equal(generatedText.includes('alwaysApply: true'), true);
|
|
80
|
+
assert.equal(readFileSync(extraRulePath, 'utf8'), 'keep-me\n');
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('--only journal skips Cursor rule generation; --only core writes them', () => {
|
|
87
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-groups-'));
|
|
88
|
+
try {
|
|
89
|
+
const generatedPath = join(
|
|
90
|
+
homeDirectory,
|
|
91
|
+
DEFAULT_CURSOR_DIRECTORY_NAME,
|
|
92
|
+
CURSOR_RULES_DIRECTORY_NAME,
|
|
93
|
+
'plain-language.mdc',
|
|
94
|
+
);
|
|
95
|
+
runInstaller(homeDirectory, ['--only', 'journal']);
|
|
96
|
+
assert.equal(existsSync(generatedPath), false);
|
|
97
|
+
|
|
98
|
+
runInstaller(homeDirectory, ['--only', 'core']);
|
|
99
|
+
assert.equal(existsSync(generatedPath), true);
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
package/bin/install.mjs
CHANGED
|
@@ -22,10 +22,14 @@ import {
|
|
|
22
22
|
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
23
23
|
MANAGED_HOOKS_DIRECTORY_NAME,
|
|
24
24
|
SETTINGS_FILE_NAME,
|
|
25
|
+
CODEX_RULES_PACKAGE_DIRECTORY_NAME,
|
|
26
|
+
CURSOR_SYNC_SCRIPT_FILE_NAME,
|
|
27
|
+
WINDOWS_PYTHON_LAUNCHER_COMMAND,
|
|
25
28
|
} from './install-constants.mjs';
|
|
26
29
|
import {
|
|
27
30
|
resolveInstallRoot,
|
|
28
31
|
parseExplicitTargetFromArgv,
|
|
32
|
+
isAllowedInstallDestination,
|
|
29
33
|
} from './resolve-install-root.mjs';
|
|
30
34
|
import {
|
|
31
35
|
parseInstallTargetSelectionFromArgv,
|
|
@@ -211,7 +215,7 @@ function discoverDependencyGroups() {
|
|
|
211
215
|
readFileSync(join(dependencyRoot, 'package.json'), 'utf8')
|
|
212
216
|
);
|
|
213
217
|
const groupName = dependencyPackageJson.claudeDevEnv?.groupName
|
|
214
|
-
|| dependencyName.replace(
|
|
218
|
+
|| dependencyName.replace(new RegExp('^@[^/]+/'), '');
|
|
215
219
|
const group = {
|
|
216
220
|
description: dependencyPackageJson.description || dependencyName,
|
|
217
221
|
packageRoot: dependencyRoot,
|
|
@@ -257,6 +261,7 @@ export const INSTALL_GROUPS = {
|
|
|
257
261
|
skills: CORE_SKILLS,
|
|
258
262
|
includeDirectories: CORE_INCLUDE_DIRECTORIES,
|
|
259
263
|
includeAllHooks: true,
|
|
264
|
+
includeCodexRules: true,
|
|
260
265
|
},
|
|
261
266
|
journal: {
|
|
262
267
|
description: 'Session logging and memory',
|
|
@@ -302,6 +307,75 @@ export function isWindowsStorePythonStub(executablePath) {
|
|
|
302
307
|
return /[\\/]windowsapps[\\/]/i.test(executablePath);
|
|
303
308
|
}
|
|
304
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Split a stored Python command into an executable and prefix arguments.
|
|
312
|
+
*
|
|
313
|
+
* Args:
|
|
314
|
+
* pythonCommand: The command the installer detected (`py -3`, `python3`, or a path).
|
|
315
|
+
*
|
|
316
|
+
* Returns:
|
|
317
|
+
* `{ file, prefixArguments }` for `execFileSync`.
|
|
318
|
+
*/
|
|
319
|
+
export function pythonFileAndPrefixArguments(pythonCommand) {
|
|
320
|
+
if (pythonCommand === WINDOWS_PYTHON_LAUNCHER_COMMAND) {
|
|
321
|
+
return { file: 'py', prefixArguments: ['-3'] };
|
|
322
|
+
}
|
|
323
|
+
const unquoted = pythonCommand.replace(/^"(.*)"$/, '$1');
|
|
324
|
+
return { file: unquoted, prefixArguments: [] };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Read generated Cursor paths from the sync manifest under a Cursor home.
|
|
329
|
+
*
|
|
330
|
+
* Args:
|
|
331
|
+
* cursorRoot: Absolute `.cursor` directory.
|
|
332
|
+
*
|
|
333
|
+
* Returns:
|
|
334
|
+
* Absolute paths the installer may record, including the sync manifest.
|
|
335
|
+
*/
|
|
336
|
+
export function collectManagedCursorSyncPaths(cursorRoot) {
|
|
337
|
+
const manifestPath = join(cursorRoot, '.sync-manifest.json');
|
|
338
|
+
if (!existsSync(manifestPath)) return [];
|
|
339
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
340
|
+
const generatedPaths = [manifestPath];
|
|
341
|
+
const allEntries = { ...(parsed.entries || {}), ...(parsed.docs_entries || {}) };
|
|
342
|
+
for (const eachRelativePath of Object.keys(allEntries)) {
|
|
343
|
+
generatedPaths.push(join(cursorRoot, eachRelativePath));
|
|
344
|
+
}
|
|
345
|
+
return generatedPaths;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Run the installed Cursor rule generator against Claude and Cursor roots.
|
|
350
|
+
*
|
|
351
|
+
* Args:
|
|
352
|
+
* pythonCommand: Interpreter command from install preflight.
|
|
353
|
+
* scriptPath: Absolute `sync_to_cursor.py` path.
|
|
354
|
+
* claudeRoot: Managed Claude root that holds `rules/` and `docs/`.
|
|
355
|
+
* cursorRoot: Cursor home that receives `rules/*.mdc`.
|
|
356
|
+
*
|
|
357
|
+
* Returns:
|
|
358
|
+
* void
|
|
359
|
+
*/
|
|
360
|
+
export function runCursorRuleSync(pythonCommand, scriptPath, claudeRoot, cursorRoot) {
|
|
361
|
+
mkdirSync(cursorRoot, { recursive: true });
|
|
362
|
+
const { file, prefixArguments } = pythonFileAndPrefixArguments(pythonCommand);
|
|
363
|
+
execFileSync(
|
|
364
|
+
file,
|
|
365
|
+
[
|
|
366
|
+
...prefixArguments,
|
|
367
|
+
scriptPath,
|
|
368
|
+
'--force',
|
|
369
|
+
'--quiet',
|
|
370
|
+
'--claude-root',
|
|
371
|
+
claudeRoot,
|
|
372
|
+
'--cursor-root',
|
|
373
|
+
cursorRoot,
|
|
374
|
+
],
|
|
375
|
+
{ stdio: 'inherit' },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
305
379
|
/**
|
|
306
380
|
* Formats an absolute interpreter path as a settings.json hook command prefix:
|
|
307
381
|
* forward-slash separators, double-quoted when the path contains a space so the
|
|
@@ -629,8 +703,7 @@ function isManagedPath(candidatePath, managedHomeDirectory = CLAUDE_HOME) {
|
|
|
629
703
|
* @returns {boolean} True when the installer itself writes the path.
|
|
630
704
|
*/
|
|
631
705
|
function isRemovableManifestRecord(candidatePath) {
|
|
632
|
-
|
|
633
|
-
return comparisonKeyForPath(candidatePath) === comparisonKeyForPath(MYPY_INI_INSTALL_PATH);
|
|
706
|
+
return isAllowedInstallDestination(candidatePath, INSTALL_ROOT_RESOLUTION);
|
|
634
707
|
}
|
|
635
708
|
|
|
636
709
|
/**
|
|
@@ -650,6 +723,10 @@ function owningManagedRoot(installedFilePath) {
|
|
|
650
723
|
const managedRoot = join(CLAUDE_HOME, directoryName);
|
|
651
724
|
if (isInsideDirectory(resolvedPath, managedRoot)) return managedRoot;
|
|
652
725
|
}
|
|
726
|
+
const codexRulesDirectory = INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory;
|
|
727
|
+
if (isInsideDirectory(resolvedPath, codexRulesDirectory)) return codexRulesDirectory;
|
|
728
|
+
const cursorInstallDirectory = INSTALL_ROOT_RESOLUTION.cursorInstallDirectory;
|
|
729
|
+
if (isInsideDirectory(resolvedPath, cursorInstallDirectory)) return cursorInstallDirectory;
|
|
653
730
|
return null;
|
|
654
731
|
}
|
|
655
732
|
|
|
@@ -1856,7 +1933,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
1856
1933
|
`${PACKAGE_NAME}: --update — removing prior managed files under ${CLAUDE_HOME}, then reinstalling from the package.\n`,
|
|
1857
1934
|
);
|
|
1858
1935
|
purgeManagedInstallation({
|
|
1859
|
-
|
|
1936
|
+
isManifestRequired: false,
|
|
1860
1937
|
throwIfFault,
|
|
1861
1938
|
});
|
|
1862
1939
|
} else if (isUpdateRefresh) {
|
|
@@ -1940,6 +2017,30 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
1940
2017
|
}
|
|
1941
2018
|
}
|
|
1942
2019
|
}
|
|
2020
|
+
const shouldInstallCodexRules = !selectedGroups
|
|
2021
|
+
|| activeGroups.some((eachGroup) => eachGroup.includeCodexRules);
|
|
2022
|
+
if (shouldInstallCodexRules) {
|
|
2023
|
+
const sourceDirectory = join(PACKAGE_ROOT, CODEX_RULES_PACKAGE_DIRECTORY_NAME);
|
|
2024
|
+
if (existsSync(sourceDirectory)) {
|
|
2025
|
+
const destinationDirectory = INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory;
|
|
2026
|
+
const stats = copyTree(sourceDirectory, destinationDirectory);
|
|
2027
|
+
summary.codexRules = stats;
|
|
2028
|
+
allInstalledFiles.push(...stats.paths);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
const shouldInstallCursorRules = !selectedGroups
|
|
2032
|
+
|| activeGroups.some((eachGroup) => (eachGroup.includeDirectories || []).includes('rules'));
|
|
2033
|
+
if (shouldInstallCursorRules) {
|
|
2034
|
+
const scriptPath = join(CLAUDE_HOME, 'scripts', CURSOR_SYNC_SCRIPT_FILE_NAME);
|
|
2035
|
+
if (!existsSync(scriptPath)) {
|
|
2036
|
+
throw new Error(`cursor rule sync script missing: ${scriptPath}`);
|
|
2037
|
+
}
|
|
2038
|
+
const cursorRoot = dirname(INSTALL_ROOT_RESOLUTION.cursorRulesInstallDirectory);
|
|
2039
|
+
runCursorRuleSync(pythonCommand, scriptPath, CLAUDE_HOME, cursorRoot);
|
|
2040
|
+
const generatedCursorPaths = collectManagedCursorSyncPaths(cursorRoot);
|
|
2041
|
+
allInstalledFiles.push(...generatedCursorPaths);
|
|
2042
|
+
summary.cursorRules = { created: generatedCursorPaths.length, updated: 0, paths: generatedCursorPaths };
|
|
2043
|
+
}
|
|
1943
2044
|
let skillsCreated = 0;
|
|
1944
2045
|
let skillsUpdated = 0;
|
|
1945
2046
|
const skillPaths = [];
|
|
@@ -2136,6 +2237,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2136
2237
|
console.log(` ${directory}: ${created + updated} files (${created} new, ${updated} updated)`);
|
|
2137
2238
|
}
|
|
2138
2239
|
}
|
|
2240
|
+
if (summary.codexRules) {
|
|
2241
|
+
const { created, updated } = summary.codexRules;
|
|
2242
|
+
console.log(` ${CODEX_RULES_PACKAGE_DIRECTORY_NAME}: ${created + updated} files (${created} new, ${updated} updated)`);
|
|
2243
|
+
}
|
|
2244
|
+
if (summary.cursorRules) {
|
|
2245
|
+
const { created } = summary.cursorRules;
|
|
2246
|
+
console.log(` cursor-rules: ${created} generated files`);
|
|
2247
|
+
}
|
|
2139
2248
|
if (summary.skills) {
|
|
2140
2249
|
const { created, updated, pruned } = summary.skills;
|
|
2141
2250
|
const staleClause = pruned > 0 ? `, ${pruned} stale moved aside` : '';
|
|
@@ -2222,14 +2331,14 @@ function removeRecordedFile(filePath) {
|
|
|
2222
2331
|
/**
|
|
2223
2332
|
* Build the uninstall plan for this managed root.
|
|
2224
2333
|
*
|
|
2225
|
-
* @param {boolean}
|
|
2334
|
+
* @param {boolean} isManifestRequired
|
|
2226
2335
|
* @returns {ReturnType<typeof buildUninstallPlan>}
|
|
2227
2336
|
*/
|
|
2228
|
-
function resolveUninstallPlan(
|
|
2337
|
+
function resolveUninstallPlan(isManifestRequired) {
|
|
2229
2338
|
return buildUninstallPlan({
|
|
2230
2339
|
managedRoot: CLAUDE_HOME,
|
|
2231
2340
|
manifestFilePath: MANIFEST_FILE,
|
|
2232
|
-
requireManifest,
|
|
2341
|
+
requireManifest: isManifestRequired,
|
|
2233
2342
|
isRemovableRecord: isRemovableManifestRecord,
|
|
2234
2343
|
});
|
|
2235
2344
|
}
|
|
@@ -2266,20 +2375,20 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
2266
2375
|
}
|
|
2267
2376
|
if (plan.skippedFiles.length > 0) {
|
|
2268
2377
|
console.warn(
|
|
2269
|
-
` ${plan.skippedFiles.length} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME} and outside ${
|
|
2378
|
+
` ${plan.skippedFiles.length} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME}, outside ${MYPY_INI_INSTALL_PATH}, outside ${INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory}, and outside ${INSTALL_ROOT_RESOLUTION.cursorInstallDirectory}`,
|
|
2270
2379
|
);
|
|
2271
2380
|
}
|
|
2272
2381
|
throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
|
|
2273
2382
|
|
|
2274
2383
|
if (existsSync(plan.settingsPath)) {
|
|
2275
2384
|
const settings = JSON.parse(readFileSync(plan.settingsPath, 'utf8'));
|
|
2276
|
-
let
|
|
2385
|
+
let didSettingsChange = false;
|
|
2277
2386
|
if (settings.hooks) {
|
|
2278
2387
|
const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
|
|
2279
2388
|
managedPackageSourceRoots(),
|
|
2280
2389
|
);
|
|
2281
2390
|
pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
|
|
2282
|
-
|
|
2391
|
+
didSettingsChange = true;
|
|
2283
2392
|
console.log(' Hook entries removed from settings.json');
|
|
2284
2393
|
}
|
|
2285
2394
|
const managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
|
|
@@ -2288,13 +2397,13 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
2288
2397
|
if (managedDenyFromPlan.length > 0) {
|
|
2289
2398
|
const pruneOutcome = pruneManagedPermissionsFromSettings(settings, managedDenyFromPlan);
|
|
2290
2399
|
if (pruneOutcome.removedCount > 0) {
|
|
2291
|
-
|
|
2400
|
+
didSettingsChange = true;
|
|
2292
2401
|
console.log(
|
|
2293
2402
|
` Permission entries removed from settings.json: ${pruneOutcome.removedCount} managed deny(s)`,
|
|
2294
2403
|
);
|
|
2295
2404
|
}
|
|
2296
2405
|
}
|
|
2297
|
-
if (
|
|
2406
|
+
if (didSettingsChange) {
|
|
2298
2407
|
writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
2299
2408
|
}
|
|
2300
2409
|
}
|
|
@@ -2327,13 +2436,13 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
2327
2436
|
* without nesting a second journal.
|
|
2328
2437
|
*
|
|
2329
2438
|
* @param {{
|
|
2330
|
-
*
|
|
2439
|
+
* isManifestRequired: boolean,
|
|
2331
2440
|
* throwIfFault?: (phase: string) => void,
|
|
2332
2441
|
* }} options
|
|
2333
2442
|
* @returns {number|void} 0 when no manifest exists and none is required.
|
|
2334
2443
|
*/
|
|
2335
|
-
function purgeManagedInstallation({
|
|
2336
|
-
const plan = resolveUninstallPlan(
|
|
2444
|
+
function purgeManagedInstallation({ isManifestRequired, throwIfFault }) {
|
|
2445
|
+
const plan = resolveUninstallPlan(isManifestRequired);
|
|
2337
2446
|
if (plan.isNoOp) {
|
|
2338
2447
|
return 0;
|
|
2339
2448
|
}
|
|
@@ -2433,6 +2542,8 @@ Examples:
|
|
|
2433
2542
|
|
|
2434
2543
|
Install location: ~/.claude/ by default; CLAUDE_CONFIG_DIR or --target selects another managed root.
|
|
2435
2544
|
Named profiles resolve under LLM_SETTINGS_PROFILES_ROOT or ~/.claude-profiles/<directoryName>.
|
|
2545
|
+
Codex exec-policy files copy into ~/.codex/rules, or CODEX_HOME/rules when CODEX_HOME is set.
|
|
2546
|
+
Cursor rule files generate into ~/.cursor/rules as stem-named mdc files, one per Claude rule.
|
|
2436
2547
|
|
|
2437
2548
|
Root precedence: --target > CLAUDE_CONFIG_DIR > ~/.claude
|
|
2438
2549
|
Profile selection (--profile/--profiles) is mutually exclusive with --target.
|
|
@@ -2564,7 +2675,7 @@ function runInstallForAllTargets(allTargets, childArgv) {
|
|
|
2564
2675
|
* @returns {number | null}
|
|
2565
2676
|
*/
|
|
2566
2677
|
function spawnInstallChild(target, childArgv) {
|
|
2567
|
-
const
|
|
2678
|
+
const childProcess = spawnSync(
|
|
2568
2679
|
process.execPath,
|
|
2569
2680
|
[
|
|
2570
2681
|
fileURLToPath(import.meta.url),
|
|
@@ -2579,11 +2690,11 @@ function spawnInstallChild(target, childArgv) {
|
|
|
2579
2690
|
env: process.env,
|
|
2580
2691
|
},
|
|
2581
2692
|
);
|
|
2582
|
-
if (
|
|
2583
|
-
console.error(`ERROR: failed to spawn install child: ${
|
|
2693
|
+
if (childProcess.error) {
|
|
2694
|
+
console.error(`ERROR: failed to spawn install child: ${childProcess.error.message}`);
|
|
2584
2695
|
return 1;
|
|
2585
2696
|
}
|
|
2586
|
-
return
|
|
2697
|
+
return childProcess.status === null ? 1 : childProcess.status;
|
|
2587
2698
|
}
|
|
2588
2699
|
|
|
2589
2700
|
if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
|
|
@@ -8,6 +8,7 @@ import { join, resolve } from 'node:path';
|
|
|
8
8
|
import { mkdtempSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { CODEX_RULES_SHIPPED_FILE_NAME } from './install-constants.mjs';
|
|
11
12
|
import {
|
|
12
13
|
resolveInstallRoot,
|
|
13
14
|
isPathWithinManagedRoot,
|
|
@@ -90,6 +91,13 @@ test('declared external mypy.ini is allowed; unrelated external paths are not',
|
|
|
90
91
|
isAllowedInstallDestination(join(resolution.managedRoot, 'hooks', 'x.py'), resolution),
|
|
91
92
|
true,
|
|
92
93
|
);
|
|
94
|
+
assert.equal(
|
|
95
|
+
isAllowedInstallDestination(
|
|
96
|
+
join(resolution.codexRulesInstallDirectory, CODEX_RULES_SHIPPED_FILE_NAME),
|
|
97
|
+
resolution,
|
|
98
|
+
),
|
|
99
|
+
true,
|
|
100
|
+
);
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
test('parseExplicitTargetFromArgv reads --target and --target=', () => {
|
|
@@ -238,6 +238,7 @@ function resolveInstallerInvocation(homeDirectory, options) {
|
|
|
238
238
|
HOME: homeDirectory,
|
|
239
239
|
USERPROFILE: homeDirectory,
|
|
240
240
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
241
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
241
242
|
};
|
|
242
243
|
if (dependencyResolvable) {
|
|
243
244
|
childEnvironment.NODE_PATH = ensureDependencyStub(homeDirectory);
|
|
@@ -1227,7 +1228,7 @@ test('an uninstall removes the home-directory .mypy.ini the install wrote and sk
|
|
|
1227
1228
|
assert.equal(
|
|
1228
1229
|
existsSync(mypyIniPath),
|
|
1229
1230
|
false,
|
|
1230
|
-
'the uninstall removes the
|
|
1231
|
+
'the uninstall removes the mypy configuration the install writes outside ~/.claude',
|
|
1231
1232
|
);
|
|
1232
1233
|
assert.equal(
|
|
1233
1234
|
installerOutput.includes(`skipping ${mypyIniPath}`),
|
|
@@ -242,6 +242,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
|
|
|
242
242
|
HOME: homeDirectory,
|
|
243
243
|
USERPROFILE: homeDirectory,
|
|
244
244
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
245
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
245
246
|
};
|
|
246
247
|
if (options.faultPhase) {
|
|
247
248
|
childEnvironment[INSTALL_FAULT_ENV] = options.faultPhase;
|
|
@@ -74,6 +74,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
|
|
|
74
74
|
HOME: homeDirectory,
|
|
75
75
|
USERPROFILE: homeDirectory,
|
|
76
76
|
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
77
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
77
78
|
};
|
|
78
79
|
if (options.faultPhase) {
|
|
79
80
|
childEnvironment[INSTALL_FAULT_ENV] = options.faultPhase;
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { join, normalize, resolve, sep } from 'node:path';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
MYPY_INI_FILE_NAME,
|
|
16
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
17
|
+
DEFAULT_CODEX_DIRECTORY_NAME,
|
|
18
|
+
CODEX_RULES_DIRECTORY_NAME,
|
|
19
|
+
DEFAULT_CURSOR_DIRECTORY_NAME,
|
|
20
|
+
CURSOR_RULES_DIRECTORY_NAME,
|
|
21
|
+
} from './install-constants.mjs';
|
|
15
22
|
|
|
16
23
|
export const CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE = 'CLAUDE_CONFIG_DIR';
|
|
17
24
|
export const DEFAULT_CLAUDE_DIRECTORY_NAME = '.claude';
|
|
@@ -33,6 +40,10 @@ export const MANIFEST_FILE_NAME = '.claude-dev-env-manifest.json';
|
|
|
33
40
|
* manifestFilePath: string,
|
|
34
41
|
* mypyIniInstallPath: string,
|
|
35
42
|
* allDeclaredExternalPaths: string[],
|
|
43
|
+
* allDeclaredExternalDirectories: string[],
|
|
44
|
+
* codexRulesInstallDirectory: string,
|
|
45
|
+
* cursorInstallDirectory: string,
|
|
46
|
+
* cursorRulesInstallDirectory: string,
|
|
36
47
|
* }} InstallRootResolution
|
|
37
48
|
*/
|
|
38
49
|
|
|
@@ -69,6 +80,18 @@ export function resolveInstallRoot(options = {}) {
|
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
const mypyIniInstallPath = resolve(join(homeDirectory, MYPY_INI_FILE_NAME));
|
|
83
|
+
const codexHomeDirectory = normalizeOptionalPath(
|
|
84
|
+
environment[CODEX_HOME_ENVIRONMENT_VARIABLE],
|
|
85
|
+
) ?? resolve(join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME));
|
|
86
|
+
const codexRulesInstallDirectory = resolve(
|
|
87
|
+
join(codexHomeDirectory, CODEX_RULES_DIRECTORY_NAME),
|
|
88
|
+
);
|
|
89
|
+
const cursorInstallDirectory = resolve(
|
|
90
|
+
join(homeDirectory, DEFAULT_CURSOR_DIRECTORY_NAME),
|
|
91
|
+
);
|
|
92
|
+
const cursorRulesInstallDirectory = resolve(
|
|
93
|
+
join(cursorInstallDirectory, CURSOR_RULES_DIRECTORY_NAME),
|
|
94
|
+
);
|
|
72
95
|
return {
|
|
73
96
|
managedRoot,
|
|
74
97
|
source,
|
|
@@ -76,6 +99,10 @@ export function resolveInstallRoot(options = {}) {
|
|
|
76
99
|
manifestFilePath: join(managedRoot, MANIFEST_FILE_NAME),
|
|
77
100
|
mypyIniInstallPath,
|
|
78
101
|
allDeclaredExternalPaths: [mypyIniInstallPath],
|
|
102
|
+
allDeclaredExternalDirectories: [codexRulesInstallDirectory, cursorInstallDirectory],
|
|
103
|
+
codexRulesInstallDirectory,
|
|
104
|
+
cursorInstallDirectory,
|
|
105
|
+
cursorRulesInstallDirectory,
|
|
79
106
|
};
|
|
80
107
|
}
|
|
81
108
|
|
|
@@ -103,8 +130,9 @@ export function isPathWithinManagedRoot(candidatePath, managedRoot) {
|
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
/**
|
|
106
|
-
* True when a write destination is allowed: inside the managed root
|
|
107
|
-
*
|
|
133
|
+
* True when a write destination is allowed: inside the managed root, the
|
|
134
|
+
* home-directory `.mypy.ini`, a file under the Codex rules directory, or a file
|
|
135
|
+
* under the Cursor rules directory.
|
|
108
136
|
*
|
|
109
137
|
* @param {string} candidatePath
|
|
110
138
|
* @param {InstallRootResolution} resolution
|
|
@@ -115,8 +143,13 @@ export function isAllowedInstallDestination(candidatePath, resolution) {
|
|
|
115
143
|
return true;
|
|
116
144
|
}
|
|
117
145
|
const normalizedCandidate = normalizePathForComparison(candidatePath);
|
|
118
|
-
|
|
146
|
+
if (resolution.allDeclaredExternalPaths.some(
|
|
119
147
|
(eachExternalPath) => normalizePathForComparison(eachExternalPath) === normalizedCandidate,
|
|
148
|
+
)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return (resolution.allDeclaredExternalDirectories ?? []).some(
|
|
152
|
+
(eachExternalDirectory) => isPathWithinManagedRoot(candidatePath, eachExternalDirectory),
|
|
120
153
|
);
|
|
121
154
|
}
|
|
122
155
|
|
|
@@ -130,18 +163,18 @@ export function parseExplicitTargetFromArgv(argv) {
|
|
|
130
163
|
for (let index = 0; index < argv.length; index += 1) {
|
|
131
164
|
const token = argv[index];
|
|
132
165
|
if (token === '--target') {
|
|
133
|
-
const
|
|
134
|
-
if (!
|
|
166
|
+
const targetPath = argv[index + 1];
|
|
167
|
+
if (!targetPath || targetPath.startsWith('--')) {
|
|
135
168
|
throw new Error('--target requires a path argument');
|
|
136
169
|
}
|
|
137
|
-
return
|
|
170
|
+
return targetPath;
|
|
138
171
|
}
|
|
139
172
|
if (token.startsWith('--target=')) {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
173
|
+
const targetPath = token.slice('--target='.length);
|
|
174
|
+
if (!targetPath) {
|
|
142
175
|
throw new Error('--target requires a path argument');
|
|
143
176
|
}
|
|
144
|
-
return
|
|
177
|
+
return targetPath;
|
|
145
178
|
}
|
|
146
179
|
}
|
|
147
180
|
return null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Shared Codex exec-policy prefix rules shipped by claude-dev-env.
|
|
2
|
+
# Codex loads every *.rules file under $CODEX_HOME/rules (default ~/.codex/rules).
|
|
3
|
+
# This file is named claude-dev-env.rules so a local default.rules stays in place.
|
|
4
|
+
# Patterns use relative repo paths only. Personal home paths stay out of this file.
|
|
5
|
+
|
|
6
|
+
prefix_rule(pattern=["gemini", "--version"], decision="allow")
|
|
7
|
+
prefix_rule(pattern=["git", "status", "--short", "--branch"], decision="allow")
|
|
8
|
+
prefix_rule(pattern=["git", "status", "--short"], decision="allow")
|
|
9
|
+
prefix_rule(pattern=["git", "diff", "--", "packages/claude-dev-env/_shared/pr-loop/scripts/code_rules_gate_parts", "packages/claude-dev-env/_shared/pr-loop/scripts/tests/test_code_rules_gate.py"], decision="allow")
|
|
10
|
+
prefix_rule(pattern=["Get-Content", "-LiteralPath", "packages/claude-dev-env/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py"], decision="allow")
|
|
11
|
+
prefix_rule(pattern=["rg", "-n", "-i", "--glob", "!**/__pycache__/**", "verified_commit_gate|verifier_verdict_minter|verdict_directory_write_blocker|verify-skip|code-verifier.*commit|commit.*code-verifier", "packages/claude-dev-env"], decision="allow")
|
|
12
|
+
prefix_rule(pattern=["python", "-m", "pytest", "-q", "packages/claude-dev-env/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py", "packages/claude-dev-env/hooks/blocking/test_bash_pre_tool_use_dispatcher.py", "packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py", "packages/claude-dev-env/hooks/blocking/test_verdict_directory_write_blocker.py"], decision="allow")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"installable-surfaces.manifest.json",
|
|
27
27
|
"_shared/",
|
|
28
28
|
"audit-rubrics/",
|
|
29
|
+
"codex-rules/",
|
|
29
30
|
"AGENTS.md",
|
|
30
31
|
"CLAUDE.md",
|
|
31
32
|
"!**/__pycache__/**",
|
|
@@ -6,12 +6,12 @@ Python package that syncs Claude rules and docs to Cursor `.mdc` files. Entry po
|
|
|
6
6
|
|
|
7
7
|
| File | Purpose |
|
|
8
8
|
|---|---|
|
|
9
|
-
| `engine.py` | Main sync logic: loads the manifest, builds rule mappings, hashes sources, writes `.mdc` files, and updates the manifest |
|
|
10
|
-
| `rules.py` | Builds `RuleMapping` objects from Claude rule markdown files; applies transforms to fit Cursor's `.mdc` format |
|
|
9
|
+
| `engine.py` | Main sync logic: loads the manifest, builds rule mappings, hashes sources, writes `.mdc` files, and updates the manifest; `--claude-root` and `--cursor-root` select explicit layouts |
|
|
10
|
+
| `rules.py` | Builds `RuleMapping` objects from Claude rule markdown files; applies transforms to fit Cursor's `.mdc` format; maps remaining `rules/*.md` files to `<stem>.mdc` |
|
|
11
11
|
| `canonical_docs.py` | Checks and syncs canonical documentation files (`CODE_RULES.md`, `TEST_QUALITY.md`) to the Cursor rules directory |
|
|
12
12
|
| `paths.py` | Resolves the Claude and Cursor layout paths; respects the `LLM_SETTINGS_ROOT` env var for non-home layouts |
|
|
13
13
|
| `hashing.py` | SHA-256 helpers that detect whether source files changed since the last sync run |
|
|
14
|
-
| `config
|
|
14
|
+
| `config/` | Package-level constants: `GENERATOR_VERSION`, `ALL_CANONICAL_DOC_FILES`, `MAX_RULE_BODY_LINES`, skipped inventory filenames, markdown suffix |
|
|
15
15
|
| `__init__.py` | Empty package marker |
|
|
16
16
|
|
|
17
17
|
## Layout resolution
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import shutil
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
from sync_to_cursor.config import
|
|
6
|
+
from sync_to_cursor.config import ALL_CANONICAL_DOC_FILES
|
|
7
7
|
from sync_to_cursor.hashing import sha256_bytes
|
|
8
8
|
|
|
9
9
|
|
|
@@ -17,19 +17,19 @@ def sync_canonical_docs(
|
|
|
17
17
|
if not dry_run:
|
|
18
18
|
docs_out.mkdir(parents=True, exist_ok=True)
|
|
19
19
|
new_docs: dict = {}
|
|
20
|
-
for
|
|
21
|
-
src = claude / "docs" /
|
|
22
|
-
dst = docs_out /
|
|
20
|
+
for each_name in ALL_CANONICAL_DOC_FILES:
|
|
21
|
+
src = claude / "docs" / each_name
|
|
22
|
+
dst = docs_out / each_name
|
|
23
23
|
if not src.is_file():
|
|
24
24
|
if dst.is_file():
|
|
25
25
|
if not dry_run:
|
|
26
26
|
dst.unlink()
|
|
27
27
|
if not quiet:
|
|
28
|
-
print(f"WARN docs/{
|
|
28
|
+
print(f"WARN docs/{each_name} (source removed — deleted stale copy at {dst})")
|
|
29
29
|
elif not quiet:
|
|
30
|
-
print(f"WARN docs/{
|
|
30
|
+
print(f"WARN docs/{each_name} (missing source: {src})")
|
|
31
31
|
continue
|
|
32
|
-
key = f"docs/{
|
|
32
|
+
key = f"docs/{each_name}"
|
|
33
33
|
src_hash = sha256_bytes(src.read_bytes())
|
|
34
34
|
if dry_run:
|
|
35
35
|
if dst.is_file():
|
|
@@ -44,10 +44,10 @@ def sync_canonical_docs(
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def check_canonical_docs(claude: Path, cursor: Path, docs_entries: dict) -> bool:
|
|
47
|
-
for
|
|
48
|
-
key = f"docs/{
|
|
49
|
-
src = claude / "docs" /
|
|
50
|
-
dst = cursor / "docs" /
|
|
47
|
+
for each_name in ALL_CANONICAL_DOC_FILES:
|
|
48
|
+
key = f"docs/{each_name}"
|
|
49
|
+
src = claude / "docs" / each_name
|
|
50
|
+
dst = cursor / "docs" / each_name
|
|
51
51
|
if not src.is_file():
|
|
52
52
|
if key in docs_entries:
|
|
53
53
|
return False
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Shared configuration for the sync-to-cursor package."""
|
|
2
|
+
|
|
3
|
+
GENERATOR_VERSION: str = "1.3.0"
|
|
4
|
+
ALL_CANONICAL_DOC_FILES: tuple[str, ...] = ("CODE_RULES.md", "TEST_QUALITY.md")
|
|
5
|
+
MAX_RULE_BODY_LINES: int = 50
|
|
6
|
+
ALL_SKIPPED_RULE_FILE_NAMES: frozenset[str] = frozenset({"CLAUDE.md", "AGENTS.md"})
|
|
7
|
+
MARKDOWN_SUFFIX: str = ".md"
|
|
8
|
+
CLAUDE_RULES_DIRECTORY_NAME: str = "rules"
|
|
@@ -140,15 +140,40 @@ def _sync_rules(
|
|
|
140
140
|
return summary, new_entries
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def _layout_from_args(args: argparse.Namespace) -> tuple[Path, Path, Path, Path]:
|
|
144
|
+
"""Resolve Claude and Cursor roots from flags or the default layout.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
args: Parsed CLI arguments.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Claude root, Cursor root, rules output directory, and manifest path.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
SystemExit: When only one of `--claude-root` / `--cursor-root` is set.
|
|
154
|
+
"""
|
|
155
|
+
claude_root = args.claude_root
|
|
156
|
+
cursor_root = args.cursor_root
|
|
157
|
+
if (claude_root is None) != (cursor_root is None):
|
|
158
|
+
raise SystemExit("both --claude-root and --cursor-root are required")
|
|
159
|
+
if claude_root is None:
|
|
160
|
+
return llm_layout_paths()
|
|
161
|
+
claude = Path(claude_root).expanduser().resolve()
|
|
162
|
+
cursor = Path(cursor_root).expanduser().resolve()
|
|
163
|
+
return claude, cursor, cursor / "rules", cursor / ".sync-manifest.json"
|
|
164
|
+
|
|
165
|
+
|
|
143
166
|
def run(argv: list[str] | None = None) -> int:
|
|
144
167
|
argument_parser = argparse.ArgumentParser(description="Sync Claude rules to Cursor .mdc files")
|
|
145
168
|
argument_parser.add_argument("--force", action="store_true", help="Regenerate all outputs")
|
|
146
169
|
argument_parser.add_argument("--dry-run", action="store_true", help="Print actions only")
|
|
147
170
|
argument_parser.add_argument("--check", action="store_true", help="Exit 1 if anything stale")
|
|
148
171
|
argument_parser.add_argument("--quiet", action="store_true", help="Minimal output when up to date")
|
|
172
|
+
argument_parser.add_argument("--claude-root", help="Claude layout root holding rules/ and docs/")
|
|
173
|
+
argument_parser.add_argument("--cursor-root", help="Cursor layout root receiving rules/ and docs/")
|
|
149
174
|
args = argument_parser.parse_args(argv)
|
|
150
175
|
|
|
151
|
-
claude, cursor, out_dir, manifest_path =
|
|
176
|
+
claude, cursor, out_dir, manifest_path = _layout_from_args(args)
|
|
152
177
|
mappings = build_mappings(claude)
|
|
153
178
|
old_manifest = _load_manifest(manifest_path)
|
|
154
179
|
entries_meta: dict = old_manifest.get("entries", {})
|
|
@@ -7,7 +7,12 @@ from dataclasses import dataclass
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Literal
|
|
9
9
|
|
|
10
|
-
from sync_to_cursor.config import
|
|
10
|
+
from sync_to_cursor.config import (
|
|
11
|
+
ALL_SKIPPED_RULE_FILE_NAMES,
|
|
12
|
+
CLAUDE_RULES_DIRECTORY_NAME,
|
|
13
|
+
MARKDOWN_SUFFIX,
|
|
14
|
+
MAX_RULE_BODY_LINES,
|
|
15
|
+
)
|
|
11
16
|
|
|
12
17
|
|
|
13
18
|
def _parse_h2_sections(markdown: str) -> dict[str, str]:
|
|
@@ -412,6 +417,61 @@ def _path_scoped_mappings(rules_directory: Path, docs_directory: Path) -> tuple[
|
|
|
412
417
|
)
|
|
413
418
|
|
|
414
419
|
|
|
420
|
+
def _description_from_rule_file(rule_file: Path, fallback_key: str) -> str:
|
|
421
|
+
"""Return the first markdown heading, or the rule key with spaces.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
rule_file: Claude rule markdown to read.
|
|
425
|
+
fallback_key: Stem used when the file has no heading.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
A one-line Cursor `description` value.
|
|
429
|
+
"""
|
|
430
|
+
for each_line in rule_file.read_text(encoding="utf-8").splitlines():
|
|
431
|
+
stripped = each_line.strip()
|
|
432
|
+
if stripped.startswith("# "):
|
|
433
|
+
return stripped[2:].strip()
|
|
434
|
+
return fallback_key.replace("-", " ")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _discovered_mappings(
|
|
438
|
+
rules_directory: Path, all_covered_source_names: set[str]
|
|
439
|
+
) -> tuple[RuleMapping, ...]:
|
|
440
|
+
"""Map remaining `*.md` rule files to `<stem>.mdc` Cursor rules.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
rules_directory: Claude `rules/` directory.
|
|
444
|
+
all_covered_source_names: Rule filenames already claimed by curated mappings.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
One mapping per remaining markdown rule, sorted by filename.
|
|
448
|
+
"""
|
|
449
|
+
if not rules_directory.is_dir():
|
|
450
|
+
return ()
|
|
451
|
+
all_discovered: list[RuleMapping] = []
|
|
452
|
+
for each_rule_file in sorted(rules_directory.glob("*.md")):
|
|
453
|
+
if each_rule_file.name in ALL_SKIPPED_RULE_FILE_NAMES:
|
|
454
|
+
continue
|
|
455
|
+
if each_rule_file.name in all_covered_source_names:
|
|
456
|
+
continue
|
|
457
|
+
key = each_rule_file.stem
|
|
458
|
+
paths_glob = _read_paths_glob(each_rule_file)
|
|
459
|
+
has_paths = paths_glob is not None
|
|
460
|
+
all_discovered.append(
|
|
461
|
+
RuleMapping(
|
|
462
|
+
key,
|
|
463
|
+
(each_rule_file,),
|
|
464
|
+
f"{key}.mdc",
|
|
465
|
+
not has_paths,
|
|
466
|
+
paths_glob,
|
|
467
|
+
_description_from_rule_file(each_rule_file, key),
|
|
468
|
+
"verbatim",
|
|
469
|
+
strip_leading_frontmatter=has_paths,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return tuple(all_discovered)
|
|
473
|
+
|
|
474
|
+
|
|
415
475
|
def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
|
|
416
476
|
"""Resolve every rule into a concrete Cursor mapping against a Claude layout.
|
|
417
477
|
|
|
@@ -420,14 +480,25 @@ def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
|
|
|
420
480
|
|
|
421
481
|
Returns:
|
|
422
482
|
One RuleMapping per rule, each path-scoped rule carrying a glob derived
|
|
423
|
-
from its source rule's `paths:` frontmatter.
|
|
483
|
+
from its source rule's `paths:` frontmatter. Curated mappings come first,
|
|
484
|
+
then one generated mapping per remaining `rules/*.md` file.
|
|
424
485
|
"""
|
|
425
486
|
rules_directory = claude / "rules"
|
|
426
487
|
docs_directory = claude / "docs"
|
|
427
|
-
|
|
488
|
+
all_curated = (
|
|
428
489
|
*_always_apply_mappings(rules_directory, docs_directory),
|
|
429
490
|
*_path_scoped_mappings(rules_directory, docs_directory),
|
|
430
491
|
)
|
|
431
|
-
mapping_by_key = {each_mapping.key: each_mapping for each_mapping in
|
|
492
|
+
mapping_by_key = {each_mapping.key: each_mapping for each_mapping in all_curated}
|
|
432
493
|
assert set(mapping_by_key) == set(_merged_mapping_key_order)
|
|
433
|
-
|
|
494
|
+
ordered_curated = tuple(mapping_by_key[each_key] for each_key in _merged_mapping_key_order)
|
|
495
|
+
all_covered_source_names = {
|
|
496
|
+
each_source.name
|
|
497
|
+
for each_mapping in ordered_curated
|
|
498
|
+
for each_source in each_mapping.sources
|
|
499
|
+
if each_source.suffix == MARKDOWN_SUFFIX
|
|
500
|
+
and each_source.parent.name == CLAUDE_RULES_DIRECTORY_NAME
|
|
501
|
+
}
|
|
502
|
+
return ordered_curated + _discovered_mappings(
|
|
503
|
+
rules_directory, all_covered_source_names
|
|
504
|
+
)
|
package/scripts/tests/AGENTS.md
CHANGED
|
@@ -10,6 +10,8 @@ pytest suite for the Python scripts and Pester suite for the PowerShell scripts
|
|
|
10
10
|
| `test_setup_project_paths_config.py` | Configuration constants used by `setup_project_paths.py` |
|
|
11
11
|
| `test_sweep_empty_dirs.py` | `sweep_empty_dirs.py` — age check, one-shot mode, and continuous-watch behavior |
|
|
12
12
|
| `test_sync_to_cursor.py` | `sync_to_cursor/` package — mapping, hashing, manifest, and path resolution |
|
|
13
|
+
| `test_rules.py` | Discovered Claude `rules/*.md` mappings to stem-named Cursor `.mdc` files |
|
|
14
|
+
| `test_engine.py` | `sync_to_cursor` engine `--claude-root` / `--cursor-root` layout flags |
|
|
13
15
|
| `test_grok_worker_constants.py` | `grok_worker_constants.py` — the accepted batch worker-key set stays in step with the worker key constants, and the unknown-key message names both its placeholders |
|
|
14
16
|
|
|
15
17
|
## PowerShell test files
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for sync_to_cursor engine CLI roots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
11
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
from sync_to_cursor.engine import run as run_sync_to_cursor
|
|
15
|
+
|
|
16
|
+
_CODE_STANDARDS_SECTION_ORDER = (
|
|
17
|
+
"COMMENT PRESERVATION",
|
|
18
|
+
"CORE PRINCIPLES",
|
|
19
|
+
"⚡ HOOK-ENFORCED RULES",
|
|
20
|
+
"3. REUSE CONSTANTS / 4. CONFIG LOCATIONS",
|
|
21
|
+
"5. NO ABBREVIATIONS",
|
|
22
|
+
"6. COMPLETE TYPE HINTS",
|
|
23
|
+
"9. SELF-CONTAINED COMPONENTS",
|
|
24
|
+
)
|
|
25
|
+
_TEST_QUALITY_SECTION_ORDER = (
|
|
26
|
+
"Delete Useless Tests",
|
|
27
|
+
"Test Dependencies MUST FAIL",
|
|
28
|
+
"Core Testing Principles",
|
|
29
|
+
"React Testing Patterns",
|
|
30
|
+
"Test File Organization",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_minimal_curated_rules(rules_directory: Path) -> None:
|
|
35
|
+
rules_directory.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
(rules_directory / "code-standards.md").write_text(
|
|
37
|
+
"# Code standards stub\n", encoding="utf-8"
|
|
38
|
+
)
|
|
39
|
+
(rules_directory / "tasklings-preferences.md").write_text(
|
|
40
|
+
'---\npaths:\n - "Y:/x/**"\n---\n\n# Tasklings\n',
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
)
|
|
43
|
+
(rules_directory / "bdd.md").write_text("# BDD\n", encoding="utf-8")
|
|
44
|
+
(rules_directory / "testing.md").write_text(
|
|
45
|
+
'---\npaths:\n - "**/test_*.py"\n---\n\n# Testing\n',
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
(rules_directory / "research-mode.md").write_text("# RM\n", encoding="utf-8")
|
|
49
|
+
(rules_directory / "conservative-action.md").write_text("# CA\n", encoding="utf-8")
|
|
50
|
+
(rules_directory / "explore-thoroughly.md").write_text("# ET\n", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_minimal_docs(docs_directory: Path) -> None:
|
|
54
|
+
docs_directory.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
(docs_directory / "CODE_RULES.md").write_text(
|
|
56
|
+
"\n\n".join(f"## {title}\n\nalpha" for title in _CODE_STANDARDS_SECTION_ORDER)
|
|
57
|
+
+ "\n",
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
(docs_directory / "TEST_QUALITY.md").write_text(
|
|
61
|
+
"\n\n".join(f"## {title}\n\nbeta" for title in _TEST_QUALITY_SECTION_ORDER)
|
|
62
|
+
+ "\n",
|
|
63
|
+
encoding="utf-8",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_explicit_roots_write_stem_named_mdc(
|
|
68
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
69
|
+
) -> None:
|
|
70
|
+
claude = tmp_path / "claude-home"
|
|
71
|
+
cursor = tmp_path / "cursor-home"
|
|
72
|
+
_write_minimal_curated_rules(claude / "rules")
|
|
73
|
+
_write_minimal_docs(claude / "docs")
|
|
74
|
+
(claude / "rules" / "plain-language.md").write_text(
|
|
75
|
+
"# Plain language\n\nUse short sentences.\n",
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
)
|
|
78
|
+
(claude / "rules" / "CLAUDE.md").write_text("# Inventory\n", encoding="utf-8")
|
|
79
|
+
monkeypatch.delenv("LLM_SETTINGS_ROOT", raising=False)
|
|
80
|
+
assert cursor.exists() is False
|
|
81
|
+
assert (
|
|
82
|
+
run_sync_to_cursor(
|
|
83
|
+
[
|
|
84
|
+
"--force",
|
|
85
|
+
"--claude-root",
|
|
86
|
+
str(claude),
|
|
87
|
+
"--cursor-root",
|
|
88
|
+
str(cursor),
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
== 0
|
|
92
|
+
)
|
|
93
|
+
generated = (cursor / "rules" / "plain-language.mdc").read_text(encoding="utf-8")
|
|
94
|
+
assert 'description: "Plain language"' in generated
|
|
95
|
+
assert "alwaysApply: true" in generated
|
|
96
|
+
assert "Use short sentences." in generated
|
|
97
|
+
assert not (cursor / "rules" / "CLAUDE.mdc").is_file()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_explicit_roots_require_both_flags(tmp_path: Path) -> None:
|
|
101
|
+
with pytest.raises(SystemExit):
|
|
102
|
+
run_sync_to_cursor(["--force", "--claude-root", str(tmp_path)])
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for discovered Claude-to-Cursor rule mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
11
|
+
|
|
12
|
+
from sync_to_cursor.rules import build_mappings
|
|
13
|
+
|
|
14
|
+
_PACKAGE_ROOT = _SCRIPTS_DIR.parent
|
|
15
|
+
_SKIPPED_RULE_FILE_NAMES = frozenset({"CLAUDE.md", "AGENTS.md"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_minimal_curated_rules(rules_directory: Path) -> None:
|
|
19
|
+
rules_directory.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
(rules_directory / "code-standards.md").write_text(
|
|
21
|
+
"# Code standards stub\n", encoding="utf-8"
|
|
22
|
+
)
|
|
23
|
+
(rules_directory / "tasklings-preferences.md").write_text(
|
|
24
|
+
'---\npaths:\n - "Y:/x/**"\n---\n\n# Tasklings\n',
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
)
|
|
27
|
+
(rules_directory / "bdd.md").write_text("# BDD\n", encoding="utf-8")
|
|
28
|
+
(rules_directory / "testing.md").write_text(
|
|
29
|
+
'---\npaths:\n - "**/test_*.py"\n---\n\n# Testing\n',
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
)
|
|
32
|
+
(rules_directory / "research-mode.md").write_text("# RM\n", encoding="utf-8")
|
|
33
|
+
(rules_directory / "conservative-action.md").write_text("# CA\n", encoding="utf-8")
|
|
34
|
+
(rules_directory / "explore-thoroughly.md").write_text("# ET\n", encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_build_mappings_emits_stem_mdc_for_remaining_claude_rules(
|
|
38
|
+
tmp_path: Path,
|
|
39
|
+
) -> None:
|
|
40
|
+
claude = tmp_path / ".claude"
|
|
41
|
+
_write_minimal_curated_rules(claude / "rules")
|
|
42
|
+
(claude / "docs").mkdir(parents=True, exist_ok=True)
|
|
43
|
+
(claude / "rules" / "plain-language.md").write_text(
|
|
44
|
+
"# Plain language\n\nBe brief.\n",
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
47
|
+
(claude / "rules" / "CLAUDE.md").write_text(
|
|
48
|
+
"# Package inventory\n", encoding="utf-8"
|
|
49
|
+
)
|
|
50
|
+
(claude / "rules" / "AGENTS.md").write_text("# Agent inventory\n", encoding="utf-8")
|
|
51
|
+
mappings = build_mappings(claude)
|
|
52
|
+
output_by_key = {each_mapping.key: each_mapping for each_mapping in mappings}
|
|
53
|
+
discovered = output_by_key["plain-language"]
|
|
54
|
+
assert discovered.output_name == "plain-language.mdc"
|
|
55
|
+
assert discovered.always_apply is True
|
|
56
|
+
assert discovered.description == "Plain language"
|
|
57
|
+
assert "CLAUDE.md" not in {
|
|
58
|
+
each_source.name
|
|
59
|
+
for each_mapping in mappings
|
|
60
|
+
for each_source in each_mapping.sources
|
|
61
|
+
}
|
|
62
|
+
assert "AGENTS.md" not in {
|
|
63
|
+
each_source.name
|
|
64
|
+
for each_mapping in mappings
|
|
65
|
+
for each_source in each_mapping.sources
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_every_shipped_claude_rule_maps_to_an_mdc() -> None:
|
|
70
|
+
mappings = build_mappings(_PACKAGE_ROOT)
|
|
71
|
+
output_name_by_rule_file = {}
|
|
72
|
+
for each_mapping in mappings:
|
|
73
|
+
for each_source in each_mapping.sources:
|
|
74
|
+
if each_source.parent.name == "rules" and each_source.suffix == ".md":
|
|
75
|
+
output_name_by_rule_file[each_source.name] = each_mapping.output_name
|
|
76
|
+
for each_rule_file in sorted((_PACKAGE_ROOT / "rules").glob("*.md")):
|
|
77
|
+
if each_rule_file.name in _SKIPPED_RULE_FILE_NAMES:
|
|
78
|
+
continue
|
|
79
|
+
assert each_rule_file.name in output_name_by_rule_file, each_rule_file.name
|