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
25 changes: 24 additions & 1 deletion .claude/rules/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ description: TypeScript and React code style conventions

## JSDoc

JSDoc block above every function/component — no `@param`/`@returns` tags; TypeScript is the source of truth. Document each param inline in the type.
JSDoc block above every function/component — no `@param`/`@returns` tags; TypeScript is the source of truth. Document each param inline in the type. Also do the same for `interface` and `type` declarations.

```typescript
/**
Expand All @@ -25,7 +25,30 @@ const myFunc = ({
}: {
/** What bar represents */
bar: number;

/** What foo represents */
foo?: string;
}): void => {};

/**
* Represents an animal of the feline species
*/
interface Cat {
/** The color of the fur **/
color: string;

/** The weight in pounds **/
weight: number;
}

/**
* Represents an animal within our system
*/
type Animal = {
/** The id of the animal in our system **/
id: string;

/** The type of animal **/
type: Cat | Dog;
};
```
61 changes: 61 additions & 0 deletions docs/testing-locally.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Testing the CLI locally against localhost

Use these steps to test `install` / `uninstall` against a local AgentMeter server and verify the service survives reboots.

## 1. Build and init

```bash
pnpm build

AGENTMETER_API_KEY=<your-key> \
AGENTMETER_API_URL=http://localhost:3000 \
pnpm cli init
```

This writes both values to `~/.agentmeter/config.json`.

## 2. Install the service

```bash
pnpm cli install
```

This bakes `AGENTMETER_API_KEY` and `AGENTMETER_API_URL` into the launchd plist (macOS) or systemd unit (Linux) so the service is self-contained after reboot.

## 3. Verify the plist looks right (macOS)

```bash
cat ~/Library/LaunchAgents/com.agentmeter.sync.plist
```

Both env vars should appear in the `EnvironmentVariables` dict.

## 4. Check it's running

```bash
pnpm cli status
launchctl list com.agentmeter.sync
```

## 5. Watch the logs

```bash
tail -f ~/.agentmeter/sync.log
```

## 6. Test reboot persistence

Restart your machine. After login:

```bash
launchctl list com.agentmeter.sync # should show a PID
tail ~/.agentmeter/sync.log # should show sync activity
```

> **Note:** `localhost:3000` won't be running automatically after reboot, so the service will log connection errors until your Next.js server is started. That's expected — it confirms the reboot persistence mechanism is working correctly.

## Uninstall when done

```bash
pnpm cli uninstall
```
14 changes: 14 additions & 0 deletions packages/cli/__tests__/commands/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ vi.mock('../../src/utils/platform.js', () => ({
getPlatform: () => 'macos',
}));

// Prevent the Cursor scanner from hitting real system files in tests —
// it would scan the developer's actual Cursor data dir if not mocked.
vi.mock('../../src/scanners/cursor.js', () => ({
CursorScanner: class {
readonly name = 'cursor';
async isAvailable() {
return false;
}
async scan() {
return [];
}
},
}));

vi.mock('../../src/services/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
setForegroundMode: vi.fn(),
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import pc from 'picocolors';
import { ApiClient } from '../services/api.js';
import { writeConfig } from '../services/config.js';

/**
* Returns a truncated preview of an API key safe for display in terminal output
*/
function maskKey(key: string): string {
if (key.length <= 8) return `${key.slice(0, 4)}...`;
return `${key.slice(0, 8)}...`;
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,51 @@ import { logger } from '../services/logger.js';
import { readSyncState, writeSyncState } from '../services/sync-state.js';
import { formatCost, formatDuration } from '../utils/format.js';

/**
* Runtime options for a sync run, controlling output and filtering
*/
export interface SyncOptions {
/** Whether to print a row for every session processed */
verbose: boolean;

/** When true, reports what would be submitted without sending any data */
dryRun: boolean;

/** ISO 8601 date string; only sessions starting on or after this date are included */
since?: string;

/** Scanner name filter (e.g. "claude"); omit to include all available scanners */
engine?: string;
}

/**
* Aggregated counts and cost totals returned from a completed sync run
*/
export interface SyncResult {
/** Number of sessions submitted for the first time */
newCount: number;

/** Number of sessions re-submitted because their status or endTime changed */
updatedCount: number;

/** Number of sessions that were already up-to-date and skipped */
skippedCount: number;

/** Number of sessions that failed to submit */
errorCount: number;

/** Sum of costCents across all successfully submitted sessions */
totalCostCents: number;
}

/**
* Sessions split into those needing submission and those already up-to-date
*/
interface SessionClassification {
/** Sessions to submit, tagged with whether they are new or just updated */
toSync: Array<{ session: LocalSession; isNew: boolean }>;

/** Sessions that match their already-synced state and can be skipped */
skipped: LocalSession[];
}

Expand Down Expand Up @@ -66,7 +94,11 @@ function classifySessions(sessions: LocalSession[], syncState: SyncState): Sessi
const existing = syncState.sessions[session.sessionId];
if (!existing) {
toSync.push({ session, isNew: true });
} else if (existing.status !== session.status) {
} else if (
existing.status !== session.status ||
existing.endTime !== (session.endTime ?? null) ||
(existing.title ?? null) !== (session.title ?? null)
) {
toSync.push({ session, isNew: false });
} else {
skipped.push(session);
Expand Down Expand Up @@ -139,6 +171,8 @@ async function submitAll(
status: session.status,
submittedAt: new Date().toISOString(),
costCents: result.costCents ?? null,
endTime: session.endTime ?? null,
title: session.title ?? null,
};

if (result.costCents) totalCostCents += result.costCents;
Expand Down
27 changes: 23 additions & 4 deletions packages/cli/src/scanners/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ const JournalEntrySchema = z
uuid: z.string().optional(),
timestamp: z.string().optional(),
cwd: z.string().optional(),
aiTitle: z.string().optional(),
message: MessageSchema.optional(),
})
.passthrough();

/** A single line parsed from a Claude Code JSONL session file */
type JournalEntry = z.infer<typeof JournalEntrySchema>;

/**
Expand Down Expand Up @@ -81,11 +83,19 @@ function stripMarkdownHeading(text: string): string {
}

/**
* Finds the first meaningful user message to use as the session title.
* Skips entries whose content begins with an XML-style tag (e.g. <ide_opened_file>).
* Strips leading markdown heading syntax (e.g. # My Title → My Title).
* Extracts the session title, preferring Claude Code's AI-generated title
* (from ai-title entries) over the first meaningful user message.
*/
function extractTitle(entries: JournalEntry[]): string | null {
// Prefer the AI-generated title Claude Code writes to the JSONL.
// Iterate in reverse — Claude Code rewrites the title as the conversation progresses,
// so the last ai-title entry is the most accurate.
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry?.type === 'ai-title' && entry.aiTitle) return entry.aiTitle.slice(0, 120);
}

// Fall back to first meaningful user message
for (const entry of entries) {
if (entry.type !== 'user' || !entry.message) continue;
const content = entry.message.content;
Expand Down Expand Up @@ -199,10 +209,19 @@ function extractTiming(entries: JournalEntry[]): {
return { startTime, endTime, durationSeconds };
}

const RUNNING_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes

/**
* Determines session status from the last assistant stop_reason
* Determines session status. Returns 'running' if the last entry timestamp is
* within 30 minutes — the session is likely still active. Otherwise uses the
* last assistant stop_reason to distinguish success from failure.
*/
function extractStatus(entries: JournalEntry[]): LocalSession['status'] {
const lastTimestamp = [...entries].reverse().find((e) => e.timestamp)?.timestamp;
if (lastTimestamp && Date.now() - new Date(lastTimestamp).getTime() < RUNNING_THRESHOLD_MS) {
return 'running';
}

for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry?.type === 'assistant' && entry.message?.stop_reason) {
Expand Down
Loading
Loading