Sets up this Claude for the marketplace: install plugins in dependency order, write the always-loaded instructions, rules and output style, doctor what is…

The installer for this marketplace, for the one developer who owns the machine: it installs plugins, checks them, and writes the instructions every agent reads.
A marketplace is useful only when a machine reaches it in one step, and an agent works with it properly only when it knows, in every session, who it works for, where things live and where feedback goes. qsm-setup exists to set up this Claude for the marketplace: it brings the plugins to the machine and writes the instructions every agent here reads before anything else. It takes the stance that setup is repeatable and probes the machine instead of remembering it. It refuses to be a package manager, a config generator for anything outside this marketplace, a judge of how the person works, or a place where a plugin hides its own install. When every plugin installs itself and the harness carries these instructions, this plugin has no reason to exist.
| Kind | Name | What it does |
|---|---|---|
| Skill | setup | Guided or direct install of plugins, with a go before anything is written |
| Hook | session.start | Relinks the bin farm when autoRelink in setup.json is on |
| Hook | $.setup noun | Gives other plug-in hooks the verbs as $.setup.<verb>() |
| CLI | qsm-setup | 6 verbs, see USAGE.md |
export QSM_MARKETPLACE=/path/to/qsm-marketplace
bun "$QSM_MARKETPLACE/plugins/qsm-setup/scripts/install.ts"
qsm-setup install --profile general
qsm-setup activate qsm-setup -- --instructions replace
qsm-setup doctor
Compiles qsm-setup into QSM_BIN_DIR (default ~/.local/bin). Details are in INSTALL.md.
Brings qsm-essentials with it: the instruction files it writes send every agent to its skills and agents, and without it those pointers lead nowhere.
qsm-setup install qsm-langdev qsm-secrets
qsm-setup doctor --verbose
qsm-setup relink --check
qsm-setup tools --fetch
Or ask: "set up qsm on this machine" loads the setup skill.
PATH.activate is the only step that changes files you own. install never runs it.activate writes into ~/.claude (or $CLAUDE_CONFIG_DIR). Details are in USAGE.md.activate after editing an instruction file.doctor reports and never fixes. Each FAIL names its fix.~/.config/qsm/setup.json holds marketplaceRoot, autoRelink and profiles.hooks/register.tsx 18 lines1// At session start the bin farm relinks itself when this machine opted in (setup.json autoRelink).
2import type { On } from "claude-code";
3import { failure, registerNoun } from "./noun.generated.ts";
4
5export function register(on: On): void {
6 registerNoun(on);
7 on("session.start", async ($, e, next) => {
8 const data = await $.setup.relink({ auto: true });
9 if (typeof data === "object" && data !== null && "relinked" in data && data.relinked === true) {
10 $.ui.toast("qsm-setup: bin farm relinked");
11 }
12 return next(e);
13 }).catch(($, e, next) => {
14 failure($, "session.start", next.error);
15 return next(e);
16 });
17}
18hooks/noun.generated.ts 129 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Setup } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 install: { words: ["qsm-setup","install"], args: [{ name: "plugins", flag: null }, { name: "all", flag: "--all", boolean: true }, { name: "profile", flag: "--profile" }, { name: "dryRun", flag: "--dry-run", boolean: true }, { name: "force", flag: "--force", boolean: true }, { name: "root", flag: "--root" }] },
22 doctor: { words: ["qsm-setup","doctor"], args: [{ name: "plugin", flag: null }, { name: "verbose", flag: "--verbose", boolean: true }, { name: "root", flag: "--root" }] },
23 relink: { words: ["qsm-setup","relink"], args: [{ name: "check", flag: "--check", boolean: true }, { name: "auto", flag: "--auto", boolean: true }, { name: "root", flag: "--root" }] },
24 buildBins: { words: ["qsm-setup","build-bins"], args: [{ name: "root", flag: "--root" }] },
25 tools: { words: ["qsm-setup","tools"], args: [{ name: "fetch", flag: "--fetch", boolean: true }, { name: "root", flag: "--root" }] },
26};
27
28/** One verb's argv: its words, each option in declaration order, the
29 * passthrough rest after `--`, and `--json` last. */
30export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
31 const argv = [...row.words];
32 for (const arg of row.args) {
33 const value = options[arg.name];
34 const values = Array.isArray(value) ? value : [value];
35 for (const one of values) {
36 if (one === undefined || one === false) {
37 continue;
38 }
39 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
40 }
41 }
42 const rest = options.rest;
43 if (row.passthrough && Array.isArray(rest)) {
44 argv.push("--", ...rest.map(String));
45 }
46 argv.push("--json");
47 return argv;
48}
49
50/** The binary prints one outcome envelope on stdout under --json:
51 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
52 * code and message become the error. A stale or misbehaving binary that
53 * prints something else (not JSON, or JSON with no numeric `exit`) throws
54 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
55export function unwrap(
56 argv: readonly string[],
57 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
58 label = argv.slice(0, 2).join(" "),
59): unknown {
60 const text = result.stdout.trim();
61 const stderr = result.stderr.trim().slice(0, 200);
62 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
63 if (text.length === 0) {
64 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
65 }
66 let parsed: unknown;
67 try {
68 parsed = JSON.parse(text);
69 } catch {
70 parsed = undefined;
71 }
72 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
73 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
74 }
75 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
76 if (envelope.exit !== 0) {
77 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
78 }
79 return envelope.data;
80}
81
82/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
83 * when the cause may carry something the toast must not show, else the cause; the cause goes to
84 * the log and comes back for the caller's own policy. */
85export function failure(
86 $: EngineInterface,
87 event: string,
88 error: Caught["error"],
89 fixedReason?: string,
90): string {
91 const said = error.message ?? error.kind;
92 $.ui.toast(`qsm-setup ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
93 $.ui.log(`${event} failed (${error.kind}): ${said}`);
94 return said;
95}
96
97/** Adds $.setup to the engine. Call once from the plugin's own
98 * `register(on, options)` — never call the engine.create hook's own
99 * `next(e)` result from anywhere but here. */
100export function registerNoun(on: On): void {
101 on("engine.create", async (_$, e, next) => {
102 const beneath = await next(e);
103 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
104 const argv = argvOf(row, options);
105 let result: Awaited<ReturnType<typeof beneath.process.run>>;
106 try {
107 result = await beneath.process.run(argv);
108 } catch (error) {
109 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
110 // check that cannot run leaves the spawn's own error as the cause.
111 const gone = await beneath.session
112 .cwd()
113 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
114 .catch(() => null);
115 if (gone !== null) {
116 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
117 }
118 throw error;
119 }
120 return unwrap(argv, result, row.words.join(" "));
121 };
122 const methods = Object.entries(verbs).map(([method, row]) => [
123 method,
124 (options?: Readonly<Record<string, unknown>>) => run(row, options),
125 ]);
126 return { ...beneath, setup: Object.fromEntries(methods) as Setup };
127 });
128}
129types/index.d.ts 37 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Setup = {
5 install(options: {
6 plugins?: (string)[];
7 all?: boolean;
8 profile?: string;
9 dryRun?: boolean;
10 force?: boolean;
11 root?: string;
12 }): Promise<unknown>;
13 doctor(options: {
14 plugin?: string;
15 verbose?: boolean;
16 root?: string;
17 }): Promise<unknown>;
18 relink(options: {
19 check?: boolean;
20 auto?: boolean;
21 root?: string;
22 }): Promise<unknown>;
23 buildBins(options: {
24 root?: string;
25 }): Promise<unknown>;
26 tools(options: {
27 fetch?: boolean;
28 root?: string;
29 }): Promise<unknown>;
30};
31
32declare module 'claude-code' {
33 interface EngineInterface {
34 setup: Setup;
35 }
36}
37