@liebstoeckel/cli 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/new.ts ADDED
@@ -0,0 +1,310 @@
1
+ import { defineCommand } from "citty";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
7
+
8
+ const titleCase = (name: string) => name.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
9
+
10
+ export interface ScaffoldOptions {
11
+ brand?: string;
12
+ /** parent directory for the new deck (default: the current working directory) */
13
+ dir?: string;
14
+ /** opt out of auto-applying the logged-in org's default brand (ADR 0059) */
15
+ noOrgBrand?: boolean;
16
+ }
17
+
18
+ /** Pure: the file map for a new minimal deck (deck-relative path → contents).
19
+ * Kept as plain templates so a richer template library can grow from here. */
20
+ /** Per-dependency version ranges for the scaffolded deck (ADR 0051). Independently
21
+ * versioned, so each is resolved from its OWN package. */
22
+ export interface DeckDeps {
23
+ engine?: string;
24
+ theme?: string;
25
+ thumbnails?: string;
26
+ }
27
+
28
+ export function deckFiles(
29
+ name: string,
30
+ brand = "liebstoeckel",
31
+ deps: DeckDeps = {},
32
+ orgBrand?: { name: string; source: string; dependencies?: string[] },
33
+ ): Record<string, string> {
34
+ const range = (k: keyof DeckDeps) => deps[k] ?? "workspace:*";
35
+ const title = titleCase(name);
36
+ // When `new` is run logged-in, the org's default brand (ADR 0059) is baked in:
37
+ // a local brands/<name>.ts wired via <Present brandThemes>, self-contained.
38
+ const brandId = orgBrand?.name ?? brand;
39
+ const brandImport = orgBrand ? `import orgBrand from "./brands/${orgBrand.name}";\n` : "";
40
+ const brandThemesProp = orgBrand ? " brandThemes={[orgBrand]}" : "";
41
+ // The default brand's catalog fonts (ADR 0074) are @fontsource packages the baked
42
+ // brands/<name>.ts imports, add them so the deck's `bun install` fetches the
43
+ // webfont (built decks inline it). `latest` (a third-party dist-tag) since the CLI
44
+ // can't resolve their version like the framework deps; the lockfile pins on install.
45
+ const fontDeps = Object.fromEntries((orgBrand?.dependencies ?? []).map((p) => [p, "latest"]));
46
+ const pkg = {
47
+ // A scaffolded deck is the user's own private project, a BARE name, not the
48
+ // framework's `@liebstoeckel/` npm scope (ADR 0054). Only the framework
49
+ // *dependencies* below keep that scope.
50
+ name,
51
+ version: "0.0.0",
52
+ private: true,
53
+ type: "module",
54
+ // Allowlist of what `bun pm pack` ships, and therefore what `liebstoeckel build`
55
+ // embeds as recoverable source and `eject` restores (ADR 0039). Deny-by-default:
56
+ // a stray .env / secret is never packed because it isn't listed here. bunfig.toml
57
+ // MUST stay listed, pack default-ignores it, and the ejected deck needs it for dev.
58
+ // Add new top-level source dirs here as the deck grows (the build warns if you forget).
59
+ files: [
60
+ "index.html",
61
+ "main.tsx",
62
+ "server.ts",
63
+ "build.ts",
64
+ "bunfig.toml",
65
+ "slides",
66
+ "elements",
67
+ "components",
68
+ "charts",
69
+ "brands",
70
+ "assets",
71
+ "public",
72
+ ],
73
+ scripts: { dev: "bun --hot ./server.ts", build: "bun run build.ts" },
74
+ dependencies: {
75
+ "@liebstoeckel/engine": range("engine"),
76
+ "@liebstoeckel/theme": range("theme"),
77
+ ...fontDeps,
78
+ },
79
+ devDependencies: { "@liebstoeckel/thumbnails": range("thumbnails") },
80
+ };
81
+
82
+ return {
83
+ "package.json": JSON.stringify(pkg, null, 2) + "\n",
84
+
85
+ // Defense-in-depth for decks used outside the monorepo: keep build output and
86
+ // secrets out of version control (and the `files` allowlist above keeps them out
87
+ // of the packed/embedded source regardless).
88
+ ".gitignore": `node_modules/
89
+ dist/
90
+ *.tgz
91
+ .env
92
+ .env.*
93
+ `,
94
+
95
+ "index.html": `<!doctype html>
96
+ <html lang="en">
97
+ <head>
98
+ <meta charset="utf-8" />
99
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
100
+ <link
101
+ rel="icon"
102
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23121212'/%3E%3Crect x='7' y='9' width='18' height='13' rx='2' fill='none' stroke='%23c8a96a' stroke-width='2'/%3E%3C/svg%3E"
103
+ />
104
+ <title>${title}</title>
105
+ </head>
106
+ <body data-brand="${brandId}">
107
+ <div id="root"></div>
108
+ <script type="module" src="./main.tsx"></script>
109
+ </body>
110
+ </html>
111
+ `,
112
+
113
+ "bunfig.toml": `# Plugins for the HMR dev server (Bun.serve HTML routes). The build path uses
114
+ # Bun.build()'s plugins array in build.ts instead.
115
+ [serve.static]
116
+ plugins = ["bun-plugin-tailwind", "@liebstoeckel/engine/mdx-plugin", "@liebstoeckel/engine/visx-esm-plugin"]
117
+ `,
118
+
119
+ "server.ts": `import index from "./index.html";
120
+
121
+ // Dev server with frontend HMR + React Fast Refresh.
122
+ const server = Bun.serve({
123
+ routes: { "/": index },
124
+ development: { hmr: true, console: true },
125
+ hostname: "0.0.0.0",
126
+ port: Number(process.env.PORT) || 3000,
127
+ });
128
+
129
+ console.log(\`▶ http://localhost:\${server.port}\`);
130
+ `,
131
+
132
+ "build.ts": `import { buildDeck } from "@liebstoeckel/thumbnails/build";
133
+
134
+ // Single self-contained .html + slide thumbnails (skip with LIEBSTOECKEL_NO_THUMBS=1).
135
+ await buildDeck({ entry: "./index.html", outdir: "./dist" });
136
+ `,
137
+
138
+ "main.tsx": `import { StrictMode } from "react";
139
+ import { createRoot } from "react-dom/client";
140
+ import { Present } from "@liebstoeckel/engine";
141
+ import "@liebstoeckel/theme/styles.css";
142
+ ${brandImport}
143
+ import Intro from "./slides/01-intro";
144
+
145
+ createRoot(document.getElementById("root")!).render(
146
+ <StrictMode>
147
+ <Present title="${title}" brands={["${brandId}"]}${brandThemesProp} slides={[Intro]} />
148
+ </StrictMode>,
149
+ );
150
+ `,
151
+
152
+ "slides/01-intro.tsx": `import { Step } from "@liebstoeckel/engine";
153
+
154
+ export default function Intro() {
155
+ return (
156
+ <div className="w-full">
157
+ <div className="mb-5 flex items-center gap-3 font-mono text-sm uppercase tracking-[0.35em] text-accent">
158
+ <span className="h-px w-8 bg-accent" />
159
+ liebstoeckel
160
+ </div>
161
+ <h1 className="font-heading text-[88px] font-semibold leading-[0.95] tracking-[-0.03em] text-text">
162
+ ${title}
163
+ </h1>
164
+ <div className="mt-10 space-y-4 font-body text-2xl text-muted">
165
+ <Step>▹ Edit slides/01-intro.tsx</Step>
166
+ <Step>▹ Add slides and wire them up in main.tsx</Step>
167
+ <Step>▹ bun run build → one self-contained .html</Step>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+ `,
173
+ // Org default brand baked in as owned source (ADR 0059).
174
+ ...(orgBrand ? { [`brands/${orgBrand.name}.ts`]: orgBrand.source } : {}),
175
+ };
176
+ }
177
+
178
+ /** Version string from the nearest package.json above a resolved module path. */
179
+ function nearestPkgVersion(entry: string): string | null {
180
+ let dir = dirname(entry.startsWith("file://") ? fileURLToPath(entry) : entry);
181
+ for (let i = 0; i < 6 && dir !== dirname(dir); i++) {
182
+ const pj = join(dir, "package.json");
183
+ if (existsSync(pj)) {
184
+ try {
185
+ return (JSON.parse(readFileSync(pj, "utf8")) as { version?: string }).version ?? null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+ dir = dirname(dir);
191
+ }
192
+ return null;
193
+ }
194
+
195
+ const caret = (v: string | null | undefined) => (v && v !== "0.0.0" ? `^${v}` : null);
196
+
197
+ /** The `^<version>` range to scaffold for a framework dep, read from that dep's
198
+ * OWN resolved package (independently versioned, ADR 0081).
199
+ *
200
+ * Invariant: every scaffolded dependency MUST be a direct dependency of
201
+ * `@liebstoeckel/cli`, so it is installed alongside the CLI and resolvable both
202
+ * in-repo and standalone. We rely on that and **fail loud** if it doesn't hold.
203
+ * The old fallback to the CLI's *own* version was only correct under lockstep;
204
+ * with graph-driven versioning it would silently stamp a wrong, lockstep-shaped
205
+ * pin onto a package that no longer shares the CLI's version, a far worse failure
206
+ * than a clear error. */
207
+ function depRange(name: string): string {
208
+ let resolved: string;
209
+ try {
210
+ resolved = import.meta.resolveSync(name);
211
+ } catch (err) {
212
+ throw new Error(
213
+ `scaffold: cannot resolve ${name} to read its version. Every scaffolded ` +
214
+ `dependency must be a direct dependency of @liebstoeckel/cli so it is ` +
215
+ `installed alongside the CLI (ADR 0081). Original error: ${(err as Error).message}`,
216
+ );
217
+ }
218
+ const version = nearestPkgVersion(resolved);
219
+ const range = caret(version);
220
+ if (!range) {
221
+ throw new Error(
222
+ `scaffold: resolved ${name} but its package.json has no usable version ` +
223
+ `(found ${version ?? "none"}); cannot emit a real range for it (ADR 0081).`,
224
+ );
225
+ }
226
+ return range;
227
+ }
228
+
229
+ /** Write a new minimal deck to disk. Throws on an invalid name or existing dir. */
230
+ export async function scaffold(
231
+ name: string,
232
+ opts: ScaffoldOptions = {},
233
+ ): Promise<{ dir: string; files: string[]; brand: string }> {
234
+ if (!VALID_NAME.test(name)) {
235
+ throw new Error(`invalid deck name "${name}", use lower-case letters, digits and hyphens`);
236
+ }
237
+ const brand = opts.brand ?? "liebstoeckel";
238
+ // Unless a brand was named explicitly (or opted out), bake the logged-in org's
239
+ // default brand so new decks are on-brand instantly (ADR 0059). Best effort, // never blocks scaffolding when offline / not logged in.
240
+ const orgBrand = !opts.brand && !opts.noOrgBrand ? await (await import("./cloud")).fetchDefaultBrand() : null;
241
+
242
+ // The deck materializes in the current directory as ./<name> (least surprising).
243
+ // Override the parent with --dir (e.g. --dir presentations).
244
+ const root = opts.dir ? resolve(opts.dir) : process.cwd();
245
+ const dir = join(root, name);
246
+ if (existsSync(dir)) throw new Error(`${dir} already exists`);
247
+
248
+ const files = deckFiles(
249
+ name,
250
+ brand,
251
+ {
252
+ engine: depRange("@liebstoeckel/engine"),
253
+ theme: depRange("@liebstoeckel/theme"),
254
+ thumbnails: depRange("@liebstoeckel/thumbnails"),
255
+ },
256
+ orgBrand ?? undefined,
257
+ );
258
+ for (const [rel, content] of Object.entries(files)) {
259
+ await Bun.write(join(dir, rel), content);
260
+ }
261
+ // A deck scaffolded here is authored by this user — trust it so building it never
262
+ // trips the untrusted-deck confirmation gate (see trust.ts).
263
+ await (await import("./trust")).trustDeck(dir);
264
+ return { dir, files: Object.keys(files), brand: orgBrand?.name ?? brand };
265
+ }
266
+
267
+ export const newCommand = defineCommand({
268
+ meta: {
269
+ name: "new",
270
+ description: "scaffold a new deck as ./<name> (or under --dir)",
271
+ },
272
+ args: {
273
+ name: {
274
+ type: "positional",
275
+ required: false,
276
+ description: "deck name (lower-case letters, digits, hyphens)",
277
+ valueHint: "name",
278
+ },
279
+ brand: { type: "string", description: "brand to apply", valueHint: "brand" },
280
+ dir: { type: "string", description: "parent directory for the new deck", valueHint: "parent" },
281
+ "org-brand": {
282
+ type: "boolean",
283
+ default: true,
284
+ description: "apply the logged-in org's default brand",
285
+ negativeDescription: "do not apply the org default brand",
286
+ },
287
+ },
288
+ async run({ args }) {
289
+ const name = args.name;
290
+ if (!name) {
291
+ console.error("usage: liebstoeckel new <name> [--brand <brand>] [--dir <parent>] [--no-org-brand]");
292
+ process.exit(1);
293
+ }
294
+ try {
295
+ const { dir, files } = await scaffold(name, {
296
+ brand: args.brand,
297
+ dir: args.dir,
298
+ noOrgBrand: args.orgBrand === false,
299
+ });
300
+ console.log(`\n✓ created deck "${name}" → ${dir}\n`);
301
+ for (const f of files) console.log(` ${f}`);
302
+ console.log(`\n next:`);
303
+ console.log(` bun install`);
304
+ console.log(` liebstoeckel live ${dir} # or: bun --cwd ${dir} run dev\n`);
305
+ } catch (e) {
306
+ console.error(`✕ ${(e as Error).message}`);
307
+ process.exit(1);
308
+ }
309
+ },
310
+ });
@@ -0,0 +1,124 @@
1
+ import { defineCommand } from "citty";
2
+ import { join } from "node:path";
3
+ import { REGISTRY_ROOT } from "@liebstoeckel/registry";
4
+ import { validateItem, type RegistryIndex, type RegistryItem } from "@liebstoeckel/registry/schema";
5
+
6
+ /**
7
+ * `liebstoeckel registry list|view`, agent-readable discovery over the bundled
8
+ * default registry (ADR 0045). Output is JSON when `--json` is passed OR when stdout
9
+ * is not a TTY (so an agent piping the command always gets structured data), and a
10
+ * compact human view on an interactive terminal.
11
+ *
12
+ * Third-party / namespaced registries (ADR 0041) are not resolved here yet, this
13
+ * reads the bundled `@liebstoeckel` registry directly.
14
+ */
15
+
16
+ // JSON when asked, or when piped (an agent), pretty only on an interactive TTY (ADR 0045).
17
+ const wantsJson = (json: boolean | undefined): boolean => !!json || !process.stdout.isTTY;
18
+
19
+ const readIndex = (): Promise<RegistryIndex> =>
20
+ Bun.file(join(REGISTRY_ROOT, "registry.json")).json() as Promise<RegistryIndex>;
21
+
22
+ async function readItem(name: string): Promise<RegistryItem> {
23
+ const f = Bun.file(join(REGISTRY_ROOT, "items", `${name}.json`));
24
+ if (!(await f.exists())) throw new Error(`registry item "${name}" not found, try \`liebstoeckel registry list\``);
25
+ const item = (await f.json()) as RegistryItem;
26
+ validateItem(item);
27
+ return item;
28
+ }
29
+
30
+ /** A catalog row enriched with the headline agent-facing meta, so `list --json` gives
31
+ * the agent the whole catalog + data shapes in a single call (fewer round-trips). */
32
+ interface CatalogRow {
33
+ name: string;
34
+ type: string;
35
+ description?: string;
36
+ exports?: string;
37
+ dataShape?: string;
38
+ }
39
+
40
+ async function catalog(): Promise<CatalogRow[]> {
41
+ const index = await readIndex();
42
+ return Promise.all(
43
+ index.items.map(async (i) => {
44
+ const item = await readItem(i.name);
45
+ return {
46
+ name: i.name,
47
+ type: i.type.replace(/^registry:/, ""),
48
+ description: i.description,
49
+ exports: item.meta?.exports,
50
+ dataShape: item.meta?.dataShape,
51
+ };
52
+ }),
53
+ );
54
+ }
55
+
56
+ function printList(rows: CatalogRow[]): void {
57
+ console.log(`\n${rows.length} registry items (\`liebstoeckel add <name>\` to scaffold):\n`);
58
+ const w = Math.max(...rows.map((r) => r.name.length));
59
+ for (const r of rows) {
60
+ console.log(` ${r.name.padEnd(w)} ${r.type.padEnd(9)} ${r.dataShape ? `data: ${r.dataShape}` : r.description ?? ""}`);
61
+ }
62
+ console.log(`\n view one: liebstoeckel registry view <name>\n`);
63
+ }
64
+
65
+ function printItem(item: RegistryItem): void {
66
+ const m = item.meta ?? {};
67
+ console.log(`\n${item.name} (${item.type.replace(/^registry:/, "")})`);
68
+ if (item.description) console.log(` ${item.description}`);
69
+ if (m.exports) console.log(`\n exports: ${m.exports}`);
70
+ if (m.props) console.log(` props: ${m.props}`);
71
+ if (m.dataShape) console.log(` data: ${m.dataShape}`);
72
+ if (m.example) console.log(` example: ${m.example}`);
73
+ if (item.dependencies?.length) console.log(`\n npm deps: ${item.dependencies.join(", ")}`);
74
+ if (item.registryDependencies?.length) console.log(` also adds: ${item.registryDependencies.join(", ")}`);
75
+ console.log(`\n scaffold: liebstoeckel add ${item.name}\n`);
76
+ }
77
+
78
+ const registryListCommand = defineCommand({
79
+ meta: { name: "list", description: "list the chart/component registry" },
80
+ args: { json: { type: "boolean", description: "machine-readable JSON (default when piped)" } },
81
+ async run({ args }) {
82
+ const json = wantsJson(args.json);
83
+ try {
84
+ const rows = await catalog();
85
+ if (json) console.log(JSON.stringify(rows, null, 2));
86
+ else printList(rows);
87
+ } catch (e) {
88
+ if (json) console.log(JSON.stringify({ error: (e as Error).message }));
89
+ else console.error(`✕ ${(e as Error).message}`);
90
+ process.exit(1);
91
+ }
92
+ },
93
+ });
94
+
95
+ const registryViewCommand = defineCommand({
96
+ meta: { name: "view", description: "show one registry item's details" },
97
+ args: {
98
+ name: { type: "positional", required: false, description: "registry item name", valueHint: "name" },
99
+ json: { type: "boolean", description: "machine-readable JSON (default when piped)" },
100
+ },
101
+ async run({ args }) {
102
+ const json = wantsJson(args.json);
103
+ if (!args.name) {
104
+ console.error("usage: liebstoeckel registry view <name> [--json]");
105
+ process.exit(1);
106
+ }
107
+ try {
108
+ const item = await readItem(args.name);
109
+ if (json) console.log(JSON.stringify(item, null, 2));
110
+ else printItem(item);
111
+ } catch (e) {
112
+ if (json) console.log(JSON.stringify({ error: (e as Error).message }));
113
+ else console.error(`✕ ${(e as Error).message}`);
114
+ process.exit(1);
115
+ }
116
+ },
117
+ });
118
+
119
+ /** `liebstoeckel registry list|view`, agent-readable discovery (ADR 0045). */
120
+ export const registryCommand = defineCommand({
121
+ meta: { name: "registry", description: "browse the chart/component registry (JSON for agents)" },
122
+ subCommands: { list: registryListCommand, view: registryViewCommand },
123
+ default: "list",
124
+ });
package/src/skill.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { defineCommand } from "citty";
2
+ import { fileURLToPath } from "node:url";
3
+ import { existsSync, readdirSync } from "node:fs";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+
7
+ /**
8
+ * `liebstoeckel skill install`, materialize the bundled `liebstoeckel-deck` Skill
9
+ * into a deck for whichever agents the user runs (ADR 0044). One canonical source
10
+ * (shipped in this package, version-pinned to the CLI) is placed at each agent's
11
+ * expected path, and a universal `AGENTS.md` block is written as the fallback for
12
+ * agents with weak or no skill support.
13
+ */
14
+
15
+ const SKILL_NAME = "liebstoeckel-deck";
16
+
17
+ // Canonical source: packages/cli/skill/ (this file is packages/cli/src/skill.ts).
18
+ const SKILL_SRC = fileURLToPath(new URL("../skill", import.meta.url));
19
+ const PKG_JSON = fileURLToPath(new URL("../package.json", import.meta.url));
20
+
21
+ type Target = "claude" | "codex" | "cursor" | "gemini";
22
+ const ALL_TARGETS: Target[] = ["claude", "codex", "cursor", "gemini"];
23
+
24
+ // Where each agent looks for a skill folder, relative to the deck root.
25
+ export const SKILL_DIR: Record<Target, string> = {
26
+ claude: join(".claude", "skills", SKILL_NAME),
27
+ codex: join(".agents", "skills", SKILL_NAME), // also read by Gemini's shared path
28
+ cursor: join(".cursor", "skills", SKILL_NAME),
29
+ gemini: join(".gemini", "skills", SKILL_NAME),
30
+ };
31
+
32
+ export async function cliVersion(): Promise<string> {
33
+ try {
34
+ return ((await Bun.file(PKG_JSON).json()) as { version?: string }).version ?? "0.0.0";
35
+ } catch {
36
+ return "0.0.0";
37
+ }
38
+ }
39
+
40
+ /** The skill's payload files (everything under skill/ except the AGENTS.md template). */
41
+ function skillFiles(): string[] {
42
+ const out = ["SKILL.md"];
43
+ const refs = join(SKILL_SRC, "references");
44
+ if (existsSync(refs)) for (const f of readdirSync(refs)) out.push(join("references", f));
45
+ return out;
46
+ }
47
+
48
+ /** Copy the skill into one destination, stamping the CLI version into SKILL.md. */
49
+ async function writeSkill(destRoot: string, version: string): Promise<void> {
50
+ for (const rel of skillFiles()) {
51
+ let content = await Bun.file(join(SKILL_SRC, rel)).text();
52
+ if (rel === "SKILL.md") content = content.replace(/(\n\s*version:\s*).*/, `$1${version}`);
53
+ const dest = join(destRoot, rel);
54
+ await mkdir(dirname(dest), { recursive: true });
55
+ await Bun.write(dest, content);
56
+ }
57
+ }
58
+
59
+ const AGENTS_BLOCK_RE = /<!-- liebstoeckel:start -->[\s\S]*?<!-- liebstoeckel:end -->/;
60
+
61
+ /** Merge the managed liebstoeckel block into an AGENTS.md body: replace it in place
62
+ * if present (idempotent), else append it. Pure, the unit-test anchor. */
63
+ export function mergeAgentsBlock(existing: string, block: string): string {
64
+ const b = block.trim();
65
+ if (AGENTS_BLOCK_RE.test(existing)) return existing.replace(AGENTS_BLOCK_RE, b);
66
+ return (existing.trim() ? `${existing.trim()}\n\n` : "") + b + "\n";
67
+ }
68
+
69
+ /** Write or replace the managed liebstoeckel block in the deck's AGENTS.md. */
70
+ async function writeAgentsBlock(deckDir: string): Promise<void> {
71
+ const block = await Bun.file(join(SKILL_SRC, "AGENTS.md")).text();
72
+ const path = join(deckDir, "AGENTS.md");
73
+ const existing = existsSync(path) ? await Bun.file(path).text() : "";
74
+ await Bun.write(path, mergeAgentsBlock(existing, block));
75
+ }
76
+
77
+ /** A small Cursor rule pointing at the placed skill (Cursor reads `.cursor/rules/*.mdc`). */
78
+ async function writeCursorRule(deckDir: string): Promise<void> {
79
+ const mdc = `---
80
+ description: Use the liebstoeckel-deck skill to create or edit presentation decks (liebstoeckel CLI).
81
+ alwaysApply: false
82
+ ---
83
+ When working on liebstoeckel presentation decks, follow ${join(".cursor", "skills", SKILL_NAME, "SKILL.md")}.
84
+ Discover components with \`liebstoeckel registry list --json\`; validate with \`liebstoeckel build --check\`.
85
+ `;
86
+ const dest = join(deckDir, ".cursor", "rules", "liebstoeckel.mdc");
87
+ await mkdir(dirname(dest), { recursive: true });
88
+ await Bun.write(dest, mdc);
89
+ }
90
+
91
+ async function applySkill(sub: "install" | "update", deckDir: string, targetArg: string | undefined): Promise<void> {
92
+ let targets: Target[];
93
+ if (sub === "update") {
94
+ // Refresh whatever is already installed (the AGENTS.md block is always
95
+ // rewritten); installing NEW agent paths stays `install`'s job.
96
+ targets = ALL_TARGETS.filter((t) => existsSync(join(deckDir, SKILL_DIR[t])));
97
+ if (targets.length === 0 && !existsSync(join(deckDir, "AGENTS.md"))) {
98
+ console.error(`✕ no liebstoeckel skill installed in ${deckDir}, run: liebstoeckel skill install`);
99
+ process.exit(1);
100
+ }
101
+ } else {
102
+ const tArg = targetArg ?? "all";
103
+ targets =
104
+ !tArg || tArg === "all" ? ALL_TARGETS : (tArg.split(",").filter((t): t is Target => (ALL_TARGETS as string[]).includes(t)));
105
+ if (targets.length === 0) {
106
+ console.error(`unknown --target "${tArg}", use one or more of: ${ALL_TARGETS.join(", ")}, or all`);
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ const version = await cliVersion();
112
+ const written: string[] = [];
113
+ try {
114
+ // de-dupe destination dirs (some agents share a path)
115
+ const seen = new Set<string>();
116
+ for (const t of targets) {
117
+ const destRoot = join(deckDir, SKILL_DIR[t]);
118
+ if (!seen.has(destRoot)) {
119
+ await writeSkill(destRoot, version);
120
+ seen.add(destRoot);
121
+ written.push(SKILL_DIR[t] + "/");
122
+ }
123
+ if (t === "cursor") {
124
+ await writeCursorRule(deckDir);
125
+ written.push(join(".cursor", "rules", "liebstoeckel.mdc"));
126
+ }
127
+ }
128
+ // AGENTS.md is the universal fallback, always write it
129
+ await writeAgentsBlock(deckDir);
130
+ written.push("AGENTS.md");
131
+
132
+ console.log(`\n✓ ${sub === "update" ? "updated" : "installed"} the liebstoeckel-deck skill (v${version}) → ${deckDir}\n`);
133
+ for (const w of written) console.log(` ${w}`);
134
+ console.log(`\n targets: ${targets.join(", ")}\n`);
135
+ } catch (e) {
136
+ console.error(`✕ ${(e as Error).message}`);
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ const skillInstallCommand = defineCommand({
142
+ meta: { name: "install", description: "install the agent skill (SKILL.md + AGENTS.md) into a deck" },
143
+ args: {
144
+ target: {
145
+ type: "string",
146
+ default: "all",
147
+ description: "agents to target: claude, codex, cursor, gemini, all (comma-separated)",
148
+ valueHint: "claude|codex|cursor|gemini|all",
149
+ },
150
+ dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
151
+ },
152
+ run: ({ args }) => applySkill("install", args.dir ?? ".", args.target),
153
+ });
154
+
155
+ const skillUpdateCommand = defineCommand({
156
+ meta: { name: "update", description: "refresh the installed agent skill to the running CLI version" },
157
+ args: {
158
+ dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
159
+ },
160
+ run: ({ args }) => applySkill("update", args.dir ?? ".", undefined),
161
+ });
162
+
163
+ /** `liebstoeckel skill install|update`, manage the bundled deck-authoring Skill. */
164
+ export const skillCommand = defineCommand({
165
+ meta: { name: "skill", description: "install/refresh the agent skill for deck authoring" },
166
+ subCommands: { install: skillInstallCommand, update: skillUpdateCommand },
167
+ });
@@ -0,0 +1,8 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /** Does this token name a deck the CLI should act on?, a `.html` path, or any
5
+ * existing path. Used by the `liebstoeckel <deck>` → `live` shorthand and by
6
+ * `licenses` (which accepts a built `.html` or a source dir). */
7
+ export const looksLikeDeck = (s: string | undefined): boolean =>
8
+ !!s && (/\.html?$/i.test(s) || existsSync(resolve(s)));
package/src/trust.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Build-time trust gate. A liebstoeckel deck is *code*: building it runs the deck's
2
+ // build-time modules (Bun macros such as the animated-code macro, build plugins) in this
3
+ // process with full filesystem and network access. Building a deck you did not write —
4
+ // cloned, downloaded, scaffolded by an agent — is therefore arbitrary code execution on
5
+ // your machine. This gate requires an explicit one-time confirmation before the first
6
+ // build of an unfamiliar deck.
7
+ //
8
+ // The trust ledger lives in the user config dir (NOT inside the deck), keyed by absolute
9
+ // path, so a shared/cloned deck can never ship a forged "trusted" marker of its own. A
10
+ // deck scaffolded here via `liebstoeckel new` is recorded as trusted automatically, so an
11
+ // author is never prompted for decks they created on this machine.
12
+ import { mkdir } from "node:fs/promises";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { CONFIG_DIR } from "./creds";
15
+
16
+ /** Ledger path. `LIEBSTOECKEL_TRUST_FILE` overrides it (a test seam; resolved per call
17
+ * so tests can point at a temp file without touching the real user config). */
18
+ function trustFile(): string {
19
+ return process.env.LIEBSTOECKEL_TRUST_FILE || join(CONFIG_DIR, "trusted-decks.json");
20
+ }
21
+
22
+ interface TrustLedger {
23
+ paths: string[];
24
+ }
25
+
26
+ async function readLedger(): Promise<TrustLedger> {
27
+ try {
28
+ const j = JSON.parse(await Bun.file(trustFile()).text()) as Partial<TrustLedger>;
29
+ return { paths: Array.isArray(j.paths) ? j.paths : [] };
30
+ } catch {
31
+ return { paths: [] };
32
+ }
33
+ }
34
+
35
+ /** Record `dir` as a deck the author trusts to execute its build-time code here. */
36
+ export async function trustDeck(dir: string): Promise<void> {
37
+ const abs = resolve(dir);
38
+ const led = await readLedger();
39
+ if (led.paths.includes(abs)) return;
40
+ led.paths.push(abs);
41
+ const file = trustFile();
42
+ await mkdir(dirname(file), { recursive: true });
43
+ await Bun.write(file, JSON.stringify(led, null, 2));
44
+ }
45
+
46
+ /** Has `dir` been trusted before (or scaffolded here)? */
47
+ export async function isDeckTrusted(dir: string): Promise<boolean> {
48
+ return (await readLedger()).paths.includes(resolve(dir));
49
+ }
50
+
51
+ export interface TrustGateOptions {
52
+ /** Pre-approved out of band (`--trust` flag or `LIEBSTOECKEL_TRUST_BUILD=1`). */
53
+ preapproved?: boolean;
54
+ /** Interactive confirm; returns true to trust. Injected so the gate stays testable.
55
+ * Omit (e.g. non-TTY) to refuse rather than prompt. */
56
+ confirm?: (dir: string) => boolean;
57
+ }
58
+
59
+ /** Decide whether a build of `dir` may proceed, remembering a yes. Returns true to build,
60
+ * false to abort. Trusted-or-scaffolded decks pass silently; an unfamiliar deck needs
61
+ * `preapproved` or a `confirm` that returns true. */
62
+ export async function ensureBuildTrust(dir: string, opts: TrustGateOptions): Promise<boolean> {
63
+ if (await isDeckTrusted(dir)) return true;
64
+ if (opts.preapproved) {
65
+ await trustDeck(dir);
66
+ return true;
67
+ }
68
+ if (opts.confirm && opts.confirm(resolve(dir))) {
69
+ await trustDeck(dir);
70
+ return true;
71
+ }
72
+ return false;
73
+ }