@christang/keel 5.73.1 → 5.75.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/README.md +20 -7
- package/README.zh-CN.md +15 -7
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel +9 -0
- package/bin/keel.js +58 -7
- package/npm-shrinkwrap.json +1137 -0
- package/package.json +2 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/scripts/session-start.js +68 -9
- package/scripts/bump_version.js +26 -7
- package/scripts/install_to_repo.py +107 -0
- package/scripts/validate_plugin.py +656 -22
|
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
38
38
|
"scripts/validate_plugin.py",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
PACKAGE_VERSION = "5.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.75.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.75.0"
|
|
43
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
44
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
45
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -2373,6 +2373,68 @@ def validate_version_alignment_scenario() -> int:
|
|
|
2373
2373
|
)
|
|
2374
2374
|
return 1
|
|
2375
2375
|
|
|
2376
|
+
# M5 — on Claude the marketplace entry is the manifest and names the
|
|
2377
|
+
# package release it installs (#164), so both of its numbers are version
|
|
2378
|
+
# markers, and the script that moves every marker has to move them.
|
|
2379
|
+
def claude_entry_versions(root: Path) -> dict:
|
|
2380
|
+
market = json.loads(
|
|
2381
|
+
(root / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
|
|
2382
|
+
)
|
|
2383
|
+
entry = next(
|
|
2384
|
+
(e for e in market.get("plugins", []) if e.get("name") == "keel"), {}
|
|
2385
|
+
)
|
|
2386
|
+
source = entry.get("source") if isinstance(entry.get("source"), dict) else {}
|
|
2387
|
+
return {"version": entry.get("version"), "source.version": source.get("version")}
|
|
2388
|
+
|
|
2389
|
+
stale = {
|
|
2390
|
+
k: v for k, v in claude_entry_versions(ROOT).items() if v != PACKAGE_VERSION
|
|
2391
|
+
}
|
|
2392
|
+
if stale:
|
|
2393
|
+
report(
|
|
2394
|
+
"version-alignment scenario claude marketplace entry version "
|
|
2395
|
+
f"expected {PACKAGE_VERSION}: {stale!r}"
|
|
2396
|
+
)
|
|
2397
|
+
return 1
|
|
2398
|
+
with tempfile.TemporaryDirectory(prefix="keel-bump-") as raw:
|
|
2399
|
+
scratch = Path(raw)
|
|
2400
|
+
for relative in (
|
|
2401
|
+
"package.json",
|
|
2402
|
+
"npm-shrinkwrap.json",
|
|
2403
|
+
".claude-plugin/marketplace.json",
|
|
2404
|
+
"plugins/keel/.claude-plugin/plugin.json",
|
|
2405
|
+
"plugins/keel/.codex-plugin/plugin.json",
|
|
2406
|
+
"scripts/bump_version.js",
|
|
2407
|
+
"scripts/validate_plugin.py",
|
|
2408
|
+
"AGENTS.md",
|
|
2409
|
+
"keel/CHANGELOG.md",
|
|
2410
|
+
):
|
|
2411
|
+
(scratch / relative).parent.mkdir(parents=True, exist_ok=True)
|
|
2412
|
+
shutil.copy2(ROOT / relative, scratch / relative)
|
|
2413
|
+
bumped = subprocess.run(
|
|
2414
|
+
["node", str(scratch / "scripts/bump_version.js"), "99.0.0"],
|
|
2415
|
+
cwd=scratch,
|
|
2416
|
+
text=True,
|
|
2417
|
+
encoding="utf-8",
|
|
2418
|
+
errors="replace",
|
|
2419
|
+
capture_output=True,
|
|
2420
|
+
check=False,
|
|
2421
|
+
)
|
|
2422
|
+
if bumped.returncode != 0:
|
|
2423
|
+
report("version-alignment scenario: bump_version.js failed in a scratch copy.")
|
|
2424
|
+
report((bumped.stderr or bumped.stdout).strip())
|
|
2425
|
+
return 1
|
|
2426
|
+
left = {
|
|
2427
|
+
k: v
|
|
2428
|
+
for k, v in claude_entry_versions(scratch).items()
|
|
2429
|
+
if v != "99.0.0"
|
|
2430
|
+
}
|
|
2431
|
+
if left:
|
|
2432
|
+
report(
|
|
2433
|
+
"version-alignment scenario claude marketplace entry version "
|
|
2434
|
+
f"was not moved by bump_version.js: {left!r}"
|
|
2435
|
+
)
|
|
2436
|
+
return 1
|
|
2437
|
+
|
|
2376
2438
|
dependencies = package.get("dependencies")
|
|
2377
2439
|
if (
|
|
2378
2440
|
not isinstance(dependencies, dict)
|
|
@@ -8293,7 +8355,7 @@ def validate_runtime_versions_are_checked_scenario() -> int:
|
|
|
8293
8355
|
# lockfile resolves. Read from this repository, because the fact under test
|
|
8294
8356
|
# is which program a developer's `keel` invokes here.
|
|
8295
8357
|
locked = None
|
|
8296
|
-
lock = json.loads((ROOT / "
|
|
8358
|
+
lock = json.loads((ROOT / "npm-shrinkwrap.json").read_text(encoding="utf-8"))
|
|
8297
8359
|
for name, entry in lock.get("packages", {}).items():
|
|
8298
8360
|
if name.endswith("@fission-ai/openspec"):
|
|
8299
8361
|
locked = entry.get("version")
|
|
@@ -8345,7 +8407,7 @@ def validate_runtime_versions_are_checked_scenario() -> int:
|
|
|
8345
8407
|
if running.group(1) != locked:
|
|
8346
8408
|
report(
|
|
8347
8409
|
f"{label} M4 validation is running against OpenSpec "
|
|
8348
|
-
f"{running.group(1)} while
|
|
8410
|
+
f"{running.group(1)} while npm-shrinkwrap.json resolves {locked}. "
|
|
8349
8411
|
"Results describe a different program: run `npm ci` so the pinned "
|
|
8350
8412
|
"OpenSpec is the one `keel openspec` resolves."
|
|
8351
8413
|
)
|
|
@@ -8403,7 +8465,7 @@ def validate_doctor_reads_the_diagnosed_repository_scenario() -> int:
|
|
|
8403
8465
|
report(f"{label} M1 `keel --doctor` emitted no openspec line.")
|
|
8404
8466
|
report((doctor.stdout or doctor.stderr or "").strip())
|
|
8405
8467
|
return 1
|
|
8406
|
-
own_lock = json.loads((ROOT / "
|
|
8468
|
+
own_lock = json.loads((ROOT / "npm-shrinkwrap.json").read_text(encoding="utf-8"))
|
|
8407
8469
|
own_declared = None
|
|
8408
8470
|
for name, entry in own_lock.get("packages", {}).items():
|
|
8409
8471
|
if name.endswith("@fission-ai/openspec"):
|
|
@@ -8490,6 +8552,41 @@ def validate_doctor_reads_the_diagnosed_repository_scenario() -> int:
|
|
|
8490
8552
|
report(f" {line}")
|
|
8491
8553
|
return 1
|
|
8492
8554
|
|
|
8555
|
+
# M4 — two lockfiles, the precedence npm applies. A repository that
|
|
8556
|
+
# publishes itself carries `npm-shrinkwrap.json` (npm never publishes
|
|
8557
|
+
# `package-lock.json`), and where both exist npm installs from the
|
|
8558
|
+
# shrinkwrap, so that is the version the repository actually pins (#164).
|
|
8559
|
+
with tempfile.TemporaryDirectory(prefix="keel-doctor-shrinkwrap-") as raw:
|
|
8560
|
+
both = Path(raw).resolve()
|
|
8561
|
+
for name, version in (
|
|
8562
|
+
("npm-shrinkwrap.json", "0.0.2"),
|
|
8563
|
+
("package-lock.json", "0.0.1"),
|
|
8564
|
+
):
|
|
8565
|
+
write_text(
|
|
8566
|
+
both / name,
|
|
8567
|
+
json.dumps(
|
|
8568
|
+
{
|
|
8569
|
+
"name": "consumer",
|
|
8570
|
+
"lockfileVersion": 3,
|
|
8571
|
+
"packages": {
|
|
8572
|
+
"node_modules/@fission-ai/openspec": {"version": version}
|
|
8573
|
+
},
|
|
8574
|
+
},
|
|
8575
|
+
indent=2,
|
|
8576
|
+
)
|
|
8577
|
+
+ "\n",
|
|
8578
|
+
)
|
|
8579
|
+
doctor = run_keel(both, "--doctor")
|
|
8580
|
+
line = openspec_line(doctor.stdout or "")
|
|
8581
|
+
if "repo pins 0.0.2" not in line:
|
|
8582
|
+
report(
|
|
8583
|
+
f"{label} M4 doctor read package-lock.json over "
|
|
8584
|
+
"npm-shrinkwrap.json, while npm installs from the shrinkwrap "
|
|
8585
|
+
"when both exist."
|
|
8586
|
+
)
|
|
8587
|
+
report(f" {line}")
|
|
8588
|
+
return 1
|
|
8589
|
+
|
|
8493
8590
|
if label not in {name for name, _ in SCENARIOS}:
|
|
8494
8591
|
report(f"{label}: the scenario registry does not include it.")
|
|
8495
8592
|
return 1
|
|
@@ -14718,10 +14815,107 @@ def validate_native_plugin_manifests_scenario() -> int:
|
|
|
14718
14815
|
),
|
|
14719
14816
|
None,
|
|
14720
14817
|
)
|
|
14721
|
-
|
|
14722
|
-
|
|
14818
|
+
# M1 — on Claude the plugin is the published package (#164): the entry
|
|
14819
|
+
# fetches it from npm, pinned to the release it is labeled with, and acts
|
|
14820
|
+
# as the manifest. An unpinned source would let a refresh between merge
|
|
14821
|
+
# and publish cache the previous package under the new label.
|
|
14822
|
+
source = (claude_entry or {}).get("source")
|
|
14823
|
+
if (
|
|
14824
|
+
not isinstance(source, dict)
|
|
14825
|
+
or source.get("source") != "npm"
|
|
14826
|
+
or source.get("package") != "@christang/keel"
|
|
14827
|
+
):
|
|
14828
|
+
report(
|
|
14829
|
+
"native-plugin-manifests claude marketplace entry is not the "
|
|
14830
|
+
f"published package: source {source!r}"
|
|
14831
|
+
)
|
|
14832
|
+
return 1
|
|
14833
|
+
pinned = {
|
|
14834
|
+
"version": claude_entry.get("version"),
|
|
14835
|
+
"source.version": source.get("version"),
|
|
14836
|
+
}
|
|
14837
|
+
unpinned = {k: v for k, v in pinned.items() if v != package_version}
|
|
14838
|
+
if unpinned:
|
|
14839
|
+
report(
|
|
14840
|
+
"native-plugin-manifests claude marketplace entry is not the "
|
|
14841
|
+
f"published package at {package_version}: {unpinned!r}"
|
|
14842
|
+
)
|
|
14843
|
+
return 1
|
|
14844
|
+
declared_paths = [
|
|
14845
|
+
*claude_entry.get("skills", []),
|
|
14846
|
+
*claude_entry.get("agents", []),
|
|
14847
|
+
]
|
|
14848
|
+
unresolved = [
|
|
14849
|
+
path for path in declared_paths if not (ROOT / path).exists()
|
|
14850
|
+
]
|
|
14851
|
+
if not declared_paths or unresolved:
|
|
14852
|
+
report(
|
|
14853
|
+
"native-plugin-manifests claude marketplace entry is not the "
|
|
14854
|
+
"published package's manifest: its skills and agents must resolve "
|
|
14855
|
+
f"inside the package; declared {declared_paths!r}, unresolved "
|
|
14856
|
+
f"{unresolved!r}"
|
|
14857
|
+
)
|
|
14723
14858
|
return 1
|
|
14724
14859
|
|
|
14860
|
+
# M2 — the entry states the hooks a second time, so it is held to the
|
|
14861
|
+
# file Codex discovers: the same events, matchers, scripts, and timeouts,
|
|
14862
|
+
# with each script path resolved from the package root instead.
|
|
14863
|
+
plugin_hooks = json.loads(
|
|
14864
|
+
(ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
|
|
14865
|
+
)["hooks"]
|
|
14866
|
+
expected_hooks = json.loads(
|
|
14867
|
+
json.dumps(plugin_hooks).replace(
|
|
14868
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/",
|
|
14869
|
+
"${CLAUDE_PLUGIN_ROOT}/plugins/keel/scripts/",
|
|
14870
|
+
)
|
|
14871
|
+
)
|
|
14872
|
+
if claude_entry.get("hooks") != expected_hooks:
|
|
14873
|
+
report(
|
|
14874
|
+
"native-plugin-manifests claude marketplace entry hooks diverge "
|
|
14875
|
+
f"from {PLUGIN_ROOT}/hooks/hooks.json after resolving script paths "
|
|
14876
|
+
f"from the package root: {claude_entry.get('hooks')!r}"
|
|
14877
|
+
)
|
|
14878
|
+
return 1
|
|
14879
|
+
|
|
14880
|
+
# M3 — what the host needs from the package to run it as a plugin: a bare
|
|
14881
|
+
# `keel` in `bin/` it can put on PATH, and a lockfile npm actually
|
|
14882
|
+
# publishes so the pinned OpenSpec is installed beside it.
|
|
14883
|
+
npm = shutil.which("npm")
|
|
14884
|
+
if npm is not None:
|
|
14885
|
+
packed = subprocess.run(
|
|
14886
|
+
[npm, "pack", "--dry-run", "--json"],
|
|
14887
|
+
cwd=ROOT,
|
|
14888
|
+
text=True,
|
|
14889
|
+
encoding="utf-8",
|
|
14890
|
+
errors="replace",
|
|
14891
|
+
capture_output=True,
|
|
14892
|
+
check=False,
|
|
14893
|
+
)
|
|
14894
|
+
try:
|
|
14895
|
+
modes = {
|
|
14896
|
+
item["path"]: item.get("mode", 0)
|
|
14897
|
+
for item in json.loads(packed.stdout)[0]["files"]
|
|
14898
|
+
}
|
|
14899
|
+
except (ValueError, KeyError, IndexError):
|
|
14900
|
+
report("native-plugin-manifests could not read npm pack --json.")
|
|
14901
|
+
report((packed.stderr or packed.stdout).strip()[:400])
|
|
14902
|
+
return 1
|
|
14903
|
+
lacking = [
|
|
14904
|
+
name
|
|
14905
|
+
for name, ok in (
|
|
14906
|
+
("bin/keel", modes.get("bin/keel", 0) & 0o111),
|
|
14907
|
+
("npm-shrinkwrap.json", "npm-shrinkwrap.json" in modes),
|
|
14908
|
+
)
|
|
14909
|
+
if not ok
|
|
14910
|
+
]
|
|
14911
|
+
if lacking:
|
|
14912
|
+
report(
|
|
14913
|
+
"native-plugin-manifests packed set lacks what the host needs "
|
|
14914
|
+
f"to run the package as a plugin: {lacking!r} (bin/keel must "
|
|
14915
|
+
"be executable)"
|
|
14916
|
+
)
|
|
14917
|
+
return 1
|
|
14918
|
+
|
|
14725
14919
|
report("native-plugin-manifests scenario passed.")
|
|
14726
14920
|
return 0
|
|
14727
14921
|
|
|
@@ -14730,7 +14924,7 @@ def run_session_start_hook(
|
|
|
14730
14924
|
repo: Path,
|
|
14731
14925
|
event: dict,
|
|
14732
14926
|
*,
|
|
14733
|
-
keel_cli: str,
|
|
14927
|
+
keel_cli: str | None,
|
|
14734
14928
|
timeout_ms: int | None = None,
|
|
14735
14929
|
panel: str | None = None,
|
|
14736
14930
|
plugin_root: Path | None = None,
|
|
@@ -14741,7 +14935,11 @@ def run_session_start_hook(
|
|
|
14741
14935
|
# suite decides rather than something it inherits from this working tree.
|
|
14742
14936
|
root = Path(plugin_root) if plugin_root is not None else ROOT / PLUGIN_ROOT
|
|
14743
14937
|
env = dict(os.environ)
|
|
14744
|
-
|
|
14938
|
+
# None leaves the hook to choose its own CLI, which is the only way to
|
|
14939
|
+
# observe that choice; every other caller pins one.
|
|
14940
|
+
env.pop("KEEL_CLI", None)
|
|
14941
|
+
if keel_cli is not None:
|
|
14942
|
+
env["KEEL_CLI"] = keel_cli
|
|
14745
14943
|
env["CLAUDE_PLUGIN_ROOT"] = str(root)
|
|
14746
14944
|
# The suite must decide the panel's state rather than inherit whatever the
|
|
14747
14945
|
# developer running it has exported, or the default-off assertion would
|
|
@@ -15234,7 +15432,12 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
15234
15432
|
# shipping hook, the CLI's by what the fake CLI prints, and the repository's by
|
|
15235
15433
|
# the managed block in its AGENTS.md.
|
|
15236
15434
|
VERSION_DRIFT_STATEMENT = "runtime versions disagree"
|
|
15237
|
-
|
|
15435
|
+
# How an update applies: the hooks are fixed when the plugin loads, and the
|
|
15436
|
+
# host's `/reload-plugins` reloads it in the running session (#164). A restart
|
|
15437
|
+
# is one way to reach the next load, not the only one, so "only after
|
|
15438
|
+
# restarting" sends a reader to the expensive remedy and is refused by name.
|
|
15439
|
+
VERSION_DRIFT_APPLY_TOKENS = ("fixed when it loads the plugin", "/reload-plugins")
|
|
15440
|
+
VERSION_DRIFT_RETIRED_WORDING = "only after restarting"
|
|
15238
15441
|
|
|
15239
15442
|
|
|
15240
15443
|
def plant_session_start_plugin(
|
|
@@ -15363,14 +15566,22 @@ def validate_runtime_version_drift_scenario() -> int:
|
|
|
15363
15566
|
return 1
|
|
15364
15567
|
missing = [
|
|
15365
15568
|
token
|
|
15366
|
-
for token in
|
|
15569
|
+
for token in VERSION_DRIFT_APPLY_TOKENS
|
|
15367
15570
|
if token not in lowered
|
|
15368
15571
|
]
|
|
15369
15572
|
if missing:
|
|
15370
15573
|
report(
|
|
15371
|
-
f"runtime-version-drift {channel} omits
|
|
15372
|
-
f"
|
|
15373
|
-
f"
|
|
15574
|
+
f"runtime-version-drift {channel} omits how an update applies "
|
|
15575
|
+
f"{missing}, so a reader who updates the plugin and sees no "
|
|
15576
|
+
f"change concludes the check is broken: {text!r}"
|
|
15577
|
+
)
|
|
15578
|
+
return 1
|
|
15579
|
+
if VERSION_DRIFT_RETIRED_WORDING in lowered:
|
|
15580
|
+
report(
|
|
15581
|
+
f"runtime-version-drift {channel} omits how an update applies "
|
|
15582
|
+
f"and still says it applies {VERSION_DRIFT_RETIRED_WORDING!r}, "
|
|
15583
|
+
f"which sends the reader to a restart when a reload would "
|
|
15584
|
+
f"do: {text!r}"
|
|
15374
15585
|
)
|
|
15375
15586
|
return 1
|
|
15376
15587
|
|
|
@@ -15621,6 +15832,396 @@ def claude_cli() -> str | None:
|
|
|
15621
15832
|
return None
|
|
15622
15833
|
|
|
15623
15834
|
|
|
15835
|
+
def plant_path_keel(directory: Path, cli_script: Path) -> Path:
|
|
15836
|
+
"""A `keel` command on PATH that answers from the given fake CLI."""
|
|
15837
|
+
node = shutil.which("node") or "node"
|
|
15838
|
+
write_text(
|
|
15839
|
+
directory / "keel",
|
|
15840
|
+
f'#!/bin/sh\nexec "{node}" "{cli_script}" "$@"\n',
|
|
15841
|
+
)
|
|
15842
|
+
(directory / "keel").chmod(0o755)
|
|
15843
|
+
write_text(
|
|
15844
|
+
directory / "keel.cmd",
|
|
15845
|
+
f'@"{node}" "{cli_script}" %*\r\n',
|
|
15846
|
+
)
|
|
15847
|
+
return directory
|
|
15848
|
+
|
|
15849
|
+
|
|
15850
|
+
def validate_plugin_runs_its_own_cli_scenario() -> int:
|
|
15851
|
+
"""Issue #164: on Claude the plugin is the published package.
|
|
15852
|
+
|
|
15853
|
+
The hook, running from inside that package, must run the CLI the package
|
|
15854
|
+
ships rather than whatever `keel` is on PATH, and must report a PATH copy
|
|
15855
|
+
that disagrees, because the host puts the user's PATH ahead of plugin
|
|
15856
|
+
`bin/` directories and the agent's own commands resolve that copy first.
|
|
15857
|
+
"""
|
|
15858
|
+
label = "plugin-runs-its-own-cli"
|
|
15859
|
+
event = {"hook_event_name": "SessionStart", "source": "startup"}
|
|
15860
|
+
with tempfile.TemporaryDirectory(
|
|
15861
|
+
prefix="keel-own-cli-", ignore_cleanup_errors=True
|
|
15862
|
+
) as raw_tmp:
|
|
15863
|
+
tmp = Path(raw_tmp)
|
|
15864
|
+
repo = tmp / "repo"
|
|
15865
|
+
write_text(repo / "openspec/changes/demo/tasks.md", task_contract_fixture())
|
|
15866
|
+
write_text(
|
|
15867
|
+
repo / "AGENTS.md",
|
|
15868
|
+
"# Keel v5.80.0 Agent Protocol\n\n"
|
|
15869
|
+
"<!-- keel:start version=5.80.0 -->\n## Session Start\n"
|
|
15870
|
+
"<!-- keel:end -->\n",
|
|
15871
|
+
)
|
|
15872
|
+
package = tmp / "package"
|
|
15873
|
+
write_text(
|
|
15874
|
+
package / "package.json",
|
|
15875
|
+
json.dumps({"name": "@christang/keel", "version": "5.80.0"}) + "\n",
|
|
15876
|
+
)
|
|
15877
|
+
fake_keel_cli(package / "bin/keel.js", "5.80.0")
|
|
15878
|
+
plugin = plant_session_start_plugin(package / "plugins/keel", "5.80.0")
|
|
15879
|
+
|
|
15880
|
+
stale_path = plant_path_keel(
|
|
15881
|
+
tmp / "stale-path", Path(fake_keel_cli(tmp / "path-cli.js", "5.70.0").split('"')[1])
|
|
15882
|
+
)
|
|
15883
|
+
spawn_log = tmp / "spawns.log"
|
|
15884
|
+
shadowed = run_session_start_hook(
|
|
15885
|
+
repo,
|
|
15886
|
+
event,
|
|
15887
|
+
keel_cli=None,
|
|
15888
|
+
plugin_root=plugin,
|
|
15889
|
+
extra_env={
|
|
15890
|
+
"PATH": str(stale_path) + os.pathsep + os.environ.get("PATH", ""),
|
|
15891
|
+
"NODE_OPTIONS": plant_spawn_recorder(tmp / "recorder.js"),
|
|
15892
|
+
"KEEL_FIXTURE_SPAWN_LOG": str(spawn_log),
|
|
15893
|
+
},
|
|
15894
|
+
)
|
|
15895
|
+
spawns = (
|
|
15896
|
+
spawn_log.read_text(encoding="utf-8").splitlines()
|
|
15897
|
+
if spawn_log.exists()
|
|
15898
|
+
else []
|
|
15899
|
+
)
|
|
15900
|
+
own = str(package / "bin/keel.js")
|
|
15901
|
+
answered = [line for line in spawns if own in line and "context" in line]
|
|
15902
|
+
# M2 — which program answered the projection.
|
|
15903
|
+
if not answered:
|
|
15904
|
+
report(
|
|
15905
|
+
f"{label} M2 ran the keel on PATH instead of the CLI its package "
|
|
15906
|
+
f"ships ({own}): {spawns!r}"
|
|
15907
|
+
)
|
|
15908
|
+
return 1
|
|
15909
|
+
# M3 — the PATH copy that disagrees is named, on both channels.
|
|
15910
|
+
for channel, text in (
|
|
15911
|
+
("additionalContext", session_start_context(shadowed) or ""),
|
|
15912
|
+
("systemMessage", session_start_message(shadowed) or ""),
|
|
15913
|
+
):
|
|
15914
|
+
lowered = text.lower()
|
|
15915
|
+
absent = [
|
|
15916
|
+
needle
|
|
15917
|
+
for needle in (
|
|
15918
|
+
VERSION_DRIFT_STATEMENT,
|
|
15919
|
+
"5.70.0",
|
|
15920
|
+
"shadows",
|
|
15921
|
+
"npm rm -g @christang/keel",
|
|
15922
|
+
)
|
|
15923
|
+
if needle not in lowered
|
|
15924
|
+
]
|
|
15925
|
+
if absent:
|
|
15926
|
+
report(
|
|
15927
|
+
f"{label} M3 {channel} did not report the keel that shadows "
|
|
15928
|
+
f"the plugin's CLI; it lacks {absent!r}: {text!r}"
|
|
15929
|
+
)
|
|
15930
|
+
return 1
|
|
15931
|
+
|
|
15932
|
+
# A PATH copy at the plugin's own version shadows nothing that
|
|
15933
|
+
# differs, and silence when aligned is the whole credibility argument.
|
|
15934
|
+
aligned_path = plant_path_keel(
|
|
15935
|
+
tmp / "aligned-path", Path(fake_keel_cli(tmp / "aligned-cli.js", "5.80.0").split('"')[1])
|
|
15936
|
+
)
|
|
15937
|
+
aligned = run_session_start_hook(
|
|
15938
|
+
repo,
|
|
15939
|
+
event,
|
|
15940
|
+
keel_cli=None,
|
|
15941
|
+
plugin_root=plugin,
|
|
15942
|
+
extra_env={"PATH": str(aligned_path) + os.pathsep + os.environ.get("PATH", "")},
|
|
15943
|
+
)
|
|
15944
|
+
if VERSION_DRIFT_STATEMENT in (session_start_context(aligned) or "").lower():
|
|
15945
|
+
report(
|
|
15946
|
+
f"{label} M3 reported drift for a PATH keel at the plugin's own "
|
|
15947
|
+
f"version: {session_start_context(aligned)!r}"
|
|
15948
|
+
)
|
|
15949
|
+
return 1
|
|
15950
|
+
|
|
15951
|
+
# M4 — the plugin-only install: nothing called `keel` on PATH at all.
|
|
15952
|
+
# PATH holds only a directory with `node` in it, so no global install
|
|
15953
|
+
# on the machine running the suite can answer.
|
|
15954
|
+
bare_path = tmp / "bare-path"
|
|
15955
|
+
bare_path.mkdir()
|
|
15956
|
+
node = shutil.which("node")
|
|
15957
|
+
if node:
|
|
15958
|
+
try:
|
|
15959
|
+
(bare_path / Path(node).name).symlink_to(node)
|
|
15960
|
+
except OSError:
|
|
15961
|
+
shutil.copy2(node, bare_path / Path(node).name)
|
|
15962
|
+
alone = run_session_start_hook(
|
|
15963
|
+
repo,
|
|
15964
|
+
event,
|
|
15965
|
+
keel_cli=None,
|
|
15966
|
+
plugin_root=plugin,
|
|
15967
|
+
extra_env={"PATH": str(bare_path)},
|
|
15968
|
+
)
|
|
15969
|
+
alone_context = session_start_context(alone) or ""
|
|
15970
|
+
if "demo#1.1" not in alone_context:
|
|
15971
|
+
report(
|
|
15972
|
+
f"{label} M4 the projection fell back with no keel on PATH, "
|
|
15973
|
+
f"which is the plugin-only install: {alone_context!r}"
|
|
15974
|
+
)
|
|
15975
|
+
return 1
|
|
15976
|
+
if VERSION_DRIFT_STATEMENT in alone_context.lower():
|
|
15977
|
+
report(
|
|
15978
|
+
f"{label} M4 the projection reported drift with no keel on "
|
|
15979
|
+
f"PATH, as if an absent copy disagreed: {alone_context!r}"
|
|
15980
|
+
)
|
|
15981
|
+
return 1
|
|
15982
|
+
report(f"{label} scenario passed.")
|
|
15983
|
+
return 0
|
|
15984
|
+
|
|
15985
|
+
|
|
15986
|
+
def stage_claude_market_under_test(tmp: Path) -> tuple[Path, str, str] | str:
|
|
15987
|
+
"""A Claude marketplace that installs this tree rather than the registry.
|
|
15988
|
+
|
|
15989
|
+
The committed entry installs the published package at this release, which
|
|
15990
|
+
does not exist on the registry until the release lands (#164), and at a
|
|
15991
|
+
version that does exist it installs the registry's copy and tests nothing
|
|
15992
|
+
here. So an install smoke packs the tree the way npm publishes it and puts
|
|
15993
|
+
it behind a copy of the committed entry whose source points at it and whose
|
|
15994
|
+
manifest fields are unchanged. A sentinel only the packed tree carries lets
|
|
15995
|
+
the caller prove which copy was installed. Returns the marketplace
|
|
15996
|
+
directory, its name, and the sentinel, or a failure message.
|
|
15997
|
+
"""
|
|
15998
|
+
npm = shutil.which("npm")
|
|
15999
|
+
if npm is None:
|
|
16000
|
+
return "needs npm to pack the package under test."
|
|
16001
|
+
packed = subprocess.run(
|
|
16002
|
+
[npm, "pack", "--json", "--pack-destination", str(tmp)],
|
|
16003
|
+
cwd=ROOT,
|
|
16004
|
+
text=True,
|
|
16005
|
+
encoding="utf-8",
|
|
16006
|
+
errors="replace",
|
|
16007
|
+
capture_output=True,
|
|
16008
|
+
check=False,
|
|
16009
|
+
)
|
|
16010
|
+
if packed.returncode != 0:
|
|
16011
|
+
return "npm pack failed: " + (packed.stderr or packed.stdout).strip()
|
|
16012
|
+
tarball = tmp / json.loads(packed.stdout)[0]["filename"]
|
|
16013
|
+
market = tmp / "claude-market"
|
|
16014
|
+
market.mkdir()
|
|
16015
|
+
subprocess.run(["tar", "-xzf", str(tarball), "-C", str(market)], check=True)
|
|
16016
|
+
sentinel = ".keel-package-under-test"
|
|
16017
|
+
write_text(market / "package" / sentinel, "\n")
|
|
16018
|
+
committed = json.loads(
|
|
16019
|
+
(ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
|
|
16020
|
+
)
|
|
16021
|
+
for entry in committed.get("plugins", []):
|
|
16022
|
+
if entry.get("name") == "keel":
|
|
16023
|
+
entry["source"] = "./package"
|
|
16024
|
+
write_text(
|
|
16025
|
+
market / ".claude-plugin/marketplace.json",
|
|
16026
|
+
json.dumps(committed, indent=2) + "\n",
|
|
16027
|
+
)
|
|
16028
|
+
return market, committed["name"], sentinel
|
|
16029
|
+
|
|
16030
|
+
|
|
16031
|
+
def installed_elsewhere(config: Path, market_name: str, sentinel: str) -> list | None:
|
|
16032
|
+
"""None when Keel was installed from the package under test, else the paths."""
|
|
16033
|
+
installed = json.loads(
|
|
16034
|
+
(config / "plugins/installed_plugins.json").read_text(encoding="utf-8")
|
|
16035
|
+
)
|
|
16036
|
+
paths = [
|
|
16037
|
+
record.get("installPath", "")
|
|
16038
|
+
for record in installed.get("plugins", {}).get(f"keel@{market_name}", [])
|
|
16039
|
+
]
|
|
16040
|
+
return None if any((Path(p) / sentinel).is_file() for p in paths) else paths
|
|
16041
|
+
|
|
16042
|
+
|
|
16043
|
+
# Issue #164: project setup declares Claude plugin auto-update. Claude reads a
|
|
16044
|
+
# marketplace's auto-update first from `autoUpdate` on its
|
|
16045
|
+
# `extraKnownMarketplaces` entry in any settings file, and defaults it off for
|
|
16046
|
+
# every marketplace that is not Anthropic's own.
|
|
16047
|
+
KEEL_MARKETPLACE_NAME = "keel-marketplace"
|
|
16048
|
+
KEEL_MARKETPLACE_ENTRY = {
|
|
16049
|
+
"source": {"source": "github", "repo": "TanglmChris/keel"},
|
|
16050
|
+
"autoUpdate": True,
|
|
16051
|
+
}
|
|
16052
|
+
AUTO_UPDATE_LABEL = "init-declares-plugin-auto-update"
|
|
16053
|
+
|
|
16054
|
+
|
|
16055
|
+
def _settings(repo: Path) -> dict | None:
|
|
16056
|
+
path = repo / ".claude/settings.json"
|
|
16057
|
+
if not path.is_file():
|
|
16058
|
+
return None
|
|
16059
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
16060
|
+
|
|
16061
|
+
|
|
16062
|
+
def _marketplace_entry(repo: Path) -> dict | None:
|
|
16063
|
+
settings = _settings(repo) or {}
|
|
16064
|
+
return (settings.get("extraKnownMarketplaces") or {}).get(KEEL_MARKETPLACE_NAME)
|
|
16065
|
+
|
|
16066
|
+
|
|
16067
|
+
def _auto_update_m1(tmp: Path) -> str | None:
|
|
16068
|
+
repo = tmp / "fresh"
|
|
16069
|
+
repo.mkdir()
|
|
16070
|
+
init = run_keel(repo, "--init", "--target", "claude")
|
|
16071
|
+
if init.returncode != 0:
|
|
16072
|
+
return f"M1 keel --init failed: {(init.stderr or init.stdout).strip()}"
|
|
16073
|
+
entry = _marketplace_entry(repo)
|
|
16074
|
+
if entry != KEEL_MARKETPLACE_ENTRY:
|
|
16075
|
+
return (
|
|
16076
|
+
f"M1 keel --init declared no plugin auto-update: {KEEL_MARKETPLACE_NAME} "
|
|
16077
|
+
f"in .claude/settings.json is {entry!r}, expected "
|
|
16078
|
+
f"{KEEL_MARKETPLACE_ENTRY!r}"
|
|
16079
|
+
)
|
|
16080
|
+
return None
|
|
16081
|
+
|
|
16082
|
+
|
|
16083
|
+
def _auto_update_m2(tmp: Path) -> str | None:
|
|
16084
|
+
permissions = {"allow": ["Bash(git:*)"]}
|
|
16085
|
+
local_source = {"source": "directory", "path": "/somewhere/keel"}
|
|
16086
|
+
cases = {
|
|
16087
|
+
"permissions-only": ({"permissions": permissions}, None),
|
|
16088
|
+
"own-source": (
|
|
16089
|
+
{"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: {"source": local_source}}},
|
|
16090
|
+
{"source": local_source, "autoUpdate": True},
|
|
16091
|
+
),
|
|
16092
|
+
"declared-off": (
|
|
16093
|
+
{
|
|
16094
|
+
"extraKnownMarketplaces": {
|
|
16095
|
+
KEEL_MARKETPLACE_NAME: {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}
|
|
16096
|
+
}
|
|
16097
|
+
},
|
|
16098
|
+
{**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False},
|
|
16099
|
+
),
|
|
16100
|
+
}
|
|
16101
|
+
for name, (existing, expected) in cases.items():
|
|
16102
|
+
repo = tmp / name
|
|
16103
|
+
write_text(repo / ".claude/settings.json", json.dumps(existing, indent=2) + "\n")
|
|
16104
|
+
installed = run_keel(repo, "--install", "--target", "claude")
|
|
16105
|
+
if installed.returncode != 0:
|
|
16106
|
+
return f"M2 {name} keel --install failed: {(installed.stderr or installed.stdout).strip()}"
|
|
16107
|
+
settings = _settings(repo) or {}
|
|
16108
|
+
want = expected if expected is not None else KEEL_MARKETPLACE_ENTRY
|
|
16109
|
+
if _marketplace_entry(repo) != want:
|
|
16110
|
+
return (
|
|
16111
|
+
f"M2 {name}: install did not merge into an existing "
|
|
16112
|
+
f".claude/settings.json as declared; entry is "
|
|
16113
|
+
f"{_marketplace_entry(repo)!r}, expected {want!r}"
|
|
16114
|
+
)
|
|
16115
|
+
if name == "permissions-only" and settings.get("permissions") != permissions:
|
|
16116
|
+
return (
|
|
16117
|
+
f"M2 {name}: the merge into an existing .claude/settings.json "
|
|
16118
|
+
f"lost what the project wrote: {settings!r}"
|
|
16119
|
+
)
|
|
16120
|
+
again = run_keel(repo, "--install", "--target", "claude")
|
|
16121
|
+
if "skip .claude/settings.json" not in (again.stdout or ""):
|
|
16122
|
+
return (
|
|
16123
|
+
f"M2 {name}: a second install did not skip the merge into an "
|
|
16124
|
+
f"existing .claude/settings.json: {again.stdout!r}"
|
|
16125
|
+
)
|
|
16126
|
+
return None
|
|
16127
|
+
|
|
16128
|
+
|
|
16129
|
+
def _auto_update_m3(tmp: Path) -> str | None:
|
|
16130
|
+
permissions = {"allow": ["Bash(git:*)"]}
|
|
16131
|
+
ours = tmp / "uninstall-ours"
|
|
16132
|
+
write_text(
|
|
16133
|
+
ours / ".claude/settings.json",
|
|
16134
|
+
json.dumps(
|
|
16135
|
+
{
|
|
16136
|
+
"permissions": permissions,
|
|
16137
|
+
"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: KEEL_MARKETPLACE_ENTRY},
|
|
16138
|
+
},
|
|
16139
|
+
indent=2,
|
|
16140
|
+
)
|
|
16141
|
+
+ "\n",
|
|
16142
|
+
)
|
|
16143
|
+
run_keel(ours, "--uninstall", "--target", "claude")
|
|
16144
|
+
settings = _settings(ours) or {}
|
|
16145
|
+
if _marketplace_entry(ours) is not None:
|
|
16146
|
+
return f"M3 uninstall left Keel's own marketplace entry behind: {settings!r}"
|
|
16147
|
+
if settings.get("permissions") != permissions:
|
|
16148
|
+
return f"M3 uninstall left the file without the project's permissions: {settings!r}"
|
|
16149
|
+
changed = {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}
|
|
16150
|
+
theirs = tmp / "uninstall-theirs"
|
|
16151
|
+
write_text(
|
|
16152
|
+
theirs / ".claude/settings.json",
|
|
16153
|
+
json.dumps({"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: changed}}, indent=2)
|
|
16154
|
+
+ "\n",
|
|
16155
|
+
)
|
|
16156
|
+
run_keel(theirs, "--uninstall", "--target", "claude")
|
|
16157
|
+
if _marketplace_entry(theirs) != changed:
|
|
16158
|
+
return (
|
|
16159
|
+
"M3 uninstall left the file without the entry the project changed; "
|
|
16160
|
+
f"it removed what Keel did not write: {_settings(theirs)!r}"
|
|
16161
|
+
)
|
|
16162
|
+
return None
|
|
16163
|
+
|
|
16164
|
+
|
|
16165
|
+
def _auto_update_m4(tmp: Path) -> str | None:
|
|
16166
|
+
states = {
|
|
16167
|
+
"on": (KEEL_MARKETPLACE_ENTRY, "plugin auto-update: ok"),
|
|
16168
|
+
"off": ({**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}, "plugin auto-update: manual"),
|
|
16169
|
+
"absent": (None, "plugin auto-update: manual"),
|
|
16170
|
+
}
|
|
16171
|
+
for name, (entry, prefix) in states.items():
|
|
16172
|
+
repo = tmp / f"doctor-{name}"
|
|
16173
|
+
settings = {"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: entry}} if entry else {}
|
|
16174
|
+
write_text(repo / ".claude/settings.json", json.dumps(settings) + "\n")
|
|
16175
|
+
doctor = run_keel(repo, "--doctor", "--target", "claude")
|
|
16176
|
+
lines = [
|
|
16177
|
+
line
|
|
16178
|
+
for line in (doctor.stdout or "").splitlines()
|
|
16179
|
+
if line.startswith("plugin auto-update:")
|
|
16180
|
+
]
|
|
16181
|
+
if len(lines) != 1:
|
|
16182
|
+
return f"M4 {name}: doctor printed no plugin auto-update line: {lines!r}"
|
|
16183
|
+
line = lines[0]
|
|
16184
|
+
if not line.startswith(prefix):
|
|
16185
|
+
return f"M4 {name}: doctor's plugin auto-update line is not {prefix!r}: {line!r}"
|
|
16186
|
+
if name == "off" and "false" not in line:
|
|
16187
|
+
return f"M4 off: the line does not name the project's false: {line!r}"
|
|
16188
|
+
if name == "absent" and "keel --install --target claude" not in line:
|
|
16189
|
+
return f"M4 absent: the line does not name the command that declares it: {line!r}"
|
|
16190
|
+
return None
|
|
16191
|
+
|
|
16192
|
+
|
|
16193
|
+
def _auto_update_m5(tmp: Path) -> str | None:
|
|
16194
|
+
repo = tmp / "codex"
|
|
16195
|
+
repo.mkdir()
|
|
16196
|
+
run_keel(repo, "--init", "--target", "codex")
|
|
16197
|
+
if (repo / ".claude/settings.json").exists():
|
|
16198
|
+
return (
|
|
16199
|
+
"M5 keel --init --target codex wrote .claude/settings.json for codex: "
|
|
16200
|
+
f"{_settings(repo)!r}"
|
|
16201
|
+
)
|
|
16202
|
+
return None
|
|
16203
|
+
|
|
16204
|
+
|
|
16205
|
+
def validate_init_declares_plugin_auto_update_scenario() -> int:
|
|
16206
|
+
with tempfile.TemporaryDirectory(
|
|
16207
|
+
prefix="keel-auto-update-", ignore_cleanup_errors=True
|
|
16208
|
+
) as raw:
|
|
16209
|
+
tmp = Path(raw)
|
|
16210
|
+
for check in (
|
|
16211
|
+
_auto_update_m1,
|
|
16212
|
+
_auto_update_m2,
|
|
16213
|
+
_auto_update_m3,
|
|
16214
|
+
_auto_update_m4,
|
|
16215
|
+
_auto_update_m5,
|
|
16216
|
+
):
|
|
16217
|
+
problem = check(tmp)
|
|
16218
|
+
if problem:
|
|
16219
|
+
report(f"{AUTO_UPDATE_LABEL} {problem}")
|
|
16220
|
+
return 1
|
|
16221
|
+
report(f"{AUTO_UPDATE_LABEL} scenario passed.")
|
|
16222
|
+
return 0
|
|
16223
|
+
|
|
16224
|
+
|
|
15624
16225
|
def validate_native_plugin_marketplaces_scenario() -> int:
|
|
15625
16226
|
codex = shutil.which("codex")
|
|
15626
16227
|
claude = claude_cli()
|
|
@@ -15699,14 +16300,18 @@ def validate_native_plugin_marketplaces_scenario() -> int:
|
|
|
15699
16300
|
report("native-plugin-marketplaces claude plugin validate failed:")
|
|
15700
16301
|
report((validated.stderr or validated.stdout).strip())
|
|
15701
16302
|
return 1
|
|
15702
|
-
|
|
16303
|
+
staged = stage_claude_market_under_test(tmp)
|
|
16304
|
+
if isinstance(staged, str):
|
|
16305
|
+
report(f"native-plugin-marketplaces {staged}")
|
|
16306
|
+
return 1
|
|
16307
|
+
claude_market, claude_market_name, sentinel = staged
|
|
16308
|
+
claude_market_add = run_claude(
|
|
16309
|
+
"plugin", "marketplace", "add", str(claude_market)
|
|
16310
|
+
)
|
|
15703
16311
|
if claude_market_add.returncode != 0:
|
|
15704
16312
|
report("native-plugin-marketplaces claude marketplace add failed:")
|
|
15705
16313
|
report((claude_market_add.stderr or claude_market_add.stdout).strip())
|
|
15706
16314
|
return 1
|
|
15707
|
-
claude_market_name = json.loads(
|
|
15708
|
-
(ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
|
|
15709
|
-
)["name"]
|
|
15710
16315
|
claude_install = run_claude(
|
|
15711
16316
|
"plugin", "install", f"keel@{claude_market_name}"
|
|
15712
16317
|
)
|
|
@@ -15714,6 +16319,22 @@ def validate_native_plugin_marketplaces_scenario() -> int:
|
|
|
15714
16319
|
report("native-plugin-marketplaces claude plugin install failed:")
|
|
15715
16320
|
report((claude_install.stderr or claude_install.stdout).strip())
|
|
15716
16321
|
return 1
|
|
16322
|
+
elsewhere = installed_elsewhere(claude_config, claude_market_name, sentinel)
|
|
16323
|
+
if elsewhere is not None:
|
|
16324
|
+
report(
|
|
16325
|
+
"native-plugin-marketplaces claude plugin install failed to use "
|
|
16326
|
+
f"the package under test; it installed from {elsewhere!r}, "
|
|
16327
|
+
"which does not carry the sentinel the packed tree does."
|
|
16328
|
+
)
|
|
16329
|
+
return 1
|
|
16330
|
+
claude_list = run_claude("plugin", "list")
|
|
16331
|
+
if f"Version: {PACKAGE_VERSION}" not in (claude_list.stdout or ""):
|
|
16332
|
+
report(
|
|
16333
|
+
"native-plugin-marketplaces claude plugin list does not show "
|
|
16334
|
+
f"keel {PACKAGE_VERSION} installed from the package under test."
|
|
16335
|
+
)
|
|
16336
|
+
report((claude_list.stderr or claude_list.stdout).strip())
|
|
16337
|
+
return 1
|
|
15717
16338
|
claude_uninstall = run_claude("plugin", "uninstall", "keel")
|
|
15718
16339
|
if claude_uninstall.returncode != 0:
|
|
15719
16340
|
report("native-plugin-marketplaces claude plugin uninstall failed:")
|
|
@@ -21801,15 +22422,26 @@ def validate_native_plugin_install_matrix_scenario() -> int:
|
|
|
21801
22422
|
).returncode != 0:
|
|
21802
22423
|
report("native-plugin-install-matrix claude validate --strict failed.")
|
|
21803
22424
|
return 1
|
|
21804
|
-
|
|
21805
|
-
|
|
21806
|
-
|
|
21807
|
-
|
|
22425
|
+
staged = stage_claude_market_under_test(tmp)
|
|
22426
|
+
if isinstance(staged, str):
|
|
22427
|
+
report(f"native-plugin-install-matrix {staged}")
|
|
22428
|
+
return 1
|
|
22429
|
+
staged_market, claude_market, sentinel = staged
|
|
22430
|
+
if run_claude(
|
|
22431
|
+
"plugin", "marketplace", "add", str(staged_market)
|
|
22432
|
+
).returncode != 0:
|
|
21808
22433
|
report("native-plugin-install-matrix claude marketplace add failed.")
|
|
21809
22434
|
return 1
|
|
21810
22435
|
if run_claude("plugin", "install", f"keel@{claude_market}").returncode != 0:
|
|
21811
22436
|
report("native-plugin-install-matrix claude install failed.")
|
|
21812
22437
|
return 1
|
|
22438
|
+
elsewhere = installed_elsewhere(claude_config, claude_market, sentinel)
|
|
22439
|
+
if elsewhere is not None:
|
|
22440
|
+
report(
|
|
22441
|
+
"native-plugin-install-matrix claude install did not use the "
|
|
22442
|
+
f"package under test; it installed from {elsewhere!r}."
|
|
22443
|
+
)
|
|
22444
|
+
return 1
|
|
21813
22445
|
if run_claude("plugin", "uninstall", "keel").returncode != 0:
|
|
21814
22446
|
report("native-plugin-install-matrix claude uninstall failed.")
|
|
21815
22447
|
return 1
|
|
@@ -30536,6 +31168,8 @@ SCENARIOS: tuple = (
|
|
|
30536
31168
|
("native-plugin-manifests", validate_native_plugin_manifests_scenario),
|
|
30537
31169
|
("native-plugin-session-start", validate_native_plugin_session_start_scenario),
|
|
30538
31170
|
("runtime-version-drift", validate_runtime_version_drift_scenario),
|
|
31171
|
+
("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
|
|
31172
|
+
("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
|
|
30539
31173
|
("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
|
|
30540
31174
|
("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
|
|
30541
31175
|
("native-goal-projection", validate_native_goal_projection_scenario),
|