@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.
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.73.1"
42
- PROTOCOL_VERSION = "5.73.1"
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 / "package-lock.json").read_text(encoding="utf-8"))
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 package-lock.json resolves {locked}. "
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 / "package-lock.json").read_text(encoding="utf-8"))
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
- if claude_entry is None or claude_entry.get("source") != "./plugins/keel":
14722
- report("native-plugin-manifests claude marketplace entry is invalid.")
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
- env["KEEL_CLI"] = keel_cli
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
- VERSION_DRIFT_RESTART_TOKENS = ("fixed at session start", "restart")
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 VERSION_DRIFT_RESTART_TOKENS
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 the restart "
15372
- f"requirement {missing}, so a reader who updates the plugin "
15373
- f"and sees no change concludes the check is broken: {text!r}"
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
- claude_market_add = run_claude("plugin", "marketplace", "add", str(ROOT))
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
- claude_market = json.loads(
21805
- (ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
21806
- )["name"]
21807
- if run_claude("plugin", "marketplace", "add", str(ROOT)).returncode != 0:
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),