SLOPSHOPPER

qsm-builder

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…

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qsm-builder
│ ┃ lens ✕ › fix the failing auth test and add an audit log call │ ┃ lens view failed: undefined is not an object │ ┃ (evaluating '$.builder.lensMarketplace') ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lens │ ⎿ qsm-builder: Marketplace view opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lens
lens view failed: undefined is not an object (evaluating '$.builder.lensMarketplace')
README

qsm-builder

Why it exists

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.

What it ships

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.

SurfaceOwns
add-plugin skillthe order and the file contract for adding a plugin, skill, hook, command, or verb
skill-standards skillthe quality bar for a skill, agent, or command prompt: its purpose first, reasons with rules, evals decide
prompting skillthe per-model tables (Claude 5.x, GPT-6) and the sources, loaded when writing or tightening a prompt
manifest checkon 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 binarythe 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

Install

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.

Caveats

  • The skills are guidance; only the manifest check enforces anything.
  • 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.
  • The check runs on 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.
Source 7 files
hooks/register.tsx 96 lines
1// 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}
96
lib/guards.ts 13 lines
1// 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}
13
lib/brief-path.ts 15 lines
1// 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}
15
lib/manifest-check.ts 131 lines
1// 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}
131
hooks/noun.generated.ts 132 lines
1// 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}
132
hooks/view.tsx 278 lines
1// 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}
278
types/index.d.ts 37 lines
1// 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