A line above the prompt with unpushed commits and installed mods behind the repo, and /ship to push, refresh the marketplace and update those mods

For this marketplace repo: what is not shipped yet, and one command to ship it.
ship · main ↑2 · ●3 uncommitted · fleet 0.4.2 → 0.5.0 · restart needed [ ship ]
The line above the prompt shows when there is something to say:
↑2: commits not pushed yet●3 uncommitted: changed files (shown, never shipped)fleet 0.4.2 → 0.5.0: a mod installed from this marketplace at another version than the repo's marketplace.jsonrestart needed: a mod was updated after this session loaded its plugins/ship (or the ship button) does the steps that follow a commit:
git push, when commits are ahead of the upstreamclaude plugin marketplace update <marketplace>, when a mod is behindclaude plugin update <mod>@<marketplace> for each mod behind, and no otherIt stops at the first failure and says why, warns when the branch is not main (the marketplace reads main, so no mod is updated), names uncommitted files it left out, and says which mods need a restart. Committing and version bumps stay with Claude.
Meant for this repo alone, so installed at the local scope from inside it:
claude plugin install ship@vampik-plugins --scope local
git status --porcelain=v1 --branch, the repo's .claude-plugin/marketplace.json and ~/.claude/plugins/installed_plugins.json.restart needed.GIT_TERMINAL_PROMPT=0, so a push that needs a password fails instead of waiting.hooks/register.tsx 223 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { ShipStatus } from "../types";
5import {
6 bandLine,
7 canShip,
8 hasNews,
9 installedVersions,
10 marketplaceVersions,
11 needRestart,
12 parseStatus,
13 staleMods,
14 tailOf,
15} from "./core.ts";
16
17const status = atom(
18 { plugin: "ship", key: "status" } as const,
19 null as ShipStatus | null,
20);
21const loaded = atom(
22 { plugin: "ship", key: "loaded" } as const,
23 {} as Record<string, string>,
24);
25const running = atom({ plugin: "ship", key: "running" } as const, false);
26
27/** git and claude must never wait on a prompt nobody sees. */
28const NO_PROMPT = { GIT_TERMINAL_PROMPT: "0" };
29const PUSH_MS = 120_000;
30const PLUGIN_MS = 180_000;
31
32/** Where the repo, its marketplace and the installed-plugins record are. */
33type Repo = { root: string; marketplace: string; installedPath: string };
34
35const parsed = <T,>(text: unknown, parse: (t: string) => T): T | undefined => {
36 if (typeof text !== "string") return undefined;
37 try {
38 return parse(text);
39 } catch {
40 return undefined;
41 }
42};
43
44async function locate($: EngineInterface): Promise<Repo | undefined> {
45 const top = await $.process
46 .run(["git", "rev-parse", "--show-toplevel"])
47 .catch(() => undefined);
48 if (top?.exitCode !== 0) return undefined;
49 const root = top.stdout.trim();
50 const mkt = parsed(
51 await $.fs.read(`${root}/.claude-plugin/marketplace.json`).catch(() => undefined),
52 marketplaceVersions,
53 );
54 const home = await $.env.get("HOME").catch(() => undefined);
55 if (!mkt?.name || !home) return undefined;
56 return {
57 root,
58 marketplace: mkt.name,
59 installedPath: `${home}/.claude/plugins/installed_plugins.json`,
60 };
61}
62
63async function readInstalled(
64 $: EngineInterface,
65 r: Repo,
66): Promise<Record<string, string>> {
67 const text = await $.fs.read(r.installedPath).catch(() => undefined);
68 return parsed(text, (t) => installedVersions(t, r.marketplace)) ?? {};
69}
70
71/** Reads git, the repo's versions and the installed ones into the state. */
72async function refresh(
73 $: EngineInterface,
74 r: Repo,
75): Promise<ShipStatus | undefined> {
76 const [st, mkt, installed, base] = await Promise.all([
77 $.process
78 .run(["git", "status", "--porcelain=v1", "--branch"], { cwd: r.root })
79 .catch(() => undefined),
80 $.fs.read(`${r.root}/.claude-plugin/marketplace.json`).catch(() => undefined),
81 readInstalled($, r),
82 read($, loaded),
83 ]);
84 const repo = parsed(mkt, marketplaceVersions);
85 if (st?.exitCode !== 0 || !repo) return undefined;
86 const next: ShipStatus = {
87 ...parseStatus(st.stdout),
88 stale: staleMods(repo.versions, installed),
89 restart: needRestart(base, installed),
90 };
91 await update($, status, () => next);
92 return next;
93}
94
95type Ran = { exitCode: number; stdout: string; stderr: string };
96
97const runIn = ($: EngineInterface, r: Repo, argv: string[], timeoutMs: number) =>
98 $.process
99 .run(argv, { cwd: r.root, env: NO_PROMPT, timeoutMs })
100 .catch((err): Ran => ({ exitCode: 1, stdout: "", stderr: String(err) }));
101
102const said = (x: Ran) => tailOf(x.stderr || x.stdout);
103
104/** Push, refresh the marketplace, update the stale mods; answers the report. */
105async function shipSteps($: EngineInterface, r: Repo): Promise<string> {
106 const s = await refresh($, r);
107 if (!s) return "ship: could not read git status or the marketplace.";
108 if (!canShip(s)) return "Nothing to ship: no commits to push and every installed mod is current.";
109 const lines: string[] = [];
110
111 if (s.ahead > 0) {
112 if (!s.hasUpstream)
113 return `ship: ${s.branch} has no upstream; push it once with git push -u origin ${s.branch}.`;
114 const push = await runIn($, r, ["git", "push"], PUSH_MS);
115 if (push.exitCode !== 0) return `ship: git push failed:\n${said(push)}`;
116 lines.push(`Pushed ${s.ahead} commit${s.ahead === 1 ? "" : "s"} on ${s.branch}.`);
117 }
118
119 if (s.stale.length > 0) {
120 if (s.branch !== "main") {
121 lines.push(`On ${s.branch}: the marketplace reads main, so no mod was updated.`);
122 } else {
123 const mk = await runIn($, r, ["claude", "plugin", "marketplace", "update", r.marketplace], PLUGIN_MS);
124 if (mk.exitCode !== 0)
125 return [...lines, `Marketplace update failed:\n${said(mk)}`].join("\n");
126 for (const m of s.stale) {
127 const up = await runIn($, r, ["claude", "plugin", "update", `${m.name}@${r.marketplace}`], PLUGIN_MS);
128 if (up.exitCode !== 0) lines.push(`${m.name}: update failed: ${tailOf(said(up), 1)}`);
129 }
130 }
131 }
132
133 const after = await refresh($, r);
134 const installed = await readInstalled($, r);
135 const updated = s.stale.filter(
136 (m) => installed[m.name] !== undefined && installed[m.name] !== m.installed,
137 );
138 if (updated.length)
139 lines.push(`Updated ${updated.map((m) => `${m.name} ${m.installed} → ${installed[m.name]}`).join(", ")}.`);
140 const behind = (after?.stale ?? []).filter((m) => s.branch === "main" || !s.stale.some((x) => x.name === m.name));
141 if (behind.length && s.branch === "main")
142 lines.push(
143 `Still behind the repo: ${behind.map((m) => `${m.name} ${m.installed} (repo ${m.repo})`).join(", ")}; is the version bump committed and pushed?`,
144 );
145 if (after?.changed)
146 lines.push(`${after.changed} uncommitted file${after.changed === 1 ? " was" : "s were"} not shipped.`);
147 if (after?.restart.length)
148 lines.push(`Restart Claude Code to load: ${after.restart.join(", ")}.`);
149 return lines.join("\n");
150}
151
152async function ship($: EngineInterface, r: Repo | undefined): Promise<string> {
153 if (!r) return "ship: this is not a plugin marketplace repo (no .claude-plugin/marketplace.json at the git root).";
154 if (await read($, running)) return "ship is already running.";
155 await update($, running, () => true);
156 try {
157 return await shipSteps($, r);
158 } finally {
159 await update($, running, () => false);
160 }
161}
162
163export const register: Register = (on) => {
164 /** Found again at each load; a module variable, so a reload starts it over. */
165 let repo: Repo | undefined;
166
167 on("session.start", async ($, e, next) => {
168 const r = await next(e);
169 await $.command.register({
170 name: "ship",
171 description:
172 "Push this repo's commits, refresh its marketplace and update the installed mods behind it",
173 });
174 repo = await locate($);
175 if (repo) {
176 // what this load of the plugins runs: a later update needs a restart
177 const installed = await readInstalled($, repo);
178 await update($, loaded, () => installed);
179 await refresh($, repo);
180 }
181 return r;
182 });
183
184 on("turn.complete", async ($, e, next) => {
185 const r = await next(e);
186 if (!e.agentId && repo) await refresh($, repo);
187 return r;
188 });
189
190 on("command.run", { command: "ship" }, async ($) => ({
191 text: await ship($, repo),
192 }));
193
194 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
195 const s = await read($, status);
196 const busy = await read($, running);
197 if (e.props.hasSurvey || !s || (!hasNews(s) && !busy)) return next(e);
198 const below = await next(e);
199 const { Box, Button, Text } = $.ui.resolve(e);
200 return (
201 <Box flexDirection="column">
202 {below}
203 <Box flexDirection="row" columnGap={2} marginTop={1}>
204 <Text dimColor>{bandLine(s)}</Text>
205 {busy ? (
206 <Text color="cyan">shipping…</Text>
207 ) : (
208 canShip(s) && (
209 <Button
210 key="ship-run"
211 dimColor
212 onPress={() => void $.command.run({ command: "ship", args: "" } as never)}
213 >
214 ship
215 </Button>
216 )
217 )}
218 </Box>
219 </Box>
220 );
221 });
222};
223hooks/core.ts 96 lines1/**
2 * core.ts — the pure half of ship: reading git status, the repo's
3 * marketplace and the installed plugins, and the words of the band.
4 */
5
6import type { ShipStale, ShipStatus } from "../types";
7
8/** `git status --porcelain=v1 --branch` → the branch, its upstream, changed files and commits ahead. */
9export function parseStatus(
10 porcelain: string,
11): Pick<ShipStatus, "branch" | "hasUpstream" | "ahead" | "changed"> {
12 const lines = porcelain.split("\n").filter(Boolean);
13 const head = lines[0]?.startsWith("## ") ? lines.shift()!.slice(3) : "";
14 const m = /^(?:No commits yet on )?(\S+?)(?:\.\.\.(\S+))?(?: \[|$)/.exec(head);
15 return {
16 branch: m?.[1] ?? "",
17 hasUpstream: Boolean(m?.[2]),
18 ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
19 changed: lines.length,
20 };
21}
22
23/** A marketplace.json → its name and each plugin's version. */
24export function marketplaceVersions(json: string): {
25 name: string;
26 versions: Record<string, string>;
27} {
28 const m = JSON.parse(json) as {
29 name?: string;
30 plugins?: { name?: string; version?: string }[];
31 };
32 const versions: Record<string, string> = {};
33 for (const p of m.plugins ?? [])
34 if (p.name && p.version) versions[p.name] = p.version;
35 return { name: m.name ?? "", versions };
36}
37
38/** installed_plugins.json → the version installed of each plugin from `marketplace`. */
39export function installedVersions(
40 json: string,
41 marketplace: string,
42): Record<string, string> {
43 const m = JSON.parse(json) as {
44 plugins?: Record<string, { version?: string }[]>;
45 };
46 const out: Record<string, string> = {};
47 const suffix = `@${marketplace}`;
48 for (const [key, entries] of Object.entries(m.plugins ?? {})) {
49 const version = entries[0]?.version;
50 if (key.endsWith(suffix) && version)
51 out[key.slice(0, -suffix.length)] = version;
52 }
53 return out;
54}
55
56/** Installed mods whose version is not the repo's, by name. */
57export function staleMods(
58 repo: Record<string, string>,
59 installed: Record<string, string>,
60): ShipStale[] {
61 return Object.keys(installed)
62 .filter((name) => repo[name] !== undefined && repo[name] !== installed[name])
63 .sort()
64 .map((name) => ({ name, installed: installed[name]!, repo: repo[name]! }));
65}
66
67/** Plugins installed now at another version than the session loaded. */
68export function needRestart(
69 loaded: Record<string, string>,
70 installed: Record<string, string>,
71): string[] {
72 return Object.keys(installed)
73 .filter((name) => loaded[name] !== undefined && loaded[name] !== installed[name])
74 .sort();
75}
76
77/** Whether the band has something to say. */
78export const hasNews = (s: ShipStatus) =>
79 s.ahead > 0 || s.stale.length > 0 || s.restart.length > 0;
80
81/** Whether /ship has something to do. */
82export const canShip = (s: ShipStatus) => s.ahead > 0 || s.stale.length > 0;
83
84/** "ship · main ↑2 · ●3 uncommitted · fleet 0.4.2 → 0.5.0 · restart needed" */
85export function bandLine(s: ShipStatus): string {
86 const parts = [`ship · ${s.branch}${s.ahead ? ` ↑${s.ahead}` : ""}`];
87 if (s.changed) parts.push(`●${s.changed} uncommitted`);
88 for (const m of s.stale) parts.push(`${m.name} ${m.installed} → ${m.repo}`);
89 if (s.restart.length) parts.push("restart needed");
90 return parts.join(" · ");
91}
92
93/** The last few lines of a command's output, for a failure's report. */
94export const tailOf = (text: string, n = 3) =>
95 text.trim().split("\n").slice(-n).join("\n");
96types/index.d.ts 32 lines1/** A mod whose installed copy is not the repo's version. */
2export type ShipStale = {
3 name: string;
4 installed: string;
5 repo: string;
6};
7
8export type ShipStatus = {
9 branch: string;
10 hasUpstream: boolean;
11 ahead: number;
12 changed: number;
13 stale: ShipStale[];
14 /** Mods installed at another version than this session loaded */
15 restart: string[];
16};
17
18declare module "claude-code" {
19 interface PluginState {
20 /**
21 * status: the repo and the installed mods, as last read; loaded: each
22 * installed plugin's version when the session (re)loaded its plugins;
23 * running: whether /ship is under way
24 */
25 ship: {
26 status: ShipStatus | null;
27 loaded: Record<string, string>;
28 running: boolean;
29 };
30 }
31}
32