ai-blogger 0.1.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 (2) hide show
  1. package/dist/cli.js +793 -0
  2. package/package.json +33 -0
package/dist/cli.js ADDED
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { spawn } from "child_process";
5
+ import { fileURLToPath } from "url";
6
+ import { readdirSync, statSync as statSync2 } from "fs";
7
+ import { join as join5 } from "path";
8
+ import { homedir as homedir3 } from "os";
9
+
10
+ // src/config.ts
11
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
12
+ import { dirname, join } from "path";
13
+ import { homedir } from "os";
14
+ var DEFAULT_CONFIG = {
15
+ anthropicApiKey: "",
16
+ notionToken: "",
17
+ notionDatabaseId: "",
18
+ threshold: 7
19
+ };
20
+ var APP_DIR = join(homedir(), ".ai-blogger");
21
+ var CONFIG_PATH = join(APP_DIR, "config.json");
22
+ function loadConfig(configPath = CONFIG_PATH) {
23
+ try {
24
+ const raw = readFileSync(configPath, "utf-8");
25
+ const parsed = JSON.parse(raw);
26
+ return { ...DEFAULT_CONFIG, ...parsed };
27
+ } catch {
28
+ return { ...DEFAULT_CONFIG };
29
+ }
30
+ }
31
+ function saveConfig(configPath, config) {
32
+ mkdirSync(dirname(configPath), { recursive: true });
33
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
34
+ }
35
+
36
+ // src/logger.ts
37
+ import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
38
+ import { dirname as dirname2, join as join2 } from "path";
39
+ var MAX_LOG_SIZE = 1024 * 1024;
40
+ var LOG_PATH = join2(APP_DIR, "ai-blogger.log");
41
+ function formatEntry(level, message) {
42
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
43
+ return `[${timestamp}] [${level}] ${message}
44
+ `;
45
+ }
46
+ function truncateIfNeeded(logPath) {
47
+ try {
48
+ const stat = statSync(logPath);
49
+ if (stat.size > MAX_LOG_SIZE) {
50
+ const content = readFileSync2(logPath, "utf-8");
51
+ const half = content.slice(content.length / 2);
52
+ const firstNewline = half.indexOf("\n");
53
+ writeFileSync2(logPath, firstNewline >= 0 ? half.slice(firstNewline + 1) : half);
54
+ }
55
+ } catch {
56
+ }
57
+ }
58
+ function writeLog(logPath, level, message) {
59
+ mkdirSync2(dirname2(logPath), { recursive: true });
60
+ truncateIfNeeded(logPath);
61
+ appendFileSync(logPath, formatEntry(level, message));
62
+ }
63
+ function createLogger(logPath = LOG_PATH) {
64
+ return {
65
+ info: (message) => writeLog(logPath, "INFO", message),
66
+ error: (message) => writeLog(logPath, "ERROR", message)
67
+ };
68
+ }
69
+
70
+ // src/transcript.ts
71
+ import { readFileSync as readFileSync3 } from "fs";
72
+ var MAX_TOOL_RESULT_LENGTH = 500;
73
+ var MAX_CHAR_BUDGET = 15e4;
74
+ function readTranscript(transcriptPath) {
75
+ try {
76
+ const raw = readFileSync3(transcriptPath, "utf-8");
77
+ const lines = raw.split("\n").filter((l) => l.trim());
78
+ const entries = [];
79
+ for (const line of lines) {
80
+ try {
81
+ entries.push(JSON.parse(line));
82
+ } catch {
83
+ }
84
+ }
85
+ return entries;
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+ function entryText(entry) {
91
+ if (typeof entry.content === "string") return entry.content;
92
+ if (entry.message) {
93
+ const c = entry.message.content;
94
+ if (typeof c === "string") return c;
95
+ if (Array.isArray(c)) {
96
+ return c.filter((p) => p.type === "text").map((p) => p.text).join("\n");
97
+ }
98
+ }
99
+ return "";
100
+ }
101
+ function estimateChars(entries) {
102
+ return entries.reduce((sum, e) => sum + entryText(e).length + 20, 0);
103
+ }
104
+ function preprocessTranscript(entries) {
105
+ let filtered = entries.filter(
106
+ (e) => e.type !== "system" && !e.isMeta
107
+ );
108
+ filtered = filtered.map((e) => {
109
+ if (e.type === "tool_result" && typeof e.content === "string" && e.content.length > MAX_TOOL_RESULT_LENGTH) {
110
+ return { ...e, content: e.content.slice(0, MAX_TOOL_RESULT_LENGTH) + "\n...[truncated]" };
111
+ }
112
+ return e;
113
+ });
114
+ if (estimateChars(filtered) <= MAX_CHAR_BUDGET) {
115
+ return filtered;
116
+ }
117
+ const headCount = Math.floor(filtered.length * 0.3);
118
+ const tailCount = Math.floor(filtered.length * 0.3);
119
+ const head = filtered.slice(0, headCount);
120
+ const tail = filtered.slice(filtered.length - tailCount);
121
+ const separator = {
122
+ type: "system",
123
+ content: `...[${filtered.length - headCount - tailCount} messages omitted for brevity]...`
124
+ };
125
+ return [...head, separator, ...tail];
126
+ }
127
+ function transcriptToText(entries) {
128
+ return entries.map((e) => {
129
+ const role = e.type === "user" ? "User" : e.type === "assistant" ? "Assistant" : e.type;
130
+ const text = entryText(e);
131
+ return `[${role}]: ${text}`;
132
+ }).join("\n\n");
133
+ }
134
+
135
+ // src/analyzer.ts
136
+ import Anthropic from "@anthropic-ai/sdk";
137
+ var SYSTEM_PROMPT = `\uB2F9\uC2E0\uC740 \uAC1C\uBC1C \uBE14\uB85C\uADF8 \uAE00\uAC10 \uBD84\uC11D \uC2DC\uC2A4\uD15C\uC785\uB2C8\uB2E4.
138
+ \uC0AC\uC6A9\uC790\uAC00 \uC81C\uACF5\uD558\uB294 \uD14D\uC2A4\uD2B8\uB294 Claude Code \uC138\uC158\uC758 \uB300\uD654 \uAE30\uB85D\uC785\uB2C8\uB2E4.
139
+ \uC774 \uB300\uD654\uB97C \uBD84\uC11D\uD558\uC5EC \uBE14\uB85C\uADF8 \uAE00\uAC10\uC774 \uB420 \uB9CC\uD55C \uB0B4\uC6A9\uC774 \uC788\uB294\uC9C0 \uD310\uB2E8\uD558\uC138\uC694.
140
+
141
+ \uC8FC\uC758: \uB300\uD654 \uB0B4\uC6A9\uC5D0 \uC751\uB2F5\uD558\uAC70\uB098 \uC774\uC5B4\uAC00\uC9C0 \uB9C8\uC138\uC694. \uC624\uC9C1 \uBD84\uC11D\uB9CC \uC218\uD589\uD558\uC138\uC694.
142
+
143
+ \uAE00\uAC10 \uAE30\uC900:
144
+ - \uD765\uBBF8\uB85C\uC6B4 \uBC84\uADF8 \uD574\uACB0 \uACFC\uC815 (\uB514\uBC84\uAE45 \uC2A4\uD1A0\uB9AC)
145
+ - \uC0C8\uB85C\uC6B4 \uAE30\uC220/\uD328\uD134\uC744 \uC801\uC6A9\uD55C \uC0AC\uB840
146
+ - \uC720\uC6A9\uD55C \uAC1C\uBC1C \uC778\uC0AC\uC774\uD2B8/\uD301
147
+
148
+ \uBC18\uB4DC\uC2DC \uC544\uB798 JSON \uD615\uC2DD\uC73C\uB85C\uB9CC \uC751\uB2F5\uD558\uC138\uC694. \uB2E4\uB978 \uD14D\uC2A4\uD2B8\uB97C \uC808\uB300 \uD3EC\uD568\uD558\uC9C0 \uB9C8\uC138\uC694:
149
+ {"score": 1~10, "category": "debugging"|"tech-pattern"|"insight", "title": "\uC81C\uC548 \uC81C\uBAA9", "reason": "\uD55C \uC904 \uC124\uBA85"}`;
150
+ var ANALYZER_SYSTEM_PROMPT = SYSTEM_PROMPT;
151
+ function buildAnalyzerUserMessage(transcriptText) {
152
+ return `\uB2E4\uC74C\uC740 Claude Code \uC138\uC158 \uB300\uD654 \uB0B4\uC6A9\uC785\uB2C8\uB2E4:
153
+
154
+ ---
155
+
156
+ ${transcriptText}`;
157
+ }
158
+ function parseAnalyzerResponse(text) {
159
+ try {
160
+ const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
161
+ const jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : text.trim();
162
+ const parsed = JSON.parse(jsonStr);
163
+ if (typeof parsed.score !== "number" || typeof parsed.category !== "string" || typeof parsed.title !== "string" || typeof parsed.reason !== "string") {
164
+ return null;
165
+ }
166
+ return {
167
+ score: parsed.score,
168
+ category: parsed.category,
169
+ title: parsed.title,
170
+ reason: parsed.reason
171
+ };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ async function analyze(transcriptText, apiKey, options) {
177
+ const log = options?.log ?? (() => {
178
+ });
179
+ const client = new Anthropic({ apiKey });
180
+ const response = await client.messages.create({
181
+ model: "claude-sonnet-4-6",
182
+ max_tokens: 300,
183
+ system: ANALYZER_SYSTEM_PROMPT,
184
+ messages: [
185
+ {
186
+ role: "user",
187
+ content: buildAnalyzerUserMessage(transcriptText)
188
+ }
189
+ ]
190
+ });
191
+ const content = response.content[0];
192
+ if (content.type !== "text") {
193
+ log(`Unexpected content type: ${content.type}`);
194
+ return null;
195
+ }
196
+ log(`Raw response: ${content.text.slice(0, 500)}`);
197
+ const result = parseAnalyzerResponse(content.text);
198
+ if (!result) {
199
+ log(`Failed to parse response: ${content.text}`);
200
+ }
201
+ return result;
202
+ }
203
+
204
+ // src/writer.ts
205
+ import Anthropic2 from "@anthropic-ai/sdk";
206
+ var SYSTEM_PROMPT2 = `\uB2F9\uC2E0\uC740 \uD55C\uAD6D\uC5B4 \uAC1C\uBC1C \uBE14\uB85C\uADF8 \uAE00 \uC0DD\uC131 \uC2DC\uC2A4\uD15C\uC785\uB2C8\uB2E4.
207
+ \uC0AC\uC6A9\uC790\uAC00 \uC81C\uACF5\uD558\uB294 \uD14D\uC2A4\uD2B8\uB294 Claude Code \uC138\uC158\uC758 \uB300\uD654 \uAE30\uB85D\uC785\uB2C8\uB2E4.
208
+ \uC774 \uB300\uD654\uB97C \uAE30\uBC18\uC73C\uB85C \uBE14\uB85C\uADF8 \uAE00\uC744 \uC791\uC131\uD558\uC138\uC694.
209
+
210
+ \uC8FC\uC758: \uB300\uD654 \uB0B4\uC6A9\uC5D0 \uC751\uB2F5\uD558\uAC70\uB098 \uC774\uC5B4\uAC00\uC9C0 \uB9C8\uC138\uC694. \uC624\uC9C1 \uBE14\uB85C\uADF8 \uAE00 \uC791\uC131\uB9CC \uC218\uD589\uD558\uC138\uC694.
211
+
212
+ \uC791\uC131 \uAC00\uC774\uB4DC:
213
+ - \uB3C5\uC790\uB294 \uAC1C\uBC1C\uC790
214
+ - \uBB38\uC81C -> \uACFC\uC815 -> \uD574\uACB0 -> \uBC30\uC6B4 \uC810 \uAD6C\uC870
215
+ - \uD575\uC2EC \uCF54\uB4DC \uC2A4\uB2C8\uD3AB \uD3EC\uD568
216
+ - \uC790\uC5F0\uC2A4\uB7EC\uC6B4 \uD55C\uAD6D\uC5B4, \uAE30\uC220 \uC6A9\uC5B4\uB294 \uC6D0\uBB38 \uC720\uC9C0
217
+ - 1000~2000\uC790 \uBD84\uB7C9
218
+
219
+ \uBC18\uB4DC\uC2DC \uC544\uB798 JSON \uD615\uC2DD\uC73C\uB85C\uB9CC \uC751\uB2F5\uD558\uC138\uC694. JSON \uC678\uC758 \uD14D\uC2A4\uD2B8\uB97C \uC808\uB300 \uD3EC\uD568\uD558\uC9C0 \uB9C8\uC138\uC694:
220
+ {"title": "\uCD5C\uC885 \uC81C\uBAA9", "tags": ["tag1", "tag2"], "content": "\uB9C8\uD06C\uB2E4\uC6B4 \uBCF8\uBB38"}`;
221
+ var WRITER_SYSTEM_PROMPT = SYSTEM_PROMPT2;
222
+ function buildWriterUserMessage(transcriptText, category, suggestedTitle) {
223
+ return `\uCE74\uD14C\uACE0\uB9AC: ${category}
224
+ \uC81C\uC548 \uC81C\uBAA9: ${suggestedTitle}
225
+
226
+ ---
227
+
228
+ ${transcriptText}`;
229
+ }
230
+ function parseWriterResponse(text) {
231
+ try {
232
+ const parsed = JSON.parse(text.trim());
233
+ if (typeof parsed.title === "string" && Array.isArray(parsed.tags) && typeof parsed.content === "string") {
234
+ return { title: parsed.title, tags: parsed.tags, content: parsed.content };
235
+ }
236
+ } catch {
237
+ }
238
+ try {
239
+ const firstBrace = text.indexOf("{");
240
+ if (firstBrace >= 0) {
241
+ let depth = 0;
242
+ let start = firstBrace;
243
+ for (let i = firstBrace; i < text.length; i++) {
244
+ if (text[i] === "{") depth++;
245
+ else if (text[i] === "}") {
246
+ depth--;
247
+ if (depth === 0) {
248
+ const candidate = text.slice(start, i + 1);
249
+ const parsed = JSON.parse(candidate);
250
+ if (typeof parsed.title === "string" && Array.isArray(parsed.tags) && typeof parsed.content === "string") {
251
+ return { title: parsed.title, tags: parsed.tags, content: parsed.content };
252
+ }
253
+ break;
254
+ }
255
+ }
256
+ }
257
+ }
258
+ } catch {
259
+ }
260
+ return null;
261
+ }
262
+ async function writeBlogPost(transcriptText, category, suggestedTitle, apiKey, options) {
263
+ const log = options?.log ?? (() => {
264
+ });
265
+ const client = new Anthropic2({ apiKey });
266
+ const response = await client.messages.create({
267
+ model: "claude-sonnet-4-6",
268
+ max_tokens: 4096,
269
+ system: WRITER_SYSTEM_PROMPT,
270
+ messages: [{ role: "user", content: buildWriterUserMessage(transcriptText, category, suggestedTitle) }]
271
+ });
272
+ const content = response.content[0];
273
+ if (content.type !== "text") {
274
+ log(`Unexpected content type: ${content.type}`);
275
+ return null;
276
+ }
277
+ log(`Raw response length: ${content.text.length} chars`);
278
+ const result = parseWriterResponse(content.text);
279
+ if (!result) {
280
+ log(`Failed to parse response. First 500 chars: ${content.text.slice(0, 500)}`);
281
+ }
282
+ return result;
283
+ }
284
+
285
+ // src/notion.ts
286
+ import { Client } from "@notionhq/client";
287
+ import { markdownToBlocks as martianMarkdownToBlocks } from "@tryfabric/martian";
288
+ function buildNotionProperties(titlePropertyName, title, tags, category, score) {
289
+ return {
290
+ [titlePropertyName]: {
291
+ title: [{ text: { content: title } }]
292
+ },
293
+ Tags: {
294
+ multi_select: tags.map((name) => ({ name }))
295
+ },
296
+ Category: {
297
+ select: { name: category }
298
+ },
299
+ Score: {
300
+ number: score
301
+ }
302
+ };
303
+ }
304
+ function markdownToBlocks(markdown) {
305
+ if (!markdown.trim()) return [];
306
+ return martianMarkdownToBlocks(markdown);
307
+ }
308
+ async function ensureProperties(notion, databaseId) {
309
+ const db = await notion.databases.retrieve({ database_id: databaseId });
310
+ let titleProp = "Name";
311
+ for (const [name, prop] of Object.entries(db.properties)) {
312
+ if (prop.type === "title") {
313
+ titleProp = name;
314
+ break;
315
+ }
316
+ }
317
+ const existing = new Set(Object.keys(db.properties));
318
+ const updates = {};
319
+ if (!existing.has("Tags")) {
320
+ updates["Tags"] = { multi_select: {} };
321
+ }
322
+ if (!existing.has("Category")) {
323
+ updates["Category"] = { select: {} };
324
+ }
325
+ if (!existing.has("Score")) {
326
+ updates["Score"] = { number: {} };
327
+ }
328
+ if (Object.keys(updates).length > 0) {
329
+ await notion.databases.update({
330
+ database_id: databaseId,
331
+ properties: updates
332
+ });
333
+ }
334
+ return titleProp;
335
+ }
336
+ async function createNotionPage(post, category, score, notionToken, databaseId) {
337
+ const notion = new Client({ auth: notionToken });
338
+ const titleProp = await ensureProperties(notion, databaseId);
339
+ const properties = buildNotionProperties(titleProp, post.title, post.tags, category, score);
340
+ const children = markdownToBlocks(post.content);
341
+ const response = await notion.pages.create({
342
+ parent: { database_id: databaseId },
343
+ properties,
344
+ children
345
+ });
346
+ return response.id;
347
+ }
348
+
349
+ // src/queue.ts
350
+ import {
351
+ readFileSync as readFileSync4,
352
+ writeFileSync as writeFileSync3,
353
+ unlinkSync,
354
+ mkdirSync as mkdirSync3
355
+ } from "fs";
356
+ import { dirname as dirname3, join as join3 } from "path";
357
+ import { createHash } from "crypto";
358
+ var LOCK_PATH = join3(APP_DIR, "lock");
359
+ var QUEUE_PATH = join3(APP_DIR, "queue.json");
360
+ var HISTORY_PATH = join3(APP_DIR, "history.json");
361
+ var MAX_HISTORY = 100;
362
+ function isProcessAlive(pid) {
363
+ try {
364
+ process.kill(pid, 0);
365
+ return true;
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+ function acquireLock(lockPath = LOCK_PATH) {
371
+ mkdirSync3(dirname3(lockPath), { recursive: true });
372
+ try {
373
+ writeFileSync3(lockPath, String(process.pid), { flag: "wx" });
374
+ return true;
375
+ } catch {
376
+ try {
377
+ const pid = parseInt(readFileSync4(lockPath, "utf-8").trim(), 10);
378
+ if (!isNaN(pid) && !isProcessAlive(pid)) {
379
+ unlinkSync(lockPath);
380
+ try {
381
+ writeFileSync3(lockPath, String(process.pid), { flag: "wx" });
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+ } catch {
388
+ }
389
+ return false;
390
+ }
391
+ }
392
+ function releaseLock(lockPath = LOCK_PATH) {
393
+ try {
394
+ unlinkSync(lockPath);
395
+ } catch {
396
+ }
397
+ }
398
+ function readJson(path, fallback) {
399
+ try {
400
+ return JSON.parse(readFileSync4(path, "utf-8"));
401
+ } catch {
402
+ return fallback;
403
+ }
404
+ }
405
+ function writeJson(path, data) {
406
+ mkdirSync3(dirname3(path), { recursive: true });
407
+ writeFileSync3(path, JSON.stringify(data));
408
+ }
409
+ function enqueue(queuePath = QUEUE_PATH, transcriptPath) {
410
+ const queue = readJson(queuePath, []);
411
+ queue.push(transcriptPath);
412
+ writeJson(queuePath, queue);
413
+ }
414
+ function dequeue(queuePath = QUEUE_PATH) {
415
+ const queue = readJson(queuePath, []);
416
+ if (queue.length === 0) return null;
417
+ const item = queue.shift();
418
+ writeJson(queuePath, queue);
419
+ return item;
420
+ }
421
+ function hashContent(content) {
422
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
423
+ }
424
+ function addToHistory(historyPath = HISTORY_PATH, hash) {
425
+ const history = readJson(historyPath, []);
426
+ history.push(hash);
427
+ const trimmed = history.slice(-MAX_HISTORY);
428
+ writeJson(historyPath, trimmed);
429
+ }
430
+ function isInHistory(historyPath = HISTORY_PATH, hash) {
431
+ const history = readJson(historyPath, []);
432
+ return history.includes(hash);
433
+ }
434
+
435
+ // src/pipeline.ts
436
+ async function processTranscript(transcriptPath, config, logger, options) {
437
+ const { dryRun = false, skipHistory = false } = options ?? {};
438
+ const entries = readTranscript(transcriptPath);
439
+ if (entries.length === 0) {
440
+ logger.info(`Empty transcript: ${transcriptPath}`);
441
+ return "empty";
442
+ }
443
+ const processed = preprocessTranscript(entries);
444
+ const text = transcriptToText(processed);
445
+ const hash = hashContent(text);
446
+ if (!skipHistory && isInHistory(HISTORY_PATH, hash)) {
447
+ logger.info(`Duplicate transcript skipped: ${hash}`);
448
+ return "duplicate";
449
+ }
450
+ logger.info(`Analyzing transcript: ${transcriptPath} (${entries.length} entries, ${text.length} chars)`);
451
+ const analysis = await analyze(text, config.anthropicApiKey, { log: (msg) => logger.info(`[analyzer] ${msg}`) });
452
+ if (!analysis) {
453
+ logger.error(`Analysis failed for: ${transcriptPath}`);
454
+ return "analyze-failed";
455
+ }
456
+ logger.info(`Analysis result: score=${analysis.score}, category=${analysis.category}, title="${analysis.title}"`);
457
+ if (analysis.score < config.threshold) {
458
+ logger.info(`Score ${analysis.score} below threshold ${config.threshold}, skipping`);
459
+ addToHistory(HISTORY_PATH, hash);
460
+ return "low-score";
461
+ }
462
+ logger.info(`Generating blog post: "${analysis.title}"`);
463
+ const post = await writeBlogPost(text, analysis.category, analysis.title, config.anthropicApiKey, { log: (msg) => logger.info(`[writer] ${msg}`) });
464
+ if (!post) {
465
+ logger.error(`Blog post generation failed for: ${analysis.title}`);
466
+ return "write-failed";
467
+ }
468
+ logger.info(`Blog post generated: "${post.title}" (${post.content.length} chars, tags: ${post.tags.join(", ")})`);
469
+ if (dryRun) {
470
+ logger.info(`[dry-run] Skipping Notion upload. Blog post preview:
471
+ ${post.content.slice(0, 500)}...`);
472
+ return "dry-run-done";
473
+ }
474
+ logger.info(`Publishing to Notion: "${post.title}"`);
475
+ try {
476
+ const pageId = await createNotionPage(
477
+ post,
478
+ analysis.category,
479
+ analysis.score,
480
+ config.notionToken,
481
+ config.notionDatabaseId
482
+ );
483
+ logger.info(`Published to Notion: pageId=${pageId}`);
484
+ } catch (err) {
485
+ logger.error(`Notion publish failed: ${err instanceof Error ? err.message : err}`);
486
+ return "publish-failed";
487
+ }
488
+ addToHistory(HISTORY_PATH, hash);
489
+ return "published";
490
+ }
491
+
492
+ // src/install.ts
493
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
494
+ import { dirname as dirname4, join as join4 } from "path";
495
+ import { homedir as homedir2 } from "os";
496
+ var SETTINGS_PATH = join4(homedir2(), ".claude", "settings.json");
497
+ var HOOK_COMMAND = "npx ai-blogger process";
498
+ var MANAGED_EVENTS = ["Stop"];
499
+ function loadSettings(path) {
500
+ try {
501
+ return JSON.parse(readFileSync5(path, "utf-8"));
502
+ } catch {
503
+ return {};
504
+ }
505
+ }
506
+ function saveSettings(settings, path) {
507
+ mkdirSync4(dirname4(path), { recursive: true });
508
+ writeFileSync4(path, JSON.stringify(settings, null, 2));
509
+ }
510
+ function isAiBloggerHook(entry) {
511
+ return entry.hooks?.some((h) => h.command?.startsWith(HOOK_COMMAND)) ?? false;
512
+ }
513
+ function makeHookEntry() {
514
+ return {
515
+ hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 15 }]
516
+ };
517
+ }
518
+ function installHooks(settingsPath = SETTINGS_PATH) {
519
+ const settings = loadSettings(settingsPath);
520
+ if (!settings.hooks) settings.hooks = {};
521
+ const added = [];
522
+ const skipped = [];
523
+ for (const event of MANAGED_EVENTS) {
524
+ const existing = settings.hooks[event] || [];
525
+ if (existing.some(isAiBloggerHook)) {
526
+ skipped.push(event);
527
+ } else {
528
+ settings.hooks[event] = [...existing, makeHookEntry()];
529
+ added.push(event);
530
+ }
531
+ }
532
+ saveSettings(settings, settingsPath);
533
+ return { added, skipped };
534
+ }
535
+ function uninstallHooks(settingsPath = SETTINGS_PATH) {
536
+ const settings = loadSettings(settingsPath);
537
+ if (!settings.hooks) settings.hooks = {};
538
+ const removed = [];
539
+ const notFound = [];
540
+ for (const event of MANAGED_EVENTS) {
541
+ const existing = settings.hooks[event] || [];
542
+ const filtered = existing.filter((e) => !isAiBloggerHook(e));
543
+ if (filtered.length < existing.length) {
544
+ removed.push(event);
545
+ if (filtered.length === 0) {
546
+ delete settings.hooks[event];
547
+ } else {
548
+ settings.hooks[event] = filtered;
549
+ }
550
+ } else {
551
+ notFound.push(event);
552
+ }
553
+ }
554
+ if (Object.keys(settings.hooks).length === 0) {
555
+ delete settings.hooks;
556
+ }
557
+ saveSettings(settings, settingsPath);
558
+ return { removed, notFound };
559
+ }
560
+
561
+ // src/cli.ts
562
+ function parseStdin(raw) {
563
+ try {
564
+ return JSON.parse(raw) || {};
565
+ } catch {
566
+ return {};
567
+ }
568
+ }
569
+ function readStdin() {
570
+ return new Promise((resolve) => {
571
+ let data = "";
572
+ const timeout = setTimeout(() => resolve(data), 5e3);
573
+ if (process.stdin.isTTY) {
574
+ clearTimeout(timeout);
575
+ resolve("");
576
+ return;
577
+ }
578
+ process.stdin.setEncoding("utf-8");
579
+ process.stdin.on("data", (chunk) => data += chunk);
580
+ process.stdin.on("end", () => {
581
+ clearTimeout(timeout);
582
+ resolve(data);
583
+ });
584
+ process.stdin.on("error", () => {
585
+ clearTimeout(timeout);
586
+ resolve("");
587
+ });
588
+ });
589
+ }
590
+ async function handleProcess() {
591
+ const stdinRaw = await readStdin();
592
+ const data = parseStdin(stdinRaw);
593
+ const transcriptPath = data.transcript_path;
594
+ if (!transcriptPath) {
595
+ console.error("No transcript_path in stdin");
596
+ return;
597
+ }
598
+ const __filename2 = fileURLToPath(import.meta.url);
599
+ const child = spawn(
600
+ process.execPath,
601
+ [__filename2, "__run-pipeline", transcriptPath],
602
+ {
603
+ detached: true,
604
+ stdio: "ignore"
605
+ }
606
+ );
607
+ child.unref();
608
+ }
609
+ async function runPipeline(transcriptPath) {
610
+ const logger = createLogger();
611
+ const config = loadConfig();
612
+ if (!config.anthropicApiKey || !config.notionToken || !config.notionDatabaseId) {
613
+ logger.error("Missing required config (anthropicApiKey, notionToken, notionDatabaseId)");
614
+ return;
615
+ }
616
+ if (!acquireLock()) {
617
+ logger.info(`Lock held, enqueueing: ${transcriptPath}`);
618
+ enqueue(QUEUE_PATH, transcriptPath);
619
+ return;
620
+ }
621
+ try {
622
+ const result = await processTranscript(transcriptPath, config, logger);
623
+ logger.info(`Pipeline result for ${transcriptPath}: ${result}`);
624
+ let next;
625
+ while ((next = dequeue()) !== null) {
626
+ logger.info(`Processing queued: ${next}`);
627
+ const qResult = await processTranscript(next, config, logger);
628
+ logger.info(`Pipeline result for ${next}: ${qResult}`);
629
+ }
630
+ } catch (err) {
631
+ logger.error(`Pipeline error: ${err instanceof Error ? err.message : err}`);
632
+ } finally {
633
+ releaseLock();
634
+ }
635
+ }
636
+ function handleConfig(args) {
637
+ if (args.includes("--show")) {
638
+ console.log(JSON.stringify(loadConfig(), null, 2));
639
+ return;
640
+ }
641
+ const VALID_KEYS = ["anthropicApiKey", "notionToken", "notionDatabaseId", "threshold"];
642
+ const setIdx = args.indexOf("--set");
643
+ if (setIdx !== -1 && args[setIdx + 1] && args[setIdx + 2]) {
644
+ const key = args[setIdx + 1];
645
+ const value = args[setIdx + 2];
646
+ if (!VALID_KEYS.includes(key)) {
647
+ console.error(`Unknown config key: ${key}. Valid keys: ${VALID_KEYS.join(", ")}`);
648
+ return;
649
+ }
650
+ const config = loadConfig();
651
+ if (key === "threshold") {
652
+ config.threshold = parseInt(value, 10);
653
+ } else {
654
+ config[key] = value;
655
+ }
656
+ saveConfig(CONFIG_PATH, config);
657
+ console.log(`Config updated: ${key} = ${value}`);
658
+ return;
659
+ }
660
+ console.log("Usage: ai-blogger config --show | --set <key> <value>");
661
+ }
662
+ function handleInstall() {
663
+ const { added, skipped } = installHooks();
664
+ if (added.length > 0) console.log(`Hooks added: ${added.join(", ")}`);
665
+ if (skipped.length > 0) console.log(`Already installed: ${skipped.join(", ")}`);
666
+ console.log("Install complete.");
667
+ }
668
+ function handleUninstall() {
669
+ const { removed, notFound } = uninstallHooks();
670
+ if (removed.length > 0) console.log(`Hooks removed: ${removed.join(", ")}`);
671
+ if (notFound.length > 0) console.log(`Not found: ${notFound.join(", ")}`);
672
+ console.log("Uninstall complete.");
673
+ }
674
+ function findLatestTranscript() {
675
+ const claudeDir = join5(homedir3(), ".claude", "projects");
676
+ try {
677
+ const projects = readdirSync(claudeDir);
678
+ let latest = null;
679
+ for (const project of projects) {
680
+ const projectDir = join5(claudeDir, project);
681
+ try {
682
+ const files = readdirSync(projectDir);
683
+ for (const file of files) {
684
+ if (!file.endsWith(".jsonl")) continue;
685
+ const fullPath = join5(projectDir, file);
686
+ const stat = statSync2(fullPath);
687
+ if (!latest || stat.mtimeMs > latest.mtime) {
688
+ latest = { path: fullPath, mtime: stat.mtimeMs };
689
+ }
690
+ }
691
+ } catch {
692
+ }
693
+ }
694
+ return latest?.path ?? null;
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+ async function handleTest(args) {
700
+ const config = loadConfig();
701
+ if (!args.includes("--dry-run") && !args.includes("--full")) {
702
+ if (!config.anthropicApiKey) {
703
+ console.error("anthropicApiKey not set. Run: ai-blogger config --set anthropicApiKey <key>");
704
+ return;
705
+ }
706
+ if (!config.notionToken || !config.notionDatabaseId) {
707
+ console.error("Notion config not set. Run: ai-blogger config --set notionToken/notionDatabaseId <value>");
708
+ return;
709
+ }
710
+ console.log("Config OK. Pipeline is ready.");
711
+ console.log(`Log file: ${LOG_PATH}`);
712
+ console.log("\nRun with --dry-run (analyze + generate, no Notion) or --full (including Notion upload)");
713
+ return;
714
+ }
715
+ if (!config.anthropicApiKey) {
716
+ console.error("anthropicApiKey not set. Run: ai-blogger config --set anthropicApiKey <key>");
717
+ return;
718
+ }
719
+ const dryRun = args.includes("--dry-run");
720
+ const full = args.includes("--full");
721
+ if (full && (!config.notionToken || !config.notionDatabaseId)) {
722
+ console.error("Notion config not set for --full test. Run: ai-blogger config --set notionToken/notionDatabaseId <value>");
723
+ return;
724
+ }
725
+ const fileIdx = args.indexOf("--file");
726
+ const transcriptPath = fileIdx !== -1 && args[fileIdx + 1] ? args[fileIdx + 1] : findLatestTranscript();
727
+ if (!transcriptPath) {
728
+ console.error("No transcript found. Use --file <path> to specify one.");
729
+ return;
730
+ }
731
+ console.log(`Using transcript: ${transcriptPath}`);
732
+ console.log(dryRun ? "Mode: dry-run (no Notion upload)" : "Mode: full (including Notion upload)");
733
+ console.log("Running pipeline...\n");
734
+ const logger = createLogger();
735
+ const consoleLogger = {
736
+ info: (msg) => {
737
+ logger.info(msg);
738
+ console.log(` [INFO] ${msg}`);
739
+ },
740
+ error: (msg) => {
741
+ logger.error(msg);
742
+ console.error(` [ERROR] ${msg}`);
743
+ }
744
+ };
745
+ const result = await processTranscript(transcriptPath, config, consoleLogger, {
746
+ dryRun,
747
+ skipHistory: true
748
+ });
749
+ console.log(`
750
+ Result: ${result}`);
751
+ }
752
+ async function main() {
753
+ const args = process.argv.slice(2);
754
+ const command = args[0];
755
+ try {
756
+ switch (command) {
757
+ case "process":
758
+ await handleProcess();
759
+ break;
760
+ case "__run-pipeline":
761
+ await runPipeline(args[1]);
762
+ break;
763
+ case "config":
764
+ handleConfig(args.slice(1));
765
+ break;
766
+ case "install":
767
+ handleInstall();
768
+ break;
769
+ case "uninstall":
770
+ handleUninstall();
771
+ break;
772
+ case "test":
773
+ await handleTest(args.slice(1));
774
+ break;
775
+ case "--version":
776
+ case "-v": {
777
+ const { createRequire } = await import("module");
778
+ const require2 = createRequire(import.meta.url);
779
+ const { version } = require2("../package.json");
780
+ console.log(version);
781
+ break;
782
+ }
783
+ default:
784
+ console.log("Usage: ai-blogger <install|uninstall|process|config|test|--version>");
785
+ }
786
+ } catch (err) {
787
+ console.error("Error:", err instanceof Error ? err.message : err);
788
+ }
789
+ }
790
+ main();
791
+ export {
792
+ parseStdin
793
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "ai-blogger",
3
+ "version": "0.1.0",
4
+ "description": "Auto-generate blog posts from Claude Code sessions to Notion",
5
+ "type": "module",
6
+ "bin": {
7
+ "ai-blogger": "./dist/cli.js"
8
+ },
9
+ "files": ["dist"],
10
+ "scripts": {
11
+ "build": "tsup",
12
+ "dev": "tsup --watch",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": ["claude-code", "blog", "notion", "ai"],
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {
23
+ "@anthropic-ai/sdk": "^0.52.0",
24
+ "@notionhq/client": "^2.3.0",
25
+ "@tryfabric/martian": "^1.2.4"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "tsup": "^8.5.1",
30
+ "typescript": "^5.7.0",
31
+ "vitest": "^3.1.0"
32
+ }
33
+ }