From d4cecc492260da77237b6f882524d62e7dc19c16 Mon Sep 17 00:00:00 2001 From: omerakben Date: Sat, 30 May 2026 10:43:57 -0400 Subject: [PATCH 1/2] feat(plugins): make the Claude Code plugin installable from a marketplace The marketplace manifest lived at plugins/.claude-plugin/marketplace.json, a subdirectory Claude Code never inspects. `claude plugin marketplace add omerakben/code-oz` only reads .claude-plugin/marketplace.json at the cloned repo root, so the plugin was un-discoverable despite being built and merged. Move the manifest to the repo root and repoint the two plugin sources to ./plugins/code-oz and ./plugins/code-oz-discipline (sources resolve relative to the marketplace root). Flip the two manifest tests to the root contract RED-first, and add an installability guard asserting every source resolves to a real plugin dir whose plugin.json name matches the entry. Document the install path in the README, including the --sparse checkout for the monorepo. Verified: claude plugin validate passes on the root marketplace and both plugins; an isolated-config `marketplace add` + `plugin install` smoke succeeds end-to-end; 3812 offline tests pass; typecheck clean. --- .../marketplace.json | 4 +-- README.md | 23 +++++++++++++ tests/plugins/discipline-manifest.test.ts | 34 ++++++++++++++++--- tests/plugins/manifest-shape.test.ts | 9 +++-- 4 files changed, 61 insertions(+), 9 deletions(-) rename {plugins/.claude-plugin => .claude-plugin}/marketplace.json (92%) diff --git a/plugins/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json similarity index 92% rename from plugins/.claude-plugin/marketplace.json rename to .claude-plugin/marketplace.json index 325027a..20c469c 100644 --- a/plugins/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "code-oz", "description": "Enforced SDLC gates + cross-family review for AI coding agents. Discovers and invokes the code-oz engine; the binary is the only writer of gates, events, and reviews.", "version": "0.21.1-alpha.0", - "source": "./code-oz", + "source": "./plugins/code-oz", "author": { "name": "Ozzy (Omer Akben)", "url": "https://github.com/omerakben/code-oz" @@ -20,7 +20,7 @@ "name": "code-oz-discipline", "description": "Advisory SDLC discipline skills (brainstorming, source-check, RED-first) for AI coding agents. Advisory only — not enforced gates; upsells to the code-oz engine for enforced gates and different-model review.", "version": "0.21.1-alpha.0", - "source": "./code-oz-discipline", + "source": "./plugins/code-oz-discipline", "author": { "name": "Ozzy (Omer Akben)", "url": "https://github.com/omerakben/code-oz" diff --git a/README.md b/README.md index 221ef05..27c806d 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,29 @@ Platform support: macOS arm64, macOS x64, Linux x64, Linux arm64. Windows and Sc If `npm install -g @tuel/code-oz` fails with a 404 or authentication error, your `~/.npmrc` is likely overriding the `@tuel` scope — see [`docs/TRUST.md` § Install gotchas](docs/TRUST.md#install-gotchas-npm-scope-routing-for-tuelcode-oz) for the fix. +## Use it inside Claude Code (plugin) + +code-oz ships two Claude Code plugins through a marketplace declared at this repo's root. From a Claude Code session, add the marketplace and install: + +```sh +/plugin marketplace add omerakben/code-oz +/plugin install code-oz@code-oz-marketplace +``` + +The `code-oz` plugin is a thin wrapper. It adds `/code-oz-run`, `/code-oz-init`, `/code-oz-doctor`, and `/code-oz-resume`, and each command discovers the `code-oz` engine on your `PATH`, falling back to `npx @tuel/code-oz` when the binary is absent. Install the engine from the channels above for the fastest path. The plugin never writes gates, events, or reviews — the engine binary is the only writer, so the gate guarantees hold whether you drive code-oz from the CLI or through the plugin. + +A second plugin, `code-oz-discipline`, is optional. It installs advisory-only skills (brainstorming, source-check, RED-first) that are guidance, not enforcement: + +```sh +/plugin install code-oz-discipline@code-oz-marketplace +``` + +Adding the marketplace clones this repository into Claude Code's plugin cache. The repo is the full engine source, so from the CLI you can restrict the checkout to just the plugin files: + +```sh +claude plugin marketplace add omerakben/code-oz --sparse .claude-plugin plugins +``` + ## Why not just Claude Code or Codex? Use Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Roo Code, or Aider directly when you want the fastest possible agent loop. diff --git a/tests/plugins/discipline-manifest.test.ts b/tests/plugins/discipline-manifest.test.ts index 3dd657e..cc3c2e4 100644 --- a/tests/plugins/discipline-manifest.test.ts +++ b/tests/plugins/discipline-manifest.test.ts @@ -20,7 +20,9 @@ const DISCIPLINE_PLUGIN_JSON_PATH = join( 'plugins/code-oz-discipline/.claude-plugin/plugin.json', ) const CODE_OZ_PLUGIN_JSON_PATH = join(REPO_ROOT, 'plugins/code-oz/.claude-plugin/plugin.json') -const MARKETPLACE_JSON_PATH = join(REPO_ROOT, 'plugins/.claude-plugin/marketplace.json') +// Repo-root manifest: `claude plugin marketplace add ` only finds +// `.claude-plugin/marketplace.json` at the cloned repo's root. +const MARKETPLACE_JSON_PATH = join(REPO_ROOT, '.claude-plugin/marketplace.json') describe('plugins/code-oz-discipline manifest shape', () => { test('plugin.json exists and parses as JSON', async () => { @@ -73,22 +75,22 @@ describe('marketplace.json with both sibling plugins', () => { expect(market.plugins).toHaveLength(2) }) - test('marketplace.json has a code-oz entry with source ./code-oz', async () => { + test('marketplace.json has a code-oz entry with source ./plugins/code-oz', async () => { const raw = await readFile(MARKETPLACE_JSON_PATH, 'utf8') const market = JSON.parse(raw) as { plugins: Array> } const entry = market.plugins.find((p) => p.name === 'code-oz') expect(entry).toBeDefined() - expect(entry!.source).toBe('./code-oz') + expect(entry!.source).toBe('./plugins/code-oz') }) - test('marketplace.json has a code-oz-discipline entry with source ./code-oz-discipline', async () => { + test('marketplace.json has a code-oz-discipline entry with source ./plugins/code-oz-discipline', async () => { const raw = await readFile(MARKETPLACE_JSON_PATH, 'utf8') const market = JSON.parse(raw) as { plugins: Array> } const entry = market.plugins.find((p) => p.name === 'code-oz-discipline') expect(entry).toBeDefined() - expect(entry!.source).toBe('./code-oz-discipline') + expect(entry!.source).toBe('./plugins/code-oz-discipline') }) test('both marketplace entries share the same version', async () => { @@ -117,4 +119,26 @@ describe('marketplace.json with both sibling plugins', () => { expect(codeOzEntry!.version).toBe(codeOz.version) expect(disciplineEntry!.version).toBe(codeOz.version) }) + + // Installability guard: every `source` path must resolve (relative to the + // marketplace root = repo root) to a real plugin directory whose plugin.json + // name matches the entry. A manifest that points at a non-existent or + // mismatched directory passes schema checks but fails at `plugin install`. + test('every marketplace source resolves to a real plugin dir with a matching plugin.json', async () => { + const raw = await readFile(MARKETPLACE_JSON_PATH, 'utf8') + const market = JSON.parse(raw) as { + plugins: Array<{ name: string; source: string }> + } + + for (const entry of market.plugins) { + // sources are repo-relative, must descend (no `..` traversal). + expect(entry.source.startsWith('./')).toBe(true) + expect(entry.source.includes('..')).toBe(false) + + const pluginJsonPath = join(REPO_ROOT, entry.source, '.claude-plugin/plugin.json') + const pluginRaw = await readFile(pluginJsonPath, 'utf8') + const plugin = JSON.parse(pluginRaw) as { name: string } + expect(plugin.name).toBe(entry.name) + } + }) }) diff --git a/tests/plugins/manifest-shape.test.ts b/tests/plugins/manifest-shape.test.ts index 62217df..757d261 100644 --- a/tests/plugins/manifest-shape.test.ts +++ b/tests/plugins/manifest-shape.test.ts @@ -16,7 +16,10 @@ import { fileURLToPath } from 'node:url' const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)).replace(/\/$/, '') const PLUGIN_JSON_PATH = join(REPO_ROOT, 'plugins/code-oz/.claude-plugin/plugin.json') -const MARKETPLACE_JSON_PATH = join(REPO_ROOT, 'plugins/.claude-plugin/marketplace.json') +// The marketplace manifest lives at the REPO ROOT so `claude plugin marketplace +// add ` discovers it — Claude Code only reads `.claude-plugin/ +// marketplace.json` at the cloned repo's root, never a subdirectory. +const MARKETPLACE_JSON_PATH = join(REPO_ROOT, '.claude-plugin/marketplace.json') const PACKAGE_JSON_PATH = join(REPO_ROOT, 'package.json') const EXPECTED_COMMANDS = [ @@ -69,7 +72,9 @@ describe('plugins/code-oz manifest shape', () => { const entry = market.plugins.find((p) => p.name === 'code-oz') expect(entry).toBeDefined() - expect(entry!.source).toBe('./code-oz') + // source is resolved relative to the marketplace root (repo root), so the + // path includes the plugins/ directory the plugin actually lives in. + expect(entry!.source).toBe('./plugins/code-oz') }) test('marketplace.json code-oz entry version matches plugin.json version', async () => { From 0ada9b3e7f484ae6f42727ac0cb18035e6a234d3 Mon Sep 17 00:00:00 2001 From: omerakben Date: Sat, 30 May 2026 10:52:35 -0400 Subject: [PATCH 2/2] fix(plugins): correct Homebrew tap form in install hints The plugin resolver and the agentskills.io skill told users to run `brew install omerakben/tap/code-oz`, which Homebrew expands to the nonexistent github.com/omerakben/homebrew-tap repo. The real tap is omerakben/homebrew-code-oz, so the correct form is `brew install omerakben/code-oz/code-oz` (the form the README already uses). A marketplace user without the engine binary or npx hits the resolver's hard-stop and npx-failure branches, so this wrong command was directly on the new plugin-install path. Fix every user-facing occurrence (resolve-code-oz.sh hard-stop + npx-failure hints, agent-skills README + SKILL.md); historical design docs are left as point-in-time records. Strengthen the two resolver tests to pin the correct tap form and reject the wrong one (RED-first). Codex review (thread 019e7958) flagged this as a real mismatch. --- agent-skills/code-oz/README.md | 2 +- agent-skills/code-oz/SKILL.md | 2 +- plugins/code-oz/scripts/resolve-code-oz.sh | 4 ++-- tests/plugins/bootstrap-resolver.test.ts | 9 ++++++++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/agent-skills/code-oz/README.md b/agent-skills/code-oz/README.md index 1aaa3c9..d559dfe 100644 --- a/agent-skills/code-oz/README.md +++ b/agent-skills/code-oz/README.md @@ -12,7 +12,7 @@ Copy this `code-oz/` folder into your agent's skills directory: - OpenClaw: your personal/project skills dir. Then ensure the engine is installed: `npm i -g @tuel/code-oz` or -`brew install omerakben/tap/code-oz`. +`brew install omerakben/code-oz/code-oz`. The skill drives code-oz in fail-closed operator mode, which bans the fake provider, blocks SHIP/push, and records operator provenance. Bind the project diff --git a/agent-skills/code-oz/SKILL.md b/agent-skills/code-oz/SKILL.md index b4210e3..abc81c4 100644 --- a/agent-skills/code-oz/SKILL.md +++ b/agent-skills/code-oz/SKILL.md @@ -13,7 +13,7 @@ write its state. 1. Check it is installed: run `code-oz --version` (or `code-oz doctor`). 2. If it is missing, STOP and tell the user to install it: - `npm i -g @tuel/code-oz` OR `brew install omerakben/tap/code-oz`. + `npm i -g @tuel/code-oz` OR `brew install omerakben/code-oz/code-oz`. Do NOT auto-run `curl`, `npx`, or `bunx` to install it yourself. ## How to drive it diff --git a/plugins/code-oz/scripts/resolve-code-oz.sh b/plugins/code-oz/scripts/resolve-code-oz.sh index eaeea26..9a3c297 100755 --- a/plugins/code-oz/scripts/resolve-code-oz.sh +++ b/plugins/code-oz/scripts/resolve-code-oz.sh @@ -103,7 +103,7 @@ if command -v npx >/dev/null 2>&1; then printf 'A @tuel scope-routing trap may be 404ing on npm.pkg.github.com.\n' >&2 printf 'To fix:\n' >&2 printf ' Option A — install via Homebrew (bypasses npm scope routing):\n' >&2 - printf ' brew install omerakben/tap/code-oz\n' >&2 + printf ' brew install omerakben/code-oz/code-oz\n' >&2 printf ' Option B — set the @tuel registry in your .npmrc:\n' >&2 printf ' @tuel:registry=https://registry.npmjs.org/\n' >&2 exit "${NPX_EXIT}" @@ -115,5 +115,5 @@ fi printf 'code-oz is not installed. Install:\n' >&2 printf ' npm i -g @tuel/code-oz\n' >&2 printf ' OR\n' >&2 -printf ' brew install omerakben/tap/code-oz\n' >&2 +printf ' brew install omerakben/code-oz/code-oz\n' >&2 exit 1 diff --git a/tests/plugins/bootstrap-resolver.test.ts b/tests/plugins/bootstrap-resolver.test.ts index 441b51c..b6c8e3d 100644 --- a/tests/plugins/bootstrap-resolver.test.ts +++ b/tests/plugins/bootstrap-resolver.test.ts @@ -184,6 +184,11 @@ describe('resolve-code-oz.sh — npx failure surfaces scope-routing caveat', () expect(result.stderr).toMatch(/@tuel/) // Must suggest Homebrew as an alternative expect(result.stderr).toMatch(/[Hh]omebrew|brew/) + // The Homebrew hint must use the real tap (brew resolves omerakben/code-oz + // to github.com/omerakben/homebrew-code-oz), not the nonexistent + // omerakben/homebrew-tap that omerakben/tap/code-oz would target. + expect(result.stderr).toContain('omerakben/code-oz/code-oz') + expect(result.stderr).not.toContain('omerakben/tap/code-oz') // Must mention the registry workaround expect(result.stderr).toMatch(/@tuel:registry/) }) @@ -204,8 +209,10 @@ describe('resolve-code-oz.sh — hard-stop (no code-oz, no npm/npx)', () => { expect(result.stdout + result.stderr).toMatch(/npm/) // Must mention the package name expect(result.stdout + result.stderr).toMatch(/@tuel\/code-oz/) - // Must mention brew as alternative + // Must mention brew as alternative, with the correct tap form. expect(result.stdout + result.stderr).toMatch(/brew/) + expect(result.stdout + result.stderr).toContain('omerakben/code-oz/code-oz') + expect(result.stdout + result.stderr).not.toContain('omerakben/tap/code-oz') }) })