@christang/keel 5.74.0 → 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 +6 -2
- package/README.zh-CN.md +4 -1
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +44 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/install_to_repo.py +107 -0
- package/scripts/validate_plugin.py +185 -2
package/README.md
CHANGED
|
@@ -55,8 +55,12 @@ claude plugin marketplace add TanglmChris/keel
|
|
|
55
55
|
claude plugin install keel@keel-marketplace
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
Updates arrive by themselves once a project is set up: `keel --init --target claude` (and
|
|
59
|
+
`keel --install`) declares auto-update for `keel-marketplace` in the project's
|
|
60
|
+
`.claude/settings.json`, which Claude reads before its own default of off. A new release is fetched
|
|
61
|
+
in the background after a session's first message; `/reload-plugins` applies it in the running
|
|
62
|
+
session, and otherwise it applies at the next start. To opt out, set that entry's `autoUpdate` to
|
|
63
|
+
`false`; Keel keeps a value the project states, and `keel --doctor` reports which one is declared.
|
|
60
64
|
|
|
61
65
|
**Codex, and your own terminal** — install the CLI as well (it also installs the bundled
|
|
62
66
|
OpenSpec CLI):
|
package/README.zh-CN.md
CHANGED
|
@@ -46,7 +46,10 @@ claude plugin marketplace add TanglmChris/keel
|
|
|
46
46
|
claude plugin install keel@keel-marketplace
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
项目初始化后,更新会自己到来:`keel --init --target claude`(以及 `keel --install`)会在项目的
|
|
50
|
+
`.claude/settings.json` 里为 `keel-marketplace` 声明自动更新,Claude 先读这个声明,而不是它默认的"关闭"。
|
|
51
|
+
新版本会在会话发出第一条消息后在后台下载;执行 `/reload-plugins` 即在当前会话生效,否则下次启动时生效。
|
|
52
|
+
不想自动更新,就把那一项的 `autoUpdate` 设为 `false`;项目写明的值 Keel 会保留,`keel --doctor` 会报告当前声明的是哪一个。
|
|
50
53
|
|
|
51
54
|
**Codex,以及你自己的终端** —— 另外装一份 CLI(同时装上捆绑的 OpenSpec CLI):
|
|
52
55
|
|
package/bin/keel.js
CHANGED
|
@@ -755,6 +755,46 @@ function openspecReportedVersion(command) {
|
|
|
755
755
|
return match ? match[0] : null;
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
// What the project declares about Keel plugin auto-update (#164). Claude reads
|
|
759
|
+
// it first from `autoUpdate` on the marketplace's `extraKnownMarketplaces`
|
|
760
|
+
// entry, and `keel --install --target claude` writes it. The host reports no
|
|
761
|
+
// auto-update state Keel could read, so this is the declaration only; whether
|
|
762
|
+
// updates actually arrive is the host's to show.
|
|
763
|
+
function pluginAutoUpdateDeclaration(repo) {
|
|
764
|
+
const settingsPath = path.join(repo, ".claude", "settings.json");
|
|
765
|
+
const observation =
|
|
766
|
+
"Keel reads the declaration; the host does not expose whether updates run";
|
|
767
|
+
let entry;
|
|
768
|
+
try {
|
|
769
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
770
|
+
entry = ((settings && settings.extraKnownMarketplaces) || {})[
|
|
771
|
+
"keel-marketplace"
|
|
772
|
+
];
|
|
773
|
+
} catch {
|
|
774
|
+
entry = undefined;
|
|
775
|
+
}
|
|
776
|
+
if (entry && entry.autoUpdate === true) {
|
|
777
|
+
return [
|
|
778
|
+
"ok",
|
|
779
|
+
`.claude/settings.json declares keel-marketplace autoUpdate: true; ${observation}`,
|
|
780
|
+
];
|
|
781
|
+
}
|
|
782
|
+
if (entry && entry.autoUpdate === false) {
|
|
783
|
+
return [
|
|
784
|
+
"manual",
|
|
785
|
+
".claude/settings.json declares keel-marketplace autoUpdate: false, "
|
|
786
|
+
+ "which is the project's choice; updates arrive only through "
|
|
787
|
+
+ "`claude plugin update`",
|
|
788
|
+
];
|
|
789
|
+
}
|
|
790
|
+
return [
|
|
791
|
+
"manual",
|
|
792
|
+
"not declared, so Claude leaves auto-update off for this marketplace; "
|
|
793
|
+
+ "run keel --install --target claude, or enable it under /plugin → "
|
|
794
|
+
+ "Marketplaces",
|
|
795
|
+
];
|
|
796
|
+
}
|
|
797
|
+
|
|
758
798
|
// The root is the repository under diagnosis, never PACKAGE_ROOT. Rooting this
|
|
759
799
|
// at Keel's own install location made the line a statement about a repository
|
|
760
800
|
// the reader was never shown: a consumer pinning 9.9.9 was told the version
|
|
@@ -1572,6 +1612,10 @@ function printTargetSurface(repo, target) {
|
|
|
1572
1612
|
: "claude plugin install keel@<marketplace>"
|
|
1573
1613
|
} and verify in a fresh session`
|
|
1574
1614
|
);
|
|
1615
|
+
if (target === "claude") {
|
|
1616
|
+
const [status, detail] = pluginAutoUpdateDeclaration(repo);
|
|
1617
|
+
printDoctorLine("plugin auto-update", status, detail);
|
|
1618
|
+
}
|
|
1575
1619
|
}
|
|
1576
1620
|
|
|
1577
1621
|
const openspecSkillRoot = openspecSkillRootForTarget(target);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christang/keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.75.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@christang/keel",
|
|
9
|
-
"version": "5.
|
|
9
|
+
"version": "5.75.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@fission-ai/openspec": "^1.4.1"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.75.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.75.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -532,6 +532,13 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
|
|
|
532
532
|
)
|
|
533
533
|
if "claude" in targets and not unmanaged_keel_content_warning(repo, "CLAUDE.md"):
|
|
534
534
|
actions.append(managed_content_action("CLAUDE.md", CLAUDE_IMPORT_BLOCK))
|
|
535
|
+
if "claude" in targets:
|
|
536
|
+
actions.append(
|
|
537
|
+
InstallAction(
|
|
538
|
+
relative_path=Path(".claude/settings.json"),
|
|
539
|
+
strategy="claude-marketplace-settings",
|
|
540
|
+
)
|
|
541
|
+
)
|
|
535
542
|
|
|
536
543
|
actions.append(openspec_config_action())
|
|
537
544
|
actions.append(keel_config_action())
|
|
@@ -923,6 +930,82 @@ def action_source_content(action: InstallAction) -> str:
|
|
|
923
930
|
return action.content or ""
|
|
924
931
|
|
|
925
932
|
|
|
933
|
+
# Claude reads a marketplace's auto-update setting first from `autoUpdate` on
|
|
934
|
+
# its `extraKnownMarketplaces` entry in any settings file, and defaults it off
|
|
935
|
+
# for every marketplace that is not Anthropic's own (#164). The project's
|
|
936
|
+
# committed `.claude/settings.json` is such a file, so declaring it here is what
|
|
937
|
+
# lets a Keel release reach a project with nobody toggling anything.
|
|
938
|
+
KEEL_MARKETPLACE_NAME = "keel-marketplace"
|
|
939
|
+
KEEL_MARKETPLACE_ENTRY = {
|
|
940
|
+
"source": {"source": "github", "repo": "TanglmChris/keel"},
|
|
941
|
+
"autoUpdate": True,
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def load_settings_object(existing: str, purpose: str) -> dict:
|
|
946
|
+
if not existing.strip():
|
|
947
|
+
return {}
|
|
948
|
+
try:
|
|
949
|
+
settings = json.loads(existing)
|
|
950
|
+
except json.JSONDecodeError as exc:
|
|
951
|
+
raise ValueError(
|
|
952
|
+
f"cannot {purpose} .claude/settings.json because it is not valid JSON: {exc}"
|
|
953
|
+
) from exc
|
|
954
|
+
if not isinstance(settings, dict):
|
|
955
|
+
raise ValueError(
|
|
956
|
+
f"cannot {purpose} .claude/settings.json because it is not a JSON object"
|
|
957
|
+
)
|
|
958
|
+
return settings
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def merge_claude_marketplace_settings(existing: str) -> tuple[str, str]:
|
|
962
|
+
"""Declare auto-update for the Keel marketplace, keeping the project's say.
|
|
963
|
+
|
|
964
|
+
An existing entry keeps its own source — a developer may have added the
|
|
965
|
+
marketplace from a local directory under this name — and any `autoUpdate`
|
|
966
|
+
it states, because a project that wrote `false` has decided.
|
|
967
|
+
"""
|
|
968
|
+
settings = load_settings_object(existing, "declare plugin auto-update in")
|
|
969
|
+
markets = settings.get("extraKnownMarketplaces", {})
|
|
970
|
+
if not isinstance(markets, dict):
|
|
971
|
+
raise ValueError(
|
|
972
|
+
"cannot declare plugin auto-update because .claude/settings.json "
|
|
973
|
+
"field 'extraKnownMarketplaces' is not an object"
|
|
974
|
+
)
|
|
975
|
+
entry = markets.get(KEEL_MARKETPLACE_NAME)
|
|
976
|
+
if isinstance(entry, dict):
|
|
977
|
+
declared = dict(entry)
|
|
978
|
+
declared.setdefault("autoUpdate", True)
|
|
979
|
+
else:
|
|
980
|
+
declared = json.loads(json.dumps(KEEL_MARKETPLACE_ENTRY))
|
|
981
|
+
merged_settings = dict(settings)
|
|
982
|
+
merged_settings["extraKnownMarketplaces"] = {**markets, KEEL_MARKETPLACE_NAME: declared}
|
|
983
|
+
merged = json.dumps(merged_settings, indent=2, ensure_ascii=False) + "\n"
|
|
984
|
+
before = (
|
|
985
|
+
json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
|
|
986
|
+
if existing.strip()
|
|
987
|
+
else ""
|
|
988
|
+
)
|
|
989
|
+
return merged, "skip" if before == merged else "update"
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def remove_claude_marketplace_settings(existing: str) -> tuple[str | None, str]:
|
|
993
|
+
"""Remove the entry only when it is exactly the one Keel writes."""
|
|
994
|
+
settings = load_settings_object(existing, "remove plugin auto-update from")
|
|
995
|
+
markets = settings.get("extraKnownMarketplaces")
|
|
996
|
+
if not isinstance(markets, dict) or markets.get(KEEL_MARKETPLACE_NAME) != KEEL_MARKETPLACE_ENTRY:
|
|
997
|
+
return existing, "skip"
|
|
998
|
+
remaining = {k: v for k, v in markets.items() if k != KEEL_MARKETPLACE_NAME}
|
|
999
|
+
updated = dict(settings)
|
|
1000
|
+
if remaining:
|
|
1001
|
+
updated["extraKnownMarketplaces"] = remaining
|
|
1002
|
+
else:
|
|
1003
|
+
updated.pop("extraKnownMarketplaces", None)
|
|
1004
|
+
if not updated:
|
|
1005
|
+
return None, "remove"
|
|
1006
|
+
return json.dumps(updated, indent=2, ensure_ascii=False) + "\n", "remove-managed"
|
|
1007
|
+
|
|
1008
|
+
|
|
926
1009
|
def load_hook_config(content: str) -> dict:
|
|
927
1010
|
try:
|
|
928
1011
|
config = json.loads(content)
|
|
@@ -1047,6 +1130,15 @@ def plan_action(
|
|
|
1047
1130
|
destination = require_inside_repo(repo, action.relative_path)
|
|
1048
1131
|
source_content = action_source_content(action)
|
|
1049
1132
|
|
|
1133
|
+
if action.strategy == "claude-marketplace-settings":
|
|
1134
|
+
existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
|
|
1135
|
+
merged, kind = merge_claude_marketplace_settings(existing)
|
|
1136
|
+
return PlannedAction(
|
|
1137
|
+
"create" if not destination.exists() else kind,
|
|
1138
|
+
action.relative_path,
|
|
1139
|
+
merged if kind != "skip" or not destination.exists() else None,
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1050
1142
|
if action.strategy == "keel-hook-settings":
|
|
1051
1143
|
existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
|
|
1052
1144
|
merged, kind = merge_keel_hook_settings(existing, source_content)
|
|
@@ -1113,6 +1205,17 @@ def plan_uninstall_managed(repo: Path, relative_path: str) -> PlannedAction:
|
|
|
1113
1205
|
return PlannedAction("remove-managed", Path(relative_path), updated)
|
|
1114
1206
|
|
|
1115
1207
|
|
|
1208
|
+
def plan_uninstall_claude_marketplace_settings(repo: Path) -> PlannedAction:
|
|
1209
|
+
relative = Path(".claude/settings.json")
|
|
1210
|
+
path = require_inside_repo(repo, relative)
|
|
1211
|
+
if not path.is_file():
|
|
1212
|
+
return PlannedAction("skip", relative)
|
|
1213
|
+
updated, kind = remove_claude_marketplace_settings(path.read_text(encoding="utf-8"))
|
|
1214
|
+
if kind == "skip":
|
|
1215
|
+
return PlannedAction("skip", relative)
|
|
1216
|
+
return PlannedAction(kind, relative, updated)
|
|
1217
|
+
|
|
1218
|
+
|
|
1116
1219
|
def plan_uninstall_template(
|
|
1117
1220
|
repo: Path,
|
|
1118
1221
|
relative_path: str,
|
|
@@ -1242,6 +1345,10 @@ def plan_uninstall_actions(repo: Path, target: str) -> list[PlannedAction]:
|
|
|
1242
1345
|
actions.append(plan_uninstall_managed(repo, "AGENTS.md"))
|
|
1243
1346
|
if "claude" in targets:
|
|
1244
1347
|
actions.append(plan_uninstall_managed(repo, "CLAUDE.md"))
|
|
1348
|
+
actions.append(plan_uninstall_claude_marketplace_settings(repo))
|
|
1349
|
+
# Removing the settings file can leave `.claude/` holding nothing that
|
|
1350
|
+
# anyone wrote, and uninstall leaves no empty directory of its own.
|
|
1351
|
+
actions.append(rmdir_if_empty_action(".claude"))
|
|
1245
1352
|
|
|
1246
1353
|
for action in openspec_schema_actions():
|
|
1247
1354
|
if action.source_path is not None:
|
|
@@ -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
|
|
@@ -16040,6 +16040,188 @@ def installed_elsewhere(config: Path, market_name: str, sentinel: str) -> list |
|
|
|
16040
16040
|
return None if any((Path(p) / sentinel).is_file() for p in paths) else paths
|
|
16041
16041
|
|
|
16042
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
|
+
|
|
16043
16225
|
def validate_native_plugin_marketplaces_scenario() -> int:
|
|
16044
16226
|
codex = shutil.which("codex")
|
|
16045
16227
|
claude = claude_cli()
|
|
@@ -30987,6 +31169,7 @@ SCENARIOS: tuple = (
|
|
|
30987
31169
|
("native-plugin-session-start", validate_native_plugin_session_start_scenario),
|
|
30988
31170
|
("runtime-version-drift", validate_runtime_version_drift_scenario),
|
|
30989
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),
|
|
30990
31173
|
("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
|
|
30991
31174
|
("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
|
|
30992
31175
|
("native-goal-projection", validate_native_goal_projection_scenario),
|