diff --git a/package.json b/package.json index fcfbb0d..cef0d87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moose-lab/devlog", - "version": "0.3.0", + "version": "0.4.0", "description": "Auto-generate dev logs from your Claude Code sessions", "type": "module", "bin": { diff --git a/src/cli.ts b/src/cli.ts index c3d6c2b..5427c64 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,11 +8,13 @@ import { todayCommand } from "./commands/today.js"; import { searchCommand } from "./commands/search.js"; import { statsCommand } from "./commands/stats.js"; import { costCommand } from "./commands/cost.js"; +import { statuslineCommand } from "./commands/statusline.js"; +import { setupStatuslineCommand } from "./commands/setup-statusline.js"; import { initOutput, outputJson } from "./utils/output.js"; import { levenshtein } from "./utils/format.js"; import type { GlobalOptions } from "./core/types.js"; -const VERSION = "0.3.0"; +const VERSION = "0.4.0"; const HELP_TEXT = ` ${chalk.bold.cyan(" ▌")} ${chalk.bold.white("DevLog")} ${chalk.dim(`v${VERSION}`)} @@ -33,6 +35,10 @@ ${chalk.cyan(" devlog search \"auth bug\"")}${chalk.dim(" Find a conversatio ${chalk.cyan(" devlog stats")}${chalk.dim(" Usage trends")} ${chalk.cyan(" devlog cost")}${chalk.dim(" Cost breakdown")} +${chalk.bold.white(" Agent Integration:")} +${chalk.cyan(" devlog setup-statusline")}${chalk.dim(" Configure Claude Code status bar")} +${chalk.cyan(" devlog statusline")}${chalk.dim(" Output status line (used by Claude Code)")} + ${chalk.bold.white(" Output Modes:")} ${chalk.cyan(" devlog --json")}${chalk.dim(" JSON output for scripts/agents")} ${chalk.cyan(" devlog -q")}${chalk.dim(" Quiet mode (no spinners/banners)")} @@ -49,6 +55,8 @@ const KNOWN_COMMANDS = [ "search", "stats", "cost", + "statusline", + "setup-statusline", ]; function getGlobalOpts(): GlobalOptions { @@ -195,6 +203,32 @@ program } }); +// ── devlog statusline ─────────────────────────────────── +program + .command("statusline") + .description("Output status line for Claude Code integration") + .option("--no-cache", "Force refresh, skip cache") + .action(async (options) => { + try { + await statuslineCommand(options); + } catch { + // Silent failure — status line must never crash + } + }); + +// ── devlog setup-statusline ───────────────────────────── +program + .command("setup-statusline") + .description("Configure Claude Code status bar integration") + .action(async () => { + const globalOpts = getGlobalOpts(); + try { + await setupStatuslineCommand(); + } catch (err) { + handleError(err, globalOpts); + } + }); + // ── "Did you mean?" for unknown commands (Principle 3) ─── // Commander treats unknown words as args to default command. // We intercept by checking process.argv before parse. diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 53fe668..7012301 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -20,8 +20,9 @@ import { import { getClaudeProjectsDir } from "../utils/paths.js"; import { outputJson, isJsonMode, isQuietMode } from "../utils/output.js"; import { toSessionJson } from "./shared.js"; +import { updateCacheFromStats } from "../core/cache.js"; -const VERSION = "0.3.0"; +const VERSION = "0.4.0"; /** * The default command. This IS the product. @@ -103,6 +104,7 @@ export async function dashboardCommand(globalOpts: GlobalOptions): Promise } const stats = computeStats(projects); + updateCacheFromStats(stats); const groups = groupSessionsByTime(projects); // ── JSON output ──────────────────────────────────── diff --git a/src/commands/setup-statusline.ts b/src/commands/setup-statusline.ts new file mode 100644 index 0000000..ac33846 --- /dev/null +++ b/src/commands/setup-statusline.ts @@ -0,0 +1,62 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { homedir } from "os"; +import { execFileSync } from "child_process"; +import chalk from "chalk"; +import { ensureInit } from "../core/config.js"; +import { discoverProjects, computeStats } from "../core/discovery.js"; +import { updateCacheFromStats } from "../core/cache.js"; + +export async function setupStatuslineCommand(): Promise { + const { config } = ensureInit(); + + // 1. Find devlog binary path + let devlogBin = "devlog"; + try { + devlogBin = execFileSync("which", ["devlog"], { encoding: "utf-8" }).trim(); + } catch { + // Fall back to "devlog" and hope it's in PATH + } + + // 2. Read existing settings + const claudeSettingsPath = join(homedir(), ".claude", "settings.json"); + let settings: Record = {}; + if (existsSync(claudeSettingsPath)) { + try { + settings = JSON.parse(readFileSync(claudeSettingsPath, "utf-8")); + } catch { + settings = {}; + } + } + + // 3. Set statusLine config + settings.statusLine = { + type: "command", + command: `${devlogBin} statusline`, + }; + + // 4. Write back + mkdirSync(dirname(claudeSettingsPath), { recursive: true }); + writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); + + console.log(); + console.log(chalk.green(" \u2713") + chalk.bold.white(" Claude Code status line configured!")); + console.log(); + + // 5. Warm cache with a full discovery scan + console.log(chalk.dim(" Warming cache...")); + try { + const projects = await discoverProjects(config.claudeDir); + if (projects.length > 0) { + updateCacheFromStats(computeStats(projects)); + } + console.log(chalk.dim(" Cache ready.")); + } catch { + console.log(chalk.dim(" Cache will be built on first use.")); + } + + console.log(); + console.log(chalk.white(" DevLog will show your daily costs and activity.")); + console.log(chalk.white(" Restart Claude Code to see it.")); + console.log(); +} diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 34b4caf..f460241 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -4,12 +4,13 @@ import dayjs from "dayjs"; import isToday from "dayjs/plugin/isToday.js"; import { ensureInit } from "../core/config.js"; import { discoverProjects, computeStats } from "../core/discovery.js"; -import type { Session, GlobalOptions } from "../core/types.js"; +import type { Session, GlobalOptions, AggregateStats } from "../core/types.js"; import { formatNumber, costWithContext, } from "../utils/format.js"; import { outputJson, isJsonMode, isQuietMode } from "../utils/output.js"; +import { updateCacheFromStats } from "../core/cache.js"; dayjs.extend(isToday); @@ -54,6 +55,11 @@ export async function statsCommand( const projects = await discoverProjects(config.claudeDir); spinner?.stop(); + // Update cache as side effect of full scan + if (projects.length > 0) { + updateCacheFromStats(computeStats(projects)); + } + const allSessions: Session[] = []; for (const p of projects) allSessions.push(...p.sessions); const filtered = filterByPeriod(allSessions, period); diff --git a/src/commands/statusline.ts b/src/commands/statusline.ts new file mode 100644 index 0000000..a52244c --- /dev/null +++ b/src/commands/statusline.ts @@ -0,0 +1,112 @@ +import { ensureInit } from "../core/config.js"; +import { readStatsCache, writeStatsCache, isCacheFresh } from "../core/cache.js"; +import { discoverTodayStats } from "../core/fast-discovery.js"; +import type { StatsCache } from "../core/types.js"; +import dayjs from "dayjs"; + +interface StatuslineOptions { + cache?: boolean; +} + +interface StdinSession { + context_window?: { + used_percentage?: number; + used_tokens?: number; + total_tokens?: number; + }; + model?: string; + turn_number?: number; + session_id?: string; +} + +export async function statuslineCommand(options: StatuslineOptions): Promise { + const { config } = ensureInit(); + const useCache = options.cache !== false; + + // 1. Read stdin if piped (non-blocking, 50ms timeout) + const stdinData = process.stdin.isTTY ? null : await readStdinWithTimeout(50); + + // 2. Read cache + let cache = useCache ? readStatsCache() : null; + + // 3. If cache fresh → use directly + if (cache && useCache && isCacheFresh(cache)) { + process.stdout.write(formatStatusLine(cache, stdinData)); + return; + } + + // 4. If stale/missing → fast-discovery → update cache + try { + const todayStats = await discoverTodayStats(config.claudeDir); + const now = dayjs(); + cache = { + timestamp: now.toISOString(), + todayDate: now.format("YYYY-MM-DD"), + today: todayStats, + allTime: cache?.allTime ?? { sessions: 0, costUSD: 0, projects: 0 }, + }; + writeStatsCache(cache); + } catch { + if (!cache) { + process.stdout.write("DevLog: scanning..."); + return; + } + } + + // 5. Format and output + process.stdout.write(formatStatusLine(cache!, stdinData)); +} + +async function readStdinWithTimeout(ms: number): Promise { + return Promise.race([ + new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk) => { data += chunk; }); + process.stdin.on("end", () => { + try { + resolve(JSON.parse(data) as StdinSession); + } catch { + resolve(null); + } + }); + process.stdin.resume(); + }), + new Promise((resolve) => setTimeout(() => resolve(null), ms)), + ]); +} + +function formatStatusLine(cache: StatsCache, stdinData: StdinSession | null): string { + const parts: string[] = []; + + // Context window from Claude Code stdin + if (stdinData?.context_window?.used_percentage != null) { + parts.push(`ctx ${stdinData.context_window.used_percentage}%`); + } + + // Today stats + if (cache.today.sessions > 0) { + const sessionWord = cache.today.sessions === 1 ? "session" : "sessions"; + parts.push(`${formatCost(cache.today.costUSD)} today (${cache.today.sessions} ${sessionWord})`); + } else { + parts.push("No sessions today"); + } + + // All-time total + if (cache.allTime.costUSD > 0) { + parts.push(`${formatCost(cache.allTime.costUSD)} total`); + } + + if (parts.length === 0) { + return "DevLog: no data yet"; + } + + return parts.join(" \u00B7 "); +} + +function formatCost(usd: number): string { + if (usd < 0.01) return `$${usd.toFixed(3)}`; + if (usd < 1) return `$${usd.toFixed(2)}`; + if (usd >= 1000) return `$${usd.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; + return `$${usd.toFixed(2)}`; +} diff --git a/src/core/cache.ts b/src/core/cache.ts new file mode 100644 index 0000000..af4043e --- /dev/null +++ b/src/core/cache.ts @@ -0,0 +1,66 @@ +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import dayjs from "dayjs"; +import type { StatsCache, AggregateStats } from "./types.js"; +import { getDevlogDir } from "../utils/paths.js"; + +const CACHE_FILENAME = "stats-cache.json"; +const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes + +function getCachePath(): string { + return join(getDevlogDir(), "db", CACHE_FILENAME); +} + +export function readStatsCache(): StatsCache | null { + try { + const raw = readFileSync(getCachePath(), "utf-8"); + return JSON.parse(raw) as StatsCache; + } catch { + return null; + } +} + +export function writeStatsCache(cache: StatsCache): void { + const cachePath = getCachePath(); + const tmpPath = cachePath + ".tmp"; + try { + mkdirSync(dirname(cachePath), { recursive: true }); + writeFileSync(tmpPath, JSON.stringify(cache, null, 2), "utf-8"); + renameSync(tmpPath, cachePath); + } catch { + // Best-effort — don't crash the command if cache write fails + } +} + +export function isCacheFresh(cache: StatsCache, maxAgeMs?: number): boolean { + const maxAge = maxAgeMs ?? DEFAULT_MAX_AGE_MS; + const todayStr = dayjs().format("YYYY-MM-DD"); + + // Invalidate on day change + if (cache.todayDate !== todayStr) return false; + + const age = Date.now() - new Date(cache.timestamp).getTime(); + return age < maxAge; +} + +export function updateCacheFromStats(stats: AggregateStats): void { + const now = dayjs(); + const cache: StatsCache = { + timestamp: now.toISOString(), + todayDate: now.format("YYYY-MM-DD"), + today: { + sessions: stats.todaySessions, + costUSD: stats.todayCostUSD, + messages: stats.todayMessages, + toolCalls: stats.totalToolCalls, // approx — full stats don't split today's tool calls + filesTouched: stats.allFilesReferenced.length, + projects: stats.mostActiveProject ? [stats.mostActiveProject] : [], + }, + allTime: { + sessions: stats.totalSessions, + costUSD: stats.totalCostUSD, + projects: stats.totalProjects, + }, + }; + writeStatsCache(cache); +} diff --git a/src/core/fast-discovery.ts b/src/core/fast-discovery.ts new file mode 100644 index 0000000..4e20133 --- /dev/null +++ b/src/core/fast-discovery.ts @@ -0,0 +1,81 @@ +import { readdir, stat } from "fs/promises"; +import { join } from "path"; +import dayjs from "dayjs"; +import type { StatsCache } from "./types.js"; +import { decodePath, getProjectName } from "../utils/paths.js"; +import { scanSession } from "./parser.js"; + +/** + * Fast discovery: only scan JSONL files modified today. + * Reduces scan set from 2000+ to ~10-50 files for <300ms response. + */ +export async function discoverTodayStats( + claudeDir: string +): Promise { + const todayMidnight = dayjs().startOf("day").valueOf(); + + const result: StatsCache["today"] = { + sessions: 0, + costUSD: 0, + messages: 0, + toolCalls: 0, + filesTouched: 0, + projects: [], + }; + + const fileSet = new Set(); + const projectSet = new Set(); + + let entries; + try { + entries = await readdir(claudeDir, { withFileTypes: true }); + } catch { + return result; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const projectDir = join(claudeDir, entry.name); + const decodedPath = decodePath(entry.name); + const projectName = getProjectName(decodedPath); + + let files; + try { + files = await readdir(projectDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const file of files) { + if (!file.isFile() || !file.name.endsWith(".jsonl")) continue; + + const filePath = join(projectDir, file.name); + try { + const fileStat = await stat(filePath); + // Skip files not modified today + if (fileStat.mtimeMs < todayMidnight) continue; + + const meta = await scanSession(filePath); + + // Only count if session actually has today activity + const lastActivity = meta.lastActivity.getTime(); + if (lastActivity < todayMidnight) continue; + + result.sessions++; + result.costUSD += meta.totalCostUSD; + result.messages += meta.messageCount; + result.toolCalls += meta.toolCalls; + for (const f of meta.filesReferenced) fileSet.add(f); + projectSet.add(projectName); + } catch { + continue; + } + } + } + + result.filesTouched = fileSet.size; + result.projects = [...projectSet]; + + return result; +} diff --git a/src/core/types.ts b/src/core/types.ts index ee7d5de..abd602d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -148,6 +148,26 @@ export interface AggregateStats { mostActiveProjectSessions: number; } +// ── Stats Cache ────────────────────────────────────────── + +export interface StatsCache { + timestamp: string; // ISO date of last computation + todayDate: string; // "YYYY-MM-DD" — invalidate on day change + today: { + sessions: number; + costUSD: number; + messages: number; + toolCalls: number; + filesTouched: number; + projects: string[]; + }; + allTime: { + sessions: number; + costUSD: number; + projects: number; + }; +} + // ── JSON Output Types ────────────────────────────────────── export interface SessionJson { diff --git a/src/index.ts b/src/index.ts index c81d6fb..9d8be11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ export * from "./core/config.js"; export * from "./core/discovery.js"; export * from "./core/parser.js"; export * from "./core/pricing.js"; +export * from "./core/cache.js"; +export * from "./core/fast-discovery.js"; export * from "./utils/paths.js"; export * from "./utils/format.js"; export * from "./utils/output.js";