@lemonppt/cli 0.2.0 → 0.2.2

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/SKILL.md CHANGED
@@ -72,14 +72,23 @@ lemonppt export ./goal.json --pptx ./deck.pptx
72
72
 
73
73
  ### 方式三:HTTP API 服务
74
74
 
75
+ 先确保已构建:`corepack pnpm -r build`(若系统未全局安装 pnpm,corepack 会自动提供)。
76
+
75
77
  ```bash
78
+ # 启动真正的 lemonPPT API 服务(默认 3456 端口)
76
79
  lemonppt serve --port 3456
80
+
81
+ # 等价的 server 别名
82
+ lemonppt server --port 3456
77
83
  ```
78
84
 
85
+ 底层启动 `apps/server/dist/index.js`,输出目录默认 `./output`。
86
+
79
87
  接口:
80
88
 
81
89
  - `POST /api/generate-goal`:自然语言 → `goal.json`
82
90
  - `POST /api/render`:`goal.json` → HTML
91
+ - `POST /api/render-editor`:`goal.json` → 单页编辑器渲染数据(`EditorData` JSON),不再生成静态文件
83
92
  - `POST /api/export/pptx`:`goal.json` → PPTX
84
93
  - `POST /api/export/pdf`:`goal.json` → PDF
85
94
  - `POST /api/layout-query`:候选版式查询
@@ -87,6 +96,31 @@ lemonppt serve --port 3456
87
96
  - `POST /api/goal-scaffold`:生成骨架
88
97
  - `POST /api/write-safe-props`:规范化 props
89
98
  - `POST /api/validate-goal-spec`:校验 goal.json
99
+ - `POST /api/stage-media`:上传 base64 图片到服务目录,返回可在 `goal.json` 中引用的 URL
100
+ - `GET /editor`:打开单页编辑器(所有主题共享同一页面,通过 `?theme=theme01` 切换主题)
101
+ - `GET /api/render-editor?theme=theme01`:基于示例 goal 返回指定主题的 `EditorData`
102
+
103
+ 调用示例:
104
+
105
+ ```bash
106
+ curl -X POST http://localhost:3456/api/render \
107
+ -H "Content-Type: application/json" \
108
+ -d @goal.json
109
+
110
+ curl -X POST http://localhost:3456/api/export/pptx \
111
+ -H "Content-Type: application/json" \
112
+ -d @goal.json \
113
+ --output deck.pptx
114
+
115
+ curl -X POST "http://localhost:3456/api/render-editor?theme=theme02" \
116
+ -H "Content-Type: application/json" \
117
+ -d @goal.json
118
+
119
+ # 上传本地图片(base64)
120
+ curl -X POST http://localhost:3456/api/stage-media \
121
+ -H "Content-Type: application/json" \
122
+ -d '{"filename":"logo.png","data":"iVBORw0KGgoAAAANSUhEUg..."}'
123
+ ```
90
124
 
91
125
  ---
92
126
 
@@ -100,6 +134,10 @@ lemonppt serve --port 3456
100
134
  | `theme04` | 玻璃糖果风 | green / yellow / blue / pink + light / dark |
101
135
  | `theme05` | 光谱报告风 | coral / amber / teal / indigo / violet + light / dark |
102
136
  | `theme06` | 深色图谱风 | volt / magma / nebula / nova + light / dark |
137
+ | `theme07` | 冷白金融投资风 | cold-white / warm-gray / ink / navy + light / dark |
138
+ | `theme08` | 曜金黑金机构风 | obsidian-gold / midnight-silver / graphite-rose / forest-gold |
139
+ | `theme09` | 墨韵杂志印刷风 | paper / ink 双基底 + primary / muted |
140
+ | `theme10` | 金指数据指数风 | gold-index / blue-index / green-index |
103
141
 
104
142
  默认主题:`theme01`。
105
143
 
@@ -140,11 +178,11 @@ lemonppt serve --port 3456
140
178
  | `goal` | 是 | 演示目标/背景 |
141
179
  | `audience` | 是 | 受众描述 |
142
180
  | `owner` | 否 | 汇报人 |
143
- | `theme` | 是 | 主题 ID |
181
+ | `theme` | 是 | 主题 ID;也兼容 `themePack` 作为别名 |
144
182
  | `colorScheme` | 否 | 主题专用配色方案,见「可用主题」 |
145
183
  | `appearance` | 否 | `light` / `dark`,部分主题支持 |
146
184
  | `language` | 否 | `zh` 或 `en`,默认 `zh` |
147
- | `pageCount` | | 总页数,必须等于 `slides.length` |
185
+ | `pageCount` | | 总页数;留空时自动等于 `slides.length` |
148
186
  | `randomSeed` | 否 | 随机种子,保证选页可复现 |
149
187
  | `slides` | 是 | 幻灯片数组 |
150
188
 
@@ -152,10 +190,12 @@ lemonppt serve --port 3456
152
190
 
153
191
  | 字段 | 必填 | 说明 |
154
192
  |---|---|---|
155
- | `role` | | 页面角色,见下方「页面角色」 |
193
+ | `role` | | 页面角色,见下方「页面角色」;留空时尝试从 `layout` 推断 |
156
194
  | `layout` | 否 | 具体版式 ID;留空时系统按 role 自动选择 |
157
195
  | `props` | 是 | 该版式所需数据 |
158
196
 
197
+ > **外部 Agent 友好**:HTTP API 与 CLI 均支持 `themePack` 替代 `theme`、`pageCount` 省略、`role` 省略(系统从 `layout` ID 推断),方便被其他 Agent 调用。未提供的必填字段将自动补全。
198
+
159
199
  ---
160
200
 
161
201
  ## 页面角色
@@ -220,8 +260,9 @@ lemonppt render <goal.json> [--out ./output] [--editable]
220
260
  # 导出
221
261
  lemonppt export <goal.json> --pptx out.pptx [--pdf out.pdf]
222
262
 
223
- # 本地服务
263
+ # 本地服务(优先启动 API 服务;未构建时回退到静态预览)
224
264
  lemonppt serve [<dir>] [--port N]
265
+ lemonppt server [<dir>] [--port N]
225
266
 
226
267
  # 主题/版式查询
227
268
  lemonppt list-themes
@@ -235,10 +276,39 @@ lemonppt validate-goal-spec <goal.json>
235
276
 
236
277
  # 安装到 Agent 技能目录
237
278
  lemonppt install-skill [--claude] [--codex] [--cursor] [--all]
279
+ lemonppt install-skill --target ./my-agent/skills/lemonppt
238
280
  ```
239
281
 
240
282
  ---
241
283
 
284
+ ## Skill 包内 npm scripts
285
+
286
+ 将 `skills/lemonppt/` 复制到 Agent 技能目录后,也可以直接进入 skill 目录调用 npm scripts:
287
+
288
+ ```bash
289
+ cd ~/.claude/skills/lemonppt
290
+
291
+ npm run layout:query -- --theme theme01 --role cover --limit 5
292
+ npm run inspect:layout -- theme01_cover_v1
293
+ npm run goal:scaffold -- --title "AI 报告" --goal "..." --theme theme01 --pages 8 --out ./goal.json
294
+ npm run props:safe -- ./goal.json --write
295
+ npm run validate:goal-spec -- ./goal.json
296
+ npm run render:goal -- ./goal.json --out ./output
297
+ npm run validate:deck -- ./output --goal ./goal.json
298
+ npm run validate:goal-copy -- ./goal.json ./output
299
+ npm run export:pptx -- ./goal.json ./deck.pptx
300
+ npm run export:pdf -- ./goal.json ./deck.pdf
301
+ npm run preview:start -- ./output --port 3456
302
+ npm run media:stage -- ./image.png --out ./output/assets
303
+ ```
304
+
305
+ 这些脚本本质上是调用 `lemonppt` CLI 的薄包装,因此首次使用前需要:
306
+
307
+ - 源码模式:确保项目已构建(`corepack pnpm -r build`),skill 包会被写入本地 CLI 路径;
308
+ - 发布模式:执行过 `npm install -g @lemonppt/cli`,或脚本自动通过 `npx @lemonppt/cli` 调用。
309
+
310
+ ---
311
+
242
312
  ## 常见错误处理
243
313
 
244
314
  1. **没有 API Key**:`lemonppt generate` 会 fallback 到内置示例内容,仍可生成完整文件。如需更贴合主题的文案,提供 `--api-key`。
package/dist/cli.js CHANGED
@@ -2,18 +2,22 @@
2
2
  // lemonPPT - AI-powered presentation generation
3
3
  // Copyright (c) 2026 lemonforme
4
4
  // SPDX-License-Identifier: AGPL-3.0-or-later
5
- import { createServer } from 'node:http';
5
+ import { createServer as createHttpServer } from 'node:http';
6
+ import { spawn } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
6
8
  import { stat, readFile } from 'node:fs/promises';
7
- import { extname, join, resolve } from 'node:path';
8
- import { exportGoalToPdf, exportGoalToPptx, generateGoalToFile, inspectLayout, listThemes, queryLayouts, readGoalFromFile, renderGoalToDir, scaffoldGoalToFile, validateGoalSpec, writeSafePropsToFile, } from './index.js';
9
+ import { extname, join, resolve, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { exportGoalToPdf, exportGoalToPptx, generateGoalToFile, inspectLayout, listThemes, queryLayouts, readGoalFromFile, renderGoalToDir, scaffoldGoalToFile, stageMediaToFile, validateDeck, validateGoalCopy, validateGoalSpec, writeSafePropsToFile, } from './index.js';
9
12
  import { installSkill } from './install-skill.js';
10
13
  function printUsage() {
11
14
  console.log(`Usage:
12
15
  lemonppt generate "<input>" [--pages N] [--theme <id>] [--language zh|en] [--out goal.json] [--api-key KEY]
13
16
  lemonppt render <goal.json> [--out ./output] [--editable]
14
17
  lemonppt export <goal.json> --pptx out.pptx [--pdf out.pdf]
15
- lemonppt serve [<dir>] [--port N]
16
- lemonppt install-skill [--claude] [--codex] [--cursor] [--all]
18
+ lemonppt serve [<dir>] [--port N] # start API server if built, else static preview
19
+ lemonppt server [<dir>] [--port N] # alias for serve
20
+ lemonppt install-skill [--claude] [--codex] [--cursor] [--all] [--target <dir>]
17
21
 
18
22
  lemonppt list-themes
19
23
  lemonppt layout-query --theme <id> --role <role> [--keyword K] [--needs-media] [--limit N] [--seed S]
@@ -21,6 +25,9 @@ function printUsage() {
21
25
  lemonppt goal-scaffold --title T --goal G --theme <id> --pages N [--out goal.json]
22
26
  lemonppt write-safe-props <goal.json> [--write]
23
27
  lemonppt validate-goal-spec <goal.json> [--strict]
28
+ lemonppt validate-deck <deckDir> [--goal goal.json]
29
+ lemonppt validate-copy <goal.json> <deckDir>
30
+ lemonppt stage-media <file> [--out ./output]
24
31
  `);
25
32
  }
26
33
  function parseArgs(argv) {
@@ -45,6 +52,31 @@ function parseArgs(argv) {
45
52
  }
46
53
  return { positional, options };
47
54
  }
55
+ const __dirname = dirname(fileURLToPath(import.meta.url));
56
+ const projectRoot = resolve(__dirname, '../../..');
57
+ const apiServerPath = join(projectRoot, 'apps/server/dist/index.js');
58
+ async function startApiServer(dir, port) {
59
+ if (!existsSync(apiServerPath)) {
60
+ throw new Error(`API server not found at ${apiServerPath}. Run 'pnpm -r build' first.`);
61
+ }
62
+ const child = spawn('node', [apiServerPath], {
63
+ stdio: 'inherit',
64
+ env: {
65
+ ...process.env,
66
+ LEMONPPT_PORT: String(port),
67
+ LEMONPPT_OUTPUT_DIR: resolve(dir),
68
+ },
69
+ });
70
+ return new Promise((resolve, reject) => {
71
+ child.on('error', reject);
72
+ child.on('exit', (code) => {
73
+ if (code === 0)
74
+ resolve();
75
+ else
76
+ reject(new Error(`API server exited with code ${code}`));
77
+ });
78
+ });
79
+ }
48
80
  async function serveDir(dir, port) {
49
81
  const root = resolve(dir);
50
82
  const mimeTypes = {
@@ -63,7 +95,7 @@ async function serveDir(dir, port) {
63
95
  '.otf': 'font/otf',
64
96
  '.eot': 'application/vnd.ms-fontobject',
65
97
  };
66
- const server = createServer(async (req, res) => {
98
+ const server = createHttpServer(async (req, res) => {
67
99
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
68
100
  let pathname = decodeURIComponent(url.pathname);
69
101
  if (pathname === '/') {
@@ -175,12 +207,20 @@ async function main() {
175
207
  }
176
208
  break;
177
209
  }
178
- case 'serve': {
210
+ case 'serve':
211
+ case 'server': {
179
212
  const dir = positional[0] || './output';
180
213
  const port = args.options.port ? Number(args.options.port) : 3456;
181
- await serveDir(dir, port);
182
- // 保持进程运行
183
- await new Promise(() => { });
214
+ if (existsSync(apiServerPath)) {
215
+ console.log(`Starting API server on port ${port}...`);
216
+ await startApiServer(dir, port);
217
+ }
218
+ else {
219
+ console.log(`API server not built, falling back to static preview.`);
220
+ await serveDir(dir, port);
221
+ // 保持进程运行
222
+ await new Promise(() => { });
223
+ }
184
224
  break;
185
225
  }
186
226
  case 'list-themes': {
@@ -297,7 +337,52 @@ async function main() {
297
337
  }
298
338
  break;
299
339
  }
340
+ case 'stage-media': {
341
+ const filePath = positional[0];
342
+ if (!filePath) {
343
+ console.error('Error: stage-media requires a file path.');
344
+ process.exit(1);
345
+ }
346
+ const result = await stageMediaToFile({
347
+ filePath,
348
+ outDir: args.options.out || './output',
349
+ });
350
+ console.log(JSON.stringify({ success: true, ...result }, null, 2));
351
+ break;
352
+ }
353
+ case 'validate-deck': {
354
+ const deckDir = positional[0];
355
+ if (!deckDir) {
356
+ console.error('Error: validate-deck requires a deck directory path.');
357
+ process.exit(1);
358
+ }
359
+ const result = await validateDeck({
360
+ deckDir,
361
+ goalPath: args.options.goal,
362
+ });
363
+ console.log(JSON.stringify(result, null, 2));
364
+ if (!result.valid)
365
+ process.exit(1);
366
+ break;
367
+ }
368
+ case 'validate-copy': {
369
+ const goalPath = positional[0];
370
+ const deckDir = positional[1];
371
+ if (!goalPath || !deckDir) {
372
+ console.error('Error: validate-copy requires <goal.json> <deckDir>.');
373
+ process.exit(1);
374
+ }
375
+ const result = await validateGoalCopy({ goalPath, deckDir });
376
+ console.log(JSON.stringify(result, null, 2));
377
+ if (!result.valid)
378
+ process.exit(1);
379
+ break;
380
+ }
300
381
  case 'install-skill': {
382
+ if (args.options.target) {
383
+ await installSkill({ target: args.options.target });
384
+ break;
385
+ }
301
386
  const agents = [];
302
387
  if (args.options.claude)
303
388
  agents.push('claude');
package/dist/index.d.ts CHANGED
@@ -28,6 +28,25 @@ export interface ExportCliOptions {
28
28
  /** 输出文件路径 */
29
29
  outFile: string;
30
30
  }
31
+ export declare function copyThemeAssets(themeId: string, assetsDir: string): Promise<void>;
32
+ export interface StageMediaOptions {
33
+ /** 源文件路径 */
34
+ filePath: string;
35
+ /** 输出目录,默认 ./output */
36
+ outDir?: string;
37
+ }
38
+ export interface StageMediaResult {
39
+ /** 安全化后的文件名 */
40
+ filename: string;
41
+ /** 本地绝对路径 */
42
+ localPath: string;
43
+ /** 相对于输出目录的引用路径 */
44
+ relativePath: string;
45
+ }
46
+ /**
47
+ * 把本地媒体文件复制到输出目录的 media/ 下,供 goal.json 引用。
48
+ */
49
+ export declare function stageMediaToFile(options: StageMediaOptions): Promise<StageMediaResult>;
31
50
  /**
32
51
  * 生成 goal.json 并可选写入文件。
33
52
  */
@@ -38,6 +57,8 @@ export declare function generateGoalToFile(options: GenerateCliOptions): Promise
38
57
  export declare function readGoalFromFile(filePath: string): Promise<DeckGoal>;
39
58
  /**
40
59
  * 渲染 deck 到输出目录。
60
+ * editable 模式下使用单页编辑器架构:复制 server 的 editor.html/editor.js,
61
+ * 内嵌 renderEditorData 生成的 EditorData,资源路径使用相对路径。
41
62
  */
42
63
  export declare function renderGoalToDir(goal: DeckGoal, options?: RenderCliOptions): Promise<{
43
64
  html: string;
@@ -150,3 +171,33 @@ export declare function validateGoalSpec(goalPath: string, strict?: boolean): Pr
150
171
  }[];
151
172
  warnings: string[];
152
173
  }>;
174
+ export interface ValidateDeckOptions {
175
+ /** 渲染输出目录(应包含 index.html) */
176
+ deckDir: string;
177
+ /** 可选的 goal.json 路径,用于比对页数 */
178
+ goalPath?: string;
179
+ }
180
+ export interface ValidateDeckResult {
181
+ valid: boolean;
182
+ slides: number;
183
+ errors: string[];
184
+ warnings: string[];
185
+ }
186
+ /**
187
+ * 校验渲染后的 HTML deck 结构是否完整。
188
+ */
189
+ export declare function validateDeck(options: ValidateDeckOptions): Promise<ValidateDeckResult>;
190
+ export interface ValidateCopyOptions {
191
+ goalPath: string;
192
+ deckDir: string;
193
+ }
194
+ export interface ValidateCopyResult {
195
+ valid: boolean;
196
+ missing: string[];
197
+ checked: number;
198
+ errors: string[];
199
+ }
200
+ /**
201
+ * 校验 goal.json 中的文案是否都出现在渲染后的 HTML 中。
202
+ */
203
+ export declare function validateGoalCopy(options: ValidateCopyOptions): Promise<ValidateCopyResult>;
package/dist/index.js CHANGED
@@ -2,18 +2,19 @@
2
2
  // Copyright (c) 2026 lemonforme
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
  import { generateGoal } from '@lemonppt/agent-prompts';
5
- import { normalizeDeckGoal, validateDeckGoal, validateDeckGoalContent, validateSlideCount } from '@lemonppt/core';
6
- import { exportDeckToPdf, exportDeckToPptx, renderDeck, } from '@lemonppt/renderer';
5
+ import { normalizeDeckGoal, preprocessAgentGoal, validateDeckGoal, validateDeckGoalContent, validateSlideCount } from '@lemonppt/core';
6
+ import { exportDeckToPdf, exportDeckToPptx, renderDeck, renderEditorData, } from '@lemonppt/renderer';
7
7
  import { getTheme, themes } from '@lemonppt/themes';
8
8
  import { getLayout, getLayoutSchema, listLayoutsByRoleAndTheme } from '@lemonppt/templates';
9
9
  import { copyFile, cp, mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
10
11
  import path from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
12
13
  function resolveTheme(themeId) {
13
14
  const id = themeId || 'theme01';
14
15
  return getTheme(id) ? id : 'theme01';
15
16
  }
16
- async function copyThemeAssets(themeId, assetsDir) {
17
+ export async function copyThemeAssets(themeId, assetsDir) {
17
18
  const theme = resolveTheme(themeId);
18
19
  await mkdir(assetsDir, { recursive: true });
19
20
  const cssSource = resolvePackagePath('@lemonppt/themes', 'src', theme, 'styles.css');
@@ -23,14 +24,39 @@ async function copyThemeAssets(themeId, assetsDir) {
23
24
  const fontsSource = resolvePackagePath('@lemonppt/renderer', 'assets', 'fonts');
24
25
  const fontsDest = path.join(assetsDir, 'fonts');
25
26
  await cp(fontsSource, fontsDest, { recursive: true, force: true });
26
- // 复制客户端离线渲染脚本,支持静态文件模式下结构编辑
27
- const clientRenderSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client-render.js');
27
+ // 复制浏览器可执行 bundle(IIFE 格式),支持静态文件模式下结构编辑
28
+ const clientRenderSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client', 'client-render.js');
28
29
  const clientRenderDest = path.join(assetsDir, 'client-render.js');
29
30
  await copyFile(clientRenderSource, clientRenderDest);
31
+ // 复制 ECharts 主题脚本
32
+ const themeEChartsSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client', 'theme-echarts.js');
33
+ const themeEChartsDest = path.join(assetsDir, 'theme-echarts.js');
34
+ await copyFile(themeEChartsSource, themeEChartsDest);
30
35
  // 复制 jQuery,供编辑器初始化自定义滚动条样式类
31
36
  const jquerySource = resolvePackagePath('jquery', 'dist', 'jquery.min.js');
32
37
  const jqueryDest = path.join(assetsDir, 'jquery.min.js');
33
38
  await copyFile(jquerySource, jqueryDest);
39
+ // 复制编辑器交互脚本,供单页编辑器动态初始化
40
+ const editorScriptSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client', 'editor-script.js');
41
+ const editorScriptDest = path.join(assetsDir, 'editor-script.js');
42
+ await copyFile(editorScriptSource, editorScriptDest);
43
+ }
44
+ /**
45
+ * 把本地媒体文件复制到输出目录的 media/ 下,供 goal.json 引用。
46
+ */
47
+ export async function stageMediaToFile(options) {
48
+ const outDir = options.outDir || './output';
49
+ const originalName = path.basename(options.filePath);
50
+ const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
51
+ const mediaDir = path.join(outDir, 'media');
52
+ await mkdir(mediaDir, { recursive: true });
53
+ const localPath = path.resolve(path.join(mediaDir, safeName));
54
+ await copyFile(options.filePath, localPath);
55
+ return {
56
+ filename: safeName,
57
+ localPath,
58
+ relativePath: path.join('media', safeName),
59
+ };
34
60
  }
35
61
  function resolvePackagePath(pkg, ...segments) {
36
62
  // 优先通过 package.json 定位包根(第三方 npm 包)
@@ -73,22 +99,50 @@ export async function generateGoalToFile(options) {
73
99
  */
74
100
  export async function readGoalFromFile(filePath) {
75
101
  const raw = await readFile(path.resolve(filePath), 'utf-8');
76
- const parsed = JSON.parse(raw);
102
+ const parsed = preprocessAgentGoal(JSON.parse(raw));
77
103
  parsed.theme = resolveTheme(parsed.theme);
78
104
  return normalizeDeckGoal(parsed);
79
105
  }
80
106
  /**
81
107
  * 渲染 deck 到输出目录。
108
+ * editable 模式下使用单页编辑器架构:复制 server 的 editor.html/editor.js,
109
+ * 内嵌 renderEditorData 生成的 EditorData,资源路径使用相对路径。
82
110
  */
83
111
  export async function renderGoalToDir(goal, options = {}) {
84
112
  const { outDir = './output', editable = false, width, height } = options;
85
113
  const outputDir = path.resolve(outDir);
86
114
  const assetsDir = path.join(outputDir, 'assets');
87
- const result = renderDeck(goal, { width, height, editable });
88
115
  await copyThemeAssets(goal.theme, assetsDir);
89
- const indexName = editable ? 'editor.html' : 'index.html';
90
- const indexPath = path.join(outputDir, indexName);
91
116
  await mkdir(outputDir, { recursive: true });
117
+ if (editable) {
118
+ const data = renderEditorData(goal, { width: width ?? 1280, height: height ?? 720 });
119
+ const rendererTemplatesDir = resolvePackagePath('@lemonppt/renderer', 'templates');
120
+ let editorHtml = await readFile(path.join(rendererTemplatesDir, 'editor.html'), 'utf-8');
121
+ let editorJs = await readFile(path.join(rendererTemplatesDir, 'editor.js'), 'utf-8');
122
+ // 静态文件模式下使用相对资源路径
123
+ editorHtml = editorHtml.replace(/\/deck\/assets\//g, './assets/');
124
+ editorHtml = editorHtml.replace(/src="\/editor\.js"/g, 'src="./editor.js"');
125
+ // 内嵌 EditorData,让 editor.js 在静态模式下无需请求 API
126
+ const embeddedData = `<script>
127
+ window.__lemonPPT_assetsBase = './assets/';
128
+ window.__lemonPPT_editorData = ${JSON.stringify(data)};
129
+ </script>`;
130
+ editorHtml = editorHtml.replace('</head>', `${embeddedData}\n</head>`);
131
+ const indexPath = path.join(outputDir, 'editor.html');
132
+ await writeFile(indexPath, editorHtml, 'utf-8');
133
+ await writeFile(path.join(outputDir, 'editor.js'), editorJs, 'utf-8');
134
+ const assets = [
135
+ './assets/fonts/fonts.css',
136
+ `./assets/${data.theme}.css`,
137
+ './assets/jquery.min.js',
138
+ './assets/editor-script.js',
139
+ './assets/client-render.js',
140
+ './assets/theme-echarts.js',
141
+ ];
142
+ return { html: editorHtml, indexPath, assetsDir, assets };
143
+ }
144
+ const result = renderDeck(goal, { width, height });
145
+ const indexPath = path.join(outputDir, 'index.html');
92
146
  await writeFile(indexPath, result.html, 'utf-8');
93
147
  return { html: result.html, indexPath, assetsDir, assets: result.assets };
94
148
  }
@@ -319,25 +373,25 @@ function normalizePropsWithSchema(props, fields) {
319
373
  */
320
374
  export async function writeSafePropsToFile(options) {
321
375
  const { goalPath, write } = options;
322
- const raw = JSON.parse(await readFile(path.resolve(goalPath), 'utf-8'));
376
+ const preprocessed = preprocessAgentGoal(JSON.parse(await readFile(path.resolve(goalPath), 'utf-8')));
323
377
  const { composeDeckFromRaw: compose } = await import('@lemonppt/composer');
324
378
  const composed = compose({
325
- title: raw.title,
326
- goal: raw.goal,
327
- audience: raw.audience,
328
- owner: raw.owner,
329
- theme: raw.theme,
330
- language: raw.language,
331
- colorScheme: raw.colorScheme,
332
- appearance: raw.appearance,
333
- pageCount: raw.pageCount,
334
- randomSeed: raw.randomSeed,
335
- slides: raw.slides.map((s) => ({ role: s.role, layout: s.layout, props: s.props })),
379
+ title: preprocessed.title,
380
+ goal: preprocessed.goal,
381
+ audience: preprocessed.audience,
382
+ owner: preprocessed.owner,
383
+ theme: preprocessed.theme,
384
+ language: preprocessed.language,
385
+ colorScheme: preprocessed.colorScheme,
386
+ appearance: preprocessed.appearance,
387
+ pageCount: preprocessed.pageCount,
388
+ randomSeed: preprocessed.randomSeed,
389
+ slides: preprocessed.slides.map((s) => ({ role: s.role, layout: s.layout, props: s.props })),
336
390
  });
337
391
  const layoutChanges = [];
338
392
  const unknownFields = [];
339
393
  const safeSlides = composed.slides.map((slide, index) => {
340
- const originalLayout = raw.slides[index]?.layout;
394
+ const originalLayout = preprocessed.slides[index]?.layout;
341
395
  const registered = getLayout(slide.layout);
342
396
  const schema = registered ? getLayoutSchema(slide.layout) : undefined;
343
397
  if (originalLayout && originalLayout !== slide.layout) {
@@ -366,7 +420,7 @@ export async function writeSafePropsToFile(options) {
366
420
  * 校验 goal.json 规范。
367
421
  */
368
422
  export async function validateGoalSpec(goalPath, strict) {
369
- const raw = JSON.parse(await readFile(path.resolve(goalPath), 'utf-8'));
423
+ const raw = preprocessAgentGoal(JSON.parse(await readFile(path.resolve(goalPath), 'utf-8')));
370
424
  const result = {
371
425
  valid: false,
372
426
  errors: [],
@@ -402,3 +456,96 @@ export async function validateGoalSpec(goalPath, strict) {
402
456
  }
403
457
  return result;
404
458
  }
459
+ /**
460
+ * 校验渲染后的 HTML deck 结构是否完整。
461
+ */
462
+ export async function validateDeck(options) {
463
+ const result = { valid: false, slides: 0, errors: [], warnings: [] };
464
+ const indexPath = path.resolve(options.deckDir, 'index.html');
465
+ if (!existsSync(indexPath)) {
466
+ result.errors.push(`deck index.html not found: ${indexPath}`);
467
+ return result;
468
+ }
469
+ const html = await readFile(indexPath, 'utf-8');
470
+ if (html.length < 100) {
471
+ result.errors.push('index.html is too short, render may have failed');
472
+ return result;
473
+ }
474
+ // 估算 slide 数量:通过 lp-slide-wrapper 容器
475
+ const wrapperMatches = html.match(/class="[^"]*\blp-slide-wrapper\b[^"]*"/g) ?? [];
476
+ const indexMatches = html.match(/data-slide-index="\d+"/g) ?? [];
477
+ result.slides = Math.max(wrapperMatches.length, indexMatches.length);
478
+ if (result.slides === 0) {
479
+ result.errors.push('no slides found in index.html');
480
+ }
481
+ if (options.goalPath) {
482
+ const goal = JSON.parse(await readFile(path.resolve(options.goalPath), 'utf-8'));
483
+ const expected = goal.slides.length;
484
+ if (result.slides !== expected) {
485
+ result.errors.push(`slide count mismatch: deck=${result.slides}, goal=${expected}`);
486
+ }
487
+ }
488
+ if (!html.includes('</html>')) {
489
+ result.warnings.push('index.html may be truncated (missing closing </html>)');
490
+ }
491
+ if (result.errors.length === 0) {
492
+ result.valid = true;
493
+ }
494
+ return result;
495
+ }
496
+ function collectTextStrings(value) {
497
+ const results = [];
498
+ function walk(v) {
499
+ if (typeof v === 'string') {
500
+ const trimmed = v.trim();
501
+ if (trimmed.length >= 4)
502
+ results.push(trimmed);
503
+ }
504
+ else if (Array.isArray(v)) {
505
+ v.forEach(walk);
506
+ }
507
+ else if (v && typeof v === 'object') {
508
+ Object.values(v).forEach(walk);
509
+ }
510
+ }
511
+ walk(value);
512
+ return [...new Set(results)];
513
+ }
514
+ /**
515
+ * 校验 goal.json 中的文案是否都出现在渲染后的 HTML 中。
516
+ */
517
+ export async function validateGoalCopy(options) {
518
+ const result = { valid: false, missing: [], checked: 0, errors: [] };
519
+ const indexPath = path.resolve(options.deckDir, 'index.html');
520
+ if (!existsSync(indexPath)) {
521
+ result.errors.push(`deck index.html not found: ${indexPath}`);
522
+ return result;
523
+ }
524
+ const goal = JSON.parse(await readFile(path.resolve(options.goalPath), 'utf-8'));
525
+ const html = await readFile(indexPath, 'utf-8');
526
+ const texts = collectTextStrings(goal.slides.map((s) => s.props));
527
+ result.checked = texts.length;
528
+ const normalizedHtml = html
529
+ .replace(/&amp;/g, '&')
530
+ .replace(/&quot;/g, '"')
531
+ .replace(/&#39;/g, "'")
532
+ .replace(/&lt;/g, '<')
533
+ .replace(/&gt;/g, '>')
534
+ .replace(/\s+/g, ' ');
535
+ for (const text of texts) {
536
+ const normalizedText = text.replace(/\s+/g, ' ');
537
+ if (!normalizedHtml.includes(normalizedText)) {
538
+ result.missing.push(text.slice(0, 80));
539
+ }
540
+ }
541
+ if (result.missing.length > 0) {
542
+ result.errors.push(`${result.missing.length} / ${result.checked} text snippets missing in rendered HTML`);
543
+ }
544
+ else if (result.checked === 0) {
545
+ result.errors.push('no text found in goal.json to validate');
546
+ }
547
+ else {
548
+ result.valid = true;
549
+ }
550
+ return result;
551
+ }
@@ -1,6 +1,8 @@
1
1
  export interface InstallSkillOptions {
2
2
  /** 指定安装的 Agent,默认全部 */
3
3
  agents?: string[];
4
+ /** 自定义目标目录(直接复制整个 skill 包) */
5
+ target?: string;
4
6
  }
5
7
  /**
6
8
  * 将 lemonPPT skill 安装到常见 AI Agent 的技能目录。
@@ -1,51 +1,100 @@
1
1
  // lemonPPT - AI-powered presentation generation
2
2
  // Copyright (c) 2026 lemonforme
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
- import { copyFile, mkdir, writeFile } from 'node:fs/promises';
4
+ import { copyFile, cp, mkdir, writeFile } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  const DEFAULT_AGENTS = ['claude', 'codex', 'cursor'];
10
10
  function resolveSkillMdPath() {
11
- // Published package: SKILL.md lives next to the package root (one level above dist/)
12
11
  const mainUrl = import.meta.resolve('@lemonppt/cli');
13
12
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(mainUrl)), '..');
13
+ // Local monorepo dev: repo root is two levels above the package root
14
+ const local = path.resolve(pkgRoot, '..', '..', 'SKILL.md');
15
+ if (existsSync(local) && existsSync(path.resolve(pkgRoot, '..', '..', 'apps', 'server'))) {
16
+ return local;
17
+ }
18
+ // Published package: SKILL.md lives next to the package root (one level above dist/)
14
19
  const published = path.join(pkgRoot, 'SKILL.md');
15
20
  if (existsSync(published)) {
16
21
  return published;
17
22
  }
18
- // Local monorepo dev: fallback to repo root SKILL.md
19
- const local = path.resolve(pkgRoot, '..', '..', 'SKILL.md');
20
- if (existsSync(local)) {
21
- return local;
22
- }
23
23
  throw new Error('SKILL.md not found. It should be bundled with @lemonppt/cli or exist at the repo root.');
24
24
  }
25
+ function resolveRepoRoot(skillMdPath) {
26
+ return path.dirname(skillMdPath);
27
+ }
28
+ function resolveSkillBundlePath(skillMdPath) {
29
+ const repoRoot = resolveRepoRoot(skillMdPath);
30
+ const bundlePath = path.join(repoRoot, 'skills', 'lemonppt');
31
+ if (existsSync(bundlePath)) {
32
+ return bundlePath;
33
+ }
34
+ throw new Error('Skill bundle not found at skills/lemonppt. Did you run the build script?');
35
+ }
36
+ function isSourceRepo(repoRoot) {
37
+ return existsSync(path.join(repoRoot, 'apps', 'server'));
38
+ }
39
+ function resolveCliWrapper(repoRoot) {
40
+ if (isSourceRepo(repoRoot)) {
41
+ const cliPath = path.join(repoRoot, 'packages', 'cli', 'dist', 'cli.js');
42
+ return {
43
+ type: 'source',
44
+ cli: cliPath,
45
+ sh: `#!/bin/bash\nset -e\nexec "${cliPath}" "$@"\n`,
46
+ ps1: `#Requires -Version 5.1\n& node "${cliPath}" @args\n`,
47
+ };
48
+ }
49
+ return {
50
+ type: 'published',
51
+ sh: `#!/bin/bash\nset -e\nexec npx lemonppt "$@"\n`,
52
+ ps1: `#Requires -Version 5.1\n& npx lemonppt @args\n`,
53
+ };
54
+ }
55
+ function resolveCliMarker(wrapper) {
56
+ return JSON.stringify(wrapper.type === 'source' && wrapper.cli
57
+ ? { type: wrapper.type, cli: wrapper.cli }
58
+ : { type: wrapper.type }, null, 2);
59
+ }
25
60
  async function installAgent(agent) {
26
61
  const homeDir = os.homedir();
27
62
  const skillDir = path.join(homeDir, `.${agent}/skills/lemonppt`);
28
63
  await mkdir(skillDir, { recursive: true });
29
64
  const skillMdSource = resolveSkillMdPath();
30
- await copyFile(skillMdSource, path.join(skillDir, 'SKILL.md'));
31
- await copyFile(skillMdSource, path.join(skillDir, 'README.md'));
65
+ const bundlePath = resolveSkillBundlePath(skillMdSource);
66
+ await cp(bundlePath, skillDir, { recursive: true, force: true });
67
+ // 兼容 OpenAI/Codex 等同时读取根目录 openai.yaml 的框架
68
+ const agentYamlSource = path.join(bundlePath, 'agents', 'openai.yaml');
69
+ if (existsSync(agentYamlSource)) {
70
+ await copyFile(agentYamlSource, path.join(skillDir, 'openai.yaml'));
71
+ }
72
+ const repoRoot = resolveRepoRoot(skillMdSource);
73
+ const wrapper = resolveCliWrapper(repoRoot);
32
74
  const scriptsDir = path.join(skillDir, 'scripts');
33
- await mkdir(scriptsDir, { recursive: true });
34
- const wrapperSh = `#!/bin/bash
35
- set -e
36
- exec npx @lemonppt/cli "$@"
37
- `;
38
- await writeFile(path.join(scriptsDir, 'lemonppt.sh'), wrapperSh, { mode: 0o755 });
39
- const wrapperPs1 = `#Requires -Version 5.1
40
- & npx @lemonppt/cli @args
41
- `;
42
- await writeFile(path.join(scriptsDir, 'lemonppt.ps1'), wrapperPs1, { mode: 0o755 });
43
- console.log(`✓ Installed lemonPPT skill for ${agent} at ${skillDir}`);
75
+ await writeFile(path.join(scriptsDir, 'lemonppt.sh'), wrapper.sh, { mode: 0o755 });
76
+ await writeFile(path.join(scriptsDir, 'lemonppt.ps1'), wrapper.ps1, { mode: 0o755 });
77
+ await writeFile(path.join(scriptsDir, '.cli-path.json'), resolveCliMarker(wrapper));
78
+ console.log(`✓ Installed lemonPPT skill for ${agent} at ${skillDir} (${wrapper.type} mode)`);
44
79
  }
45
80
  /**
46
81
  * 将 lemonPPT skill 安装到常见 AI Agent 的技能目录。
47
82
  */
48
83
  export async function installSkill(options = {}) {
84
+ if (options.target) {
85
+ const skillMdSource = resolveSkillMdPath();
86
+ const bundlePath = resolveSkillBundlePath(skillMdSource);
87
+ await mkdir(options.target, { recursive: true });
88
+ await cp(bundlePath, options.target, { recursive: true, force: true });
89
+ const repoRoot = resolveRepoRoot(skillMdSource);
90
+ const wrapper = resolveCliWrapper(repoRoot);
91
+ const scriptsDir = path.join(options.target, 'scripts');
92
+ await writeFile(path.join(scriptsDir, 'lemonppt.sh'), wrapper.sh, { mode: 0o755 });
93
+ await writeFile(path.join(scriptsDir, 'lemonppt.ps1'), wrapper.ps1, { mode: 0o755 });
94
+ await writeFile(path.join(scriptsDir, '.cli-path.json'), resolveCliMarker(wrapper));
95
+ console.log(`✓ Copied skill bundle to ${options.target} (${wrapper.type} mode)`);
96
+ return;
97
+ }
49
98
  const agents = options.agents?.length ? options.agents : DEFAULT_AGENTS;
50
99
  for (const agent of agents) {
51
100
  await installAgent(agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemonppt/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "homepage": "https://github.com/lemonforme/lemonPPT#readme",
33
33
  "dependencies": {
34
- "@lemonppt/agent-prompts": "0.2.0",
35
- "@lemonppt/renderer": "0.2.0",
36
- "@lemonppt/core": "0.2.0",
37
- "@lemonppt/themes": "0.2.0"
34
+ "@lemonppt/agent-prompts": "0.2.2",
35
+ "@lemonppt/core": "0.2.2",
36
+ "@lemonppt/themes": "0.2.2",
37
+ "@lemonppt/renderer": "0.2.2"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^20.0.0",