@christang/keel 5.73.1 → 5.74.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 +16 -7
- package/README.zh-CN.md +12 -7
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel +9 -0
- package/bin/keel.js +14 -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/validate_plugin.py +473 -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.74.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.74.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
|
+
)
|
|
14858
|
+
return 1
|
|
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
|
+
)
|
|
14723
14878
|
return 1
|
|
14724
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,214 @@ 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
|
+
|
|
15624
16043
|
def validate_native_plugin_marketplaces_scenario() -> int:
|
|
15625
16044
|
codex = shutil.which("codex")
|
|
15626
16045
|
claude = claude_cli()
|
|
@@ -15699,14 +16118,18 @@ def validate_native_plugin_marketplaces_scenario() -> int:
|
|
|
15699
16118
|
report("native-plugin-marketplaces claude plugin validate failed:")
|
|
15700
16119
|
report((validated.stderr or validated.stdout).strip())
|
|
15701
16120
|
return 1
|
|
15702
|
-
|
|
16121
|
+
staged = stage_claude_market_under_test(tmp)
|
|
16122
|
+
if isinstance(staged, str):
|
|
16123
|
+
report(f"native-plugin-marketplaces {staged}")
|
|
16124
|
+
return 1
|
|
16125
|
+
claude_market, claude_market_name, sentinel = staged
|
|
16126
|
+
claude_market_add = run_claude(
|
|
16127
|
+
"plugin", "marketplace", "add", str(claude_market)
|
|
16128
|
+
)
|
|
15703
16129
|
if claude_market_add.returncode != 0:
|
|
15704
16130
|
report("native-plugin-marketplaces claude marketplace add failed:")
|
|
15705
16131
|
report((claude_market_add.stderr or claude_market_add.stdout).strip())
|
|
15706
16132
|
return 1
|
|
15707
|
-
claude_market_name = json.loads(
|
|
15708
|
-
(ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
|
|
15709
|
-
)["name"]
|
|
15710
16133
|
claude_install = run_claude(
|
|
15711
16134
|
"plugin", "install", f"keel@{claude_market_name}"
|
|
15712
16135
|
)
|
|
@@ -15714,6 +16137,22 @@ def validate_native_plugin_marketplaces_scenario() -> int:
|
|
|
15714
16137
|
report("native-plugin-marketplaces claude plugin install failed:")
|
|
15715
16138
|
report((claude_install.stderr or claude_install.stdout).strip())
|
|
15716
16139
|
return 1
|
|
16140
|
+
elsewhere = installed_elsewhere(claude_config, claude_market_name, sentinel)
|
|
16141
|
+
if elsewhere is not None:
|
|
16142
|
+
report(
|
|
16143
|
+
"native-plugin-marketplaces claude plugin install failed to use "
|
|
16144
|
+
f"the package under test; it installed from {elsewhere!r}, "
|
|
16145
|
+
"which does not carry the sentinel the packed tree does."
|
|
16146
|
+
)
|
|
16147
|
+
return 1
|
|
16148
|
+
claude_list = run_claude("plugin", "list")
|
|
16149
|
+
if f"Version: {PACKAGE_VERSION}" not in (claude_list.stdout or ""):
|
|
16150
|
+
report(
|
|
16151
|
+
"native-plugin-marketplaces claude plugin list does not show "
|
|
16152
|
+
f"keel {PACKAGE_VERSION} installed from the package under test."
|
|
16153
|
+
)
|
|
16154
|
+
report((claude_list.stderr or claude_list.stdout).strip())
|
|
16155
|
+
return 1
|
|
15717
16156
|
claude_uninstall = run_claude("plugin", "uninstall", "keel")
|
|
15718
16157
|
if claude_uninstall.returncode != 0:
|
|
15719
16158
|
report("native-plugin-marketplaces claude plugin uninstall failed:")
|
|
@@ -21801,15 +22240,26 @@ def validate_native_plugin_install_matrix_scenario() -> int:
|
|
|
21801
22240
|
).returncode != 0:
|
|
21802
22241
|
report("native-plugin-install-matrix claude validate --strict failed.")
|
|
21803
22242
|
return 1
|
|
21804
|
-
|
|
21805
|
-
|
|
21806
|
-
|
|
21807
|
-
|
|
22243
|
+
staged = stage_claude_market_under_test(tmp)
|
|
22244
|
+
if isinstance(staged, str):
|
|
22245
|
+
report(f"native-plugin-install-matrix {staged}")
|
|
22246
|
+
return 1
|
|
22247
|
+
staged_market, claude_market, sentinel = staged
|
|
22248
|
+
if run_claude(
|
|
22249
|
+
"plugin", "marketplace", "add", str(staged_market)
|
|
22250
|
+
).returncode != 0:
|
|
21808
22251
|
report("native-plugin-install-matrix claude marketplace add failed.")
|
|
21809
22252
|
return 1
|
|
21810
22253
|
if run_claude("plugin", "install", f"keel@{claude_market}").returncode != 0:
|
|
21811
22254
|
report("native-plugin-install-matrix claude install failed.")
|
|
21812
22255
|
return 1
|
|
22256
|
+
elsewhere = installed_elsewhere(claude_config, claude_market, sentinel)
|
|
22257
|
+
if elsewhere is not None:
|
|
22258
|
+
report(
|
|
22259
|
+
"native-plugin-install-matrix claude install did not use the "
|
|
22260
|
+
f"package under test; it installed from {elsewhere!r}."
|
|
22261
|
+
)
|
|
22262
|
+
return 1
|
|
21813
22263
|
if run_claude("plugin", "uninstall", "keel").returncode != 0:
|
|
21814
22264
|
report("native-plugin-install-matrix claude uninstall failed.")
|
|
21815
22265
|
return 1
|
|
@@ -30536,6 +30986,7 @@ SCENARIOS: tuple = (
|
|
|
30536
30986
|
("native-plugin-manifests", validate_native_plugin_manifests_scenario),
|
|
30537
30987
|
("native-plugin-session-start", validate_native_plugin_session_start_scenario),
|
|
30538
30988
|
("runtime-version-drift", validate_runtime_version_drift_scenario),
|
|
30989
|
+
("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
|
|
30539
30990
|
("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
|
|
30540
30991
|
("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
|
|
30541
30992
|
("native-goal-projection", validate_native_goal_projection_scenario),
|