@laitszkin/apollo-toolkit 4.1.3 → 5.0.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 (205) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/apollo-toolkit.ts +4 -0
  3. package/dist/bin/apollo-toolkit.js +4 -0
  4. package/package.json +7 -2
  5. package/packages/cli/dist/help-text-builder.d.ts +23 -0
  6. package/packages/cli/dist/help-text-builder.js +166 -0
  7. package/packages/cli/dist/index.d.ts +6 -17
  8. package/packages/cli/dist/index.js +52 -246
  9. package/packages/cli/dist/installer.d.ts +1 -0
  10. package/packages/cli/dist/installer.js +20 -7
  11. package/packages/cli/dist/parsers/install-parser.d.ts +15 -0
  12. package/packages/cli/dist/parsers/install-parser.js +87 -0
  13. package/packages/cli/dist/parsers/parser-utils.d.ts +9 -0
  14. package/packages/cli/dist/parsers/parser-utils.js +16 -0
  15. package/packages/cli/dist/parsers/tool-parser.d.ts +16 -0
  16. package/packages/cli/dist/parsers/tool-parser.js +58 -0
  17. package/packages/cli/dist/parsers/types.d.ts +50 -0
  18. package/packages/cli/dist/parsers/types.js +1 -0
  19. package/packages/cli/dist/parsers/uninstall-parser.d.ts +15 -0
  20. package/packages/cli/dist/parsers/uninstall-parser.js +67 -0
  21. package/packages/cli/dist/tool-registration.d.ts +2 -0
  22. package/packages/cli/dist/tool-registration.js +2 -0
  23. package/packages/cli/dist/tsconfig.tsbuildinfo +1 -1
  24. package/packages/cli/dist/types.d.ts +3 -1
  25. package/packages/cli/dist/updater.js +11 -5
  26. package/packages/cli/help-text-builder.ts +180 -0
  27. package/packages/cli/index.ts +59 -251
  28. package/packages/cli/installer.ts +19 -7
  29. package/packages/cli/package.json +6 -3
  30. package/packages/cli/parsers/install-parser.ts +94 -0
  31. package/packages/cli/parsers/parser-utils.ts +17 -0
  32. package/packages/cli/parsers/tool-parser.ts +65 -0
  33. package/packages/cli/parsers/types.ts +56 -0
  34. package/packages/cli/parsers/uninstall-parser.ts +75 -0
  35. package/packages/cli/tool-registration.ts +3 -0
  36. package/packages/cli/types.ts +6 -1
  37. package/packages/cli/updater.ts +11 -5
  38. package/packages/tool-registry/dist/registry.js +3 -4
  39. package/packages/tool-registry/dist/tsconfig.tsbuildinfo +1 -1
  40. package/packages/tool-registry/dist/types.d.ts +2 -9
  41. package/packages/tool-registry/package.json +3 -3
  42. package/packages/tool-registry/registry.ts +3 -4
  43. package/packages/tool-registry/tsconfig.json +6 -2
  44. package/packages/tool-registry/types.ts +3 -9
  45. package/packages/tool-utils/app-error.ts +97 -0
  46. package/packages/tool-utils/dist/app-error.d.ts +49 -0
  47. package/packages/tool-utils/dist/app-error.js +80 -0
  48. package/packages/tool-utils/dist/index.d.ts +5 -0
  49. package/packages/tool-utils/dist/index.js +3 -0
  50. package/packages/tool-utils/dist/platform-adapter.d.ts +48 -0
  51. package/packages/tool-utils/dist/platform-adapter.js +73 -0
  52. package/packages/tool-utils/dist/schema.d.ts +68 -0
  53. package/packages/tool-utils/dist/schema.js +67 -0
  54. package/packages/tool-utils/dist/tsconfig.tsbuildinfo +1 -1
  55. package/packages/tool-utils/index.ts +12 -0
  56. package/packages/tool-utils/package.json +3 -3
  57. package/packages/tool-utils/platform-adapter.ts +112 -0
  58. package/packages/tool-utils/schema.ts +122 -0
  59. package/packages/tools/architecture/dist/index.d.ts +13 -0
  60. package/packages/tools/architecture/dist/index.js +55 -57
  61. package/packages/tools/architecture/dist/index.test.js +17 -4
  62. package/packages/tools/architecture/dist/tsconfig.tsbuildinfo +1 -1
  63. package/packages/tools/architecture/index.test.ts +27 -14
  64. package/packages/tools/architecture/index.ts +85 -88
  65. package/packages/tools/architecture/package.json +3 -3
  66. package/packages/tools/codegraph/dist/index.js +21 -17
  67. package/packages/tools/codegraph/dist/tsconfig.tsbuildinfo +1 -1
  68. package/packages/tools/codegraph/index.ts +21 -17
  69. package/packages/tools/codegraph/package.json +3 -3
  70. package/packages/tools/create-review-report/dist/index.d.ts +1 -2
  71. package/packages/tools/create-review-report/dist/index.js +46 -77
  72. package/packages/tools/create-review-report/dist/tsconfig.tsbuildinfo +1 -1
  73. package/packages/tools/create-review-report/index.ts +52 -81
  74. package/packages/tools/create-review-report/package.json +3 -3
  75. package/packages/tools/create-specs/dist/index.d.ts +1 -2
  76. package/packages/tools/create-specs/dist/index.js +70 -123
  77. package/packages/tools/create-specs/dist/tsconfig.tsbuildinfo +1 -1
  78. package/packages/tools/create-specs/index.ts +82 -128
  79. package/packages/tools/create-specs/package.json +3 -3
  80. package/packages/tools/docs-to-voice/dist/index.d.ts +1 -2
  81. package/packages/tools/docs-to-voice/dist/index.js +116 -219
  82. package/packages/tools/docs-to-voice/dist/tsconfig.tsbuildinfo +1 -1
  83. package/packages/tools/docs-to-voice/index.ts +265 -385
  84. package/packages/tools/docs-to-voice/package.json +3 -3
  85. package/packages/tools/enforce-video-aspect-ratio/dist/index.d.ts +1 -2
  86. package/packages/tools/enforce-video-aspect-ratio/dist/index.js +77 -154
  87. package/packages/tools/enforce-video-aspect-ratio/dist/tsconfig.tsbuildinfo +1 -1
  88. package/packages/tools/enforce-video-aspect-ratio/index.ts +87 -172
  89. package/packages/tools/enforce-video-aspect-ratio/package.json +3 -3
  90. package/packages/tools/eval/dist/index.js +7 -0
  91. package/packages/tools/eval/dist/tsconfig.tsbuildinfo +1 -1
  92. package/packages/tools/eval/index.ts +8 -0
  93. package/packages/tools/eval/package.json +3 -3
  94. package/packages/tools/extract-conversations/dist/index.d.ts +1 -2
  95. package/packages/tools/extract-conversations/dist/index.js +31 -29
  96. package/packages/tools/extract-conversations/dist/tsconfig.tsbuildinfo +1 -1
  97. package/packages/tools/extract-conversations/index.ts +37 -30
  98. package/packages/tools/extract-conversations/package.json +3 -3
  99. package/packages/tools/extract-pdf-text/dist/index.d.ts +1 -2
  100. package/packages/tools/extract-pdf-text/dist/index.js +44 -65
  101. package/packages/tools/extract-pdf-text/dist/tsconfig.tsbuildinfo +1 -1
  102. package/packages/tools/extract-pdf-text/index.ts +55 -74
  103. package/packages/tools/extract-pdf-text/package.json +3 -3
  104. package/packages/tools/filter-logs/dist/index.js +60 -84
  105. package/packages/tools/filter-logs/dist/tsconfig.tsbuildinfo +1 -1
  106. package/packages/tools/filter-logs/index.ts +67 -97
  107. package/packages/tools/filter-logs/package.json +3 -3
  108. package/packages/tools/find-github-issues/dist/index.d.ts +10 -0
  109. package/packages/tools/find-github-issues/dist/index.js +34 -5
  110. package/packages/tools/find-github-issues/dist/tsconfig.tsbuildinfo +1 -1
  111. package/packages/tools/find-github-issues/index.ts +37 -5
  112. package/packages/tools/find-github-issues/package.json +3 -3
  113. package/packages/tools/generate-storyboard-images/dist/index.d.ts +1 -2
  114. package/packages/tools/generate-storyboard-images/dist/index.js +98 -173
  115. package/packages/tools/generate-storyboard-images/dist/tsconfig.tsbuildinfo +1 -1
  116. package/packages/tools/generate-storyboard-images/index.ts +100 -188
  117. package/packages/tools/generate-storyboard-images/package.json +3 -3
  118. package/packages/tools/open-github-issue/dist/index.d.ts +13 -0
  119. package/packages/tools/open-github-issue/dist/index.js +67 -68
  120. package/packages/tools/open-github-issue/dist/tsconfig.tsbuildinfo +1 -1
  121. package/packages/tools/open-github-issue/index.ts +71 -72
  122. package/packages/tools/open-github-issue/package.json +3 -3
  123. package/packages/tools/read-github-issue/dist/index.d.ts +16 -1
  124. package/packages/tools/read-github-issue/dist/index.js +32 -40
  125. package/packages/tools/read-github-issue/dist/tsconfig.tsbuildinfo +1 -1
  126. package/packages/tools/read-github-issue/index.ts +32 -45
  127. package/packages/tools/read-github-issue/package.json +3 -3
  128. package/packages/tools/render-error-book/dist/index.d.ts +1 -2
  129. package/packages/tools/render-error-book/dist/index.js +74 -95
  130. package/packages/tools/render-error-book/dist/tsconfig.tsbuildinfo +1 -1
  131. package/packages/tools/render-error-book/index.ts +88 -103
  132. package/packages/tools/render-error-book/package.json +3 -3
  133. package/packages/tools/render-katex/dist/index.d.ts +1 -2
  134. package/packages/tools/render-katex/dist/index.js +70 -157
  135. package/packages/tools/render-katex/dist/tsconfig.tsbuildinfo +1 -1
  136. package/packages/tools/render-katex/index.ts +138 -222
  137. package/packages/tools/render-katex/package.json +3 -3
  138. package/packages/tools/review-threads/dist/index.d.ts +12 -0
  139. package/packages/tools/review-threads/dist/index.js +83 -86
  140. package/packages/tools/review-threads/dist/tsconfig.tsbuildinfo +1 -1
  141. package/packages/tools/review-threads/index.ts +90 -84
  142. package/packages/tools/review-threads/package.json +3 -3
  143. package/packages/tools/search-logs/dist/index.js +100 -136
  144. package/packages/tools/search-logs/dist/tsconfig.tsbuildinfo +1 -1
  145. package/packages/tools/search-logs/index.ts +113 -145
  146. package/packages/tools/search-logs/package.json +3 -3
  147. package/packages/tools/sync-memory-index/dist/index.js +34 -28
  148. package/packages/tools/sync-memory-index/dist/tsconfig.tsbuildinfo +1 -1
  149. package/packages/tools/sync-memory-index/index.ts +37 -28
  150. package/packages/tools/sync-memory-index/package.json +3 -3
  151. package/packages/tools/validate-openai-agent-config/dist/index.js +13 -7
  152. package/packages/tools/validate-openai-agent-config/dist/tsconfig.tsbuildinfo +1 -1
  153. package/packages/tools/validate-openai-agent-config/index.ts +13 -7
  154. package/packages/tools/validate-openai-agent-config/package.json +3 -3
  155. package/packages/tools/validate-skill-frontmatter/dist/index.js +12 -6
  156. package/packages/tools/validate-skill-frontmatter/dist/tsconfig.tsbuildinfo +1 -1
  157. package/packages/tools/validate-skill-frontmatter/index.ts +12 -6
  158. package/packages/tools/validate-skill-frontmatter/package.json +3 -3
  159. package/packages/tui/dist/index.d.ts +2 -1
  160. package/packages/tui/dist/index.js +1 -0
  161. package/packages/tui/dist/stdio-adapter.d.ts +36 -0
  162. package/packages/tui/dist/stdio-adapter.js +69 -0
  163. package/packages/tui/dist/terminal.js +3 -1
  164. package/packages/tui/dist/tsconfig.tsbuildinfo +1 -1
  165. package/packages/tui/dist/types.d.ts +17 -0
  166. package/packages/tui/index.ts +2 -1
  167. package/packages/tui/package.json +6 -5
  168. package/packages/tui/stdio-adapter.ts +85 -0
  169. package/packages/tui/terminal.ts +3 -1
  170. package/packages/tui/tsconfig.json +5 -2
  171. package/packages/tui/types.ts +19 -0
  172. package/resources/project-architecture/assets/architecture.css +2 -1
  173. package/resources/project-architecture/atlas/atlas.history.log +1 -0
  174. package/resources/project-architecture/atlas/atlas.history.undo.json +13 -2
  175. package/resources/project-architecture/atlas/atlas.history.undo.stack.json +610 -0
  176. package/resources/project-architecture/atlas/atlas.index.yaml +81 -5
  177. package/resources/project-architecture/atlas/features/cli-dispatch.yaml +43 -0
  178. package/resources/project-architecture/atlas/features/terminal-ui.yaml +29 -0
  179. package/resources/project-architecture/atlas/features/tool-registry.yaml +22 -0
  180. package/resources/project-architecture/atlas/features/tool-utils.yaml +22 -0
  181. package/resources/project-architecture/features/cli-dispatch/arg-parser.html +40 -0
  182. package/resources/project-architecture/features/cli-dispatch/help-builder.html +40 -0
  183. package/resources/project-architecture/features/cli-dispatch/index.html +64 -0
  184. package/resources/project-architecture/features/cli-dispatch/installer-core.html +40 -0
  185. package/resources/project-architecture/features/cli-dispatch/tool-discovery.html +40 -0
  186. package/resources/project-architecture/features/cli-dispatch/update-checker.html +40 -0
  187. package/resources/project-architecture/features/terminal-ui/banner-display.html +40 -0
  188. package/resources/project-architecture/features/terminal-ui/index.html +50 -0
  189. package/resources/project-architecture/features/terminal-ui/interactive-prompts.html +40 -0
  190. package/resources/project-architecture/features/terminal-ui/terminal-detection.html +40 -0
  191. package/resources/project-architecture/features/tool-registry/formatter.html +40 -0
  192. package/resources/project-architecture/features/tool-registry/index.html +43 -0
  193. package/resources/project-architecture/features/tool-registry/registry-core.html +40 -0
  194. package/resources/project-architecture/features/tool-utils/index.html +43 -0
  195. package/resources/project-architecture/features/tool-utils/log-utils.html +40 -0
  196. package/resources/project-architecture/features/tool-utils/skill-discovery.html +40 -0
  197. package/resources/project-architecture/index.html +365 -121
  198. package/scripts/rewrite-imports.mjs +2 -2
  199. package/scripts/test.sh +144 -8
  200. package/skills/design/SKILL.md +57 -64
  201. package/skills/design/assets/templates/DESIGN.md +12 -0
  202. package/skills/design/references/code-smells.md +94 -0
  203. package/skills/design/references/module-boundary-adjustment.md +126 -0
  204. package/skills/design/references/module-internal-restructuring.md +132 -0
  205. package/skills/design/references/module-internal-simplification.md +164 -0
@@ -4,32 +4,13 @@ import path from 'node:path';
4
4
  import https from 'node:https';
5
5
  import http from 'node:http';
6
6
  import type { ToolDefinition, ToolContext } from '@laitszkin/tool-registry';
7
+ import { SystemError, UserInputError, createToolRunner } from '@laitszkin/tool-utils';
7
8
 
8
9
  const DEFAULT_API_ENDPOINT =
9
10
  'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
10
11
  const DEFAULT_API_MODEL = 'qwen3-tts';
11
12
  const DEFAULT_API_VOICE = 'Cherry';
12
13
 
13
- interface DocsToVoiceArgs {
14
- inputText: string | null;
15
- inputFile: string | null;
16
- projectDir: string;
17
- projectName: string | null;
18
- outputName: string | null;
19
- mode: string;
20
- voice: string | null;
21
- rate: string | null;
22
- speechRate: string | null;
23
- apiEndpoint: string;
24
- apiModel: string;
25
- apiVoice: string;
26
- apiKey: string | null;
27
- maxChars: string | null;
28
- noAutoProsody: boolean;
29
- force: boolean;
30
- help: boolean;
31
- }
32
-
33
14
  interface TimelineEntry {
34
15
  index: number;
35
16
  text: string;
@@ -39,113 +20,15 @@ interface TimelineEntry {
39
20
  endMs: number;
40
21
  }
41
22
 
42
- function parseArgs(args: string[]): DocsToVoiceArgs {
43
- const parsed: DocsToVoiceArgs = {
44
- inputText: null,
45
- inputFile: null,
46
- projectDir: '.',
47
- projectName: null,
48
- outputName: null,
49
- mode: 'say',
50
- voice: null,
51
- rate: null,
52
- speechRate: null,
53
- apiEndpoint: DEFAULT_API_ENDPOINT,
54
- apiModel: DEFAULT_API_MODEL,
55
- apiVoice: DEFAULT_API_VOICE,
56
- apiKey: null,
57
- maxChars: null,
58
- noAutoProsody: false,
59
- force: false,
60
- help: false,
61
- };
62
-
63
- for (let i = 0; i < args.length; i++) {
64
- const arg = args[i];
65
- if (arg === '--help' || arg === '-h') {
66
- parsed.help = true;
67
- continue;
68
- }
69
- if (arg.startsWith('--')) {
70
- const eqIndex = arg.indexOf('=');
71
- let key: string;
72
- let value: string;
73
-
74
- if (eqIndex !== -1) {
75
- key = arg.slice(2, eqIndex);
76
- value = arg.slice(eqIndex + 1);
77
- } else {
78
- key = arg.slice(2);
79
- value = args[++i] || '';
80
- }
81
-
82
- switch (key) {
83
- case 'text':
84
- parsed.inputText = value;
85
- break;
86
- case 'input':
87
- case 'input-file':
88
- parsed.inputFile = value;
89
- break;
90
- case 'project-dir':
91
- parsed.projectDir = value;
92
- break;
93
- case 'project-name':
94
- parsed.projectName = value;
95
- break;
96
- case 'output-name':
97
- parsed.outputName = value;
98
- break;
99
- case 'engine':
100
- case 'mode':
101
- parsed.mode = value.toLowerCase();
102
- break;
103
- case 'voice':
104
- parsed.voice = value;
105
- break;
106
- case 'rate':
107
- parsed.rate = value;
108
- break;
109
- case 'speech-rate':
110
- parsed.speechRate = value;
111
- break;
112
- case 'api-endpoint':
113
- parsed.apiEndpoint = value;
114
- break;
115
- case 'api-model':
116
- parsed.apiModel = value;
117
- break;
118
- case 'api-voice':
119
- parsed.apiVoice = value;
120
- break;
121
- case 'api-key':
122
- parsed.apiKey = value;
123
- break;
124
- case 'max-chars':
125
- parsed.maxChars = value;
126
- break;
127
- case 'no-auto-prosody':
128
- parsed.noAutoProsody = true;
129
- break;
130
- case 'force':
131
- parsed.force = true;
132
- break;
133
- }
134
- }
135
- }
136
-
137
- return parsed;
138
- }
139
-
140
- function readInputText(opts: DocsToVoiceArgs): string {
141
- if (opts.inputFile) {
142
- const inputPath = path.resolve(opts.inputFile);
23
+ function readInputText(inputFile: string | null, inputText: string | null): string {
24
+ if (inputFile) {
25
+ const inputPath = path.resolve(inputFile);
143
26
  if (!fs.existsSync(inputPath)) {
144
- throw new Error(`Input file not found: ${inputPath}`);
27
+ throw new UserInputError(`Input file not found: ${inputPath}`);
145
28
  }
146
29
  return fs.readFileSync(inputPath, 'utf-8');
147
30
  }
148
- return opts.inputText || '';
31
+ return inputText || '';
149
32
  }
150
33
 
151
34
  function splitSentences(rawText: string): string[] {
@@ -349,8 +232,10 @@ function applySpeechRateToAudio(outputPath: string, speechRate: number): void {
349
232
  }
350
233
  } catch (err: unknown) {
351
234
  if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
352
- throw new Error(
235
+ throw new SystemError(
353
236
  `ffmpeg failed while applying --speech-rate: ${err instanceof Error ? err.message : 'unknown error'}`,
237
+ undefined,
238
+ { cause: err },
354
239
  );
355
240
  }
356
241
  }
@@ -400,7 +285,7 @@ function splitTextForTts(text: string, maxChars: number | null): string[] {
400
285
 
401
286
  function concatAudioFiles(partPaths: string[], outputPath: string): void {
402
287
  if (partPaths.length === 0) {
403
- throw new Error('No chunk audio generated for concatenation.');
288
+ throw new SystemError('No chunk audio generated for concatenation.');
404
289
  }
405
290
  if (partPaths.length === 1) {
406
291
  fs.copyFileSync(partPaths[0], outputPath);
@@ -419,8 +304,10 @@ function concatAudioFiles(partPaths: string[], outputPath: string): void {
419
304
  { stdio: 'ignore', timeout: 120000 },
420
305
  );
421
306
  } catch (err: unknown) {
422
- throw new Error(
307
+ throw new SystemError(
423
308
  `ffmpeg concat failed: ${err instanceof Error ? err.message : 'unknown error'}`,
309
+ undefined,
310
+ { cause: err },
424
311
  );
425
312
  } finally {
426
313
  try { fs.unlinkSync(listFile); fs.rmdirSync(path.dirname(listFile)); } catch { /* ignore */ }
@@ -484,323 +371,316 @@ function requestAlibabaCloudTTS(
484
371
  const audioFormat = audio.format || audio.mime_type || '';
485
372
 
486
373
  if (!audioUrl && !audioData) {
487
- reject(new Error('API response does not contain output.audio.url or output.audio.data'));
374
+ reject(new SystemError('API response does not contain output.audio.url or output.audio.data'));
488
375
  return;
489
376
  }
490
377
 
491
378
  resolve({ audioUrl, audioData, audioFormat });
492
379
  } catch {
493
- reject(new Error('API response is not valid JSON.'));
380
+ reject(new SystemError('API response is not valid JSON.'));
494
381
  }
495
382
  });
496
383
  res.on('error', reject);
497
384
  });
498
385
 
499
386
  req.on('error', reject);
500
- req.on('timeout', () => { req.destroy(); reject(new Error('API request timed out')); });
387
+ req.on('timeout', () => { req.destroy(); reject(new SystemError('API request timed out')); });
501
388
  req.write(payload);
502
389
  req.end();
503
390
  });
504
391
  }
505
392
 
506
- export async function docsToVoiceHandler(args: string[], context: ToolContext): Promise<number> {
507
- const stdout = context.stdout || process.stdout;
508
- const stderr = context.stderr || process.stderr;
509
-
510
- try {
511
- const opts = parseArgs(args);
512
-
513
- if (opts.help) {
514
- stdout.write(`Usage: apltk docs-to-voice [options]
515
-
516
- Convert text into audio and sentence timelines.
517
-
518
- Options:
519
- --input, --input-file <path> Path to input text file
520
- --text <string> Raw text input
521
- --project-dir <path> Root project directory (default: .)
522
- --project-name <name> Folder name under DIR/audio/
523
- --output-name <name> Output filename
524
- --engine, --mode <mode> TTS mode: say (default) | api
525
- --voice <name> macOS say voice
526
- --rate <wpm> macOS say rate
527
- --speech-rate <factor> Speech rate multiplier (e.g. 1.2)
528
- --api-endpoint <url> Alibaba Cloud TTS endpoint
529
- --api-model <name> Alibaba Cloud model (default: qwen3-tts)
530
- --api-voice <name> Alibaba Cloud voice (default: Cherry)
531
- --api-key <key> Alibaba Cloud API key
532
- --max-chars <n> Max chars per TTS chunk (0 disables)
533
- --no-auto-prosody Disable punctuation pause enhancement
534
- --force Overwrite existing files
535
- `);
536
- return 0;
537
- }
538
-
539
- if (opts.mode !== 'say' && opts.mode !== 'api') {
540
- stderr.write('Error: --mode must be one of: say, api\n');
541
- return 1;
542
- }
543
-
544
- const sourceText = readInputText(opts);
545
- if (!sourceText.trim()) {
546
- stderr.write('Error: No text content found for conversion.\n');
547
- return 1;
548
- }
549
-
550
- // Resolve output directory
551
- const projectDir = path.resolve(opts.projectDir);
552
- const projectName = opts.projectName || path.basename(projectDir);
553
- if (!projectName) {
554
- stderr.write('Error: Unable to determine project name.\n');
555
- return 1;
556
- }
557
-
558
- const outputDir = path.join(projectDir, 'audio', projectName);
559
- fs.mkdirSync(outputDir, { recursive: true });
560
-
561
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
562
- const outputName = opts.outputName || `voice-${timestamp}`;
563
- const hasExtension = outputName.includes('.');
564
-
565
- if (opts.mode === 'say') {
566
- // macOS say mode
567
- const textChunks = splitTextForTts(sourceText, opts.maxChars ? parseInt(opts.maxChars, 10) || null : null);
568
- if (textChunks.length === 0) {
569
- stderr.write('Error: No text content found for conversion.\n');
570
- return 1;
393
+ const schema = {
394
+ options: {
395
+ 'text': { type: 'string' as const },
396
+ 'input': { type: 'string' as const },
397
+ 'input-file': { type: 'string' as const },
398
+ 'project-dir': { type: 'string' as const, default: '.' },
399
+ 'project-name': { type: 'string' as const },
400
+ 'output-name': { type: 'string' as const },
401
+ 'engine': { type: 'string' as const },
402
+ 'mode': { type: 'string' as const, default: 'say' },
403
+ 'voice': { type: 'string' as const },
404
+ 'rate': { type: 'string' as const },
405
+ 'speech-rate': { type: 'string' as const },
406
+ 'api-endpoint': { type: 'string' as const, default: DEFAULT_API_ENDPOINT },
407
+ 'api-model': { type: 'string' as const, default: DEFAULT_API_MODEL },
408
+ 'api-voice': { type: 'string' as const, default: DEFAULT_API_VOICE },
409
+ 'api-key': { type: 'string' as const },
410
+ 'max-chars': { type: 'string' as const },
411
+ 'no-auto-prosody': { type: 'boolean' as const, default: false },
412
+ 'force': { type: 'boolean' as const, default: false },
413
+ },
414
+ allowPositionals: true,
415
+ usage: 'apltk docs-to-voice [options]',
416
+ description: 'Convert text into audio and sentence timelines.',
417
+ handler: async (
418
+ values: Record<string, unknown>,
419
+ _positionals: string[],
420
+ context: ToolContext,
421
+ ): Promise<number> => {
422
+ const stdout = context.stdout ?? process.stdout;
423
+ const stderr = context.stderr ?? process.stderr;
424
+
425
+ // Resolve args (previously handled by parseCliArgs)
426
+ const inputText: string | null = (values['text'] as string | undefined) ?? null;
427
+ const inputFile: string | null = (values['input'] as string | undefined) || (values['input-file'] as string | undefined) || null;
428
+ const projectDir: string = (values['project-dir'] as string) || '.';
429
+ const projectName: string | null = (values['project-name'] as string | undefined) ?? null;
430
+ const outputName: string | null = (values['output-name'] as string | undefined) ?? null;
431
+ const mode: string = ((values['mode'] as string | undefined) || (values['engine'] as string | undefined) || 'say').toLowerCase();
432
+ const voice: string | null = (values['voice'] as string | undefined) ?? null;
433
+ const rate: string | null = (values['rate'] as string | undefined) ?? null;
434
+ const speechRate: string | null = (values['speech-rate'] as string | undefined) ?? null;
435
+ const apiEndpoint: string = (values['api-endpoint'] as string) || DEFAULT_API_ENDPOINT;
436
+ const apiModel: string = (values['api-model'] as string) || DEFAULT_API_MODEL;
437
+ const apiVoice: string = (values['api-voice'] as string) || DEFAULT_API_VOICE;
438
+ const apiKey: string | null = (values['api-key'] as string | undefined) ?? null;
439
+ const maxChars: string | null = (values['max-chars'] as string | undefined) ?? null;
440
+ const noAutoProsody: boolean = !!values['no-auto-prosody'];
441
+ const force: boolean = !!values['force'];
442
+
443
+ if (mode !== 'say' && mode !== 'api') {
444
+ throw new UserInputError('--mode must be one of: say, api');
571
445
  }
572
446
 
573
- // Check if `say` is available
574
- try {
575
- execSync('which say', { stdio: 'ignore' });
576
- } catch {
577
- stderr.write("Error: macOS 'say' command not found.\n");
578
- return 1;
447
+ const sourceText = readInputText(inputFile, inputText);
448
+ if (!sourceText.trim()) {
449
+ throw new UserInputError('No text content found for conversion.');
579
450
  }
580
451
 
581
- const finalOutputName = hasExtension ? outputName : `${outputName}.aiff`;
582
- const outputPath = path.join(outputDir, finalOutputName);
583
-
584
- if (fs.existsSync(outputPath) && !opts.force) {
585
- stderr.write(`Error: Output already exists: ${outputPath}. Use --force to overwrite.\n`);
586
- return 1;
452
+ // Resolve output directory
453
+ const resolvedProjectDir = path.resolve(projectDir);
454
+ const resolvedProjectName = projectName || path.basename(resolvedProjectDir);
455
+ if (!resolvedProjectName) {
456
+ throw new UserInputError('Unable to determine project name.');
587
457
  }
588
458
 
589
- // Build prosody-enhanced text
590
- const chunks = opts.noAutoProsody ? textChunks : textChunks.map(buildAutoProsodyText);
459
+ const outputDir = path.join(resolvedProjectDir, 'audio', resolvedProjectName);
460
+ fs.mkdirSync(outputDir, { recursive: true });
591
461
 
592
- if (chunks.length === 1) {
593
- // Single say command
594
- const sayArgs = ['-o', outputPath];
595
- if (opts.voice) sayArgs.push('-v', opts.voice);
596
- if (opts.rate) sayArgs.push('-r', opts.rate);
462
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
463
+ const outName = outputName || `voice-${timestamp}`;
464
+ const hasExtension = outName.includes('.');
597
465
 
598
- const tmpFile = path.join(fs.mkdtempSync('docs-to-voice-'), 'input.txt');
599
- fs.mkdirSync(path.dirname(tmpFile), { recursive: true });
600
- fs.writeFileSync(tmpFile, chunks[0], 'utf-8');
601
- sayArgs.push('-f', tmpFile);
466
+ if (mode === 'say') {
467
+ const finalOutputName = hasExtension ? outName : `${outName}.aiff`;
468
+ const outputPath = path.join(outputDir, finalOutputName);
602
469
 
603
- try {
604
- execSync(`say ${sayArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`, {
605
- stdio: 'ignore',
606
- timeout: 300000,
607
- });
608
- } catch (err: unknown) {
609
- const msg = err instanceof Error ? err.message : 'unknown error';
610
- throw new Error(`say mode failed: ${msg}`);
611
- } finally {
612
- try { fs.unlinkSync(tmpFile); fs.rmdirSync(path.dirname(tmpFile)); } catch { /* ignore */ }
470
+ if (fs.existsSync(outputPath) && !force) {
471
+ throw new UserInputError(`Output already exists: ${outputPath}. Use --force to overwrite.`);
613
472
  }
614
- } else {
615
- // Multiple chunks: generate then concat
616
- const tempDir = fs.mkdtempSync('docs-to-voice-say-');
617
- const partPaths: string[] = [];
618
- const partExt = path.extname(outputPath) || '.aiff';
619
473
 
620
- try {
621
- for (let i = 0; i < chunks.length; i++) {
622
- const partPath = path.join(tempDir, `part-${String(i + 1).padStart(4, '0')}${partExt}`);
623
- const sayArgs = ['-o', partPath];
624
- if (opts.voice) sayArgs.push('-v', opts.voice);
625
- if (opts.rate) sayArgs.push('-r', opts.rate);
626
-
627
- const tmpFile = path.join(tempDir, `chunk-${i}.txt`);
628
- fs.writeFileSync(tmpFile, chunks[i], 'utf-8');
629
- sayArgs.push('-f', tmpFile);
630
-
631
- execSync(
632
- `say ${sayArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`,
633
- { stdio: 'ignore', timeout: 300000 },
634
- );
635
- partPaths.push(partPath);
474
+ // Build prosody-enhanced text
475
+ const textChunks = splitTextForTts(sourceText, maxChars ? parseInt(maxChars, 10) || null : null);
476
+ if (textChunks.length === 0) {
477
+ throw new UserInputError('No text content found for conversion.');
478
+ }
479
+ const chunks = noAutoProsody ? textChunks : textChunks.map(buildAutoProsodyText);
480
+
481
+ if (chunks.length === 1) {
482
+ // Single say command
483
+ const sayArgs = ['-o', outputPath];
484
+ if (voice) sayArgs.push('-v', voice);
485
+ if (rate) sayArgs.push('-r', rate);
486
+
487
+ const tmpFile = path.join(fs.mkdtempSync('docs-to-voice-'), 'input.txt');
488
+ fs.mkdirSync(path.dirname(tmpFile), { recursive: true });
489
+ fs.writeFileSync(tmpFile, chunks[0], 'utf-8');
490
+ sayArgs.push('-f', tmpFile);
491
+
492
+ try {
493
+ execSync(`say ${sayArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`, {
494
+ stdio: 'ignore',
495
+ timeout: 300000,
496
+ });
497
+ } catch (err: unknown) {
498
+ const msg = err instanceof Error ? err.message : 'unknown error';
499
+ throw new SystemError(`say mode failed: ${msg}`);
500
+ } finally {
501
+ try { fs.unlinkSync(tmpFile); fs.rmdirSync(path.dirname(tmpFile)); } catch { /* ignore */ }
636
502
  }
503
+ } else {
504
+ // Multiple chunks: generate then concat
505
+ const tempDir = fs.mkdtempSync('docs-to-voice-say-');
506
+ const partPaths: string[] = [];
507
+ const partExt = path.extname(outputPath) || '.aiff';
508
+
509
+ try {
510
+ for (let i = 0; i < chunks.length; i++) {
511
+ const partPath = path.join(tempDir, `part-${String(i + 1).padStart(4, '0')}${partExt}`);
512
+ const sArgs = ['-o', partPath];
513
+ if (voice) sArgs.push('-v', voice);
514
+ if (rate) sArgs.push('-r', rate);
515
+
516
+ const tFile = path.join(tempDir, `chunk-${i}.txt`);
517
+ fs.writeFileSync(tFile, chunks[i], 'utf-8');
518
+ sArgs.push('-f', tFile);
519
+
520
+ execSync(
521
+ `say ${sArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`,
522
+ { stdio: 'ignore', timeout: 300000 },
523
+ );
524
+ partPaths.push(partPath);
525
+ }
637
526
 
638
- concatAudioFiles(partPaths, outputPath);
639
- } finally {
640
- try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ }
527
+ concatAudioFiles(partPaths, outputPath);
528
+ } finally {
529
+ try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ }
530
+ }
641
531
  }
642
- }
643
532
 
644
- // Apply speech rate if requested
645
- if (opts.speechRate) {
646
- const rate = parseFloat(opts.speechRate);
647
- if (rate > 0) applySpeechRateToAudio(outputPath, rate);
648
- }
533
+ // Apply speech rate if requested
534
+ if (speechRate) {
535
+ const rateVal = parseFloat(speechRate);
536
+ if (rateVal > 0) applySpeechRateToAudio(outputPath, rateVal);
537
+ }
649
538
 
650
- // Write timeline files
651
- writeTimelineFiles(sourceText, outputPath, null);
652
- stdout.write(`${outputPath}\n`);
653
- } else {
654
- // API mode
655
- const apiKey = opts.apiKey;
656
- if (!apiKey) {
657
- stderr.write('Error: --api-key is required for api mode.\n');
658
- return 1;
659
- }
539
+ // Write timeline files
540
+ writeTimelineFiles(sourceText, outputPath, null);
541
+ stdout.write(`${outputPath}\n`);
542
+ } else {
543
+ // API mode
544
+ if (!apiKey) {
545
+ throw new UserInputError('--api-key is required for api mode.');
546
+ }
660
547
 
661
- const sentences = splitSentences(sourceText);
662
- if (sentences.length === 0) {
663
- stderr.write('Error: No text content found for conversion.\n');
664
- return 1;
665
- }
548
+ const sentences = splitSentences(sourceText);
549
+ if (sentences.length === 0) {
550
+ throw new UserInputError('No text content found for conversion.');
551
+ }
666
552
 
667
- const maxChars = opts.maxChars ? parseInt(opts.maxChars, 10) || null : null;
553
+ const maxCharsNum = maxChars ? parseInt(maxChars, 10) || null : null;
668
554
 
669
- // Build request items from sentences
670
- interface RequestItem {
671
- sentenceIndex: number;
672
- text: string;
673
- }
674
- const requestItems: RequestItem[] = [];
675
- for (let si = 0; si < sentences.length; si++) {
676
- const sentence = sentences[si];
677
- if (maxChars && sentence.length > maxChars) {
678
- for (let i = 0; i < sentence.length; i += maxChars) {
679
- requestItems.push({ sentenceIndex: si, text: sentence.slice(i, i + maxChars) });
555
+ // Build request items from sentences
556
+ interface RequestItem {
557
+ sentenceIndex: number;
558
+ text: string;
559
+ }
560
+ const requestItems: RequestItem[] = [];
561
+ for (let si = 0; si < sentences.length; si++) {
562
+ const sentence = sentences[si];
563
+ if (maxCharsNum && sentence.length > maxCharsNum) {
564
+ for (let i = 0; i < sentence.length; i += maxCharsNum) {
565
+ requestItems.push({ sentenceIndex: si, text: sentence.slice(i, i + maxCharsNum) });
566
+ }
567
+ } else {
568
+ requestItems.push({ sentenceIndex: si, text: sentence });
680
569
  }
681
- } else {
682
- requestItems.push({ sentenceIndex: si, text: sentence });
683
570
  }
684
- }
685
571
 
686
- if (requestItems.length === 0) {
687
- stderr.write('Error: No text content found for conversion.\n');
688
- return 1;
689
- }
572
+ if (requestItems.length === 0) {
573
+ throw new UserInputError('No text content found for conversion.');
574
+ }
690
575
 
691
- const tempDir = fs.mkdtempSync('docs-to-voice-api-');
692
- const partPaths: string[] = [];
693
- let partExt = '';
694
- const sentenceDurations = new Array(sentences.length).fill(0);
695
- const sentenceDurationKnown = new Array(sentences.length).fill(true);
696
-
697
- try {
698
- for (let i = 0; i < requestItems.length; i++) {
699
- const item = requestItems[i];
700
- const apiResult = await requestAlibabaCloudTTS(
701
- opts.apiEndpoint,
702
- apiKey,
703
- opts.apiModel,
704
- opts.apiVoice,
705
- item.text,
706
- );
707
-
708
- const currentExt = apiResult.audioFormat || 'wav';
709
- if (!partExt) partExt = currentExt;
710
-
711
- const partPath = path.join(tempDir, `part-${String(i + 1).padStart(4, '0')}.${currentExt}`);
712
- if (apiResult.audioUrl) {
713
- await downloadBinary(apiResult.audioUrl, partPath);
714
- } else if (apiResult.audioData) {
715
- fs.writeFileSync(partPath, Buffer.from(apiResult.audioData, 'base64'));
716
- } else {
717
- throw new Error('No audio data in API response.');
718
- }
576
+ const tempDir = fs.mkdtempSync('docs-to-voice-api-');
577
+ const partPaths: string[] = [];
578
+ let partExt = '';
579
+ const sentenceDurations = new Array(sentences.length).fill(0);
580
+ const sentenceDurationKnown = new Array(sentences.length).fill(true);
581
+
582
+ try {
583
+ for (let i = 0; i < requestItems.length; i++) {
584
+ const item = requestItems[i];
585
+ const apiResult = await requestAlibabaCloudTTS(
586
+ apiEndpoint,
587
+ apiKey,
588
+ apiModel,
589
+ apiVoice,
590
+ item.text,
591
+ );
592
+
593
+ const currentExt = apiResult.audioFormat || 'wav';
594
+ if (!partExt) partExt = currentExt;
595
+
596
+ const partPath = path.join(tempDir, `part-${String(i + 1).padStart(4, '0')}.${currentExt}`);
597
+ if (apiResult.audioUrl) {
598
+ await downloadBinary(apiResult.audioUrl, partPath);
599
+ } else if (apiResult.audioData) {
600
+ fs.writeFileSync(partPath, Buffer.from(apiResult.audioData, 'base64'));
601
+ } else {
602
+ throw new SystemError('No audio data in API response.');
603
+ }
604
+
605
+ if (!fs.existsSync(partPath) || fs.statSync(partPath).size === 0) {
606
+ throw new SystemError(`Failed to generate audio chunk ${i + 1}.`);
607
+ }
608
+ partPaths.push(partPath);
719
609
 
720
- if (!fs.existsSync(partPath) || fs.statSync(partPath).size === 0) {
721
- throw new Error(`Failed to generate audio chunk ${i + 1}.`);
610
+ const partDuration = readDurationSeconds(partPath);
611
+ if (partDuration === null || partDuration <= 0) {
612
+ sentenceDurationKnown[item.sentenceIndex] = false;
613
+ } else {
614
+ sentenceDurations[item.sentenceIndex] += partDuration;
615
+ }
722
616
  }
723
- partPaths.push(partPath);
724
617
 
725
- const partDuration = readDurationSeconds(partPath);
726
- if (partDuration === null || partDuration <= 0) {
727
- sentenceDurationKnown[item.sentenceIndex] = false;
728
- } else {
729
- sentenceDurations[item.sentenceIndex] += partDuration;
618
+ const finalOutputName = hasExtension
619
+ ? outName
620
+ : `${outName}.${partExt || 'wav'}`;
621
+ const outputPath = path.join(outputDir, finalOutputName);
622
+
623
+ if (fs.existsSync(outputPath) && !force) {
624
+ throw new UserInputError(`Output already exists: ${outputPath}. Use --force to overwrite.`);
730
625
  }
731
- }
732
626
 
733
- const finalOutputName = hasExtension
734
- ? outputName
735
- : `${outputName}.${partExt || 'wav'}`;
736
- const outputPath = path.join(outputDir, finalOutputName);
627
+ concatAudioFiles(partPaths, outputPath);
737
628
 
738
- if (fs.existsSync(outputPath) && !opts.force) {
739
- stderr.write(`Error: Output already exists: ${outputPath}. Use --force to overwrite.\n`);
740
- return 1;
741
- }
629
+ // Build timeline durations
630
+ let timelineDurations: number[] | null = null;
631
+ const unknownIndexes = sentenceDurationKnown
632
+ .map((known, idx) => (known ? -1 : idx))
633
+ .filter((idx) => idx >= 0);
634
+
635
+ if (unknownIndexes.length === 0 && sentenceDurations.reduce((a, b) => a + b, 0) > 0) {
636
+ timelineDurations = sentenceDurations;
637
+ } else if (unknownIndexes.length > 0) {
638
+ const outputDuration = readDurationSeconds(outputPath);
639
+ const knownTotal = sentenceDurations.reduce(
640
+ (sum, val, idx) => (sentenceDurationKnown[idx] ? sum + val : sum),
641
+ 0,
642
+ );
742
643
 
743
- concatAudioFiles(partPaths, outputPath);
744
-
745
- // Build timeline durations
746
- let timelineDurations: number[] | null = null;
747
- const unknownIndexes = sentenceDurationKnown
748
- .map((known, idx) => (known ? -1 : idx))
749
- .filter((idx) => idx >= 0);
750
-
751
- if (unknownIndexes.length === 0 && sentenceDurations.reduce((a, b) => a + b, 0) > 0) {
752
- timelineDurations = sentenceDurations;
753
- } else if (unknownIndexes.length > 0) {
754
- const outputDuration = readDurationSeconds(outputPath);
755
- const knownTotal = sentenceDurations.reduce(
756
- (sum, val, idx) => (sentenceDurationKnown[idx] ? sum + val : sum),
757
- 0,
758
- );
759
-
760
- if (outputDuration && outputDuration > knownTotal) {
761
- const remaining = outputDuration - knownTotal;
762
- const unknownWeights = unknownIndexes.map((idx) => sentenceWeight(sentences[idx]));
763
- const totalUnknownWeight = unknownWeights.reduce((a, b) => a + b, 0);
764
-
765
- if (totalUnknownWeight > 0) {
766
- for (let wi = 0; wi < unknownIndexes.length; wi++) {
767
- sentenceDurations[unknownIndexes[wi]] +=
768
- remaining * (unknownWeights[wi] / totalUnknownWeight);
644
+ if (outputDuration && outputDuration > knownTotal) {
645
+ const remaining = outputDuration - knownTotal;
646
+ const unknownWeights = unknownIndexes.map((idx) => sentenceWeight(sentences[idx]));
647
+ const totalUnknownWeight = unknownWeights.reduce((a, b) => a + b, 0);
648
+
649
+ if (totalUnknownWeight > 0) {
650
+ for (let wi = 0; wi < unknownIndexes.length; wi++) {
651
+ sentenceDurations[unknownIndexes[wi]] +=
652
+ remaining * (unknownWeights[wi] / totalUnknownWeight);
653
+ }
654
+ timelineDurations = sentenceDurations;
769
655
  }
770
- timelineDurations = sentenceDurations;
771
656
  }
772
657
  }
773
- }
774
658
 
775
- // Apply speech rate if requested
776
- if (opts.speechRate) {
777
- const rate = parseFloat(opts.speechRate);
778
- if (rate > 0) {
779
- applySpeechRateToAudio(outputPath, rate);
780
- if (timelineDurations) {
781
- timelineDurations = timelineDurations.map((d) => d / rate);
659
+ // Apply speech rate if requested
660
+ if (speechRate) {
661
+ const rateVal = parseFloat(speechRate);
662
+ if (rateVal > 0) {
663
+ applySpeechRateToAudio(outputPath, rateVal);
664
+ if (timelineDurations) {
665
+ timelineDurations = timelineDurations.map((d) => d / rateVal);
666
+ }
782
667
  }
783
668
  }
784
- }
785
669
 
786
- writeTimelineFiles(sourceText, outputPath, timelineDurations);
787
- stdout.write(`${outputPath}\n`);
788
- } finally {
789
- try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ }
670
+ writeTimelineFiles(sourceText, outputPath, timelineDurations);
671
+ stdout.write(`${outputPath}\n`);
672
+ } finally {
673
+ try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ }
674
+ }
790
675
  }
791
- }
792
676
 
793
- return 0;
794
- } catch (err: unknown) {
795
- const msg = err instanceof Error ? err.message : 'Unknown error';
796
- stderr.write(`Error: ${msg}\n`);
797
- return 1;
798
- }
799
- }
677
+ return 0;
678
+ },
679
+ };
800
680
 
801
681
  export const tool: ToolDefinition = {
802
682
  name: 'docs-to-voice',
803
683
  category: 'media',
804
684
  description: 'Convert text into audio and sentence timelines.',
805
- handler: docsToVoiceHandler,
685
+ handler: createToolRunner(schema),
806
686
  };