portable-agent-layer 0.28.1 → 0.29.0

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.
Files changed (39) hide show
  1. package/assets/skills/presentation/README.md +21 -0
  2. package/assets/skills/presentation/SKILL.md +172 -0
  3. package/assets/skills/presentation/demo/slides/001-title.md +5 -0
  4. package/assets/skills/presentation/demo/slides/002-agenda.md +13 -0
  5. package/assets/skills/presentation/demo/slides/003-section-text-heavy.md +3 -0
  6. package/assets/skills/presentation/demo/slides/004-content.md +12 -0
  7. package/assets/skills/presentation/demo/slides/005-two-column.md +22 -0
  8. package/assets/skills/presentation/demo/slides/006-quote.md +4 -0
  9. package/assets/skills/presentation/demo/slides/007-section-structured.md +3 -0
  10. package/assets/skills/presentation/demo/slides/008-table.md +11 -0
  11. package/assets/skills/presentation/demo/slides/009-comparison.md +25 -0
  12. package/assets/skills/presentation/demo/slides/010-image-text.md +20 -0
  13. package/assets/skills/presentation/demo/slides/011-code.md +19 -0
  14. package/assets/skills/presentation/demo/slides/012-closing.md +5 -0
  15. package/assets/skills/presentation/template/README.md +15 -0
  16. package/assets/skills/presentation/template/slides/001-title.md +5 -0
  17. package/assets/skills/presentation/template/slides/002-content.md +6 -0
  18. package/assets/skills/presentation/template/slides/003-closing.md +3 -0
  19. package/assets/skills/presentation/theme-base/base.css +167 -0
  20. package/assets/skills/presentation/theme-base/layouts.css +216 -0
  21. package/assets/skills/presentation/theme-base/skeleton.html +53 -0
  22. package/assets/skills/presentation/tools/build.ts +160 -0
  23. package/assets/skills/presentation/tools/lib/inline.ts +35 -0
  24. package/assets/skills/presentation/tools/lib/paths.ts +16 -0
  25. package/assets/skills/presentation/tools/lib/registry.ts +59 -0
  26. package/assets/skills/presentation/tools/list-templates.ts +21 -0
  27. package/assets/skills/presentation/tools/new-deck.ts +101 -0
  28. package/assets/skills/presentation/tools/present.ts +70 -0
  29. package/assets/skills/presentation/tools/setup-template.ts +351 -0
  30. package/assets/skills/presentation/vendor/reveal/plugin/highlight/highlight.js +5 -0
  31. package/assets/skills/presentation/vendor/reveal/plugin/highlight/monokai.css +71 -0
  32. package/assets/skills/presentation/vendor/reveal/plugin/markdown/markdown.js +7 -0
  33. package/assets/skills/presentation/vendor/reveal/plugin/notes/notes.js +1 -0
  34. package/assets/skills/presentation/vendor/reveal/reveal.css +8 -0
  35. package/assets/skills/presentation/vendor/reveal/reveal.js +9 -0
  36. package/assets/templates/settings.claude.json +20 -0
  37. package/package.json +1 -1
  38. package/src/hooks/CompactRecover.ts +105 -0
  39. package/src/hooks/PreCompactPersist.ts +86 -0
@@ -0,0 +1,216 @@
1
+ /* presentation skill — theme-base/layouts.css
2
+ * Per-layout styling via [data-layout="..."] attribute selectors.
3
+ * 11 layouts. Brand-neutral — templates override colours via --brand-* vars. */
4
+
5
+ /* Reveal default is centered text; we want left for content slides. */
6
+ .reveal .slides > section { text-align: left; }
7
+
8
+ /* ── 1. title ── Cover slide */
9
+ .reveal section[data-layout="title"] {
10
+ text-align: center !important;
11
+ display: flex !important;
12
+ flex-direction: column;
13
+ justify-content: center;
14
+ align-items: center;
15
+ padding: var(--space-5) var(--space-4);
16
+ }
17
+ .reveal section[data-layout="title"] h1 {
18
+ font-size: 3.0em;
19
+ margin-bottom: var(--space-2);
20
+ color: var(--brand-primary);
21
+ }
22
+ .reveal section[data-layout="title"] h2 {
23
+ font-size: 1.4em;
24
+ font-weight: 400;
25
+ color: var(--brand-muted);
26
+ margin-bottom: var(--space-4);
27
+ }
28
+ .reveal section[data-layout="title"]::before {
29
+ content: '';
30
+ background: var(--brand-logo) no-repeat center / contain;
31
+ width: 320px;
32
+ height: 96px;
33
+ margin-bottom: var(--space-4);
34
+ display: block;
35
+ }
36
+
37
+ /* ── 2. section ── Section divider, full-bleed */
38
+ .reveal section[data-layout="section"] {
39
+ background: var(--brand-primary);
40
+ color: #fff;
41
+ text-align: left !important;
42
+ display: flex !important;
43
+ flex-direction: column;
44
+ justify-content: center;
45
+ padding: var(--space-5);
46
+ }
47
+ .reveal section[data-layout="section"] h1 {
48
+ color: #fff;
49
+ font-size: 2.8em;
50
+ font-weight: 700;
51
+ margin-bottom: var(--space-2);
52
+ }
53
+ .reveal section[data-layout="section"] h2,
54
+ .reveal section[data-layout="section"] h3 {
55
+ color: rgba(255,255,255,0.7);
56
+ font-size: 1.3em;
57
+ font-weight: 400;
58
+ margin-bottom: var(--space-2);
59
+ }
60
+ .reveal section[data-layout="section"] p {
61
+ color: rgba(255,255,255,0.85);
62
+ font-size: 1.1em;
63
+ }
64
+ .reveal section[data-layout="section"]::after { display: none !important; }
65
+
66
+ /* ── 3. content ── default; no overrides needed */
67
+
68
+ /* ── 4. two-column ── */
69
+ .reveal section[data-layout="two-column"] .col-left,
70
+ .reveal section[data-layout="two-column"] .col-right {
71
+ display: inline-block;
72
+ width: 47%;
73
+ vertical-align: top;
74
+ font-size: 0.9em;
75
+ }
76
+ .reveal section[data-layout="two-column"] .col-left { padding-right: var(--space-3); }
77
+ .reveal section[data-layout="two-column"] .col-right {
78
+ padding-left: var(--space-3);
79
+ border-left: 2px solid var(--brand-divider);
80
+ }
81
+
82
+ /* ── 5. image-text ── */
83
+ .reveal section[data-layout="image-text"] .image,
84
+ .reveal section[data-layout="image-text"] .text {
85
+ display: inline-block;
86
+ width: 47%;
87
+ vertical-align: middle;
88
+ }
89
+ .reveal section[data-layout="image-text"] .image { padding-right: var(--space-3); }
90
+ .reveal section[data-layout="image-text"] .image img,
91
+ .reveal section[data-layout="image-text"] .image svg {
92
+ width: 100%; height: auto; max-height: 60vh; object-fit: contain;
93
+ border-radius: 4px;
94
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
95
+ display: block;
96
+ }
97
+ .reveal section[data-layout="image-text"] .text { padding-left: var(--space-3); }
98
+
99
+ /* ── 6. quote ── */
100
+ .reveal section[data-layout="quote"] {
101
+ text-align: center !important;
102
+ display: flex !important;
103
+ flex-direction: column;
104
+ justify-content: center;
105
+ padding: var(--space-5);
106
+ }
107
+ .reveal section[data-layout="quote"] blockquote {
108
+ border-left: none;
109
+ background: transparent;
110
+ font-size: 1.4em;
111
+ font-style: italic;
112
+ color: var(--brand-fg);
113
+ padding: 0;
114
+ margin: 0 auto;
115
+ max-width: 80%;
116
+ position: relative;
117
+ }
118
+ .reveal section[data-layout="quote"] blockquote::before {
119
+ content: '"';
120
+ font-family: Georgia, serif;
121
+ font-size: 4em;
122
+ color: var(--brand-accent);
123
+ position: absolute;
124
+ top: -0.5em;
125
+ left: -0.4em;
126
+ line-height: 1;
127
+ }
128
+
129
+ /* ── 7. closing ── */
130
+ .reveal section[data-layout="closing"] {
131
+ text-align: center !important;
132
+ display: flex !important;
133
+ flex-direction: column;
134
+ justify-content: center;
135
+ align-items: center;
136
+ padding: var(--space-5) var(--space-4);
137
+ background: var(--brand-primary);
138
+ color: #fff;
139
+ }
140
+ .reveal section[data-layout="closing"] h1 {
141
+ color: #fff;
142
+ font-size: 3.4em;
143
+ margin-bottom: var(--space-2);
144
+ }
145
+ .reveal section[data-layout="closing"] h2 {
146
+ color: rgba(255,255,255,0.85);
147
+ font-weight: 400;
148
+ font-size: 1.4em;
149
+ }
150
+ .reveal section[data-layout="closing"] p { color: rgba(255,255,255,0.85); }
151
+ .reveal section[data-layout="closing"]::after { display: none !important; }
152
+
153
+ /* ── 8. agenda ── */
154
+ .reveal section[data-layout="agenda"] ol {
155
+ margin: var(--space-2) 0 0 0;
156
+ padding-left: 0;
157
+ list-style: none;
158
+ counter-reset: agenda;
159
+ }
160
+ .reveal section[data-layout="agenda"] ol li {
161
+ counter-increment: agenda;
162
+ font-size: 0.85em;
163
+ padding: 0.6rem 0;
164
+ border-bottom: 1px solid var(--brand-divider);
165
+ position: relative;
166
+ padding-left: 2.6em;
167
+ margin-bottom: 0;
168
+ }
169
+ .reveal section[data-layout="agenda"] ol li::before {
170
+ content: counter(agenda, decimal-leading-zero);
171
+ position: absolute;
172
+ left: 0;
173
+ top: 0.6rem;
174
+ color: var(--brand-accent);
175
+ font-weight: 700;
176
+ font-size: 1.0em;
177
+ font-family: var(--font-display);
178
+ letter-spacing: 0.05em;
179
+ }
180
+
181
+ /* ── 9. table ── styling already in base; just ensure breathing room */
182
+ .reveal section[data-layout="table"] table {
183
+ margin-top: var(--space-3);
184
+ }
185
+
186
+ /* ── 10. comparison ── */
187
+ .reveal section[data-layout="comparison"] .compare {
188
+ display: flex;
189
+ gap: var(--space-3);
190
+ margin-top: var(--space-3);
191
+ align-items: stretch;
192
+ }
193
+ .reveal section[data-layout="comparison"] .option {
194
+ flex: 1;
195
+ background: var(--brand-surface);
196
+ padding: var(--space-3);
197
+ border-radius: 6px;
198
+ border-top: 4px solid var(--brand-accent);
199
+ font-size: 0.85em;
200
+ }
201
+ .reveal section[data-layout="comparison"] .option:nth-child(1) { border-top-color: var(--brand-primary); }
202
+ .reveal section[data-layout="comparison"] .option p:first-child strong:first-child,
203
+ .reveal section[data-layout="comparison"] .option > strong:first-child {
204
+ display: block;
205
+ font-size: 1.3em;
206
+ margin-bottom: var(--space-2);
207
+ color: var(--brand-primary);
208
+ }
209
+
210
+ /* ── 11. code ── code-focused; pre fills more vertical space */
211
+ .reveal section[data-layout="code"] pre {
212
+ width: 100%;
213
+ max-height: 65vh;
214
+ margin: var(--space-2) 0;
215
+ }
216
+ .reveal section[data-layout="code"] pre code { font-size: 0.9em; }
@@ -0,0 +1,53 @@
1
+ <!doctype html>
2
+ <html lang="{{LANG}}">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{TITLE}}</title>
7
+ {{FONTS_LINK}}
8
+ <style>{{REVEAL_CSS}}</style>
9
+ <style>{{HIGHLIGHT_CSS}}</style>
10
+ <style>{{BASE_CSS}}</style>
11
+ <style>{{LAYOUTS_CSS}}</style>
12
+ <style>{{TEMPLATE_CSS}}</style>
13
+ <style>:root { --brand-logo: {{LOGO_DATA_URI}}; }</style>
14
+ {{DECK_OVERRIDES_CSS}}
15
+ </head>
16
+ <body>
17
+ <div class="reveal">
18
+ <div class="slides">
19
+ <section data-markdown
20
+ data-separator="^---$"
21
+ data-separator-vertical="^--$"
22
+ data-separator-notes="^Note:">
23
+ <textarea data-template>
24
+ {{CONTENT_MD}}
25
+ </textarea>
26
+ </section>
27
+ </div>
28
+ </div>
29
+ <script>{{REVEAL_JS}}</script>
30
+ <script>{{MARKDOWN_PLUGIN_JS}}</script>
31
+ <script>{{HIGHLIGHT_PLUGIN_JS}}</script>
32
+ <script>{{NOTES_PLUGIN_JS}}</script>
33
+ <script>
34
+ Reveal.initialize({
35
+ hash: true,
36
+ width: {{WIDTH}},
37
+ height: {{HEIGHT}},
38
+ margin: 0.05,
39
+ controls: false,
40
+ progress: true,
41
+ slideNumber: 'c/t',
42
+ plugins: [RevealMarkdown, RevealHighlight, RevealNotes],
43
+ markdown: { smartypants: true }
44
+ }).then(() => {
45
+ // Re-parent Reveal's .slide-number element into .slides so it shares the canvas
46
+ // coordinate system with the logo (.slides::after) and moves with `margin` changes.
47
+ const sn = document.querySelector('.reveal > .slide-number');
48
+ const slides = document.querySelector('.reveal .slides');
49
+ if (sn && slides) slides.appendChild(sn);
50
+ });
51
+ </script>
52
+ </body>
53
+ </html>
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env bun
2
+ // presentation skill — build a deck folder to a single self-contained HTML.
3
+ //
4
+ // Usage:
5
+ // bun build.ts <deck-dir>
6
+
7
+ import { constants as fsConst } from "node:fs";
8
+ import { access, mkdir, readdir, writeFile } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ import { dataUri, escapeForTextarea, readText } from "./lib/inline";
11
+ import { THEME_BASE, VENDOR_REVEAL } from "./lib/paths";
12
+ import { getTemplate } from "./lib/registry";
13
+
14
+ const ASPECTS: Record<string, [number, number]> = {
15
+ "16:9": [1920, 1080],
16
+ "16:10": [1920, 1200],
17
+ "4:3": [1440, 1080],
18
+ };
19
+
20
+ // Minimal YAML reader — only handles flat key: value lines (sufficient for slides.config.yml + template.yml).
21
+ function parseSimpleYaml(s: string): Record<string, string> {
22
+ const out: Record<string, string> = {};
23
+ for (const raw of s.split("\n")) {
24
+ const line = raw.replace(/\s+#.*$/, "").trim();
25
+ if (!line || line.startsWith("#")) continue;
26
+ const m = /^([a-zA-Z_][\w-]*):\s*(.*?)\s*$/.exec(line);
27
+ if (!m) continue;
28
+ let v = m[2];
29
+ if (
30
+ (v.startsWith('"') && v.endsWith('"')) ||
31
+ (v.startsWith("'") && v.endsWith("'"))
32
+ ) {
33
+ try {
34
+ v = JSON.parse(v.replace(/^'(.*)'$/, '"$1"'));
35
+ } catch {
36
+ v = v.slice(1, -1);
37
+ }
38
+ }
39
+ out[m[1]] = v;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ async function exists(p: string): Promise<boolean> {
45
+ try {
46
+ await access(p, fsConst.F_OK);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function main() {
54
+ const argv = process.argv.slice(2);
55
+ if (argv.length === 0) {
56
+ console.error("usage: build.ts <deck-dir>");
57
+ process.exit(1);
58
+ }
59
+ const deckDir = resolve(argv[0]);
60
+
61
+ const cfgPath = join(deckDir, "slides.config.yml");
62
+ if (!(await exists(cfgPath))) {
63
+ console.error(`slides.config.yml not found at ${cfgPath}`);
64
+ process.exit(1);
65
+ }
66
+ const cfg = parseSimpleYaml(await readText(cfgPath));
67
+ const templateName = cfg.template;
68
+ if (!templateName) {
69
+ console.error("slides.config.yml missing 'template'");
70
+ process.exit(1);
71
+ }
72
+
73
+ const template = await getTemplate(templateName);
74
+ const tplYml = parseSimpleYaml(await readText(join(template.path, "template.yml")));
75
+ const tplCss = await readText(join(template.path, "template.css"));
76
+ const logoFile = join(template.path, tplYml.logo || "logo.svg");
77
+ const logoUri = await dataUri(logoFile);
78
+
79
+ const aspect = cfg.aspect || tplYml.aspect || "16:9";
80
+ const [width, height] = ASPECTS[aspect] || ASPECTS["16:9"];
81
+
82
+ const baseCss = await readText(join(THEME_BASE, "base.css"));
83
+ const layoutsCss = await readText(join(THEME_BASE, "layouts.css"));
84
+ const skeleton = await readText(join(THEME_BASE, "skeleton.html"));
85
+ const revealCss = await readText(join(VENDOR_REVEAL, "reveal.css"));
86
+ const highlightCss = await readText(
87
+ join(VENDOR_REVEAL, "plugin", "highlight", "monokai.css")
88
+ );
89
+ const revealJs = await readText(join(VENDOR_REVEAL, "reveal.js"));
90
+ const markdownJs = await readText(
91
+ join(VENDOR_REVEAL, "plugin", "markdown", "markdown.js")
92
+ );
93
+ const highlightJs = await readText(
94
+ join(VENDOR_REVEAL, "plugin", "highlight", "highlight.js")
95
+ );
96
+ const notesJs = await readText(join(VENDOR_REVEAL, "plugin", "notes", "notes.js"));
97
+
98
+ // Author surface: either a slides/ folder (preferred — many small files) or a single content.md.
99
+ // slides/ wins if present. Files inside are sorted by filename, then joined with the slide separator.
100
+ const slidesDir = join(deckDir, "slides");
101
+ let contentMd: string;
102
+ if (await exists(slidesDir)) {
103
+ const files = (await readdir(slidesDir)).filter((f) => f.endsWith(".md")).sort();
104
+ if (files.length === 0) {
105
+ console.error(`slides/ is empty at ${slidesDir}`);
106
+ process.exit(1);
107
+ }
108
+ const parts = await Promise.all(files.map((f) => readText(join(slidesDir, f))));
109
+ contentMd = parts.map((p) => p.trim()).join("\n\n---\n\n") + "\n";
110
+ } else {
111
+ contentMd = await readText(join(deckDir, "content.md"));
112
+ }
113
+
114
+ let deckOverridesCss = "";
115
+ const overridesPath = join(deckDir, "overrides.css");
116
+ if (await exists(overridesPath)) {
117
+ deckOverridesCss = `<style>${await readText(overridesPath)}</style>`;
118
+ }
119
+
120
+ const fontsLink =
121
+ tplYml.fonts && tplYml.fonts !== "system"
122
+ ? `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="${tplYml.fonts}">`
123
+ : "";
124
+
125
+ // Use a function for replacements so $-sequences in the inserted text aren't interpreted by JS.
126
+ const subs: Record<string, string> = {
127
+ LANG: cfg.lang || "en",
128
+ TITLE: cfg.title || "Presentation",
129
+ FONTS_LINK: fontsLink,
130
+ REVEAL_CSS: revealCss,
131
+ HIGHLIGHT_CSS: highlightCss,
132
+ BASE_CSS: baseCss,
133
+ LAYOUTS_CSS: layoutsCss,
134
+ TEMPLATE_CSS: tplCss,
135
+ LOGO_DATA_URI: logoUri,
136
+ DECK_OVERRIDES_CSS: deckOverridesCss,
137
+ CONTENT_MD: escapeForTextarea(contentMd),
138
+ REVEAL_JS: revealJs,
139
+ MARKDOWN_PLUGIN_JS: markdownJs,
140
+ HIGHLIGHT_PLUGIN_JS: highlightJs,
141
+ NOTES_PLUGIN_JS: notesJs,
142
+ WIDTH: String(width),
143
+ HEIGHT: String(height),
144
+ };
145
+
146
+ const html = skeleton.replace(/\{\{(\w+)\}\}/g, (_, k) => subs[k] ?? "");
147
+
148
+ const distDir = join(deckDir, "dist");
149
+ await mkdir(distDir, { recursive: true });
150
+ const outPath = join(distDir, "index.html");
151
+ await writeFile(outPath, html, "utf8");
152
+
153
+ const sizeMb = (Buffer.byteLength(html) / 1024 / 1024).toFixed(2);
154
+ console.log(`✓ built ${outPath} (${sizeMb} MB self-contained)`);
155
+ }
156
+
157
+ main().catch((e) => {
158
+ console.error(e?.message ?? e);
159
+ process.exit(1);
160
+ });
@@ -0,0 +1,35 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname } from "node:path";
3
+
4
+ export async function readText(path: string): Promise<string> {
5
+ return readFile(path, "utf8");
6
+ }
7
+
8
+ export async function dataUri(path: string): Promise<string> {
9
+ const ext = extname(path).toLowerCase();
10
+ const mime =
11
+ ext === ".svg"
12
+ ? "image/svg+xml"
13
+ : ext === ".png"
14
+ ? "image/png"
15
+ : ext === ".jpg" || ext === ".jpeg"
16
+ ? "image/jpeg"
17
+ : ext === ".webp"
18
+ ? "image/webp"
19
+ : "application/octet-stream";
20
+
21
+ if (ext === ".svg") {
22
+ // Inline SVGs as URL-encoded text — smaller than base64 and renders crisply at any size.
23
+ const svg = await readFile(path, "utf8");
24
+ const enc = encodeURIComponent(svg).replace(/'/g, "%27").replace(/"/g, "%22");
25
+ return `url("data:${mime};utf8,${enc}")`;
26
+ }
27
+
28
+ const buf = await readFile(path);
29
+ return `url("data:${mime};base64,${buf.toString("base64")}")`;
30
+ }
31
+
32
+ // Escape literal "</textarea>" inside markdown so it doesn't terminate the data-template wrapper.
33
+ export function escapeForTextarea(content: string): string {
34
+ return content.replace(/<\/textarea>/gi, "<\\/textarea>");
35
+ }
@@ -0,0 +1,16 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+
7
+ // Skill source root: this file lives at <skill>/tools/lib/paths.ts → up two = skill root.
8
+ export const SKILL_ROOT = resolve(here, "..", "..");
9
+ export const VENDOR_REVEAL = join(SKILL_ROOT, "vendor", "reveal");
10
+ export const THEME_BASE = join(SKILL_ROOT, "theme-base");
11
+ export const SKILL_TEMPLATE = join(SKILL_ROOT, "template");
12
+ export const SKILL_DEMO = join(SKILL_ROOT, "demo");
13
+
14
+ // User-data — runtime templates registered by setup-template.
15
+ export const TEMPLATES_ROOT = join(homedir(), ".pal-data", "presentation-templates");
16
+ export const REGISTRY_PATH = join(TEMPLATES_ROOT, "registry.json");
@@ -0,0 +1,59 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { REGISTRY_PATH, TEMPLATES_ROOT } from "./paths";
3
+
4
+ export type LogoPlacement = "cover-only" | "footer" | "both" | "none";
5
+ export type Aspect = "16:9" | "4:3" | "16:10";
6
+
7
+ export type TemplateMeta = {
8
+ primary: string;
9
+ accent: string;
10
+ footer: string;
11
+ logoPlacement: LogoPlacement;
12
+ fonts: string;
13
+ aspect: Aspect;
14
+ };
15
+
16
+ export type TemplateEntry = {
17
+ name: string;
18
+ path: string;
19
+ meta: TemplateMeta;
20
+ };
21
+
22
+ export type Registry = Record<string, TemplateEntry>;
23
+
24
+ export async function loadRegistry(): Promise<Registry> {
25
+ try {
26
+ const raw = await readFile(REGISTRY_PATH, "utf8");
27
+ return JSON.parse(raw) as Registry;
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ export async function saveRegistry(reg: Registry): Promise<void> {
34
+ await mkdir(TEMPLATES_ROOT, { recursive: true });
35
+ const tmp = `${REGISTRY_PATH}.tmp`;
36
+ await writeFile(tmp, JSON.stringify(reg, null, 2), "utf8");
37
+ await rename(tmp, REGISTRY_PATH);
38
+ }
39
+
40
+ export async function registerTemplate(entry: TemplateEntry): Promise<void> {
41
+ const reg = await loadRegistry();
42
+ reg[entry.name] = entry;
43
+ await saveRegistry(reg);
44
+ }
45
+
46
+ export async function getTemplate(name: string): Promise<TemplateEntry> {
47
+ const reg = await loadRegistry();
48
+ if (!(name in reg)) {
49
+ throw new Error(
50
+ `template "${name}" not registered. Run setup-template.ts first or check 'list-templates.ts'.`
51
+ );
52
+ }
53
+ return reg[name];
54
+ }
55
+
56
+ export async function listTemplates(): Promise<TemplateEntry[]> {
57
+ const reg = await loadRegistry();
58
+ return Object.values(reg);
59
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+ import { listTemplates } from "./lib/registry";
3
+
4
+ async function main() {
5
+ const all = await listTemplates();
6
+ if (all.length === 0) {
7
+ console.log("no templates registered. run setup-template.ts to add one.");
8
+ return;
9
+ }
10
+ const nameW = Math.max(4, ...all.map((t) => t.name.length));
11
+ console.log(`${"NAME".padEnd(nameW)} PRIMARY ASPECT PATH`);
12
+ for (const t of all) {
13
+ console.log(
14
+ `${t.name.padEnd(nameW)} ${t.meta.primary} ${t.meta.aspect.padEnd(6)} ${t.path}`
15
+ );
16
+ }
17
+ }
18
+ main().catch((e) => {
19
+ console.error(e?.message ?? e);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bun
2
+ // presentation skill — scaffold a new deck folder.
3
+ //
4
+ // Usage:
5
+ // bun new-deck.ts <deck-dir> [--template <name>] [--title "Deck title"] [--showcase]
6
+
7
+ import { constants as fsConst } from "node:fs";
8
+ import { access, copyFile, mkdir, readdir, writeFile } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ import { SKILL_DEMO, SKILL_TEMPLATE } from "./lib/paths";
11
+ import { listTemplates } from "./lib/registry";
12
+
13
+ async function exists(p: string): Promise<boolean> {
14
+ try {
15
+ await access(p, fsConst.F_OK);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ async function main() {
23
+ const argv = process.argv.slice(2);
24
+ if (argv.length === 0) {
25
+ console.error(
26
+ 'usage: new-deck.ts <deck-dir> [--template <name>] [--title "Deck title"] [--showcase]'
27
+ );
28
+ process.exit(1);
29
+ }
30
+ const target = resolve(argv[0]);
31
+ let templateName: string | undefined;
32
+ let title = "New deck";
33
+ let showcase = false;
34
+ for (let i = 1; i < argv.length; i++) {
35
+ if (argv[i] === "--template") templateName = argv[++i];
36
+ else if (argv[i] === "--title") title = argv[++i];
37
+ else if (argv[i] === "--showcase") showcase = true;
38
+ }
39
+
40
+ if (await exists(target)) {
41
+ console.error(`target already exists: ${target}`);
42
+ process.exit(1);
43
+ }
44
+
45
+ const all = await listTemplates();
46
+ if (all.length === 0) {
47
+ console.error("no templates registered. run setup-template.ts first.");
48
+ process.exit(1);
49
+ }
50
+
51
+ if (!templateName) {
52
+ if (all.length === 1) templateName = all[0].name;
53
+ else {
54
+ console.error("multiple templates registered, specify --template <name>:");
55
+ for (const t of all) console.error(` - ${t.name} (${t.meta.primary})`);
56
+ process.exit(1);
57
+ }
58
+ }
59
+ if (!all.find((t) => t.name === templateName)) {
60
+ console.error(`template "${templateName}" not registered`);
61
+ process.exit(1);
62
+ }
63
+
64
+ await mkdir(target, { recursive: true });
65
+ await mkdir(join(target, "assets"), { recursive: true });
66
+
67
+ const cfg = `template: ${templateName}
68
+ title: ${JSON.stringify(title)}
69
+ lang: en
70
+ # aspect: "16:9" # optional, falls back to template default
71
+ `;
72
+ await writeFile(join(target, "slides.config.yml"), cfg, "utf8");
73
+
74
+ // Author surface = slides/ folder of small files (one slide per file). At build time they're
75
+ // concatenated in filename order. One file per slide means a malformed edit only takes down
76
+ // its own slide, and parallel writes don't conflict.
77
+ const sourceSlidesDir = join(showcase ? SKILL_DEMO : SKILL_TEMPLATE, "slides");
78
+ const slidesDir = join(target, "slides");
79
+ await mkdir(slidesDir, { recursive: true });
80
+ const sourceFiles = (await readdir(sourceSlidesDir))
81
+ .filter((f) => f.endsWith(".md"))
82
+ .sort();
83
+ for (const f of sourceFiles) {
84
+ await copyFile(join(sourceSlidesDir, f), join(slidesDir, f));
85
+ }
86
+
87
+ await writeFile(join(target, ".gitignore"), "dist/\n", "utf8");
88
+
89
+ console.log(`✓ deck scaffolded at ${target}`);
90
+ console.log(` template: ${templateName}`);
91
+ console.log(` showcase: ${showcase ? "yes" : "no"}`);
92
+ console.log(` slides: ${sourceFiles.length} file(s) in slides/`);
93
+ console.log(`\nNext:`);
94
+ console.log(` $EDITOR ${slidesDir}/`);
95
+ console.log(` bun ~/.pal/skills/presentation/tools/build.ts ${target}`);
96
+ }
97
+
98
+ main().catch((e) => {
99
+ console.error(e?.message ?? e);
100
+ process.exit(1);
101
+ });