# @theglitchking/claude-plugin-runtime

Shared postinstall + SessionStart + CLI-subcommand runtime for Claude Code
plugins distributed via npm. One small package (~13 KB, zero runtime deps)
that handles the boilerplate every plugin in the Glitch Kingdom marketplace
needs:

- **Skill symlinking** — bundled skills in `node_modules/` get linked into
  `<project>/.claude/skills/` so Claude Code can discover them.
- **Default policy config** — writes `<project>/.claude/<plugin>.json` with
  `{ "updatePolicy": "nudge" }` if it doesn't exist.
- **Hook registration with dedup** — registers a SessionStart hook in
  `<project>/.claude/settings.json`, but skips when the Claude Code plugin
  marketplace version is already enabled globally, or when the project
  already has a matching hook.
- **SessionStart update check** — `off` / `nudge` / `auto` policies with
  a 3s network budget, 6h cache, CI-skip, and plugin/npm dedup at runtime.
- **CLI subcommand registration** — `update`, `policy`, `status`, `relink`
  for terminal parity with the slash commands.

## Install

```bash
npm install --save @theglitchking/claude-plugin-runtime
```

## Usage

Three entry points. Each plugin wires them up once and inherits every
behavior change made to this package.

### Postinstall

In `scripts/link-skills.js` (or whatever you call your postinstall script):

```js
#!/usr/bin/env node
import { runPostinstall } from "@theglitchking/claude-plugin-runtime";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");

runPostinstall({
  packageName: "@theglitchking/my-plugin",
  pluginName: "my-plugin",
  configFile: "my-plugin.json",
  skillsDir: "skills",
  packageRoot,
  hookCommand: "node ./node_modules/@theglitchking/my-plugin/hooks/session-start.js",
});
```

Wire it in `package.json`:

```json
{
  "scripts": { "postinstall": "node scripts/link-skills.js" }
}
```

### SessionStart hook

In `hooks/session-start.js`:

```js
#!/usr/bin/env node
import { runSessionStart } from "@theglitchking/claude-plugin-runtime";

await runSessionStart({
  packageName: "@theglitchking/my-plugin",
  pluginName: "my-plugin",
  configFile: "my-plugin.json",
  reconcile: (projectRoot) => {
    // Plugin-specific setup: .mcp.json reconciliation, scaffolding, etc.
    // Thrown errors are caught and logged — the update check still runs.
  },
});
```

Register it in the plugin's `hooks/hooks.json`:

```json
{
  "hooks": {
    "SessionStart": [
      { "hooks": [
          { "type": "command",
            "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js\"" }
      ]}
    ]
  }
}
```

### CLI subcommands

In your commander-based CLI:

```ts
import { program } from "commander";
import { registerUpdateCommands } from "@theglitchking/claude-plugin-runtime";
import { fileURLToPath } from "node:url";
import { dirname, resolve, join } from "node:path";
import { spawnSync } from "node:child_process";

registerUpdateCommands(program, {
  packageName: "@theglitchking/my-plugin",
  pluginName: "my-plugin",
  configFile: "my-plugin.json",
  onAfterUpdate: (cwd) => {
    const linker = join(cwd, "node_modules", "@theglitchking", "my-plugin", "scripts", "link-skills.js");
    spawnSync(process.execPath, [linker], {
      cwd,
      env: { ...process.env, INIT_CWD: cwd },
      stdio: "inherit",
    });
  },
});
```

## Policy resolution

1. `<ENV_PREFIX>_UPDATE_POLICY` env var (one-shot override).
2. `<project>/.claude/<configFile>` → `updatePolicy`.
3. Default: `nudge`.

`ENV_PREFIX` defaults to the upper-snake form of `pluginName` (e.g.
`semantic-pages` → `SEMANTIC_PAGES`). Override via `envPrefix` if you want
something different.

## Env opt-outs

| Variable | Effect |
|----------|--------|
| `<PREFIX>_SKIP_LINK=1` | Skip skill symlinking in postinstall. |
| `<PREFIX>_SKIP_HOOK_REGISTER=1` | Skip settings.json hook registration. |
| `<PREFIX>_UPDATE_POLICY=off\|nudge\|auto` | One-shot policy override. |

## Dedup between plugin and npm install

When a user has both a Claude Code plugin install and the npm dep:

- **At install time**: `runPostinstall` scans `~/.claude/settings.json` →
  `enabledPlugins`. If `<pluginName>@*: true`, it skips registering the
  project-level hook.
- **At runtime**: `runSessionStart` checks
  `process.env.CLAUDE_PLUGIN_ROOT`. If it's set (the hook was invoked by
  the plugin marketplace) and the project's `.claude/settings.json`
  contains a SessionStart entry whose command includes the plugin name,
  the plugin instance defers to the project-registered one.

Detection is substring-based on the plugin name — no magic tags or marker
fields. Any command string in settings.json containing the plugin name is
treated as "someone else is handling this," and we step aside.

## Full authoring recipe

See [`docs/PLUGIN_AUTHORING_SCAFFOLD.md`](./docs/PLUGIN_AUTHORING_SCAFFOLD.md)
for a copy-paste template for a new plugin, including file layout,
commander CLI wiring, slash commands, and the testing checklist.

## Reference implementation

[`@theglitchking/semantic-pages`](https://github.com/TheGlitchKing/semantic-pages)
is the canonical consumer. Diff its
[`scripts/link-skills.js`](https://github.com/TheGlitchKing/semantic-pages/blob/main/scripts/link-skills.js)
and [`hooks/session-start.js`](https://github.com/TheGlitchKing/semantic-pages/blob/main/hooks/session-start.js)
against a new plugin to see the minimal per-plugin delta (just the `reconcile` body and the config field names).

## License

MIT
