@gobing-ai/knowledge-kit 0.0.11 → 0.0.13

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 (99) hide show
  1. package/dist/index.js +451 -152
  2. package/package.json +1 -1
  3. package/plugins/generations/content-gen/dist/index.js +22167 -0
  4. package/plugins/generations/content-gen/plugin.json +1 -1
  5. package/plugins/generations/content-gen/src/storm.ts +99 -50
  6. package/plugins/generations/core-facts-gen/dist/index.js +22048 -0
  7. package/plugins/generations/core-facts-gen/package.json +17 -0
  8. package/plugins/generations/core-facts-gen/plugin.json +7 -0
  9. package/plugins/generations/core-facts-gen/src/index.ts +116 -0
  10. package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
  11. package/plugins/generations/daily-article-gen/dist/index.js +22022 -0
  12. package/plugins/generations/daily-article-gen/package.json +17 -0
  13. package/plugins/generations/daily-article-gen/plugin.json +7 -0
  14. package/plugins/generations/daily-article-gen/src/index.ts +91 -0
  15. package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
  16. package/plugins/generations/dailynews-gen/dist/index.js +22324 -0
  17. package/plugins/generations/dailynews-gen/plugin.json +1 -1
  18. package/plugins/generations/dailynews-gen/src/index.ts +11 -0
  19. package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
  20. package/plugins/generations/episode-plan-gen/dist/index.js +22479 -0
  21. package/plugins/generations/episode-plan-gen/package.json +17 -0
  22. package/plugins/generations/episode-plan-gen/plugin.json +7 -0
  23. package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
  24. package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
  25. package/plugins/generations/image-gen/config.example.yaml +75 -0
  26. package/plugins/generations/image-gen/dist/index.js +22619 -0
  27. package/plugins/generations/image-gen/package.json +17 -0
  28. package/plugins/generations/image-gen/plugin.json +7 -0
  29. package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
  30. package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
  31. package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
  32. package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
  33. package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
  34. package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
  35. package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
  36. package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
  37. package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
  38. package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
  39. package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
  40. package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
  41. package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
  42. package/plugins/generations/image-gen/src/bytes.ts +19 -0
  43. package/plugins/generations/image-gen/src/index.ts +319 -0
  44. package/plugins/generations/image-gen/src/job.ts +143 -0
  45. package/plugins/generations/image-gen/src/paths.ts +31 -0
  46. package/plugins/generations/image-gen/src/presets.ts +344 -0
  47. package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
  48. package/plugins/generations/image-gen/src/providers/types.ts +285 -0
  49. package/plugins/generations/image-gen/tsconfig.json +8 -0
  50. package/plugins/generations/voice-gen/dist/index.js +23027 -0
  51. package/plugins/generations/voice-gen/plugin.json +1 -1
  52. package/plugins/generations/voice-gen/src/index.ts +102 -11
  53. package/plugins/generations/voice-gen/src/qc.ts +154 -9
  54. package/plugins/ingestions/aihot-ingest/dist/index.js +22358 -0
  55. package/plugins/ingestions/aihot-ingest/plugin.json +2 -2
  56. package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
  57. package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
  58. package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
  59. package/plugins/ingestions/horizon-ingest/dist/index.js +22105 -0
  60. package/plugins/ingestions/horizon-ingest/package.json +17 -0
  61. package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
  62. package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
  63. package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
  64. package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
  65. package/plugins/ingestions/karakeep-local/plugin.json +1 -1
  66. package/plugins/ingestions/last30days-ingest/dist/index.js +22050 -0
  67. package/plugins/ingestions/last30days-ingest/package.json +17 -0
  68. package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
  69. package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
  70. package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
  71. package/plugins/ingestions/web-search/dist/index.js +24379 -0
  72. package/plugins/ingestions/web-search/plugin.json +1 -1
  73. package/plugins/kk/commands/image-generate.md +15 -0
  74. package/plugins/kk/config.example.yaml +80 -0
  75. package/plugins/kk/skills/image-authoring/SKILL.md +250 -0
  76. package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
  77. package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
  78. package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
  79. package/plugins/kk/skills/image-authoring/references/style-extraction.md +134 -0
  80. package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
  81. package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
  82. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
  83. package/plugins/publishings/emdash-pub/dist/index.js +22243 -0
  84. package/plugins/publishings/emdash-pub/plugin.json +1 -1
  85. package/plugins/publishings/podcast-pub/dist/index.js +22596 -0
  86. package/plugins/publishings/podcast-pub/package.json +17 -0
  87. package/plugins/publishings/podcast-pub/plugin.json +13 -0
  88. package/plugins/publishings/podcast-pub/src/index.ts +538 -0
  89. package/plugins/publishings/podcast-pub/src/map.ts +165 -0
  90. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
  91. package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
  92. package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
  93. package/plugins/publishings/qiita-pub/dist/index.js +22081 -0
  94. package/plugins/publishings/qiita-pub/plugin.json +1 -1
  95. package/plugins/publishings/surfdash-pub/dist/index.js +22233 -0
  96. package/plugins/publishings/surfdash-pub/plugin.json +1 -1
  97. package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
  98. package/plugins/publishings/zenn-pub/dist/index.js +22122 -0
  99. package/plugins/publishings/zenn-pub/plugin.json +1 -1
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/podcast-pub",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/publish-harness": "workspace:*",
11
+ "@gobing-ai/ts-runtime": "catalog:",
12
+ "@gobing-ai/ts-utils": "catalog:"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "podcast-pub",
3
+ "kind": "publish",
4
+ "entry": "./dist/index.js",
5
+ "version": "1.0.0",
6
+ "description": "Publish plugin for a self-hosted microfeed instance: podcast episodes via the Bearer-authenticated /api/v1/ REST API plus item management operations",
7
+ "operations": {
8
+ "show-notes": {
9
+ "input": "doc-list",
10
+ "output": "markdown"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,538 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { type Content, ContentSchema, DocListSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
5
+ import { FakeFileCliTransport, type PublishTransport, type PublishTransportPayload } from '@gobing-ai/publish-harness';
6
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
7
+ import { echoError } from '@gobing-ai/ts-utils';
8
+ import { type EpisodeMapping, mapContentToEpisode } from './map';
9
+ import { MicrofeedClient, type MicrofeedItemInput, resolveTimeoutMs } from './microfeed-client';
10
+ import { renderShowNotesMarkdown, type ShowNotesRenderOptions } from './show-notes';
11
+
12
+ /** Result target name for this plugin. */
13
+ const TARGET = 'podcast-pub';
14
+
15
+ /** Management operations routed via `options.operation` (design D5). */
16
+ const OPERATIONS = ['publish', 'show-notes', 'list', 'get', 'update', 'delete', 'validate'] as const;
17
+ type Operation = (typeof OPERATIONS)[number];
18
+
19
+ /** Target-specific options carried on the invoke payload. */
20
+ interface PodcastPubOptions {
21
+ operation?: unknown;
22
+ itemId?: unknown;
23
+ /** Item input (`apiItemInputSchema` shape) sent by the `update` op / `validate` op. */
24
+ item?: unknown;
25
+ /** Episode plan Doc[] rendered by the `show-notes` op (transport-level calls carry it here). */
26
+ docs?: unknown;
27
+ /** Show-notes render options; CLI callers may omit these (env fallbacks apply). */
28
+ title?: unknown;
29
+ runDate?: unknown;
30
+ articleBaseUrl?: unknown;
31
+ /** Episode-assembly options for the `publish` op (absorb publish-prep's merge; additive). */
32
+ audioFile?: unknown;
33
+ coverPath?: unknown;
34
+ durationSec?: unknown;
35
+ }
36
+
37
+ /** Re-reads env each call so tests can set credentials without constructor plumbing (design D4). */
38
+ function resolveClient(): { client: MicrofeedClient } | { error: string } {
39
+ const baseUrl = process.env.MICROFEED_BASE_URL;
40
+ const apiKey = process.env.MICROFEED_API_KEY;
41
+ if (!baseUrl || baseUrl.trim().length === 0) {
42
+ return {
43
+ error: 'MICROFEED_BASE_URL environment variable is required: the microfeed instance base URL (e.g. https://<instance>.workers.dev)',
44
+ };
45
+ }
46
+ if (!apiKey || apiKey.trim().length === 0) {
47
+ return {
48
+ error: 'MICROFEED_API_KEY environment variable is required: a microfeed named API key with write scope',
49
+ };
50
+ }
51
+ return {
52
+ client: new MicrofeedClient(baseUrl.trim(), apiKey.trim(), resolveTimeoutMs(process.env.MICROFEED_TIMEOUT_MS)),
53
+ };
54
+ }
55
+
56
+ function fail(error: string): Result {
57
+ return ResultSchema.parse({ ok: false, target: TARGET, error });
58
+ }
59
+
60
+ /** Parses and routes `options.operation`; unknown or misplaced values fail loud. */
61
+ function resolveOperation(options: PodcastPubOptions): { operation: Operation } | { error: string } {
62
+ const raw = options.operation ?? 'publish';
63
+ if (typeof raw !== 'string' || !OPERATIONS.includes(raw as Operation)) {
64
+ return { error: `invalid options.operation "${String(raw)}": expected one of ${OPERATIONS.join(', ')}` };
65
+ }
66
+ return { operation: raw as Operation };
67
+ }
68
+
69
+ /** `options.itemId` is required for the item-scoped operations (design D5). */
70
+ function resolveItemId(options: PodcastPubOptions): { itemId: string } | { error: string } {
71
+ const raw = options.itemId;
72
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
73
+ return { error: `options.itemId is required for the "${String(options.operation)}" operation` };
74
+ }
75
+ return { itemId: raw.trim() };
76
+ }
77
+
78
+ /** Options the `show-notes` operation reads on the payload (a subset of {@link PodcastPubOptions}). */
79
+ type ShowNotesOpOptions = Pick<PodcastPubOptions, 'docs' | 'title' | 'runDate' | 'articleBaseUrl'>;
80
+
81
+ /**
82
+ * Option-then-env resolution for the show-notes render options: `kk executor run`
83
+ * passes only `--in`/`--out`, so workflow callers (task 0097) supply `title` and
84
+ * `runDate` as `PODCAST_PUB_*` environment variables, matching the dailynews-gen
85
+ * env-option convention. Raw values — no trimming — to keep the render byte-stable.
86
+ */
87
+ function resolveOptionString(raw: unknown, envKey: string): string | undefined {
88
+ if (typeof raw === 'string' && raw.length > 0) return raw;
89
+ const fromEnv = process.env[envKey];
90
+ return fromEnv !== undefined && fromEnv.length > 0 ? fromEnv : undefined;
91
+ }
92
+
93
+ /** Resolves `{title, runDate, articleBaseUrl?}`; title and runDate are required. */
94
+ function resolveShowNotesRenderOptions(
95
+ options: ShowNotesOpOptions,
96
+ ): { renderOptions: ShowNotesRenderOptions } | { error: string } {
97
+ const title = resolveOptionString(options.title, 'PODCAST_PUB_TITLE');
98
+ if (title === undefined) {
99
+ return { error: 'options.title (or PODCAST_PUB_TITLE) is required for the "show-notes" operation' };
100
+ }
101
+ const runDate = resolveOptionString(options.runDate, 'PODCAST_PUB_RUN_DATE');
102
+ if (runDate === undefined) {
103
+ return { error: 'options.runDate (or PODCAST_PUB_RUN_DATE) is required for the "show-notes" operation' };
104
+ }
105
+ const articleBaseUrl = resolveOptionString(options.articleBaseUrl, 'PODCAST_PUB_ARTICLE_BASE_URL');
106
+ return {
107
+ renderOptions: { title, runDate, ...(articleBaseUrl !== undefined ? { articleBaseUrl } : {}) },
108
+ };
109
+ }
110
+
111
+ /** Validates the plan Doc[] and renders show-notes markdown (pure render; no microfeed call). */
112
+ function renderShowNotesFromOptions(options: ShowNotesOpOptions): { markdown: string } | { error: string } {
113
+ const docs = DocListSchema.safeParse(options.docs);
114
+ if (!docs.success) {
115
+ const reason = docs.error.issues[0]?.message ?? 'invalid document list';
116
+ return { error: `options.docs must be an episode plan Doc[] array: ${reason}` };
117
+ }
118
+ const renderOptions = resolveShowNotesRenderOptions(options);
119
+ if ('error' in renderOptions) return { error: renderOptions.error };
120
+ return { markdown: renderShowNotesMarkdown(docs.data, renderOptions.renderOptions) };
121
+ }
122
+
123
+ /** Episode-assembly options (R2): present keys make the plugin merge article + audio + cover itself. */
124
+ interface AssemblyOptions {
125
+ audioFile?: string;
126
+ coverPath?: string;
127
+ durationSec?: number;
128
+ }
129
+
130
+ /** Resolves the additive assembly options; each key is independent, well-typed, and fails loud. */
131
+ function resolveAssemblyOptions(options: PodcastPubOptions): { assembly: AssemblyOptions } | { error: string } {
132
+ const assembly: AssemblyOptions = {};
133
+ if (options.audioFile !== undefined) {
134
+ if (typeof options.audioFile !== 'string' || options.audioFile.trim().length === 0) {
135
+ return { error: 'invalid options.audioFile: expected a nonempty local audio file path' };
136
+ }
137
+ assembly.audioFile = options.audioFile.trim();
138
+ }
139
+ if (options.coverPath !== undefined) {
140
+ if (typeof options.coverPath !== 'string' || options.coverPath.trim().length === 0) {
141
+ return { error: 'invalid options.coverPath: expected a nonempty local cover image path' };
142
+ }
143
+ assembly.coverPath = options.coverPath.trim();
144
+ }
145
+ if (options.durationSec !== undefined) {
146
+ if (
147
+ typeof options.durationSec !== 'number' ||
148
+ !Number.isFinite(options.durationSec) ||
149
+ options.durationSec < 0
150
+ ) {
151
+ return {
152
+ error: `invalid options.durationSec: expected a nonnegative number, got ${String(options.durationSec)}`,
153
+ };
154
+ }
155
+ assembly.durationSec = options.durationSec;
156
+ }
157
+ return { assembly };
158
+ }
159
+
160
+ /** Builds the RSS-enclosure attachment from the presigned-upload media URL (design D2). */
161
+ function audioAttachment(
162
+ audio: ReturnType<typeof mapContentToEpisode>['audio'],
163
+ mediaUrl: string,
164
+ sizeInBytes?: number,
165
+ durationSeconds?: number,
166
+ ): MicrofeedItemInput {
167
+ return {
168
+ category: 'audio',
169
+ url: mediaUrl,
170
+ mime_type: audio.mimeType,
171
+ ...(sizeInBytes !== undefined ? { size_in_bytes: sizeInBytes } : {}),
172
+ ...(durationSeconds !== undefined ? { duration_in_seconds: durationSeconds } : {}),
173
+ };
174
+ }
175
+
176
+ /** Spreads optional episode fields (image, date, link, itunes) into a microfeed item payload. */
177
+ function episodeExtras(episode: EpisodeMapping): MicrofeedItemInput {
178
+ return {
179
+ ...(episode.imageUrl ? { image: episode.imageUrl } : {}),
180
+ ...(episode.datePublishedMs !== undefined ? { date_published_ms: episode.datePublishedMs } : {}),
181
+ ...(episode.link ? { url: episode.link } : {}),
182
+ ...{
183
+ _microfeed: {
184
+ 'itunes:episodeType': episode.itunes?.episodeType ?? 'full',
185
+ ...(episode.itunes?.title ? { 'itunes:title': episode.itunes.title } : {}),
186
+ ...(episode.itunes?.season !== undefined ? { 'itunes:season': episode.itunes.season } : {}),
187
+ ...(episode.itunes?.episode !== undefined ? { 'itunes:episode': episode.itunes.episode } : {}),
188
+ ...(episode.itunes?.explicit !== undefined ? { 'itunes:explicit': episode.itunes.explicit } : {}),
189
+ ...(episode.itunes?.block !== undefined ? { 'itunes:block': episode.itunes.block } : {}),
190
+ },
191
+ },
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Two-phase publish (design D2, the only legal order): create a draft item →
197
+ * presign with `item_id` → PUT bytes → PUT item with attachment + target status.
198
+ * `metadata.audioUrl` (already hosted) skips the upload steps and creates the
199
+ * published item in a single call.
200
+ */
201
+ async function publishEpisode(
202
+ client: MicrofeedClient,
203
+ payload: PublishTransportPayload,
204
+ options: PodcastPubOptions,
205
+ ): Promise<Result> {
206
+ const assembly = resolveAssemblyOptions(options);
207
+ if ('error' in assembly) return fail(assembly.error);
208
+
209
+ // Assembly options are additive (L1 R6): present keys make the plugin own that channel
210
+ // (publish-prep's merge absorbed — options.audioFile retires metadata.audioUrl so the
211
+ // map.ts ambiguity guard is not tripped); absent keys leave the existing
212
+ // Content→episode mapping (map.ts) as the fallback, unchanged.
213
+ const { audioFile, coverPath, durationSec } = assembly.assembly;
214
+ const content: Content =
215
+ audioFile !== undefined || coverPath !== undefined || durationSec !== undefined
216
+ ? {
217
+ ...payload.content,
218
+ metadata: {
219
+ ...payload.content.metadata,
220
+ ...(audioFile !== undefined ? { audioFile, audioUrl: undefined } : {}),
221
+ ...(coverPath !== undefined ? { coverImage: coverPath } : {}),
222
+ ...(durationSec !== undefined ? { durationSeconds: durationSec } : {}),
223
+ },
224
+ }
225
+ : payload.content;
226
+
227
+ let episode: EpisodeMapping;
228
+ try {
229
+ episode = mapContentToEpisode(content);
230
+ } catch (error: unknown) {
231
+ return fail(`Invalid Content: ${error instanceof Error ? error.message : String(error)}`);
232
+ }
233
+
234
+ // Caller-supplied uuid per publish attempt (design D3; microfeed makes create
235
+ // idempotent for 24h, so a retry after a lost response cannot duplicate the item).
236
+ const idempotencyKey = crypto.randomUUID();
237
+
238
+ if (episode.audio.kind === 'url') {
239
+ const itemId = await client.createItem(
240
+ {
241
+ title: episode.title,
242
+ content_html: episode.contentHtml,
243
+ status: episode.status,
244
+ attachments: [audioAttachment(episode.audio, episode.audio.url, undefined, episode.durationSeconds)],
245
+ ...episodeExtras(episode),
246
+ },
247
+ idempotencyKey,
248
+ );
249
+ return ResultSchema.parse({
250
+ ok: true,
251
+ target: TARGET,
252
+ url: client.itemUrl(itemId),
253
+ id: itemId,
254
+ message: 'Published successfully to microfeed (hosted audio, single-call create)',
255
+ });
256
+ }
257
+
258
+ // Phase 1: draft item without attachment (audio presign requires item_id).
259
+ const itemId = await client.createItem(
260
+ {
261
+ title: episode.title,
262
+ content_html: episode.contentHtml,
263
+ status: 'unpublished',
264
+ },
265
+ idempotencyKey,
266
+ );
267
+
268
+ // Phase 2-3: presign against the draft, then PUT the raw bytes.
269
+ const bytes = new Uint8Array(await readFile(episode.audio.path));
270
+ const prepared = await client.prepareUpload({
271
+ category: 'audio',
272
+ full_local_file_path: episode.audio.path,
273
+ item_id: itemId,
274
+ size: bytes.byteLength,
275
+ type: episode.audio.mimeType,
276
+ });
277
+ await client.uploadBytes(prepared.presignedUrl, bytes, episode.audio.mimeType);
278
+
279
+ // Phase 2b: upload a local cover via the presigned image flow (no item_id needed).
280
+ let imageUrl = episode.imageUrl;
281
+ if (episode.imagePath && !imageUrl) {
282
+ const imageBytes = new Uint8Array(await readFile(episode.imagePath));
283
+ const imagePrepared = await client.prepareUpload({
284
+ category: 'image',
285
+ full_local_file_path: episode.imagePath,
286
+ size: imageBytes.byteLength,
287
+ type: 'image/png',
288
+ });
289
+ await client.uploadBytes(imagePrepared.presignedUrl, imageBytes, 'image/png');
290
+ imageUrl = imagePrepared.mediaUrl;
291
+ }
292
+
293
+ // Phase 4: attach + publish; the response is the public item (url when live).
294
+ const item = await client.updateItem(itemId, {
295
+ title: episode.title,
296
+ content_html: episode.contentHtml,
297
+ status: episode.status,
298
+ attachments: [audioAttachment(episode.audio, prepared.mediaUrl, bytes.byteLength, episode.durationSeconds)],
299
+ ...(imageUrl ? { image: imageUrl } : {}),
300
+ ...episodeExtras(episode),
301
+ });
302
+
303
+ return ResultSchema.parse({
304
+ ok: true,
305
+ target: TARGET,
306
+ url: typeof item.url === 'string' && item.url.length > 0 ? item.url : client.itemUrl(itemId),
307
+ id: itemId,
308
+ message: 'Published successfully to microfeed (two-phase upload + publish)',
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Token-API transport for a self-hosted microfeed instance (design D1):
314
+ * publishes podcast episodes over `/api/v1/` with Bearer auth and routes thin
315
+ * management operations via `options.operation` (design D5).
316
+ */
317
+ export class PodcastPubTransport implements PublishTransport {
318
+ public readonly kind = 'file-cli';
319
+
320
+ public async publish(payload: PublishTransportPayload): Promise<Result> {
321
+ const options: PodcastPubOptions =
322
+ payload.options !== undefined && typeof payload.options === 'object' && payload.options !== null
323
+ ? (payload.options as PodcastPubOptions)
324
+ : {};
325
+
326
+ const routed = resolveOperation(options);
327
+ if ('error' in routed) return fail(routed.error);
328
+
329
+ // Pure render: no microfeed client (hence no credentials) and no API call (design L1 R6).
330
+ if (routed.operation === 'show-notes') {
331
+ const rendered = renderShowNotesFromOptions(options);
332
+ if ('error' in rendered) return fail(rendered.error);
333
+ return ResultSchema.parse({
334
+ ok: true,
335
+ target: TARGET,
336
+ message: 'Rendered show notes markdown (pure render; no microfeed API call)',
337
+ metadata: { markdown: rendered.markdown },
338
+ });
339
+ }
340
+
341
+ const resolved = resolveClient();
342
+ if ('error' in resolved) return fail(resolved.error);
343
+ const { client } = resolved;
344
+
345
+ try {
346
+ switch (routed.operation) {
347
+ case 'publish':
348
+ return await publishEpisode(client, payload, options);
349
+ case 'list': {
350
+ const feed = await client.listItems();
351
+ return ResultSchema.parse({
352
+ ok: true,
353
+ target: TARGET,
354
+ url: typeof feed.feed_url === 'string' ? feed.feed_url : undefined,
355
+ message: `Listed ${feed.items?.length ?? 0} microfeed item(s)`,
356
+ metadata: { items: feed.items ?? [] },
357
+ });
358
+ }
359
+ case 'get': {
360
+ const itemId = resolveItemId(options);
361
+ if ('error' in itemId) return fail(itemId.error);
362
+ const feed = await client.getItem(itemId.itemId);
363
+ const item = feed.items?.[0];
364
+ if (!item) return fail(`microfeed item "${itemId.itemId}" not found in the feed response`);
365
+ return ResultSchema.parse({
366
+ ok: true,
367
+ target: TARGET,
368
+ id: itemId.itemId,
369
+ url: typeof item.url === 'string' ? item.url : undefined,
370
+ message: `Fetched microfeed item ${itemId.itemId}`,
371
+ metadata: { item },
372
+ });
373
+ }
374
+ case 'update': {
375
+ const itemId = resolveItemId(options);
376
+ if ('error' in itemId) return fail(itemId.error);
377
+ if (options.item === undefined || typeof options.item !== 'object' || options.item === null) {
378
+ return fail('options.item (microfeed item input object) is required for the update operation');
379
+ }
380
+ const item = await client.updateItem(itemId.itemId, options.item as MicrofeedItemInput);
381
+ return ResultSchema.parse({
382
+ ok: true,
383
+ target: TARGET,
384
+ id: itemId.itemId,
385
+ url: typeof item.url === 'string' ? item.url : undefined,
386
+ message: `Updated microfeed item ${itemId.itemId}`,
387
+ metadata: { item },
388
+ });
389
+ }
390
+ case 'delete': {
391
+ const itemId = resolveItemId(options);
392
+ if ('error' in itemId) return fail(itemId.error);
393
+ await client.deleteItem(itemId.itemId);
394
+ return ResultSchema.parse({
395
+ ok: true,
396
+ target: TARGET,
397
+ id: itemId.itemId,
398
+ message: `Deleted microfeed item ${itemId.itemId}`,
399
+ });
400
+ }
401
+ case 'validate': {
402
+ // Validate the caller-supplied item input, or the Content-mapped
403
+ // draft when no explicit input is given (server-side schema check).
404
+ const input =
405
+ options.item !== undefined && typeof options.item === 'object' && options.item !== null
406
+ ? (options.item as MicrofeedItemInput)
407
+ : (() => {
408
+ const episode = mapContentToEpisode(payload.content);
409
+ return {
410
+ title: episode.title,
411
+ content_html: episode.contentHtml,
412
+ status: episode.status,
413
+ } satisfies MicrofeedItemInput;
414
+ })();
415
+ await client.validateItem(input);
416
+ return ResultSchema.parse({
417
+ ok: true,
418
+ target: TARGET,
419
+ message: 'microfeed accepted the item input (POST /api/v1/items/validate)',
420
+ });
421
+ }
422
+ }
423
+ } catch (error: unknown) {
424
+ return fail(`${error instanceof Error ? error.message : String(error)}`);
425
+ }
426
+ }
427
+ }
428
+
429
+ /** Resolves the default PublishTransport based on environment configuration. */
430
+ export function getPublishTransport(): PublishTransport {
431
+ if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
432
+ return new FakeFileCliTransport();
433
+ }
434
+ return new PodcastPubTransport();
435
+ }
436
+
437
+ /** Writes a Result JSON to the output path, creating the directory as needed. */
438
+ async function writePublishResult(outputPath: string, result: Result): Promise<void> {
439
+ const outDir = dirname(outputPath);
440
+ if (outDir && outDir !== '.') {
441
+ await createNodeFileSystem().ensureDir(outDir);
442
+ }
443
+ await createNodeFileSystem().writeFile(outputPath, JSON.stringify(result, null, 2));
444
+ }
445
+
446
+ /**
447
+ * Show-notes operation IO: `--in` is the episode plan Doc[], `--out` the markdown
448
+ * file (pure render — no Content, no transport call). Render options come from the
449
+ * `PODCAST_PUB_*` environment variables. Failure writes a fail-loud Result to the
450
+ * output path and rejects, matching the Content path.
451
+ */
452
+ async function renderShowNotesIO(docs: unknown[], outputPath: string): Promise<void> {
453
+ const rendered = renderShowNotesFromOptions({ docs });
454
+ if ('error' in rendered) {
455
+ const message = `show-notes render failed: ${rendered.error}`;
456
+ await writePublishResult(outputPath, fail(message));
457
+ throw new Error(message);
458
+ }
459
+ await createNodeFileSystem().writeFile(outputPath, rendered.markdown);
460
+ }
461
+
462
+ /**
463
+ * Processes input Content and writes output Result via transport.
464
+ *
465
+ * A bare JSON array input selects the `show-notes` operation: `--in` is the
466
+ * episode plan Doc[] and `--out` receives the markdown file itself.
467
+ *
468
+ * The input JSON may otherwise carry a top-level `options` object (`operation`,
469
+ * `itemId`, `item`) alongside the Content fields for management operations;
470
+ * validation failure and transport failure both write a fail-loud Result
471
+ * (ok: false + error) to the output path and reject (spec §2.5).
472
+ */
473
+ export async function processPublishIO(
474
+ inputPath: string,
475
+ outputPath: string,
476
+ transport: PublishTransport = getPublishTransport(),
477
+ ): Promise<void> {
478
+ await createNodeFileSystem().deleteFile(outputPath);
479
+
480
+ let content: Content;
481
+ let options: Record<string, unknown> | undefined;
482
+ try {
483
+ const rawInput = await createNodeFileSystem().readFile(inputPath);
484
+ const raw = JSON.parse(rawInput) as Record<string, unknown>;
485
+ if (Array.isArray(raw)) {
486
+ await renderShowNotesIO(raw, outputPath);
487
+ return;
488
+ }
489
+ content = ContentSchema.parse(raw);
490
+ if (raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null) {
491
+ options = raw.options as Record<string, unknown>;
492
+ }
493
+ } catch (error: unknown) {
494
+ const message = `Invalid Content input: ${error instanceof Error ? error.message : String(error)}`;
495
+ await writePublishResult(outputPath, ResultSchema.parse({ ok: false, target: TARGET, error: message }));
496
+ throw new Error(message);
497
+ }
498
+
499
+ const result = await transport.publish({ content, options });
500
+ const validatedResult = ResultSchema.parse(result);
501
+ await writePublishResult(outputPath, validatedResult);
502
+
503
+ if (!validatedResult.ok) {
504
+ throw new Error(validatedResult.error ?? 'Publish failed');
505
+ }
506
+ }
507
+
508
+ export async function main(): Promise<number> {
509
+ let values: { in?: string; out?: string };
510
+ try {
511
+ ({ values } = parseArgs({
512
+ options: {
513
+ in: { type: 'string' },
514
+ out: { type: 'string' },
515
+ },
516
+ }));
517
+ } catch (error: unknown) {
518
+ echoError(`Invalid arguments: ${error instanceof Error ? error.message : String(error)}`);
519
+ return 1;
520
+ }
521
+
522
+ if (!values.in || !values.out) {
523
+ echoError('Missing required arguments: --in and --out');
524
+ return 1;
525
+ }
526
+
527
+ try {
528
+ await processPublishIO(values.in, values.out);
529
+ return 0;
530
+ } catch (error: unknown) {
531
+ echoError(`podcast-pub failed: ${error instanceof Error ? error.message : String(error)}`);
532
+ return 1;
533
+ }
534
+ }
535
+
536
+ if (import.meta.main) {
537
+ process.exit(await main());
538
+ }