The one path for extending this marketplace: a skill that scaffolds a plugin or places content in the one that already owns the concept, a skill that holds the…

This marketplace has rules that are easy to state and easy to break in the moment of adding something, and prompts that are easy to write and hard to write well. qsm-builder exists so that adding anything follows the same short path every time, and so that every skill and agent prompt meets one bar, with a guide to why the rules are there and how to use them without misusing them. It takes the stance that the marketplace grows only when a capability is missing, never because something looked useful. It refuses to be a boilerplate generator or a second place where the rules are written. If the repository ever stops changing, it has nothing left to guard.
The one path for extending this marketplace: search first, decide the unit, why it exists, spec, build, validate, eval. Plus two hooks: one refuses a broken plugin.json, one tells the agent a plugin's open lens edits and feedback. And one slash command, /lens [plugin], that opens a read-only pane of the marketplace, or of one plugin: why it exists, skills and agents, dependencies, open tasks.
| Surface | Owns |
|---|---|
add-plugin skill | the order and the file contract for adding a plugin, skill, hook, command, or verb |
skill-standards skill | the quality bar for a skill, agent, or command prompt: its purpose first, reasons with rules, evals decide |
prompting skill | the per-model tables (Claude 5.x, GPT-6) and the sources, loaded when writing or tightening a prompt |
| manifest check | on Write and Edit of a plugin.json: kebab-case name, numeric version, non-empty description, author, string-array keywords, hooks as a .json path (never null) |
qsm-builder binary | the lens: one page of every plugin, why it exists, its dependencies, skills, agents and evals, served live or exported; edits become tasks, never file writes |
claude plugin install qsm-builder@qsm-marketplace
Depends on qsm-openspec (the spec flow add-plugin hands to) and qsm-tasks (the lens reads and makes tasks through it). bun plugins/qsm-builder/scripts/install.ts compiles the qsm-builder binary. qsm-builder lens export [dir] [--out file] writes one static HTML page of the whole marketplace (default .qsm/lens.html): every plugin, why it exists, its dependencies with their reasons, skills, agents, eval cases and what it refers to. qsm-builder lens readme [dir] [--check] writes the root README's generated block and the banners from the same model. qsm-builder lens [dir] [--port] serves the same page on 127.0.0.1, redrawn as files change; a pen on each text turns an edit into one task in the task tool (never a file write), and qsm-builder lens tasks [plugin] lists a plugin's open tasks, edits and feedback, each marked stale when its file changed since. The exported page, published as an artifact, saves each pen edit as a row in the artifact database; qsm-builder lens pull <artifact-url> turns each untaken row into the same task and marks it taken. Opened from disk, the page is read-only and says where edits are made. A hook tells the agent a plugin's open edits and feedback the first time in a session it reads or edits a file of it.
evals/ holds the trigger and negative cases for these skills; run them with bun run eval qsm-builder, which evals a copy without dependencies because the eval sandbox cannot resolve them.Write and Edit only, not on shell or patch edits, and needs function hooks enabled in the harness. Registration and name-matches-directory are the commit-time gate's job.hooks/register.tsx 96 lines1// qsm-builder's function-hooks module: the noun, the manifest check at the moment a manifest is
2// written, and the open tasks of a plugin told to the agent the first time it touches its files.
3import type { On } from "claude-code";
4import { isRecord } from "../lib/guards.ts";
5import { pluginOfPath } from "../lib/brief-path.ts";
6import { applyEdit, checkManifest, denial, isManifestPath } from "../lib/manifest-check.ts";
7import { registerNoun } from "./noun.generated.ts";
8import { registerView } from "./view.tsx";
9
10/** The plugins already told about, per session and marketplace: `<session>:<root>:<plugin>`. */
11const told = new Set<string>();
12
13/** The `text` of the brief verb's data: a string, or null when there is nothing to tell. */
14function briefText(data: unknown): string | null {
15 return isRecord(data) && typeof data.text === "string" ? data.text : null;
16}
17
18function registerBrief(on: On): void {
19 // The first Read, Write or Edit of a file of a plugin in this session adds that plugin's open
20 // lens edits and feedback to what the model reads after the result, once per plugin. A
21 // failure toasts and logs its cause and leaves the result as the tool made it.
22 on("tool.call", { tool: ["Read", "Write", "Edit"] }, async ($, e, next) => {
23 const result = await next(e);
24 const where = pluginOfPath(e.file_path);
25 if (result.deny !== undefined || result.isError === true || where === null) {
26 return result;
27 }
28 const key = `${await $.session.id()}:${where.root}:${where.plugin}`;
29 if (told.has(key)) {
30 return result;
31 }
32 told.add(key);
33 const text = briefText(await $.builder.lensBrief({ file: e.file_path }));
34 return text === null ? result : { ...result, context: [...(result.context ?? []), text] };
35 }).catch(($, e, next) => {
36 const cause =
37 next.error.message ?? `${next.error.kind}: ran past its ${next.error.budget} ms budget`;
38 $.ui.toast(`qsm-builder ERROR: tool.call: ${cause.slice(0, 240)}`);
39 $.ui.log(
40 `qsm-builder: the open tasks of a plugin were not added to ${e.file_path} (${next.error.kind}): ${cause}`,
41 );
42 return next(e);
43 });
44}
45
46function registerManifestCheck(on: On): void {
47 // Refuses a Write of a plugin.json that breaks the manifest rules, before the file lands.
48 on("tool.call", { tool: "Write" }, (_$, e, next) => {
49 if (!isManifestPath(e.file_path)) {
50 return next(e);
51 }
52 const found = checkManifest(e.content);
53 return found.length === 0 ? next(e) : { deny: denial(e.file_path, found) };
54 }).catch(($, e, next) => {
55 const reason = `${next.error.kind}${next.error.message === undefined ? "" : `: ${next.error.message.slice(0, 120)}`}`;
56 $.ui.toast(`qsm-builder ERROR: tool.call: ${reason}`);
57 $.ui.log(`the manifest check on Write failed (${next.error.kind})`);
58 return next(e);
59 });
60
61 // Refuses an Edit whose result would be a plugin.json that breaks the manifest rules.
62 on("tool.call", { tool: "Edit" }, async ($, e, next) => {
63 if (!isManifestPath(e.file_path)) {
64 return next(e);
65 }
66 // A manifest the edit names but cannot be read is not this hook's to
67 // report: the tool's own error says it better.
68 let current: string;
69 try {
70 current = await $.fs.read(e.file_path);
71 } catch {
72 return next(e);
73 }
74 const after = applyEdit(current, e.old_string, e.new_string, e.replace_all ?? false);
75 if (after === null) {
76 return next(e);
77 }
78 const found = checkManifest(after);
79 return found.length === 0 ? next(e) : { deny: denial(e.file_path, found) };
80 }).catch(($, e, next) => {
81 const reason = `${next.error.kind}${next.error.message === undefined ? "" : `: ${next.error.message.slice(0, 120)}`}`;
82 $.ui.toast(`qsm-builder ERROR: tool.call: ${reason}`);
83 $.ui.log(`the manifest check on Edit failed (${next.error.kind})`);
84 return next(e);
85 });
86}
87
88export function register(on: On): void {
89 // Adds $.builder: one method per qsm-builder verb, generated by
90 // lib/generate-noun.ts. Never hand-edit noun.generated.ts or types/index.d.ts.
91 registerNoun(on);
92 registerBrief(on);
93 registerManifestCheck(on);
94 registerView(on);
95}
96lib/guards.ts 13 lines1// Narrowing for values whose shape is not known until they are read.
2// differs: hooks sandbox (the hooks module imports no root lib, so it keeps its own isRecord)
3
4/** A JSON object: not null, not an array. */
5export function isRecord(value: unknown): value is Record<string, unknown> {
6 return typeof value === "object" && value !== null && !Array.isArray(value);
7}
8
9/** The items of a list, or none when the value is not a list. */
10export function listOf(value: unknown): unknown[] {
11 return Array.isArray(value) ? value : [];
12}
13lib/brief-path.ts 15 lines1// Which plugin of a marketplace a file belongs to, as text: the hook asks it before it spends a
2// verb, and the verb asks it again to find the marketplace root.
3
4export interface PluginFile {
5 /** the marketplace root: the path before `/plugins/<name>/` */
6 readonly root: string;
7 readonly plugin: string;
8}
9
10/** The plugin an absolute path lies inside (`<root>/plugins/<name>/...`), or null for any other path. */
11export function pluginOfPath(path: string): PluginFile | null {
12 const found = /^(\/.*?)\/plugins\/([^/]+)\/.+/.exec(path);
13 return found === null ? null : { root: found[1] ?? "", plugin: found[2] ?? "" };
14}
15lib/manifest-check.ts 131 lines1// What a plugin.json must hold, checked as text before the write lands.
2import { isRecord } from "./guards.ts";
3
4/** A plugin name: lowercase words joined by single dashes. */
5const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6/** A version starts with a dotted triple of numbers; a suffix after it is free. */
7const DOTTED_TRIPLE = /^\d+\.\d+\.\d+/;
8
9function show(value: unknown): string {
10 return value === undefined ? "nothing" : JSON.stringify(value);
11}
12
13function isText(value: unknown): value is string {
14 return typeof value === "string" && value.trim().length > 0;
15}
16
17/** True when the path names a plugin manifest, whatever directory holds it. */
18export function isManifestPath(path: string): boolean {
19 const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
20 return path.slice(cut + 1) === "plugin.json";
21}
22
23function checkName(json: Record<string, unknown>, found: string[]): void {
24 const { name } = json;
25 if (!isText(name)) {
26 found.push(`name must be a non-empty string, found ${show(name)}`);
27 } else if (!KEBAB_CASE.test(name)) {
28 found.push(`name must be kebab-case, found ${show(name)}`);
29 }
30}
31
32function checkVersion(json: Record<string, unknown>, found: string[]): void {
33 const { version } = json;
34 if (version === undefined) {
35 return;
36 }
37 if (!isText(version)) {
38 found.push(`version must be a non-empty string, found ${show(version)}`);
39 } else if (!DOTTED_TRIPLE.test(version)) {
40 found.push(`version must start with a dotted triple of numbers, found ${show(version)}`);
41 }
42}
43
44function checkAuthor(json: Record<string, unknown>, found: string[]): void {
45 const { author } = json;
46 if (isText(author)) {
47 return;
48 }
49 const named = isRecord(author) && isText(author.name);
50 if (!named) {
51 found.push(`author must be a name, or an object naming one, found ${show(author)}`);
52 }
53}
54
55function checkKeywords(json: Record<string, unknown>, found: string[]): void {
56 const { keywords } = json;
57 if (!Array.isArray(keywords)) {
58 found.push(`keywords must be an array of strings, found ${show(keywords)}`);
59 return;
60 }
61 const bad = keywords.filter((k) => typeof k !== "string");
62 if (bad.length > 0) {
63 found.push(`every keyword must be a string, found ${show(bad)}`);
64 }
65}
66
67/** The key Claude Code refuses as null: it is a path to a hooks file, or it
68 * is absent. A manifest that carries `"hooks": null` loads nowhere. */
69function checkHooks(json: Record<string, unknown>, found: string[]): void {
70 if (!("hooks" in json)) {
71 return;
72 }
73 const { hooks } = json;
74 if (hooks === null) {
75 found.push("hooks must be omitted rather than set to null");
76 } else if (!(typeof hooks === "string" && hooks.endsWith(".json"))) {
77 found.push(`hooks must be a path ending .json, or absent, found ${show(hooks)}`);
78 }
79}
80
81/** Every violation in the manifest text, in field order. Empty means it passes. */
82export function checkManifest(text: string): string[] {
83 let parsed: unknown;
84 try {
85 parsed = JSON.parse(text);
86 } catch (error) {
87 // differs: hooks sandbox, root lib's messageOf
88 return [`not valid JSON: ${error instanceof Error ? error.message : String(error)}`];
89 }
90 if (!isRecord(parsed)) {
91 return [`must be a JSON object, found ${show(parsed)}`];
92 }
93 const json = parsed;
94 const found: string[] = [];
95 checkName(json, found);
96 checkVersion(json, found);
97 if (!isText(json.description)) {
98 found.push(`description must be a non-empty string, found ${show(json.description)}`);
99 }
100 checkAuthor(json, found);
101 checkKeywords(json, found);
102 checkHooks(json, found);
103 return found;
104}
105
106/**
107 * The text an Edit would leave behind, or null when its old text is empty or is not there to
108 * replace.
109 */
110export function applyEdit(
111 current: string,
112 oldString: string,
113 newString: string,
114 replaceAll = false,
115): string | null {
116 if (oldString.length === 0 || !current.includes(oldString)) {
117 return null;
118 }
119 if (replaceAll) {
120 return current.split(oldString).join(newString);
121 }
122 const at = current.indexOf(oldString);
123 return current.slice(0, at) + newString + current.slice(at + oldString.length);
124}
125
126/** The refusal the agent reads: the file, then one line per violation. */
127export function denial(path: string, found: readonly string[]): string {
128 const count = found.length === 1 ? "1 violation" : `${found.length} violations`;
129 return [`${path}: ${count}`, ...found.map((f) => ` - ${f}`)].join("\n");
130}
131hooks/noun.generated.ts 132 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 { Builder } 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 lensExport: { words: ["qsm-builder","lens","export"], args: [{ name: "dir", flag: null }, { name: "out", flag: "--out" }] },
22 lensReadme: { words: ["qsm-builder","lens","readme"], args: [{ name: "dir", flag: null }, { name: "check", flag: "--check", boolean: true }] },
23 lensPull: { words: ["qsm-builder","lens","pull"], args: [{ name: "url", flag: null }] },
24 lensBrief: { words: ["qsm-builder","lens","brief"], args: [{ name: "file", flag: null }] },
25 lensTasks: { words: ["qsm-builder","lens","tasks"], args: [{ name: "plugin", flag: null }] },
26 lensMarketplace: { words: ["qsm-builder","lens","marketplace"], args: [{ name: "dir", flag: "--dir" }] },
27 lensPlugin: { words: ["qsm-builder","lens","plugin"], args: [{ name: "plugin", flag: null }, { name: "dir", flag: "--dir" }] },
28 doctor: { words: ["qsm-builder","doctor"], args: [] },
29};
30
31/** One verb's argv: its words, each option in declaration order, the
32 * passthrough rest after `--`, and `--json` last. */
33export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
34 const argv = [...row.words];
35 for (const arg of row.args) {
36 const value = options[arg.name];
37 const values = Array.isArray(value) ? value : [value];
38 for (const one of values) {
39 if (one === undefined || one === false) {
40 continue;
41 }
42 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
43 }
44 }
45 const rest = options.rest;
46 if (row.passthrough && Array.isArray(rest)) {
47 argv.push("--", ...rest.map(String));
48 }
49 argv.push("--json");
50 return argv;
51}
52
53/** The binary prints one outcome envelope on stdout under --json:
54 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
55 * code and message become the error. A stale or misbehaving binary that
56 * prints something else (not JSON, or JSON with no numeric `exit`) throws
57 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
58export function unwrap(
59 argv: readonly string[],
60 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
61 label = argv.slice(0, 2).join(" "),
62): unknown {
63 const text = result.stdout.trim();
64 const stderr = result.stderr.trim().slice(0, 200);
65 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
66 if (text.length === 0) {
67 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
68 }
69 let parsed: unknown;
70 try {
71 parsed = JSON.parse(text);
72 } catch {
73 parsed = undefined;
74 }
75 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
76 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
77 }
78 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
79 if (envelope.exit !== 0) {
80 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
81 }
82 return envelope.data;
83}
84
85/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
86 * when the cause may carry something the toast must not show, else the cause; the cause goes to
87 * the log and comes back for the caller's own policy. */
88export function failure(
89 $: EngineInterface,
90 event: string,
91 error: Caught["error"],
92 fixedReason?: string,
93): string {
94 const said = error.message ?? error.kind;
95 $.ui.toast(`qsm-builder ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
96 $.ui.log(`${event} failed (${error.kind}): ${said}`);
97 return said;
98}
99
100/** Adds $.builder to the engine. Call once from the plugin's own
101 * `register(on, options)` — never call the engine.create hook's own
102 * `next(e)` result from anywhere but here. */
103export function registerNoun(on: On): void {
104 on("engine.create", async (_$, e, next) => {
105 const beneath = await next(e);
106 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
107 const argv = argvOf(row, options);
108 let result: Awaited<ReturnType<typeof beneath.process.run>>;
109 try {
110 result = await beneath.process.run(argv);
111 } catch (error) {
112 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
113 // check that cannot run leaves the spawn's own error as the cause.
114 const gone = await beneath.session
115 .cwd()
116 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
117 .catch(() => null);
118 if (gone !== null) {
119 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
120 }
121 throw error;
122 }
123 return unwrap(argv, result, row.words.join(" "));
124 };
125 const methods = Object.entries(verbs).map(([method, row]) => [
126 method,
127 (options?: Readonly<Record<string, unknown>>) => run(row, options),
128 ]);
129 return { ...beneath, builder: Object.fromEntries(methods) as Builder };
130 });
131}
132hooks/view.tsx 278 lines1// A read-only view of the lens's model in a pane. `/lens` opens the whole marketplace, `/lens
2// <plugin>` one plugin, and a plugin line or a skill or agent line can be pressed to open the
3// next level. It draws what `qsm-builder lens marketplace` and `lens plugin` read; it edits
4// nothing. What the pane shows travels in its id (a pane id holds letters, digits, `_` and `-`),
5// so the pane keeps no state.
6import type { EngineInterface, On } from "claude-code";
7import { isRecord, listOf } from "../lib/guards.ts";
8
9const MARKETPLACE = "lens";
10const PREFIX = "lens_";
11const ITEM_SEP = "__";
12const WIDE = 100;
13
14interface Task {
15 readonly id: string;
16 readonly kind: string;
17 readonly title: string;
18 readonly stale: boolean | null;
19}
20
21interface MarketView {
22 readonly name: string;
23 readonly why: string | null;
24 readonly plugins: readonly {
25 name: string;
26 line: string | null;
27 dependencies: readonly string[];
28 openTasks: number | null;
29 }[];
30 readonly tasksNote: string;
31}
32
33interface PluginView {
34 readonly plugin: string;
35 readonly why: string | null;
36 readonly items: readonly {
37 kind: string;
38 name: string;
39 line: string;
40 description: string;
41 triggers: readonly string[];
42 }[];
43 readonly dependencies: readonly { name: string; reason: string | null }[];
44 readonly tasks: readonly Task[] | null;
45 readonly tasksNote: string;
46}
47
48const isStrings = (v: unknown): boolean => listOf(v).every((x) => typeof x === "string");
49const isTextOrNull = (v: unknown): boolean => v === null || typeof v === "string";
50
51function isMarketPlugin(p: unknown): p is MarketView["plugins"][number] {
52 return (
53 isRecord(p) &&
54 typeof p.name === "string" &&
55 isTextOrNull(p.line) &&
56 Array.isArray(p.dependencies) &&
57 isStrings(p.dependencies) &&
58 (p.openTasks === null || typeof p.openTasks === "number")
59 );
60}
61
62function isMarketView(v: unknown): v is MarketView {
63 return (
64 isRecord(v) &&
65 typeof v.name === "string" &&
66 isTextOrNull(v.why) &&
67 typeof v.tasksNote === "string" &&
68 Array.isArray(v.plugins) &&
69 v.plugins.every(isMarketPlugin)
70 );
71}
72
73const isTask = (v: unknown): v is Task =>
74 isRecord(v) &&
75 typeof v.id === "string" &&
76 typeof v.kind === "string" &&
77 typeof v.title === "string" &&
78 (v.stale === null || typeof v.stale === "boolean");
79
80function isPluginView(v: unknown): v is PluginView {
81 return (
82 isRecord(v) &&
83 typeof v.plugin === "string" &&
84 isTextOrNull(v.why) &&
85 typeof v.tasksNote === "string" &&
86 Array.isArray(v.items) &&
87 v.items.every(
88 (i: unknown) =>
89 isRecord(i) &&
90 typeof i.kind === "string" &&
91 typeof i.name === "string" &&
92 typeof i.line === "string" &&
93 typeof i.description === "string" &&
94 Array.isArray(i.triggers) &&
95 isStrings(i.triggers),
96 ) &&
97 Array.isArray(v.dependencies) &&
98 v.dependencies.every(
99 (d: unknown) => isRecord(d) && typeof d.name === "string" && isTextOrNull(d.reason),
100 ) &&
101 (v.tasks === null || (Array.isArray(v.tasks) && v.tasks.every(isTask)))
102 );
103}
104
105/** What a verb read: the marketplace, or one plugin. */
106type Loaded = { readonly market: MarketView } | { readonly plugin: PluginView };
107
108type Elements = ReturnType<EngineInterface["ui"]["resolve"]>;
109
110/** What a draw needs: the elements, the width, and how a press opens the next level. */
111interface Ctx {
112 readonly els: Pick<Elements, "Box" | "Text" | "Button">;
113 readonly room: number;
114 readonly open: (id: string) => Promise<unknown>;
115}
116
117const cut = (text: string, room: number): string =>
118 text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`;
119
120// One verb call per opening, not per redraw: the view is kept by pane id until the command runs
121// again. A pane that outlives a reload of this module reads it again on its next draw.
122const views = new Map<string, Promise<Loaded>>();
123
124const idOf = (plugin: string, item?: string): string =>
125 item === undefined ? `${PREFIX}${plugin}` : `${PREFIX}${plugin}${ITEM_SEP}${item}`;
126
127function load($: EngineInterface, id: string): Promise<Loaded> {
128 const read = async (): Promise<Loaded> => {
129 const dir = await $.session.cwd();
130 if (id === MARKETPLACE) {
131 const market: unknown = await $.builder.lensMarketplace({ dir });
132 if (!isMarketView(market)) {
133 throw new Error("the marketplace view has an unexpected shape");
134 }
135 return { market };
136 }
137 const [name = ""] = id.slice(PREFIX.length).split(ITEM_SEP);
138 const plugin: unknown = await $.builder.lensPlugin({ plugin: name, dir });
139 if (!isPluginView(plugin)) {
140 throw new Error(`the view of ${name} has an unexpected shape`);
141 }
142 return { plugin };
143 };
144 const loaded = read();
145 views.set(id, loaded);
146 return loaded;
147}
148
149function tasksLines(Text: Elements["Text"], view: PluginView, room: number) {
150 if (view.tasks === null) {
151 return <Text color="yellow">{cut(view.tasksNote, room * 2)}</Text>;
152 }
153 if (view.tasks.length === 0) {
154 return <Text dimColor>none</Text>;
155 }
156 return view.tasks.map((t) => (
157 <Text>{cut(`${t.id} ${t.kind}${t.stale === true ? " (stale)" : ""}: ${t.title}`, room)}</Text>
158 ));
159}
160
161function drawMarketplace({ els, open, room }: Ctx, view: MarketView) {
162 const { Box, Text, Button } = els;
163 return (
164 <Box flexDirection="column">
165 <Text bold>{view.name}</Text>
166 <Text bold>Why it exists</Text>
167 <Text>{view.why ?? "the README has no Why it exists section"}</Text>
168 <Text bold>Plugins ({view.plugins.length})</Text>
169 {view.plugins.map((p) => (
170 <Box flexDirection="column">
171 <Button key={`open-${p.name}`} plain onPress={() => open(idOf(p.name))}>
172 {cut(`${p.name} (${p.openTasks ?? "tasks not read"} open tasks)`, room)}
173 </Button>
174 <Text>{cut(` ${p.line ?? "no Why it exists section"}`, room)}</Text>
175 <Text dimColor>
176 {cut(
177 ` depends on: ${p.dependencies.length === 0 ? "none" : p.dependencies.join(", ")}`,
178 room,
179 )}
180 </Text>
181 </Box>
182 ))}
183 {view.plugins.every((p) => p.openTasks === null) && (
184 <Text color="yellow">{cut(view.tasksNote, room * 2)}</Text>
185 )}
186 </Box>
187 );
188}
189
190function drawPlugin({ els, open, room }: Ctx, view: PluginView) {
191 const { Box, Text, Button } = els;
192 return (
193 <Box flexDirection="column">
194 <Text bold>{view.plugin}</Text>
195 <Text bold>Why it exists</Text>
196 <Text>{view.why ?? "this plugin's README has no Why it exists section"}</Text>
197 <Text bold>Skills and agents ({view.items.length})</Text>
198 {view.items.map((i) => (
199 <Button key={`open-${i.name}`} plain onPress={() => open(idOf(view.plugin, i.name))}>
200 {cut(`${i.kind} ${i.name}: ${i.line}`, room)}
201 </Button>
202 ))}
203 <Text bold>Dependencies ({view.dependencies.length})</Text>
204 {view.dependencies.length === 0 && <Text dimColor>none</Text>}
205 {view.dependencies.map((d) => (
206 <Text>{cut(`${d.name}: ${d.reason ?? "the spec gives no reason"}`, room)}</Text>
207 ))}
208 <Text bold>Open tasks ({view.tasks?.length ?? "not read"})</Text>
209 {tasksLines(Text, view, room)}
210 </Box>
211 );
212}
213
214function drawItem({ els }: Ctx, view: PluginView, name: string) {
215 const { Box, Text } = els;
216 const item = view.items.find((i) => i.name === name);
217 if (item === undefined) {
218 return <Text color="red">{`${view.plugin} has no skill or agent ${name}`}</Text>;
219 }
220 return (
221 <Box flexDirection="column">
222 <Text bold>{`${view.plugin}: ${item.kind} ${item.name}`}</Text>
223 <Text bold>Description</Text>
224 <Text>{item.description}</Text>
225 <Text bold>Trigger words</Text>
226 <Text>{item.triggers.length === 0 ? "none written" : item.triggers.join(", ")}</Text>
227 </Box>
228 );
229}
230
231function draw(ctx: Ctx, id: string, loaded: Loaded) {
232 if ("market" in loaded) {
233 return drawMarketplace(ctx, loaded.market);
234 }
235 const [, item] = id.slice(PREFIX.length).split(ITEM_SEP);
236 return item === undefined ? drawPlugin(ctx, loaded.plugin) : drawItem(ctx, loaded.plugin, item);
237}
238
239export function registerView(on: On): void {
240 on("session.start", async ($, e, next) => {
241 await $.command.register({
242 name: "lens",
243 description:
244 "Open a read-only view of the marketplace, or of one plugin: why it exists, skills, dependencies, open tasks",
245 });
246 return next(e);
247 });
248
249 on("command.run", { command: "lens" }, async ($, e) => {
250 const name = e.args.trim();
251 const id = name === "" ? MARKETPLACE : idOf(name);
252 views.set(id, load($, id));
253 await $.ui.open({ id, title: name === "" ? "lens" : name });
254 return { text: name === "" ? "Marketplace view opened." : `Plugin view of ${name} opened.` };
255 });
256
257 on("ui.render", { component: "Pane" }, async ($, e, next) => {
258 const id = e.requestId;
259 if (id !== MARKETPLACE && !id.startsWith(PREFIX)) {
260 return next(e);
261 }
262 const els = $.ui.resolve(e);
263 const room = Math.max(20, (e.props.bodyColumns ?? WIDE) - 4);
264 // A press opens the next level in its own pane, loaded when that pane first draws.
265 const open = (target: string) =>
266 $.ui.open({ id: target, title: target.slice(PREFIX.length).replace(ITEM_SEP, " / ") });
267 let loaded: Loaded;
268 try {
269 loaded = await (views.get(id) ?? load($, id));
270 } catch (error) {
271 // differs: hooks sandbox, root lib's messageOf
272 const why = error instanceof Error ? error.message : String(error);
273 return <els.Text color="red">{cut(`lens view failed: ${why}`, room * 3)}</els.Text>;
274 }
275 return draw({ els, open, room }, id, loaded);
276 });
277}
278types/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 Builder = {
5 lensExport(options: {
6 dir?: string;
7 out?: string;
8 }): Promise<unknown>;
9 lensReadme(options: {
10 dir?: string;
11 check?: boolean;
12 }): Promise<unknown>;
13 lensPull(options: {
14 url: string;
15 }): Promise<unknown>;
16 lensBrief(options: {
17 file: string;
18 }): Promise<unknown>;
19 lensTasks(options: {
20 plugin?: string;
21 }): Promise<unknown>;
22 lensMarketplace(options: {
23 dir?: string;
24 }): Promise<unknown>;
25 lensPlugin(options: {
26 plugin: string;
27 dir?: string;
28 }): Promise<unknown>;
29 doctor(): Promise<unknown>;
30};
31
32declare module 'claude-code' {
33 interface EngineInterface {
34 builder: Builder;
35 }
36}
37