@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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.73.1",
5
+ "version": "5.75.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -17,6 +17,7 @@
17
17
  "src/core/",
18
18
  "assets/",
19
19
  "README.md",
20
+ "npm-shrinkwrap.json",
20
21
  "plugins/",
21
22
  "!**/__pycache__",
22
23
  "!**/*.pyc"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.73.1",
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.73.1",
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",
@@ -160,13 +160,16 @@ function protocolVersion(cwd) {
160
160
  // stamped into its managed block. Keel reports the disagreement and stops
161
161
  // there — installing and updating are the host's, which already has commands
162
162
  // for both.
163
- function versionReport(cwd, cli) {
163
+ function versionReport(cwd, cli, pathCli = null) {
164
164
  const plugin = pluginManifest();
165
165
  const found = [
166
166
  ["plugin", plugin.version],
167
167
  ["CLI", cli],
168
168
  ["protocol", protocolVersion(cwd)],
169
169
  ];
170
+ // A PATH copy is compared only when it exists. Its absence is the
171
+ // plugin-only install, which is not drift and not an unread version.
172
+ if (pathCli) found.push(["PATH keel", pathCli]);
170
173
  // Missing is not mismatched. A version nobody can discover never produces a
171
174
  // line on its own, or a repository with no managed block would be warned at
172
175
  // every session until its reader stopped looking — and fewer than two
@@ -184,10 +187,31 @@ function versionReport(cwd, cli) {
184
187
  .map(([name]) => name)
185
188
  .join(" and ");
186
189
  const missing = unread ? ` (${unread} undiscovered, not compared)` : "";
190
+ // Naming the reload rather than a restart (#164): the host's
191
+ // `/reload-plugins` swaps hooks in the running session, so "restart" sent
192
+ // the reader to the expensive remedy for a cheap one.
193
+ // The host puts the user's PATH ahead of plugin `bin/` directories, so a
194
+ // global install is what the agent's own `keel` commands run even though
195
+ // this hook ran the plugin's. Both remedies are named; neither is run.
196
+ const shadow = pathCli && pathCli !== cli
197
+ ? ` The \`keel\` on PATH (${pathCli}) shadows this plugin's CLI for the `
198
+ + "agent's commands, because the host puts your PATH first: remove it "
199
+ + "with `npm rm -g @christang/keel`, since the plugin carries its own, "
200
+ + `or align it with \`npm i -g @christang/keel@${cli}\`.`
201
+ : "";
187
202
  return `runtime versions disagree: ${named}${missing}. A session's hooks are `
188
- + "fixed at session start, so an updated plugin applies only after "
189
- + `restarting. Updating is ${plugin.remedy}, which Keel names and does `
190
- + "not run.";
203
+ + "fixed when it loads the plugin, so an updated plugin applies after "
204
+ + "`/reload-plugins` or at the next session start. Updating is "
205
+ + `${plugin.remedy}, which Keel names and does not run.${shadow}`;
206
+ }
207
+
208
+ // The `keel` a bare command resolves, asked only when this hook ran its own
209
+ // CLI; with no such command there is nothing to compare.
210
+ function pathCliVersion(cwd) {
211
+ const probe = runKeel(cwd, ["--version"], "keel");
212
+ if (probe.error || probe.status !== 0) return null;
213
+ const match = String(probe.stdout || "").match(/\d+\.\d+\.\d+/);
214
+ return match ? match[0] : null;
191
215
  }
192
216
 
193
217
  // The Keel mark. A keel is the carina, the ridge on a bird's sternum, so the
@@ -244,8 +268,38 @@ function panel(lines) {
244
268
  ].join("\n");
245
269
  }
246
270
 
247
- function runKeel(cwd, args) {
248
- const cli = (process.env.KEEL_CLI || "keel").trim();
271
+ // On Claude the plugin is the published package (#164), so the CLI it shipped
272
+ // with sits three levels above this script. It is recognized by the package's
273
+ // name and not by the path alone: a Codex cache holds only `plugins/keel`, and
274
+ // whatever lies above that is not this plugin's to run.
275
+ function packagedCli() {
276
+ const root = path.join(__dirname, "..", "..", "..");
277
+ try {
278
+ const pkg = JSON.parse(
279
+ fs.readFileSync(path.join(root, "package.json"), "utf8")
280
+ );
281
+ const cli = path.join(root, "bin", "keel.js");
282
+ if (pkg.name === "@christang/keel" && fs.existsSync(cli)) return cli;
283
+ } catch {
284
+ // No package around this script: the plugin was copied on its own.
285
+ }
286
+ return null;
287
+ }
288
+
289
+ // An explicit KEEL_CLI first, then the CLI this plugin shipped with, then
290
+ // whatever `keel` is on PATH. The packaged CLI runs under the node running
291
+ // this hook, so it needs neither PATH nor an executable mode.
292
+ function keelCommand() {
293
+ const explicit = (process.env.KEEL_CLI || "").trim();
294
+ if (explicit) return { command: explicit, packaged: false };
295
+ const own = packagedCli();
296
+ if (own) {
297
+ return { command: `"${process.execPath}" "${own}"`, packaged: true };
298
+ }
299
+ return { command: "keel", packaged: false };
300
+ }
301
+
302
+ function runKeel(cwd, args, cli) {
249
303
  return spawnSync(`${cli} ${args.join(" ")}`, {
250
304
  cwd,
251
305
  shell: true,
@@ -283,7 +337,8 @@ function main() {
283
337
  return 0;
284
338
  }
285
339
 
286
- const version = runKeel(cwd, ["--version"]);
340
+ const cli = keelCommand();
341
+ const version = runKeel(cwd, ["--version"], cli.command);
287
342
  const versionMatch = String(version.stdout || "").match(/(\d+)\.\d+\.\d+/);
288
343
  if (
289
344
  version.error
@@ -298,7 +353,7 @@ function main() {
298
353
  return 0;
299
354
  }
300
355
 
301
- const result = runKeel(cwd, ["context", "--json"]);
356
+ const result = runKeel(cwd, ["context", "--json"], cli.command);
302
357
  if (result.error || result.status !== 0 || !String(result.stdout || "").trim()) {
303
358
  fallback("`keel context --json` failed or timed out.");
304
359
  return 0;
@@ -375,7 +430,11 @@ function main() {
375
430
  + "does not guess among candidates."
376
431
  );
377
432
  }
378
- const drift = versionReport(cwd, versionMatch[0]);
433
+ const drift = versionReport(
434
+ cwd,
435
+ versionMatch[0],
436
+ cli.packaged ? pathCliVersion(cwd) : null
437
+ );
379
438
  if (drift) {
380
439
  lines.push(`- ${drift}`);
381
440
  human.splice(human.length - 1, 0, drift[0].toUpperCase() + drift.slice(1));
@@ -4,22 +4,23 @@
4
4
  // One-shot version bump across every place Keel pins its version.
5
5
  //
6
6
  // The Keel validation suite requires the same version in package.json,
7
- // package-lock.json, both native plugin manifests, the validator constants,
8
- // the protocol docs, and the changelog. This script updates all of them
9
- // together so a release never ships half-aligned.
7
+ // npm-shrinkwrap.json, both native plugin manifests, the Claude marketplace
8
+ // entry and the package release it pins, the validator constants, the
9
+ // protocol docs, and the changelog. This script updates all of them together
10
+ // so a release never ships half-aligned.
10
11
  //
11
12
  // Usage:
12
13
  // node scripts/bump_version.js <patch|minor|major|explicit-version>
13
14
  //
14
- // After running: fill in the CHANGELOG entry, then `npm test`, commit,
15
- // tag `vX.Y.Z`, push, and publish a GitHub Release.
15
+ // After running, follow the steps it prints: the release lands through a pull
16
+ // request, and nothing is tagged by hand (#162).
16
17
 
17
18
  const fs = require("fs");
18
19
  const path = require("path");
19
20
 
20
21
  const ROOT = path.resolve(__dirname, "..");
21
22
  const PKG_PATH = path.join(ROOT, "package.json");
22
- const LOCK_PATH = path.join(ROOT, "package-lock.json");
23
+ const LOCK_PATH = path.join(ROOT, "npm-shrinkwrap.json");
23
24
  const CHANGELOG_PATH = path.join(ROOT, "keel", "CHANGELOG.md");
24
25
  const CHANGELOG_HEADER = "# Keel Changelog\n\n";
25
26
  const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
@@ -58,7 +59,24 @@ function bumpPackageFiles(newVersion) {
58
59
  lock.packages[""].version = newVersion;
59
60
  }
60
61
  writeJson(LOCK_PATH, lock);
61
- process.stdout.write(" updated package-lock.json\n");
62
+ process.stdout.write(" updated npm-shrinkwrap.json\n");
63
+ }
64
+
65
+ // On Claude the plugin is the published package (#164): the marketplace entry
66
+ // is its manifest, labeled with the release and pinned to that same release of
67
+ // `@christang/keel`. Both numbers move, or a refresh installs the old package.
68
+ function bumpClaudeMarketplace(newVersion) {
69
+ const relPath = ".claude-plugin/marketplace.json";
70
+ const filePath = path.join(ROOT, relPath);
71
+ const market = JSON.parse(fs.readFileSync(filePath, "utf8"));
72
+ const entry = (market.plugins || []).find((plugin) => plugin.name === "keel");
73
+ if (!entry || !entry.source || typeof entry.source !== "object") {
74
+ fail(`expected an npm-sourced keel entry in ${relPath}`);
75
+ }
76
+ entry.version = newVersion;
77
+ entry.source.version = newVersion;
78
+ writeJson(filePath, market);
79
+ process.stdout.write(` updated ${relPath}\n`);
62
80
  }
63
81
 
64
82
  function replaceInFile(relPath, replacements) {
@@ -145,6 +163,7 @@ function main() {
145
163
  process.stdout.write(`Bumping ${oldVersion} -> ${newVersion}\n`);
146
164
 
147
165
  bumpPackageFiles(newVersion);
166
+ bumpClaudeMarketplace(newVersion);
148
167
  replaceInFile("plugins/keel/.claude-plugin/plugin.json", [
149
168
  [`"version": "${oldVersion}"`, `"version": "${newVersion}"`],
150
169
  ]);
@@ -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: