Skip to content
Open
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
91 changes: 91 additions & 0 deletions apps/droid/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<div align="center">
<img src="https://cdn.jsdelivr.net/gh/ryoppippi/ccusage@main/docs/public/logo.svg" alt="ccusage logo" width="256" height="256">
<h1>@ccusage/droid</h1>
</div>

> Analyze Factory Droid usage logs with the same reporting experience as `ccusage`.

## Quick Start

```bash
# Recommended - always include @latest
npx @ccusage/droid@latest --help
bunx @ccusage/droid@latest --help

# Alternative package runners
pnpm dlx @ccusage/droid
pnpx @ccusage/droid
```

## Common Commands

```bash
# Daily usage grouped by date (default command)
npx @ccusage/droid@latest daily

# Monthly usage grouped by month
npx @ccusage/droid@latest monthly

# Session-level usage grouped by Factory session
npx @ccusage/droid@latest session

# JSON output for scripting
npx @ccusage/droid@latest daily --json

# Filter by date range
npx @ccusage/droid@latest daily --since 2026-01-01 --until 2026-01-10

# Read from a custom Factory data dir
npx @ccusage/droid@latest daily --factoryDir /path/to/.factory
```

## Data Source

This CLI reads Factory Droid logs from:

- `~/.factory/logs/droid-log-*.log`

You can override the Factory data directory via:

- `--factoryDir /path/to/.factory`
- `FACTORY_DIR=/path/to/.factory`

## Pricing

Costs are calculated from token counts using LiteLLM's pricing dataset.

- Use `--offline` to avoid fetching updated pricing.
- If a model is missing pricing data, its cost is treated as `$0` and reported as a warning.

## Custom Models

Factory supports custom model IDs (often prefixed with `custom:`). This CLI resolves them using:

- `~/.factory/settings.json` → `customModels[]`

Example:

```json
{
"customModels": [
{
"id": "custom:GPT-5.2-(High)-18",
"model": "gpt-5.2(high)",
"provider": "openai"
}
]
}
```

In tables, custom models are displayed as `gpt-5.2(high) [custom]`.

When a log line is missing a model tag, the CLI resolves the model from the session settings file and marks it as `[...] [inferred]`.

## Environment Variables

- `FACTORY_DIR` - override the Factory data directory
- `LOG_LEVEL` - control log verbosity (0 silent … 5 trace)

## License

MIT © [@ryoppippi](https://github.com/ryoppippi)
16 changes: 16 additions & 0 deletions apps/droid/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ryoppippi } from '@ryoppippi/eslint-config';

/** @type {import('eslint').Linter.FlatConfig[]} */
const config = ryoppippi(
{
type: 'app',
stylistic: false,
},
{
rules: {
'test/no-importing-vitest-globals': 'error',
},
},
);

export default config;
78 changes: 78 additions & 0 deletions apps/droid/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"name": "@ccusage/droid",
"type": "module",
"version": "18.0.5",
"description": "Usage analysis tool for Factory Droid sessions",
"author": "ryoppippi",
"license": "MIT",
"funding": "https://github.com/ryoppippi/ccusage?sponsor=1",
"homepage": "https://github.com/ryoppippi/ccusage#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ryoppippi/ccusage.git",
"directory": "apps/droid"
},
"bugs": {
"url": "https://github.com/ryoppippi/ccusage/issues"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"bin": {
"ccusage-droid": "./src/index.ts"
},
"files": [
"dist"
],
"publishConfig": {
"bin": {
"ccusage-droid": "./dist/index.js"
}
},
"engines": {
"node": ">=20.19.4"
},
"scripts": {
"build": "tsdown",
"format": "pnpm run lint --fix",
"lint": "eslint --cache .",
"prepack": "pnpm run build && clean-pkg-json",
"prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build",
"start": "bun ./src/index.ts",
"test": "TZ=UTC vitest",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@ccusage/internal": "workspace:*",
"@ccusage/terminal": "workspace:*",
"@praha/byethrow": "catalog:runtime",
"@ryoppippi/eslint-config": "catalog:lint",
"@typescript/native-preview": "catalog:types",
"clean-pkg-json": "catalog:release",
"eslint": "catalog:lint",
"fast-sort": "catalog:runtime",
"fs-fixture": "catalog:testing",
"gunshi": "catalog:runtime",
"path-type": "catalog:runtime",
"picocolors": "catalog:runtime",
"tinyglobby": "catalog:runtime",
"tsdown": "catalog:build",
"unplugin-macros": "catalog:build",
"unplugin-unused": "catalog:build",
"valibot": "catalog:runtime",
"vitest": "catalog:testing"
},
"devEngines": {
"runtime": [
{
"name": "node",
"version": ">=20.19.4",
"onFail": "download"
},
{
"name": "bun",
"version": "^1.3.2",
"onFail": "download"
}
]
}
}
15 changes: 15 additions & 0 deletions apps/droid/src/_consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @fileoverview Default paths and constants for Factory Droid usage tracking.
*/

import os from 'node:os';
import path from 'node:path';

export const FACTORY_DIR_ENV = 'FACTORY_DIR';
export const DEFAULT_FACTORY_DIR = path.join(os.homedir(), '.factory');
export const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
export const DEFAULT_LOCALE = 'en-CA';

export const DROID_LOG_GLOB = 'droid-log-*.log';
export const FACTORY_LOGS_SUBDIR = 'logs';
export const FACTORY_SESSIONS_SUBDIR = 'sessions';
37 changes: 37 additions & 0 deletions apps/droid/src/_macro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @fileoverview Lightweight helper for prefetching Factory model pricing.
*/

import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';
import {
createPricingDataset,
fetchLiteLLMPricingDataset,
filterPricingDataset,
} from '@ccusage/internal/pricing-fetch-utils';
import { logger } from './logger.ts';

const FACTORY_MODEL_PREFIXES = [
'openai/',
'azure/',
'anthropic/',
'openrouter/',
'gpt-',
'claude-',
'gemini-',
'google/',
'vertex_ai/',
];

function isFactoryModel(modelName: string, _pricing: LiteLLMModelPricing): boolean {
return FACTORY_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix));
}

export async function prefetchFactoryPricing(): Promise<Record<string, LiteLLMModelPricing>> {
try {
const dataset = await fetchLiteLLMPricingDataset();
return filterPricingDataset(dataset, isFactoryModel);
} catch (error) {
logger.warn('Failed to prefetch Factory pricing data, proceeding with empty cache.', error);
return createPricingDataset();
}
}
64 changes: 64 additions & 0 deletions apps/droid/src/_shared-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @fileoverview Shared CLI arguments for `@ccusage/droid` commands.
*/

import type { Args } from 'gunshi';
import { DEFAULT_LOCALE, DEFAULT_TIMEZONE } from './_consts.ts';

/**
* Common CLI args shared by `daily`, `monthly`, and `session` commands.
*/
export const sharedArgs = {
json: {
type: 'boolean',
short: 'j',
description: 'Output report as JSON',
default: false,
},
since: {
type: 'string',
short: 's',
description: 'Filter from date (YYYY-MM-DD or YYYYMMDD)',
},
until: {
type: 'string',
short: 'u',
description: 'Filter until date (inclusive)',
},
timezone: {
type: 'string',
short: 'z',
description: 'Timezone for date grouping (IANA)',
default: DEFAULT_TIMEZONE,
},
locale: {
type: 'string',
short: 'l',
description: 'Locale for formatting',
default: DEFAULT_LOCALE,
},
offline: {
type: 'boolean',
short: 'O',
description: 'Use cached pricing data instead of fetching from LiteLLM',
default: false,
negatable: true,
},
compact: {
type: 'boolean',
description: 'Force compact table layout for narrow terminals',
default: false,
},
factoryDir: {
type: 'string',
description: 'Path to Factory data directory (default: ~/.factory)',
},
color: {
type: 'boolean',
description: 'Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.',
},
noColor: {
type: 'boolean',
description: 'Disable colored output (default: auto). NO_COLOR=1 has the same effect.',
},
} as const satisfies Args;
Loading