claude-dev-env 8.54.8 → 8.55.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/_shared/pr-loop/scripts/fix_pr_test_proof.py +87 -9
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +4 -1
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +94 -4
- package/bin/install.agents-home.test.mjs +2 -0
- package/bin/install.codex-rules.test.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +19 -20
- package/bin/install.profile-settings.test.mjs +77 -0
- package/bin/install.profiles.test.mjs +2 -2
- package/bin/install.prune.test.mjs +2 -0
- package/bin/install.question-presentation.test.mjs +2 -0
- package/bin/install.settings-defaults.test.mjs +8 -3
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +7 -3
- package/bin/install.transaction.test.mjs +14 -0
- package/bin/install.uninstall-transaction.test.mjs +4 -0
- package/bin/install.update-keeps-hooks.test.mjs +2 -0
- package/bin/merge_profile_settings.mjs +372 -0
- package/bin/merge_profile_settings.test.mjs +208 -0
- package/installable-surfaces.manifest.json +2 -1
- package/package.json +3 -2
- package/profile-settings/config/__init__.py +1 -0
- package/profile-settings/config/session_title_gate_constants.py +15 -0
- package/profile-settings/session_title_status_gate.py +77 -0
- package/profile-settings/test_session_title_status_gate.py +101 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +8 -4
- package/scripts/private_term_scan.py +15 -3
- package/scripts/tests/test_private_term_scan.py +81 -8
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
title "docs: ..." -> pass, nothing to prove
|
|
6
6
|
title "fix: ..." changing only docs or .github -> pass, nothing to prove
|
|
7
7
|
title "fix: ..." with no changed Python test -> fail
|
|
8
|
-
title "fix: ..." with no changed Python or
|
|
8
|
+
title "fix: ..." with no changed Python, Node, or plugin test -> fail
|
|
9
9
|
changed test passes on head, fails on base -> pass
|
|
10
10
|
changed test passes on both -> fail
|
|
11
11
|
any changed test fails on head -> fail
|
|
@@ -14,8 +14,10 @@ Python tests reuse the regression gate's detached worktree: the changed test
|
|
|
14
14
|
files are copied onto the base checkout, which runs them with import isolation.
|
|
15
15
|
Node tests (``*.test.mjs``, ``*.test.js``, ``*.test.cjs``) run through
|
|
16
16
|
``node --test`` on the head, then on a detached base checkout that holds a copy
|
|
17
|
-
of the changed test files.
|
|
18
|
-
|
|
17
|
+
of the changed test files. Plugin tests (``*.test.ts``, ``*.test.tsx`` under a
|
|
18
|
+
directory that holds ``.claude-plugin/``) run through ``claude plugin test`` in
|
|
19
|
+
each plugin root on the head, then on the same kind of detached base checkout.
|
|
20
|
+
The fix is proven when at least one language's changed tests fail on the base.
|
|
19
21
|
"""
|
|
20
22
|
|
|
21
23
|
import argparse
|
|
@@ -23,7 +25,9 @@ import shutil
|
|
|
23
25
|
import subprocess
|
|
24
26
|
import sys
|
|
25
27
|
import tempfile
|
|
28
|
+
from collections.abc import Callable
|
|
26
29
|
from enum import Enum
|
|
30
|
+
from functools import partial
|
|
27
31
|
from pathlib import Path
|
|
28
32
|
|
|
29
33
|
from code_rules_gate_parts import staged_test_regression, staged_test_running
|
|
@@ -32,6 +36,8 @@ from code_rules_gate_parts.wrapper_plumb_check import is_test_path
|
|
|
32
36
|
from pr_loop_shared_constants.fix_pr_test_proof_constants import (
|
|
33
37
|
ALL_NODE_TEST_COMMAND,
|
|
34
38
|
ALL_NODE_TEST_SUFFIXES,
|
|
39
|
+
ALL_PLUGIN_TEST_COMMAND,
|
|
40
|
+
ALL_PLUGIN_TEST_SUFFIXES,
|
|
35
41
|
ALL_PRODUCTION_CODE_SUFFIXES,
|
|
36
42
|
CI_CONFIG_DIRECTORY_NAME,
|
|
37
43
|
FAILED_EXIT_CODE,
|
|
@@ -44,6 +50,7 @@ from pr_loop_shared_constants.fix_pr_test_proof_constants import (
|
|
|
44
50
|
NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX,
|
|
45
51
|
NOT_A_FIX_MESSAGE,
|
|
46
52
|
PASSED_EXIT_CODE,
|
|
53
|
+
PLUGIN_MANIFEST_DIRECTORY_NAME,
|
|
47
54
|
PROVEN_MESSAGE,
|
|
48
55
|
WORKTREE_FAILED_MESSAGE,
|
|
49
56
|
)
|
|
@@ -102,6 +109,25 @@ def _changed_node_tests(all_changed_paths: list[Path]) -> list[Path]:
|
|
|
102
109
|
]
|
|
103
110
|
|
|
104
111
|
|
|
112
|
+
def _plugin_root(test_path: Path, repository_root: Path) -> Path | None:
|
|
113
|
+
for each_directory in test_path.parents:
|
|
114
|
+
if (each_directory / PLUGIN_MANIFEST_DIRECTORY_NAME).is_dir():
|
|
115
|
+
return each_directory
|
|
116
|
+
if each_directory == repository_root:
|
|
117
|
+
return None
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _changed_plugin_tests(all_changed_paths: list[Path], repository_root: Path) -> list[Path]:
|
|
122
|
+
return [
|
|
123
|
+
each_path
|
|
124
|
+
for each_path in all_changed_paths
|
|
125
|
+
if each_path.name.endswith(ALL_PLUGIN_TEST_SUFFIXES)
|
|
126
|
+
and each_path.is_file()
|
|
127
|
+
and _plugin_root(each_path, repository_root) is not None
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
105
131
|
def _python_proof_verdict(
|
|
106
132
|
all_changed_tests: list[Path], repository_root: Path, base_revision: str
|
|
107
133
|
) -> ProofVerdict:
|
|
@@ -130,7 +156,7 @@ def _python_proof_verdict(
|
|
|
130
156
|
return ProofVerdict.PROVEN
|
|
131
157
|
|
|
132
158
|
|
|
133
|
-
def _node_tests_pass(
|
|
159
|
+
def _node_tests_pass(all_relative_tests: list[Path], working_directory: Path) -> bool:
|
|
134
160
|
node_run = subprocess.run(
|
|
135
161
|
[*ALL_NODE_TEST_COMMAND, *(each_path.as_posix() for each_path in all_relative_tests)],
|
|
136
162
|
cwd=working_directory,
|
|
@@ -141,6 +167,20 @@ def _node_tests_pass(working_directory: Path, all_relative_tests: list[Path]) ->
|
|
|
141
167
|
return node_run.returncode == 0
|
|
142
168
|
|
|
143
169
|
|
|
170
|
+
def _plugin_tests_pass(all_relative_plugin_roots: list[Path], working_directory: Path) -> bool:
|
|
171
|
+
return all(
|
|
172
|
+
subprocess.run(
|
|
173
|
+
ALL_PLUGIN_TEST_COMMAND,
|
|
174
|
+
cwd=working_directory / each_relative_root,
|
|
175
|
+
capture_output=True,
|
|
176
|
+
text=True,
|
|
177
|
+
check=False,
|
|
178
|
+
).returncode
|
|
179
|
+
== 0
|
|
180
|
+
for each_relative_root in all_relative_plugin_roots
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
144
184
|
def _copy_tests_onto(
|
|
145
185
|
base_worktree: Path, repository_root: Path, all_relative_tests: list[Path]
|
|
146
186
|
) -> None:
|
|
@@ -150,11 +190,14 @@ def _copy_tests_onto(
|
|
|
150
190
|
shutil.copy2(repository_root / each_relative_test, base_test_path)
|
|
151
191
|
|
|
152
192
|
|
|
153
|
-
def
|
|
154
|
-
all_changed_tests: list[Path],
|
|
193
|
+
def _head_and_base_proof_verdict(
|
|
194
|
+
all_changed_tests: list[Path],
|
|
195
|
+
repository_root: Path,
|
|
196
|
+
base_revision: str,
|
|
197
|
+
tests_pass: Callable[[Path], bool],
|
|
155
198
|
) -> ProofVerdict:
|
|
156
199
|
all_relative_tests = [each_path.relative_to(repository_root) for each_path in all_changed_tests]
|
|
157
|
-
if not
|
|
200
|
+
if not tests_pass(repository_root):
|
|
158
201
|
sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=repository_root) + "\n")
|
|
159
202
|
return ProofVerdict.HEAD_FAILED
|
|
160
203
|
with tempfile.TemporaryDirectory(
|
|
@@ -168,7 +211,7 @@ def _node_proof_verdict(
|
|
|
168
211
|
return ProofVerdict.BASE_UNAVAILABLE
|
|
169
212
|
try:
|
|
170
213
|
_copy_tests_onto(base_worktree, repository_root, all_relative_tests)
|
|
171
|
-
passes_on_base =
|
|
214
|
+
passes_on_base = tests_pass(base_worktree)
|
|
172
215
|
finally:
|
|
173
216
|
staged_test_regression._remove_baseline_worktree(repository_root, base_worktree)
|
|
174
217
|
if passes_on_base:
|
|
@@ -177,6 +220,36 @@ def _node_proof_verdict(
|
|
|
177
220
|
return ProofVerdict.PROVEN
|
|
178
221
|
|
|
179
222
|
|
|
223
|
+
def _node_proof_verdict(
|
|
224
|
+
all_changed_tests: list[Path], repository_root: Path, base_revision: str
|
|
225
|
+
) -> ProofVerdict:
|
|
226
|
+
all_relative_tests = [each_path.relative_to(repository_root) for each_path in all_changed_tests]
|
|
227
|
+
return _head_and_base_proof_verdict(
|
|
228
|
+
all_changed_tests,
|
|
229
|
+
repository_root,
|
|
230
|
+
base_revision,
|
|
231
|
+
partial(_node_tests_pass, all_relative_tests),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _plugin_proof_verdict(
|
|
236
|
+
all_changed_tests: list[Path], repository_root: Path, base_revision: str
|
|
237
|
+
) -> ProofVerdict:
|
|
238
|
+
all_relative_plugin_roots = sorted(
|
|
239
|
+
{
|
|
240
|
+
each_root.relative_to(repository_root)
|
|
241
|
+
for each_path in all_changed_tests
|
|
242
|
+
if (each_root := _plugin_root(each_path, repository_root)) is not None
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
return _head_and_base_proof_verdict(
|
|
246
|
+
all_changed_tests,
|
|
247
|
+
repository_root,
|
|
248
|
+
base_revision,
|
|
249
|
+
partial(_plugin_tests_pass, all_relative_plugin_roots),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
180
253
|
def _proof_exit_code(
|
|
181
254
|
all_verdicts: list[ProofVerdict], base_revision: str
|
|
182
255
|
) -> int:
|
|
@@ -213,7 +286,8 @@ def main(all_arguments: list[str]) -> int:
|
|
|
213
286
|
return PASSED_EXIT_CODE
|
|
214
287
|
all_changed_python_tests = _changed_python_tests(all_changed_paths)
|
|
215
288
|
all_changed_node_tests = _changed_node_tests(all_changed_paths)
|
|
216
|
-
|
|
289
|
+
all_changed_plugin_tests = _changed_plugin_tests(all_changed_paths, repository_root)
|
|
290
|
+
if not (all_changed_python_tests or all_changed_node_tests or all_changed_plugin_tests):
|
|
217
291
|
sys.stderr.write(NO_CHANGED_TEST_MESSAGE + "\n")
|
|
218
292
|
return FAILED_EXIT_CODE
|
|
219
293
|
base_revision = parsed_arguments.base_revision
|
|
@@ -226,6 +300,10 @@ def main(all_arguments: list[str]) -> int:
|
|
|
226
300
|
all_verdicts.append(
|
|
227
301
|
_node_proof_verdict(all_changed_node_tests, repository_root, base_revision)
|
|
228
302
|
)
|
|
303
|
+
if all_changed_plugin_tests:
|
|
304
|
+
all_verdicts.append(
|
|
305
|
+
_plugin_proof_verdict(all_changed_plugin_tests, repository_root, base_revision)
|
|
306
|
+
)
|
|
229
307
|
return _proof_exit_code(all_verdicts, base_revision)
|
|
230
308
|
|
|
231
309
|
|
|
@@ -17,6 +17,9 @@ ALL_PRODUCTION_CODE_SUFFIXES: tuple[str, ...] = (
|
|
|
17
17
|
)
|
|
18
18
|
ALL_NODE_TEST_SUFFIXES: tuple[str, ...] = (".test.mjs", ".test.js", ".test.cjs")
|
|
19
19
|
ALL_NODE_TEST_COMMAND: tuple[str, ...] = ("node", "--test")
|
|
20
|
+
ALL_PLUGIN_TEST_SUFFIXES: tuple[str, ...] = (".test.ts", ".test.tsx")
|
|
21
|
+
ALL_PLUGIN_TEST_COMMAND: tuple[str, ...] = ("claude", "plugin", "test", ".")
|
|
22
|
+
PLUGIN_MANIFEST_DIRECTORY_NAME: str = ".claude-plugin"
|
|
20
23
|
NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX: str = "fix_pr_test_proof_node_base_"
|
|
21
24
|
NODE_BASE_WORKTREE_DIRECTORY_NAME: str = "tree"
|
|
22
25
|
CI_CONFIG_DIRECTORY_NAME: str = ".github"
|
|
@@ -28,7 +31,7 @@ NO_PRODUCTION_CHANGE_MESSAGE: str = (
|
|
|
28
31
|
"fix_pr_test_proof: the fix changes no production code; nothing to prove."
|
|
29
32
|
)
|
|
30
33
|
NO_CHANGED_TEST_MESSAGE: str = (
|
|
31
|
-
"fix_pr_test_proof: this fix changes production code and no Python or
|
|
34
|
+
"fix_pr_test_proof: this fix changes production code and no Python, Node, or plugin test. "
|
|
32
35
|
"Add a test that fails on the base and passes on the head."
|
|
33
36
|
)
|
|
34
37
|
WORKTREE_FAILED_MESSAGE: str = (
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Behavioral tests for the fix pull request test-proof check.
|
|
2
2
|
|
|
3
|
-
Every case drives a git repository under ``tmp_path`` and a pytest
|
|
4
|
-
``node --test`` subprocess, the same shape the CI job runs.
|
|
3
|
+
Every case drives a git repository under ``tmp_path`` and a pytest,
|
|
4
|
+
``node --test``, or ``claude plugin test`` subprocess, the same shape the CI job runs.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
@@ -39,6 +39,25 @@ NODE_ALWAYS_PASSING_TEST_TEXT = (
|
|
|
39
39
|
"import { test } from 'node:test';\n\n"
|
|
40
40
|
"test('nothing in particular', () => {});\n"
|
|
41
41
|
)
|
|
42
|
+
PLUGIN_MANIFEST_TEXT = '{"name": "calc"}\n'
|
|
43
|
+
PLUGIN_HOOKS_TEXT = '{ "modules": ["./index.ts"] }\n'
|
|
44
|
+
BUGGY_PLUGIN_PRODUCTION_TEXT = (
|
|
45
|
+
"export function add(left: number, right: number): number {\n"
|
|
46
|
+
" return left - right\n}\n"
|
|
47
|
+
)
|
|
48
|
+
FIXED_PLUGIN_PRODUCTION_TEXT = (
|
|
49
|
+
"export function add(left: number, right: number): number {\n"
|
|
50
|
+
" return left + right\n}\n"
|
|
51
|
+
)
|
|
52
|
+
PLUGIN_PROOF_TEST_TEXT = (
|
|
53
|
+
"import { test, expect } from 'claude-code/testing'\n"
|
|
54
|
+
"import { add } from '../hooks/index'\n\n"
|
|
55
|
+
"test('add sums both operands', () => {\n expect(add(1, 2)).toBe(3)\n})\n"
|
|
56
|
+
)
|
|
57
|
+
PLUGIN_ALWAYS_PASSING_TEST_TEXT = (
|
|
58
|
+
"import { test, expect } from 'claude-code/testing'\n\n"
|
|
59
|
+
"test('nothing in particular', () => {\n expect(1).toBe(1)\n})\n"
|
|
60
|
+
)
|
|
42
61
|
|
|
43
62
|
|
|
44
63
|
def _commit_files(repository_root: Path, all_file_texts: dict[str, str]) -> None:
|
|
@@ -66,6 +85,19 @@ def _repository_at_buggy_node_base(tmp_path: Path) -> tuple[Path, str]:
|
|
|
66
85
|
return repository_root, _head_revision(repository_root)
|
|
67
86
|
|
|
68
87
|
|
|
88
|
+
def _repository_at_buggy_plugin_base(tmp_path: Path) -> tuple[Path, str]:
|
|
89
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
90
|
+
_commit_files(
|
|
91
|
+
repository_root,
|
|
92
|
+
{
|
|
93
|
+
"plugins/calc/.claude-plugin/plugin.json": PLUGIN_MANIFEST_TEXT,
|
|
94
|
+
"plugins/calc/hooks/hooks.json": PLUGIN_HOOKS_TEXT,
|
|
95
|
+
"plugins/calc/hooks/index.ts": BUGGY_PLUGIN_PRODUCTION_TEXT,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
return repository_root, _head_revision(repository_root)
|
|
99
|
+
|
|
100
|
+
|
|
69
101
|
def _run_check(repository_root: Path, title: str, base_revision: str) -> int:
|
|
70
102
|
return fix_pr_test_proof.main(
|
|
71
103
|
[
|
|
@@ -100,7 +132,7 @@ def test_a_fix_with_no_changed_test_fails(
|
|
|
100
132
|
_commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
|
|
101
133
|
|
|
102
134
|
assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
|
|
103
|
-
assert "no Python or
|
|
135
|
+
assert "no Python, Node, or plugin test" in capsys.readouterr().err
|
|
104
136
|
|
|
105
137
|
|
|
106
138
|
def test_a_fix_whose_test_fails_on_base_and_passes_on_head_passes(
|
|
@@ -186,6 +218,64 @@ def test_a_node_fix_whose_changed_test_fails_on_head_fails(
|
|
|
186
218
|
assert "fails on the head" in capsys.readouterr().err
|
|
187
219
|
|
|
188
220
|
|
|
221
|
+
def test_a_plugin_fix_with_no_changed_test_fails(
|
|
222
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
223
|
+
) -> None:
|
|
224
|
+
repository_root, base_revision = _repository_at_buggy_plugin_base(tmp_path)
|
|
225
|
+
_commit_files(repository_root, {"plugins/calc/hooks/index.ts": FIXED_PLUGIN_PRODUCTION_TEXT})
|
|
226
|
+
|
|
227
|
+
assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 1
|
|
228
|
+
assert "no Python, Node, or plugin test" in capsys.readouterr().err
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_a_plugin_fix_whose_test_fails_on_base_and_passes_on_head_passes(
|
|
232
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
233
|
+
) -> None:
|
|
234
|
+
repository_root, base_revision = _repository_at_buggy_plugin_base(tmp_path)
|
|
235
|
+
_commit_files(
|
|
236
|
+
repository_root,
|
|
237
|
+
{
|
|
238
|
+
"plugins/calc/hooks/index.ts": FIXED_PLUGIN_PRODUCTION_TEXT,
|
|
239
|
+
"plugins/calc/tests/calc.test.ts": PLUGIN_PROOF_TEST_TEXT,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 0
|
|
244
|
+
assert "fail on the base and pass on the head" in capsys.readouterr().out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_a_plugin_fix_whose_changed_test_also_passes_on_base_fails(
|
|
248
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
249
|
+
) -> None:
|
|
250
|
+
repository_root, base_revision = _repository_at_buggy_plugin_base(tmp_path)
|
|
251
|
+
_commit_files(
|
|
252
|
+
repository_root,
|
|
253
|
+
{
|
|
254
|
+
"plugins/calc/hooks/index.ts": FIXED_PLUGIN_PRODUCTION_TEXT,
|
|
255
|
+
"plugins/calc/tests/calc.test.ts": PLUGIN_ALWAYS_PASSING_TEST_TEXT,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 1
|
|
260
|
+
assert "passes on the base" in capsys.readouterr().err
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_a_plugin_fix_whose_changed_test_fails_on_head_fails(
|
|
264
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
265
|
+
) -> None:
|
|
266
|
+
repository_root, base_revision = _repository_at_buggy_plugin_base(tmp_path)
|
|
267
|
+
_commit_files(
|
|
268
|
+
repository_root,
|
|
269
|
+
{
|
|
270
|
+
"plugins/calc/tests/calc.test.ts": PLUGIN_PROOF_TEST_TEXT,
|
|
271
|
+
"plugins/calc/hooks/extra.ts": "export const VALUE = 1\n",
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 1
|
|
276
|
+
assert "fails on the head" in capsys.readouterr().err
|
|
277
|
+
|
|
278
|
+
|
|
189
279
|
BUGGY_GREETING_TEXT = 'def greet() -> str:\n return "helo"\n'
|
|
190
280
|
FIXED_GREETING_TEXT = 'def greet() -> str:\n return "hello"\n'
|
|
191
281
|
GREETING_TEST_TEXT = (
|
|
@@ -222,7 +312,7 @@ def test_a_fix_without_its_own_test_fails_when_the_base_branch_moved_with_a_test
|
|
|
222
312
|
)
|
|
223
313
|
|
|
224
314
|
assert _run_check(repository_root, "fix: sum operands", MERGE_REF_FIRST_PARENT) == 1
|
|
225
|
-
assert "no Python or
|
|
315
|
+
assert "no Python, Node, or plugin test" in capsys.readouterr().err
|
|
226
316
|
|
|
227
317
|
|
|
228
318
|
def test_a_fix_on_a_moved_base_branch_judges_only_its_own_test(
|
|
@@ -50,6 +50,8 @@ const REVIEW_PROCEDURE_FILE_NAME = 'high.md';
|
|
|
50
50
|
function runInstaller(homeDirectory, extraArguments, environmentOverrides = {}) {
|
|
51
51
|
const installerEnvironment = {
|
|
52
52
|
...process.env,
|
|
53
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
54
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
53
55
|
CDE_INSTALL_PSTACK: '0',
|
|
54
56
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
55
57
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
@@ -56,6 +56,8 @@ function runInstaller(homeDirectory, extraArguments) {
|
|
|
56
56
|
encoding: 'utf8',
|
|
57
57
|
env: {
|
|
58
58
|
...process.env,
|
|
59
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
60
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
59
61
|
CDE_INSTALL_PSTACK: '0',
|
|
60
62
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
61
63
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
@@ -86,6 +88,8 @@ function scanTextWithProductionPiiScanner(scannedText) {
|
|
|
86
88
|
encoding: 'utf8',
|
|
87
89
|
env: {
|
|
88
90
|
...process.env,
|
|
91
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
92
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
89
93
|
CDE_INSTALL_PSTACK: '0',
|
|
90
94
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
91
95
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
@@ -31,6 +31,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
|
|
|
31
31
|
encoding: 'utf8',
|
|
32
32
|
env: {
|
|
33
33
|
...process.env,
|
|
34
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
34
35
|
CDE_INSTALL_PSTACK: '0',
|
|
35
36
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
36
37
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
package/bin/install.mjs
CHANGED
|
@@ -29,6 +29,11 @@ import {
|
|
|
29
29
|
mergeMissingSettingsDefaults,
|
|
30
30
|
settingsDefaultsFromPackageSettings,
|
|
31
31
|
} from './merge_settings_defaults.mjs';
|
|
32
|
+
import {
|
|
33
|
+
describeProfileSettingsOutcome,
|
|
34
|
+
hostConfigurationIndent,
|
|
35
|
+
mergeProfileSettings,
|
|
36
|
+
} from './merge_profile_settings.mjs';
|
|
32
37
|
import {
|
|
33
38
|
SKIPPED_SOURCE_ENTRY_NAMES,
|
|
34
39
|
SKIPPED_SOURCE_FILE_EXTENSIONS,
|
|
@@ -1548,15 +1553,19 @@ function startEventFromHookGroupList(settings, eventType) {
|
|
|
1548
1553
|
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
1549
1554
|
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
1550
1555
|
* @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to
|
|
1551
|
-
* (the installer's `~/.claude`
|
|
1556
|
+
* (the installer's managed root, `~/.claude` or a profile root).
|
|
1552
1557
|
* @param {string} pythonCommand Interpreter command that replaces python3.
|
|
1553
1558
|
* @param {string|null} [sharedSettingsRealPath] Resolved path of the settings
|
|
1554
1559
|
* file being merged. Hooks of every root whose settings.json resolves to it
|
|
1555
1560
|
* count as managed, so roots sharing one file keep one entry per script.
|
|
1561
|
+
* @param {string} [homeDirectory] The home directory `~/`, `$HOME`, and
|
|
1562
|
+
* `${HOME}` expand to. It is the user's home whatever root installs, so a
|
|
1563
|
+
* profile root under `~/.claude-profiles` expands `~/` the same way the
|
|
1564
|
+
* profile settings merge does.
|
|
1556
1565
|
* @returns {number} Count of matcher groups merged.
|
|
1557
1566
|
*/
|
|
1558
1567
|
export function mergeHooksIntoSettings(
|
|
1559
|
-
settings, hooksConfig, pluginRootDir, pythonCommand, sharedSettingsRealPath = null,
|
|
1568
|
+
settings, hooksConfig, pluginRootDir, pythonCommand, sharedSettingsRealPath = null, homeDirectory = homedir(),
|
|
1560
1569
|
) {
|
|
1561
1570
|
const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
|
|
1562
1571
|
const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
|
|
@@ -1604,7 +1613,7 @@ export function mergeHooksIntoSettings(
|
|
|
1604
1613
|
groupCount++;
|
|
1605
1614
|
}
|
|
1606
1615
|
}
|
|
1607
|
-
expandHomeDirectoryTokensInSettings(settings,
|
|
1616
|
+
expandHomeDirectoryTokensInSettings(settings, homeDirectory);
|
|
1608
1617
|
return groupCount;
|
|
1609
1618
|
}
|
|
1610
1619
|
|
|
@@ -1883,23 +1892,6 @@ function stripRetiredHookEntries(settings, retiredHookRelativePaths, ownership)
|
|
|
1883
1892
|
return removedCount;
|
|
1884
1893
|
}
|
|
1885
1894
|
|
|
1886
|
-
const DEFAULT_HOST_CONFIGURATION_INDENT = ' ';
|
|
1887
|
-
|
|
1888
|
-
/**
|
|
1889
|
-
* Read the indent the host configuration file already uses.
|
|
1890
|
-
*
|
|
1891
|
-
* ~/.claude/settings.json uses four spaces and ~/.codex/hooks.json uses two.
|
|
1892
|
-
* Forcing four spaces would rewrite every line of one of those files. The first
|
|
1893
|
-
* indented line supplies the indent, tabs included.
|
|
1894
|
-
*
|
|
1895
|
-
* @param {string} settingsText The host configuration file as it stands on disk.
|
|
1896
|
-
* @returns {string} The indent one nesting level uses.
|
|
1897
|
-
*/
|
|
1898
|
-
function hostConfigurationIndent(settingsText) {
|
|
1899
|
-
const firstIndentedLine = /\n([ \t]+)\S/.exec(settingsText);
|
|
1900
|
-
return firstIndentedLine ? firstIndentedLine[1] : DEFAULT_HOST_CONFIGURATION_INDENT;
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
1895
|
/**
|
|
1904
1896
|
* Remove every entry of one host hook configuration file that runs a retired
|
|
1905
1897
|
* managed hook script, writing the file only when an entry left it.
|
|
@@ -3027,6 +3019,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3027
3019
|
if (settingsDefaultsMerge.addedKeys.length > 0) {
|
|
3028
3020
|
console.log(` Settings: added ${settingsDefaultsMerge.addedKeys.join(', ')}`);
|
|
3029
3021
|
}
|
|
3022
|
+
const mergeOutcome = mergeProfileSettings(
|
|
3023
|
+
[join(CLAUDE_HOME, SETTINGS_FILE_NAME)],
|
|
3024
|
+
{ dryRun: false, homeDirectory: homedir() },
|
|
3025
|
+
);
|
|
3026
|
+
for (const eachLine of describeProfileSettingsOutcome(mergeOutcome, false)) {
|
|
3027
|
+
console.log(` Profile settings: ${eachLine}`);
|
|
3028
|
+
}
|
|
3030
3029
|
|
|
3031
3030
|
const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
|
|
3032
3031
|
if (existsSync(agentsHubSource)) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installing twice into a profile root under ~/.claude-profiles leaves exactly
|
|
3
|
+
* one session-title gate Stop hook, that hook runs the gate under ~/.claude, and
|
|
4
|
+
* the second install leaves settings.json byte for byte.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import { strict as assert } from 'node:assert';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
import { ALL_SESSION_TITLE_GATE_RELATIVE_PATHS, SESSION_TITLE_GATE_FILE_NAME } from './merge_profile_settings.mjs';
|
|
16
|
+
|
|
17
|
+
const INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
|
|
18
|
+
|
|
19
|
+
function installEnvironment(homeDirectory) {
|
|
20
|
+
return {
|
|
21
|
+
...process.env,
|
|
22
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
23
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
24
|
+
HOME: homeDirectory,
|
|
25
|
+
USERPROFILE: homeDirectory,
|
|
26
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
27
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
28
|
+
CDE_INSTALL_PSTACK: '0',
|
|
29
|
+
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runCoreInstall(homeDirectory, profileRoot) {
|
|
34
|
+
const installRun = spawnSync(
|
|
35
|
+
process.execPath,
|
|
36
|
+
[INSTALLER_PATH, '--only', 'core', '--target', profileRoot],
|
|
37
|
+
{ encoding: 'utf8', env: installEnvironment(homeDirectory) },
|
|
38
|
+
);
|
|
39
|
+
assert.equal(installRun.status, 0, installRun.stdout + installRun.stderr);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stopHookCommands(settingsPath) {
|
|
43
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
44
|
+
return (settings.hooks?.Stop ?? []).flatMap(
|
|
45
|
+
(eachGroup) => (eachGroup.hooks ?? []).map((eachHook) => eachHook.command.replace(/\\/g, '/')),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('two installs into a profile root keep one gate Stop hook that runs the gate under ~/.claude', () => {
|
|
50
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-profile-settings-'));
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(join(homeDirectory, '.gitconfig'), '');
|
|
53
|
+
const profileRoot = join(homeDirectory, '.claude-profiles', 'work');
|
|
54
|
+
mkdirSync(profileRoot, { recursive: true });
|
|
55
|
+
const settingsPath = join(profileRoot, 'settings.json');
|
|
56
|
+
const forwardSlashedHome = homeDirectory.replace(/\\/g, '/');
|
|
57
|
+
const expectedGateCommand = `python3 ${forwardSlashedHome}/.claude/${SESSION_TITLE_GATE_FILE_NAME}`;
|
|
58
|
+
|
|
59
|
+
runCoreInstall(homeDirectory, profileRoot);
|
|
60
|
+
const settingsBytesAfterFirstInstall = readFileSync(settingsPath);
|
|
61
|
+
runCoreInstall(homeDirectory, profileRoot);
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(readFileSync(settingsPath), settingsBytesAfterFirstInstall, 'second install leaves settings.json byte for byte');
|
|
64
|
+
|
|
65
|
+
const allGateCommands = stopHookCommands(settingsPath)
|
|
66
|
+
.filter((eachCommand) => eachCommand.includes(SESSION_TITLE_GATE_FILE_NAME));
|
|
67
|
+
assert.deepEqual(allGateCommands, [expectedGateCommand]);
|
|
68
|
+
for (const eachRelativePath of ALL_SESSION_TITLE_GATE_RELATIVE_PATHS) {
|
|
69
|
+
assert.ok(
|
|
70
|
+
existsSync(join(homeDirectory, '.claude', eachRelativePath)),
|
|
71
|
+
`${eachRelativePath} is installed under ~/.claude`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
@@ -235,7 +235,7 @@ test('repeated and selected multi-profile dry runs write one ownership manifest
|
|
|
235
235
|
test('install.mjs --help documents profile target selection', () => {
|
|
236
236
|
const result = spawnSync(process.execPath, [INSTALL_MODULE_PATH, '--help'], {
|
|
237
237
|
encoding: 'utf8',
|
|
238
|
-
env: process.env,
|
|
238
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: undefined, LLM_SETTINGS_PROFILES_ROOT: undefined },
|
|
239
239
|
});
|
|
240
240
|
assert.equal(result.status, 0, result.stderr);
|
|
241
241
|
assert.match(result.stdout, /--profile/);
|
|
@@ -247,7 +247,7 @@ test('install.mjs --help documents profile target selection', () => {
|
|
|
247
247
|
test('install.mjs --help states that a positional path argument carries no meaning', () => {
|
|
248
248
|
const result = spawnSync(process.execPath, [INSTALL_MODULE_PATH, '--help'], {
|
|
249
249
|
encoding: 'utf8',
|
|
250
|
-
env: process.env,
|
|
250
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: undefined, LLM_SETTINGS_PROFILES_ROOT: undefined },
|
|
251
251
|
});
|
|
252
252
|
assert.equal(result.status, 0, result.stderr);
|
|
253
253
|
assert.match(result.stdout, /reads only flags/i);
|
|
@@ -259,6 +259,8 @@ function runInstaller(homeDirectory, extraArguments) {
|
|
|
259
259
|
function resolveInstallerInvocation(homeDirectory) {
|
|
260
260
|
const childEnvironment = {
|
|
261
261
|
...process.env,
|
|
262
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
263
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
262
264
|
CDE_INSTALL_PSTACK: '0',
|
|
263
265
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
264
266
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
@@ -22,6 +22,8 @@ function installInScratchHome(homeDirectory, argumentsList = [], extraEnvironmen
|
|
|
22
22
|
encoding: 'utf8',
|
|
23
23
|
env: {
|
|
24
24
|
...process.env,
|
|
25
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
26
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
25
27
|
CDE_INSTALL_PSTACK: '0',
|
|
26
28
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
27
29
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
mergeMissingSettingsDefaults,
|
|
26
26
|
settingsDefaultsFromPackageSettings,
|
|
27
27
|
} from './merge_settings_defaults.mjs';
|
|
28
|
+
import { DECLARED_PROFILE_SETTINGS } from './merge_profile_settings.mjs';
|
|
28
29
|
|
|
29
30
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
31
|
const PACKAGE_SETTINGS_PATH = join(PACKAGE_ROOT, 'settings.json');
|
|
@@ -32,6 +33,8 @@ const INSTALL_ENTRY = join(PACKAGE_ROOT, 'bin', 'install.mjs');
|
|
|
32
33
|
|
|
33
34
|
const EXPECTED_DENY_ENTRIES = [];
|
|
34
35
|
const EXPECTED_ALLOW_ENTRIES = ['WebFetch(domain:docs.github.com)'];
|
|
36
|
+
const PROFILE_ALLOW_ENTRIES = DECLARED_PROFILE_SETTINGS
|
|
37
|
+
.find((eachEntry) => eachEntry.keyPath.join('.') === 'permissions.allow').items;
|
|
35
38
|
const SAMPLE_MANAGED_DENY_ENTRIES = ['Edit($HOME/.claude/managed-test/**)'];
|
|
36
39
|
const SAMPLE_MANAGED_PERMISSIONS = { allow: [], deny: SAMPLE_MANAGED_DENY_ENTRIES };
|
|
37
40
|
const SAMPLE_MANAGED_ALLOW_PERMISSIONS = { allow: ['WebFetch(domain:example.com)'], deny: [] };
|
|
@@ -56,6 +59,8 @@ function runInstallerInSandbox(sandboxHome, installerArguments = []) {
|
|
|
56
59
|
encoding: 'utf8',
|
|
57
60
|
env: {
|
|
58
61
|
...process.env,
|
|
62
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
63
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
59
64
|
CDE_INSTALL_PSTACK: '0',
|
|
60
65
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
61
66
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
@@ -171,7 +176,7 @@ test('sandbox install keeps default managed denies empty on repeat', () => {
|
|
|
171
176
|
const firstSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
172
177
|
const firstDeny = firstSettings.permissions?.deny ?? [];
|
|
173
178
|
assert.deepEqual(firstDeny, EXPECTED_DENY_ENTRIES);
|
|
174
|
-
assert.deepEqual(firstSettings.permissions?.allow, EXPECTED_ALLOW_ENTRIES);
|
|
179
|
+
assert.deepEqual(firstSettings.permissions?.allow, [...EXPECTED_ALLOW_ENTRIES, ...PROFILE_ALLOW_ENTRIES]);
|
|
175
180
|
|
|
176
181
|
const manifestPath = join(sandboxHome, '.claude', '.claude-dev-env-manifest.json');
|
|
177
182
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
@@ -183,7 +188,7 @@ test('sandbox install keeps default managed denies empty on repeat', () => {
|
|
|
183
188
|
const secondSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
184
189
|
const secondDeny = secondSettings.permissions?.deny ?? [];
|
|
185
190
|
assert.deepEqual(secondDeny, EXPECTED_DENY_ENTRIES);
|
|
186
|
-
assert.deepEqual(secondSettings.permissions?.allow, EXPECTED_ALLOW_ENTRIES);
|
|
191
|
+
assert.deepEqual(secondSettings.permissions?.allow, [...EXPECTED_ALLOW_ENTRIES, ...PROFILE_ALLOW_ENTRIES]);
|
|
187
192
|
} finally {
|
|
188
193
|
rmSync(sandboxHome, { recursive: true, force: true });
|
|
189
194
|
}
|
|
@@ -214,7 +219,7 @@ test('a normal upgrade retires manifest-owned denies and preserves user entries'
|
|
|
214
219
|
|
|
215
220
|
const upgradedSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
216
221
|
assert.deepEqual(upgradedSettings.permissions?.deny, [USER_OWNED_DENY_ENTRY]);
|
|
217
|
-
assert.deepEqual(upgradedSettings.permissions?.allow, ['Bash(git status)', ...EXPECTED_ALLOW_ENTRIES]);
|
|
222
|
+
assert.deepEqual(upgradedSettings.permissions?.allow, ['Bash(git status)', ...EXPECTED_ALLOW_ENTRIES, ...PROFILE_ALLOW_ENTRIES]);
|
|
218
223
|
assert.deepEqual(upgradedSettings.permissions?.ask, ['Edit(./**)']);
|
|
219
224
|
|
|
220
225
|
const upgradedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
@@ -25,6 +25,7 @@ const PACKAGE_HOOKS_JSON_PATH = fileURLToPath(new URL('../hooks/hooks.json', imp
|
|
|
25
25
|
function installEnvironment(homeDirectory) {
|
|
26
26
|
return {
|
|
27
27
|
...process.env,
|
|
28
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
28
29
|
HOME: homeDirectory,
|
|
29
30
|
USERPROFILE: homeDirectory,
|
|
30
31
|
CLAUDE_CONFIG_DIR: '',
|