harulog 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.
package/.env.example ADDED
@@ -0,0 +1,14 @@
1
+ HARULOG_HOME=
2
+ HARULOG_LAUNCH_AGENTS_DIR=
3
+ GEMINI_API_KEY=
4
+ GOOGLE_API_KEY=
5
+ GEMINI_MODEL=gemini-2.5-flash
6
+ CODEX_HOME=
7
+ LOCAL_GIT_REPOS=
8
+ LOCAL_GIT_SCAN_ROOTS=
9
+ LOCAL_GIT_SCAN_DEPTH=2
10
+ TOPIC_COUNT=5
11
+ RECOMMENDATION_WINDOW_DAYS=14
12
+ SCHEDULE_WEEKDAY=5
13
+ SCHEDULE_HOUR=9
14
+ LLM_PROVIDER=gemini
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # haruLog
2
+
3
+ `harulog`는 Codex 대화 이력과 로컬 git 작업 흔적을 읽어서 글감과 초안을 추천하는 macOS용 CLI다. 배포 목표는 `bunx harulog ...` 한 줄로 어느 PC에서든 설정하고, `launchd`가 매주 금요일 오전 9시에 자동 실행되도록 만드는 것이다.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ bunx harulog setup
9
+ bunx harulog run --force --ignore-history
10
+ ```
11
+
12
+ 기본 설정이 끝나면 사용자 데이터는 `~/Library/Application Support/harulog` 아래에 저장된다.
13
+
14
+ - `config.json`: 사용자 설정
15
+ - `state.json`: 마지막 실행 상태
16
+ - `topic_history.json`: 최근 추천 이력
17
+ - `outputs/`: JSON/Markdown 결과
18
+ - `logs/`: scheduler 로그
19
+ - `bin/`: scheduler wrapper script
20
+
21
+ ## Commands
22
+
23
+ ```bash
24
+ bunx harulog setup
25
+ bunx harulog run
26
+ bunx harulog run --force --ignore-history
27
+ bunx harulog scheduler install
28
+ bunx harulog scheduler status
29
+ bunx harulog doctor
30
+ ```
31
+
32
+ - `setup`: 대화형 설정 마법사. Gemini 키, Codex 경로, git scan root, 스케줄을 저장하고 필요하면 launchd를 설치한다.
33
+ - `run`: 수동으로 1회 실행한다.
34
+ - `run --scheduled`: launchd용 실행 모드. 이번 주 금요일 09:00 스케줄 창이 열렸을 때만 동작한다.
35
+ - `run --force`: 스케줄 조건을 무시하고 강제로 실행한다.
36
+ - `run --ignore-history`: 최근 추천 이력을 무시하고 테스트용으로 다시 생성한다.
37
+ - `scheduler install`: `~/Library/LaunchAgents/com.harulog.weekly-topics.plist`와 wrapper script를 설치한다.
38
+ - `scheduler uninstall`: launchd 등록과 wrapper script를 제거한다.
39
+ - `scheduler status`: 설치 상태와 plist/wrapper/log 경로를 보여준다.
40
+ - `doctor`: Bun, Gemini 키, Codex 경로, git repo 탐색, launchd 상태를 점검한다.
41
+
42
+ ## Scheduler
43
+
44
+ 자동 실행은 `launchd` LaunchAgent를 사용한다.
45
+
46
+ - 스케줄: 매주 금요일 09:00
47
+ - `RunAtLoad`: 로그인 직후 catch-up 실행
48
+ - wrapper: `~/Library/Application Support/harulog/bin/run-weekly.sh`
49
+ - 실제 실행 명령: 설치 시점 버전으로 고정된 `bun x --bun harulog@<version> run --scheduled`
50
+
51
+ 즉 scheduler는 “항상 최신”이 아니라 설치 시점 패키지 버전을 고정해서 실행한다.
52
+
53
+ ## Config
54
+
55
+ `setup`이 만드는 기본 설정 구조는 아래와 같다.
56
+
57
+ ```json
58
+ {
59
+ "llmProvider": "gemini",
60
+ "geminiModel": "gemini-2.5-flash",
61
+ "geminiApiKeySource": "config",
62
+ "codexHome": "/Users/me/.codex",
63
+ "localGitRepos": [],
64
+ "localGitScanRoots": [
65
+ "/Users/me/Documents/work"
66
+ ],
67
+ "localGitScanDepth": 2,
68
+ "schedule": {
69
+ "weekday": 5,
70
+ "hour": 9
71
+ },
72
+ "topicCount": 5,
73
+ "recommendationWindowDays": 14
74
+ }
75
+ ```
76
+
77
+ Gemini API 키는 기본적으로 `config.json`에 저장할 수 있고, `GEMINI_API_KEY` 또는 `GOOGLE_API_KEY` 환경변수가 있으면 그 값이 우선한다.
78
+
79
+ ## Development
80
+
81
+ repo 안에서 개발할 때는 아래 명령을 쓴다.
82
+
83
+ ```bash
84
+ bun install
85
+ bun run dev
86
+ bun run job:weekly
87
+ bun run job:weekly:force
88
+ bun run src/cli.ts help
89
+ bun run check
90
+ ```
91
+
92
+ - `job:weekly`: CLI의 `run --scheduled`와 같다.
93
+ - `job:weekly:force`: CLI의 `run --scheduled --force`와 같다.
94
+ - `job:daily`, `job:daily:force`: 이전 이름과의 호환 alias다.
95
+
96
+ ## Optional Env Overrides
97
+
98
+ repo 개발이나 디버깅용으로는 [.env.example](/Users/seojeonghyeon/Documents/work/haruLog/.env.example)에 있는 환경변수 override를 사용할 수 있다.
99
+
100
+ - `HARULOG_HOME`
101
+ - `HARULOG_LAUNCH_AGENTS_DIR`
102
+ - `GEMINI_API_KEY` / `GOOGLE_API_KEY`
103
+ - `CODEX_HOME`
104
+ - `LOCAL_GIT_REPOS`
105
+ - `LOCAL_GIT_SCAN_ROOTS`
106
+ - `LOCAL_GIT_SCAN_DEPTH`
107
+ - `TOPIC_COUNT`
108
+ - `RECOMMENDATION_WINDOW_DAYS`
109
+ - `SCHEDULE_WEEKDAY`
110
+ - `SCHEDULE_HOUR`
111
+ - `LLM_PROVIDER`
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "harulog",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "packageManager": "bun@1.3.6",
7
+ "bin": {
8
+ "harulog": "./src/cli.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ ".env.example"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "dev": "bun run src/index.ts",
20
+ "cli": "bun run src/cli.ts",
21
+ "job:weekly": "bun run src/cli.ts run --scheduled",
22
+ "job:weekly:force": "bun run src/cli.ts run --scheduled --force",
23
+ "job:daily": "bun run src/cli.ts run --scheduled",
24
+ "job:daily:force": "bun run src/cli.ts run --scheduled --force",
25
+ "check": "tsc --noEmit"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.6.0",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "dependencies": {}
32
+ }
@@ -0,0 +1,67 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const APP_NAME = "harulog";
6
+ export const LAUNCH_AGENT_LABEL = "com.harulog.weekly-topics";
7
+
8
+ export interface AppPaths {
9
+ packageRoot: string;
10
+ packageManifestPath: string;
11
+ appHome: string;
12
+ configPath: string;
13
+ statePath: string;
14
+ topicHistoryPath: string;
15
+ outputsDir: string;
16
+ logsDir: string;
17
+ binDir: string;
18
+ launchAgentsDir: string;
19
+ launchAgentPath: string;
20
+ launchAgentLogPath: string;
21
+ schedulerWrapperPath: string;
22
+ }
23
+
24
+ function resolvePackageRoot(): string {
25
+ const currentFile = fileURLToPath(import.meta.url);
26
+ return path.resolve(path.dirname(currentFile), "..");
27
+ }
28
+
29
+ export function resolveAppHome(): string {
30
+ const override = process.env.HARULOG_HOME?.trim();
31
+ if (override) {
32
+ return path.resolve(override);
33
+ }
34
+
35
+ return path.join(os.homedir(), "Library", "Application Support", APP_NAME);
36
+ }
37
+
38
+ export function resolveLaunchAgentsDir(): string {
39
+ const override = process.env.HARULOG_LAUNCH_AGENTS_DIR?.trim();
40
+ if (override) {
41
+ return path.resolve(override);
42
+ }
43
+
44
+ return path.join(os.homedir(), "Library", "LaunchAgents");
45
+ }
46
+
47
+ export function resolveAppPaths(): AppPaths {
48
+ const packageRoot = resolvePackageRoot();
49
+ const appHome = resolveAppHome();
50
+ const launchAgentsDir = resolveLaunchAgentsDir();
51
+
52
+ return {
53
+ packageRoot,
54
+ packageManifestPath: path.join(packageRoot, "package.json"),
55
+ appHome,
56
+ configPath: path.join(appHome, "config.json"),
57
+ statePath: path.join(appHome, "state.json"),
58
+ topicHistoryPath: path.join(appHome, "topic_history.json"),
59
+ outputsDir: path.join(appHome, "outputs"),
60
+ logsDir: path.join(appHome, "logs"),
61
+ binDir: path.join(appHome, "bin"),
62
+ launchAgentsDir,
63
+ launchAgentPath: path.join(launchAgentsDir, `${LAUNCH_AGENT_LABEL}.plist`),
64
+ launchAgentLogPath: path.join(appHome, "logs", "launchd-weekly-topics.log"),
65
+ schedulerWrapperPath: path.join(appHome, "bin", "run-weekly.sh")
66
+ };
67
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { loadConfig } from "./config";
4
+ import { runDoctor } from "./doctor";
5
+ import { runTopicsJob } from "./services/topicsJob";
6
+ import { getSchedulerStatus, installScheduler, uninstallScheduler } from "./scheduler";
7
+ import { runSetupWizard } from "./setup";
8
+
9
+ function hasFlag(args: string[], flag: string): boolean {
10
+ return args.includes(flag);
11
+ }
12
+
13
+ function printUsage(): void {
14
+ console.log(`harulog
15
+
16
+ Usage:
17
+ harulog setup [--defaults] [--install-scheduler] [--skip-scheduler] [--write-only-scheduler]
18
+ harulog run [--force] [--ignore-history] [--scheduled]
19
+ harulog scheduler install [--write-only]
20
+ harulog scheduler uninstall [--write-only]
21
+ harulog scheduler status
22
+ harulog doctor
23
+ harulog version
24
+ `);
25
+ }
26
+
27
+ async function handleRun(args: string[]): Promise<number> {
28
+ const result = await runTopicsJob({
29
+ force: hasFlag(args, "--force"),
30
+ ignoreHistory: hasFlag(args, "--ignore-history"),
31
+ scheduled: hasFlag(args, "--scheduled")
32
+ });
33
+
34
+ console.log(result.reason);
35
+ console.log(`Suggestions: ${result.suggestions.length}`);
36
+ return result.skipped ? 0 : 0;
37
+ }
38
+
39
+ async function handleScheduler(args: string[]): Promise<number> {
40
+ const subcommand = args[0];
41
+ const config = await loadConfig();
42
+
43
+ if (subcommand === "install") {
44
+ const status = await installScheduler(config, { writeOnly: hasFlag(args, "--write-only") });
45
+ console.log(`Installed scheduler: ${status.plistPath}`);
46
+ return 0;
47
+ }
48
+
49
+ if (subcommand === "uninstall") {
50
+ await uninstallScheduler(config, { writeOnly: hasFlag(args, "--write-only") });
51
+ console.log(`Removed scheduler: ${config.launchAgentPath}`);
52
+ return 0;
53
+ }
54
+
55
+ if (subcommand === "status") {
56
+ const status = await getSchedulerStatus(config);
57
+ console.log(`label: ${status.label}`);
58
+ console.log(`installed: ${status.installed}`);
59
+ console.log(`loaded: ${status.loaded}`);
60
+ console.log(`plist: ${status.plistPath}`);
61
+ console.log(`wrapper: ${status.wrapperPath}`);
62
+ console.log(`log: ${status.logPath}`);
63
+ return 0;
64
+ }
65
+
66
+ throw new Error("scheduler requires one of: install, uninstall, status");
67
+ }
68
+
69
+ async function handleVersion(): Promise<number> {
70
+ const config = await loadConfig();
71
+ console.log(config.packageVersion);
72
+ return 0;
73
+ }
74
+
75
+ async function main(argv: string[]): Promise<number> {
76
+ const [command = "help", ...rest] = argv;
77
+
78
+ if (command === "help" || command === "--help" || command === "-h") {
79
+ printUsage();
80
+ return 0;
81
+ }
82
+
83
+ if (command === "version" || command === "--version" || command === "-v") {
84
+ return handleVersion();
85
+ }
86
+
87
+ if (command === "setup") {
88
+ await runSetupWizard({
89
+ defaults: hasFlag(rest, "--defaults"),
90
+ installScheduler: hasFlag(rest, "--install-scheduler"),
91
+ skipScheduler: hasFlag(rest, "--skip-scheduler"),
92
+ writeOnlyScheduler: hasFlag(rest, "--write-only-scheduler")
93
+ });
94
+ return 0;
95
+ }
96
+
97
+ if (command === "run") {
98
+ return handleRun(rest);
99
+ }
100
+
101
+ if (command === "scheduler") {
102
+ return handleScheduler(rest);
103
+ }
104
+
105
+ if (command === "doctor") {
106
+ return runDoctor();
107
+ }
108
+
109
+ throw new Error(`Unknown command: ${command}`);
110
+ }
111
+
112
+ main(process.argv.slice(2))
113
+ .then((code) => {
114
+ process.exitCode = code;
115
+ })
116
+ .catch((error) => {
117
+ console.error("[error] harulog command failed");
118
+ console.error(error);
119
+ process.exitCode = 1;
120
+ });
@@ -0,0 +1,72 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import type { AppConfig } from "../config";
4
+ import type { ActivityItem } from "../types";
5
+ import { logWarn } from "../utils/logger";
6
+
7
+ interface CodexSessionIndexRecord {
8
+ id: string;
9
+ thread_name?: string;
10
+ updated_at: string;
11
+ }
12
+
13
+ function sanitizeThreadName(value: string | undefined): string {
14
+ const raw = value?.trim();
15
+ if (!raw) {
16
+ return "Untitled Codex session";
17
+ }
18
+
19
+ return raw
20
+ .replace(/[\u0000-\u001f]+/g, " ")
21
+ .replace(/[\]\)\}"'`〛】]+$/g, "")
22
+ .replace(/\s+/g, " ")
23
+ .trim() || "Untitled Codex session";
24
+ }
25
+
26
+ function toTags(threadName: string): string[] {
27
+ return threadName
28
+ .toLowerCase()
29
+ .split(/[^a-z0-9가-힣]+/u)
30
+ .map((token) => token.trim())
31
+ .filter((token) => token.length >= 2)
32
+ .slice(0, 6);
33
+ }
34
+
35
+ function mapRecordToActivity(record: CodexSessionIndexRecord, config: AppConfig): ActivityItem {
36
+ const title = sanitizeThreadName(record.thread_name);
37
+
38
+ return {
39
+ id: `codex:${record.id}`,
40
+ source: "codex",
41
+ type: "session",
42
+ title,
43
+ summary: `Codex session captured from session_index.jsonl: ${title}`,
44
+ timestamp: record.updated_at,
45
+ tags: toTags(title),
46
+ weight: 2,
47
+ status: "done",
48
+ evidence: [config.codexSessionIndexPath],
49
+ metadata: {
50
+ threadId: record.id,
51
+ hasLogsIndex: true
52
+ }
53
+ };
54
+ }
55
+
56
+ export async function collectCodexActivities(config: AppConfig): Promise<ActivityItem[]> {
57
+ try {
58
+ const content = await readFile(config.codexSessionIndexPath, "utf8");
59
+ const items = content
60
+ .split("\n")
61
+ .map((line) => line.trim())
62
+ .filter(Boolean)
63
+ .map((line) => JSON.parse(line) as CodexSessionIndexRecord)
64
+ .filter((record) => record.id && record.updated_at)
65
+ .map((record) => mapRecordToActivity(record, config));
66
+
67
+ return items;
68
+ } catch (error) {
69
+ logWarn(`Codex collector could not read ${config.codexSessionIndexPath}: ${String(error)}`);
70
+ return [];
71
+ }
72
+ }
@@ -0,0 +1,8 @@
1
+ import type { AppConfig } from "../config";
2
+ import type { ActivityItem } from "../types";
3
+ import { logInfo } from "../utils/logger";
4
+
5
+ export async function collectGitHubActivities(_config: AppConfig): Promise<ActivityItem[]> {
6
+ logInfo("GitHub collector is still a placeholder. Returning no activities for now.");
7
+ return [];
8
+ }