diff --git a/.claude/structural-budgets.json b/.claude/structural-budgets.json new file mode 100644 index 0000000..e811b21 --- /dev/null +++ b/.claude/structural-budgets.json @@ -0,0 +1,29 @@ +{ + "_comment": "Per-repo exceptions for a structural-budget hook (structural-sensor) that some contributors run in Claude Code; they win over that hook's global budgets. These three modules were already over its 'standard' tier (400 lines) on 2026-10-25 at 641142d, so no fix could touch them. Each limit is the size measured then plus about 3% on lines and one more function or import. Lower a limit when a module shrinks; splitting them is a separate refactor that is not scheduled yet.", + "exceptions": [ + { + "pattern": "src/kitctl/model.py", + "max_file_loc": 916, + "max_methods": 38, + "max_imports": 12, + "max_classes": 5, + "reason": "Measured 889 lines, 37 functions, 11 imports, 5 classes: the warehouse schema and validation in one module." + }, + { + "pattern": "src/kitctl/resolve.py", + "max_file_loc": 1270, + "max_methods": 33, + "max_imports": 8, + "max_classes": 2, + "reason": "Measured 1233 lines, 32 functions, 7 imports, 2 classes: the per-harness compiler." + }, + { + "pattern": "src/kitctl/cli.py", + "max_file_loc": 568, + "max_methods": 16, + "max_imports": 17, + "max_classes": 1, + "reason": "Measured 551 lines, 15 functions, 16 imports, 0 classes: argparse wiring for every command." + } + ] +} diff --git a/docs/harness-reference.md b/docs/harness-reference.md index d5f5ed3..58f18db 100644 --- a/docs/harness-reference.md +++ b/docs/harness-reference.md @@ -212,7 +212,8 @@ throwaway config. These facts supersede the "documented" entries above. therefore writes the table and reports the upgrade as a one-time manual step. - **verified**: `[plugins."brand-builder@brand-builder"] enabled = true` in a trusted project's config, after the marketplace upgrade, lists the plugin as - "installed, enabled 0.3.1". No separate `codex plugin add` is needed. + "installed, enabled 0.3.1". No separate `codex plugin add` is needed on 0.163.0 + (0.144.1 needs one; see 2026-10-25 below). - **verified**: `[[skills.config]] name = "…" enabled = false` in a trusted project's config is **ignored**: `codex debug prompt-input` still lists the skill. The same entry in user config removes it. Single-skill disables need user scope, which @@ -220,6 +221,23 @@ throwaway config. These facts supersede the "documented" entries above. - **verified**: `[mcp_servers.]` in a trusted project's config is listed and enabled by `codex mcp list`. +### Older CLIs on Connor's Windows machine (2026-10-25) + +First real apply (cairn, Gestalt's loadouts), with the project trusted in +`~/.codex/config.toml`. + +- **verified, codex-cli 0.144.1**: the trusted project's `[marketplaces.]` is + not read. `codex plugin marketplace upgrade ` fails with "marketplace `` + is not configured as a Git marketplace", and `codex plugin marketplace list` shows + only user marketplaces. What worked: `codex plugin marketplace add + --ref `, then `codex plugin add @` (an enabled plugin was + not installed by itself). Plans name this route after the 0.163.0 one. +- **verified, Claude Code 2.1.304 CLI**: `claude plugin marketplace list` and + `claude plugin list`, run inside the project, ignore its `extraKnownMarketplaces`. + `claude plugin install --marketplace --scope local` installed + the plugin for that project only (`.claude/settings.local.json`). Whether the + interactive session offers the project's marketplaces was not exercised. + ### Codex CLI 0.163.0, corrections (2026-10-20) Two earlier statements in this file were wrong for 0.163.0. Both are **verified** diff --git a/docs/owner-checklist.md b/docs/owner-checklist.md index 5043708..92850a1 100644 --- a/docs/owner-checklist.md +++ b/docs/owner-checklist.md @@ -67,7 +67,9 @@ that ships inside a plugin. - [ ] Codex: trust the project when Codex prompts. Until then the project's `.codex/config.toml` is ignored. -- [ ] Codex: `codex plugin marketplace upgrade brand-builder`, run once. +- [ ] Codex: `codex plugin marketplace upgrade brand-builder`, run once. On a Codex + older than 0.163.0 (0.144.1 was seen) use the plan's other route: `codex plugin + marketplace add`, then `codex plugin add brand-builder@brand-builder`. - [ ] Codex: review and trust the brand-builder plugin hooks. - [ ] Claude: approve the project's MCP servers and plugin when prompted. - [ ] `kitctl status --repo `: every owned entry should read `ok`. diff --git a/docs/pilot-brand-studio.md b/docs/pilot-brand-studio.md index 6dd721f..39e7cef 100644 --- a/docs/pilot-brand-studio.md +++ b/docs/pilot-brand-studio.md @@ -44,7 +44,9 @@ harnesses. The `voice-compile` setup step runs after `kitctl use` when you confi tagged. - Codex: Kit writes `[marketplaces.brand-builder]` pinned to the commit. That pin is verified with Codex 0.163.0. You then run - `codex plugin marketplace upgrade brand-builder` once to fetch it. + `codex plugin marketplace upgrade brand-builder` once to fetch it. Codex + 0.144.1 ignores the project table; there, follow the plan's `marketplace add` + route (see harness-reference.md). - **Trust.** Codex loads the project's `.codex/` folder only for trusted projects, and runs plugin hooks only after the user reviews them. Kit never writes trust. diff --git a/docs/publishing.md b/docs/publishing.md index de800d5..269a677 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -102,7 +102,7 @@ uvx --from kitctl==0.0.1 kitctl --version ## npm -Build and verify the exact tarball: +Build and verify the exact tarball. `` is the release, for example `0.1.1`: ```sh uv sync --locked @@ -113,8 +113,8 @@ npm --prefix npm test cd npm npm pack --dry-run npm pack -npm publish kitctl-0.0.1.tgz --access public -npm view kitctl@0.0.1 version maintainers dist.integrity +npm publish kitctl-.tgz --access public +npm view kitctl@ version maintainers dist.integrity ``` Publishing requires [2FA or an eligible granular token](https://docs.npmjs.com/requiring-2fa-for-package-publishing-and-settings-modification/). diff --git a/src/kitctl/model.py b/src/kitctl/model.py index ed06863..89355ab 100644 --- a/src/kitctl/model.py +++ b/src/kitctl/model.py @@ -652,6 +652,12 @@ def parse_primitive(root: Path, ref: str) -> Primitive: _str_list(data["tags"], f"{where}.tags", problems) if "source" in data: _check_source(data["source"], f"{where}.source", problems) + # A marketplace is registered by its git URL, external or not. A non-mapping source is + # already a "type" problem from _check_source. + source = data.get("source", {}) + git = source.get("git") if isinstance(source, dict) else "" + if kind == "marketplace" and isinstance(source, dict) and not (isinstance(git, str) and git): + problems.append(Problem(f"{where}.source.git", "required", "a marketplace needs a git URL")) for slot in _str_list(data.get("provides", []), f"{where}.provides", problems): if not SLOT_RE.match(slot): problems.append(Problem(f"{where}.provides", "slot", f"bad slot name {slot!r}")) diff --git a/src/kitctl/resolve.py b/src/kitctl/resolve.py index 6a97175..f24a466 100644 --- a/src/kitctl/resolve.py +++ b/src/kitctl/resolve.py @@ -422,13 +422,14 @@ def marketplace(self, primitive: Primitive, layer: str) -> None: return slug = _github_slug(url) or url ref = tag or sha + add_market = f"codex plugin marketplace add {slug} --ref {ref}" if not self.fact("marketplace_file").supported: self.gap( primitive.identity, "marketplace_file", "manual-step", self.fact("marketplace_file").note, - remedy=f"codex plugin marketplace add {slug} --ref {ref}", + remedy=add_market, ) return self.add( @@ -445,7 +446,9 @@ def marketplace(self, primitive: Primitive, layer: str) -> None: "marketplace_fetch", "manual-step", self.fact("marketplace_file").note, - remedy=f"after trusting the project: codex plugin marketplace upgrade {name}", + # Verified on Codex 0.163.0; 0.144.1 ignores the project table (harness-reference.md). + remedy=f"after trusting the project: codex plugin marketplace upgrade {name}; on " + f"an older Codex: {add_market}, then codex plugin add @{name}", ) if not tag and not self.fact("marketplace_sha_pin").supported: self.gap( diff --git a/tests/fixtures/expected/brand-studio.audit.codex.json b/tests/fixtures/expected/brand-studio.audit.codex.json index b5d9ffa..8f18da6 100644 --- a/tests/fixtures/expected/brand-studio.audit.codex.json +++ b/tests/fixtures/expected/brand-studio.audit.codex.json @@ -276,7 +276,7 @@ "capability": "marketplace_fetch", "severity": "manual-step", "reason": "[marketplaces.] source_type/source/ref in a trusted project config is read, but Codex fetches it only on `codex plugin marketplace upgrade `", - "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder", + "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder; on an older Codex: codex plugin marketplace add ConnorBritain/brand-builder --ref aa4d814acb353934359f95526961babe07d2e0e6, then codex plugin add @brand-builder", "fallback": null }, { diff --git a/tests/fixtures/expected/brand-studio.codex.json b/tests/fixtures/expected/brand-studio.codex.json index 414a594..1327ac3 100644 --- a/tests/fixtures/expected/brand-studio.codex.json +++ b/tests/fixtures/expected/brand-studio.codex.json @@ -304,7 +304,7 @@ "capability": "marketplace_fetch", "severity": "manual-step", "reason": "[marketplaces.] source_type/source/ref in a trusted project config is read, but Codex fetches it only on `codex plugin marketplace upgrade `", - "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder", + "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder; on an older Codex: codex plugin marketplace add ConnorBritain/brand-builder --ref aa4d814acb353934359f95526961babe07d2e0e6, then codex plugin add @brand-builder", "fallback": null }, { diff --git a/tests/fixtures/expected/brand-studio.voice.codex.json b/tests/fixtures/expected/brand-studio.voice.codex.json index 71b2656..7e92bfb 100644 --- a/tests/fixtures/expected/brand-studio.voice.codex.json +++ b/tests/fixtures/expected/brand-studio.voice.codex.json @@ -223,7 +223,7 @@ "capability": "marketplace_fetch", "severity": "manual-step", "reason": "[marketplaces.] source_type/source/ref in a trusted project config is read, but Codex fetches it only on `codex plugin marketplace upgrade `", - "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder", + "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder; on an older Codex: codex plugin marketplace add ConnorBritain/brand-builder --ref aa4d814acb353934359f95526961babe07d2e0e6, then codex plugin add @brand-builder", "fallback": null }, { diff --git a/tests/test_resolve.py b/tests/test_resolve.py index a7807fc..122513a 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -225,6 +225,35 @@ def test_credential_literal_is_rejected(cases: Path) -> None: assert "not-a-reference" not in str(error.value.problems) +# A marketplace is registered by its git URL; without one, `validate` used to die in resolve with +# a traceback instead of naming the file, so a warehouse author never learned what to fix. +@pytest.mark.parametrize( + "external", + ["", "external: true\n", f"source: {{git: true, sha: '{'0' * 40}'}}\n"], +) +def test_marketplace_without_a_git_source_is_a_problem( + cases: Path, tmp_path: Path, external: str +) -> None: + body = f"schema_version: 2\nkind: marketplace\nname: bare\ndescription: no source\n{external}" + (cases / "primitives/marketplaces").mkdir(exist_ok=True) + (cases / "primitives/marketplaces/bare.yaml").write_text(body) + with pytest.raises(ModelError) as error: + parse_primitive(cases, "marketplace:bare") + assert "required" in codes(error) + root = tmp_path / "wh" + for rel, text in { + "primitives/marketplaces/bare.yaml": body, + "primitives/plugins/p.yaml": "schema_version: 2\nkind: plugin\nname: p\n" + "description: p\nmarketplace: marketplace:bare\n", + "loadouts/l.yaml": "schema_version: 2\nname: l\ndescription: l\n" + "marketplaces: [marketplace:bare]\nplugins: [plugin:p]\n", + }.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + report = validate(root) + assert any(p["path"].endswith("bare.yaml.source.git") for p in report["problems"]) + + def test_url_with_embedded_password_is_rejected(cases: Path) -> None: (cases / "primitives/mcp/remote.yaml").write_text( "schema_version: 2\nkind: mcp\nname: remote\ntransport: http\n"