Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
36 changes: 35 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)}
Expand All @@ -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)")}
Expand All @@ -49,6 +55,8 @@ const KNOWN_COMMANDS = [
"search",
"stats",
"cost",
"statusline",
"setup-statusline",
];

function getGlobalOpts(): GlobalOptions {
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/commands/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function dashboardCommand(globalOpts: GlobalOptions): Promise<void>
}

const stats = computeStats(projects);
updateCacheFromStats(stats);
const groups = groupSessionsByTime(projects);

// ── JSON output ────────────────────────────────────
Expand Down
62 changes: 62 additions & 0 deletions src/commands/setup-statusline.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown> = {};
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();
}
8 changes: 7 additions & 1 deletion src/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
112 changes: 112 additions & 0 deletions src/commands/statusline.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<StdinSession | null> {
return Promise.race<StdinSession | null>([
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)}`;
}
66 changes: 66 additions & 0 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading