mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 06:54:26 +00:00
Merge origin/main into feat/optimize
Resolve conflicts in src/dashboard.tsx: keep optimize view plumbing (setOptimizeResult, OptimizeResult state, o/b keys) while integrating project/exclude filters on reloadData and renderDashboard entry.
This commit is contained in:
commit
1cd96ea19f
12 changed files with 538 additions and 58 deletions
52
CHANGELOG.md
52
CHANGELOG.md
|
|
@ -1,5 +1,57 @@
|
|||
# Changelog
|
||||
|
||||
## 0.6.1 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- **JSON output on `report`, `today`, `month`.** `--format json` writes the
|
||||
full dashboard (overview, daily, projects, models, activities, tools, MCP
|
||||
servers, shell commands, top sessions) to stdout. Contributed by @mallek.
|
||||
- **Project filters.** `--project <name>` and `--exclude <name>` on all
|
||||
commands (`report`, `today`, `month`, `status`, `export`). Case-insensitive
|
||||
substring match against project name and path. Both flags are repeatable.
|
||||
Contributed by @mallek.
|
||||
- **claude-opus-4-7 model mapping and pricing.** Displays as `Opus 4.7` with
|
||||
the same Opus pricing as 4.6 and a 6x fast multiplier. Contributed by @mallek.
|
||||
- **Unit tests for `filterProjectsByName`** covering include/exclude
|
||||
semantics, case-insensitivity, path matching, and input immutability.
|
||||
|
||||
### Fixed
|
||||
- **Top Sessions panel truncating the calls column.** Row width filled the
|
||||
full panel width without leaving room for the border and padding, so Ink
|
||||
truncated the last 4 characters -- landing exactly on the calls column and
|
||||
producing rows like `$182.58 ...` with no value.
|
||||
- **SwiftBar custom plugin directory** now honoured when installing the
|
||||
menubar widget. Reads the configured path from SwiftBar's defaults before
|
||||
falling back to the standard location. Contributed by @Galeas.
|
||||
- **`status --format menubar` per-provider today totals** now respect
|
||||
`--project`/`--exclude`. The main period blocks already did, the provider
|
||||
breakdown loop was the one spot that bypassed the filter.
|
||||
|
||||
## 0.6.0 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- **GitHub Copilot provider.** Parses `~/.copilot/session-state/*/events.jsonl`
|
||||
and tracks model changes via `session.model_change` events. Picks up six new
|
||||
model prices (`gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-5-mini`, `o3`,
|
||||
`o4-mini`). Contributed by @theodorosD. Note: Copilot logs only output
|
||||
tokens, so cost rows will sit below actual API cost.
|
||||
- **All Time period (key `5`).** Shows every recorded session since CodeBurn
|
||||
started tracking. Daily Activity expands to every available day instead of
|
||||
the fixed 14- or 31-day window. `codeburn report -p all` also works from
|
||||
the CLI. Contributed by @lfl1337.
|
||||
- **avg/s column in By Project.** Average cost per session next to the
|
||||
existing total cost and session count. Surfaces projects where individual
|
||||
sessions are expensive even if the total is modest. Contributed by @lfl1337.
|
||||
- **Top Sessions panel.** Highlights the five most expensive sessions across
|
||||
all projects with date, project, cost, and API call count. Helps spot
|
||||
outliers that drag weekly or monthly totals. Contributed by @lfl1337.
|
||||
|
||||
### Fixed
|
||||
- `modelDisplayName` now matches longest key first so `gpt-4.1-mini` resolves
|
||||
to `GPT-4.1 Mini` instead of `GPT-4.1`.
|
||||
- `TopSessions` handles missing `firstTimestamp` gracefully with a
|
||||
`----------` placeholder instead of rendering a stray whitespace row.
|
||||
|
||||
## 0.5.0 - 2026-04-15
|
||||
|
||||
### Added
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -19,7 +19,7 @@
|
|||
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
||||
</p>
|
||||
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, and **Pi** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
|
||||
|
||||
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
|
||||
|
||||
|
|
@ -38,42 +38,79 @@ npx codeburn
|
|||
### Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, and/or Pi (`~/.pi/agent/sessions/`)
|
||||
- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, Pi (`~/.pi/agent/sessions/`), and/or GitHub Copilot (`~/.copilot/session-state/`)
|
||||
- For Cursor/OpenCode support: `better-sqlite3` is installed automatically as an optional dependency
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
codeburn # interactive dashboard (default: 7 days)
|
||||
codeburn today # today's usage
|
||||
codeburn month # this month's usage
|
||||
codeburn report -p 30days # rolling 30-day window
|
||||
codeburn report --refresh 60 # auto-refresh every 60 seconds
|
||||
codeburn status # compact one-liner (today + month)
|
||||
codeburn # interactive dashboard (default: 7 days)
|
||||
codeburn today # today's usage
|
||||
codeburn month # this month's usage
|
||||
codeburn report -p 30days # rolling 30-day window
|
||||
codeburn report -p all # every recorded session
|
||||
codeburn report --format json # full dashboard data as JSON
|
||||
codeburn report --refresh 60 # auto-refresh every 60 seconds
|
||||
codeburn status # compact one-liner (today + month)
|
||||
codeburn status --format json
|
||||
codeburn export # CSV with today, 7 days, 30 days
|
||||
codeburn export -f json # JSON export
|
||||
codeburn export # CSV with today, 7 days, 30 days
|
||||
codeburn export -f json # JSON export
|
||||
```
|
||||
|
||||
Arrow keys switch between Today / 7 Days / 30 Days / Month. Press `q` to quit, `1` `2` `3` `4` as shortcuts.
|
||||
Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
|
||||
|
||||
### JSON output
|
||||
|
||||
`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
|
||||
|
||||
```bash
|
||||
codeburn report --format json # 7-day JSON report
|
||||
codeburn today --format json # today's data as JSON
|
||||
codeburn month --format json # this month as JSON
|
||||
codeburn report -p 30days --format json # 30-day window
|
||||
```
|
||||
|
||||
The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects, models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering:
|
||||
|
||||
```bash
|
||||
codeburn report --format json | jq '.projects'
|
||||
codeburn today --format json | jq '.overview.cost'
|
||||
```
|
||||
|
||||
For the lighter `status --format json` (today + month totals only) or file-based exports (`export -f json`), see above.
|
||||
|
||||
## Providers
|
||||
|
||||
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
|
||||
|
||||
```bash
|
||||
codeburn report # all providers combined (default)
|
||||
codeburn report --provider claude # Claude Code only
|
||||
codeburn report # all providers combined (default)
|
||||
codeburn report --provider claude # Claude Code only
|
||||
codeburn report --provider codex # Codex only
|
||||
codeburn report --provider cursor # Cursor only
|
||||
codeburn report --provider opencode # OpenCode only
|
||||
codeburn report --provider pi # Pi only
|
||||
codeburn today --provider codex # Codex today
|
||||
codeburn export --provider claude # export Claude data only
|
||||
codeburn report --provider cursor # Cursor only
|
||||
codeburn report --provider opencode # OpenCode only
|
||||
codeburn report --provider pi # Pi only
|
||||
codeburn report --provider copilot # GitHub Copilot only
|
||||
codeburn today --provider codex # Codex today
|
||||
codeburn export --provider claude # export Claude data only
|
||||
```
|
||||
|
||||
The `--provider` flag works on all commands: `report`, `today`, `month`, `status`, `export`.
|
||||
|
||||
### Project filtering
|
||||
|
||||
Filter results by project name (case-insensitive substring match). Both flags are repeatable:
|
||||
|
||||
```bash
|
||||
codeburn report --project myapp # show only projects matching "myapp"
|
||||
codeburn report --exclude myapp # show everything except "myapp"
|
||||
codeburn report --exclude myapp --exclude tests # exclude multiple projects
|
||||
codeburn month --project api --project web # include multiple projects
|
||||
codeburn export --project inventory # export only "inventory" project data
|
||||
```
|
||||
|
||||
The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`.
|
||||
|
||||
### Supported providers
|
||||
|
||||
| Provider | Data location | Status |
|
||||
|
|
@ -84,12 +121,15 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status
|
|||
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported |
|
||||
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
|
||||
| Pi | `~/.pi/agent/sessions/` | Supported |
|
||||
| GitHub Copilot | `~/.copilot/session-state/` | Supported (output tokens only) |
|
||||
| Amp | -- | Planned (provider plugin system) |
|
||||
|
||||
Codex tool names are normalized to match Claude's conventions (`exec_command` shows as `Bash`, `read_file` as `Read`, etc.) so the activity classifier and tool breakdown work across providers.
|
||||
|
||||
Cursor reads token usage from its local SQLite database. Since Cursor's "Auto" mode hides the actual model used, costs are estimated using Sonnet pricing (labeled "Auto (Sonnet est.)" in the dashboard). The Cursor view shows a **Languages** panel (extracted from code blocks) instead of Core Tools/Shell/MCP panels, since Cursor does not log individual tool calls. First run on a large Cursor database may take up to a minute; results are cached and subsequent runs are instant.
|
||||
|
||||
GitHub Copilot only logs output tokens in its session state, so Copilot cost rows sit below actual API cost. The model is tracked via `session.model_change` events; messages before the first model change are skipped to avoid silent misattribution.
|
||||
|
||||
### Adding a provider
|
||||
|
||||
The provider plugin system makes adding a new provider a single file. Each provider implements session discovery, JSONL parsing, tool normalization, and model display names. See `src/providers/codex.ts` for an example.
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codeburn",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.5.7",
|
||||
"version": "0.6.1",
|
||||
"description": "See where your AI coding tokens go - by task, tool, model, and project",
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
|
|||
203
src/cli.ts
203
src/cli.ts
|
|
@ -1,7 +1,8 @@
|
|||
import { Command } from 'commander'
|
||||
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
||||
import { loadPricing } from './models.js'
|
||||
import { parseAllSessions } from './parser.js'
|
||||
import { parseAllSessions, filterProjectsByName } from './parser.js'
|
||||
import { convertCost } from './currency.js'
|
||||
import { renderStatusBar } from './format.js'
|
||||
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
|
|
@ -51,7 +52,9 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
}
|
||||
}
|
||||
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
function toPeriod(s: string): Period {
|
||||
if (s === 'today') return 'today'
|
||||
if (s === 'month') return 'month'
|
||||
if (s === '30days') return '30days'
|
||||
|
|
@ -59,6 +62,18 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
|||
return 'week'
|
||||
}
|
||||
|
||||
function collect(val: string, acc: string[]): string[] {
|
||||
acc.push(val)
|
||||
return acc
|
||||
}
|
||||
|
||||
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
|
||||
}
|
||||
|
||||
const program = new Command()
|
||||
.name('codeburn')
|
||||
.description('See where your AI coding tokens go - by task, tool, model, and project')
|
||||
|
|
@ -68,14 +83,150 @@ program.hook('preAction', async () => {
|
|||
await loadCurrency()
|
||||
})
|
||||
|
||||
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const { code } = getCurrency()
|
||||
|
||||
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
|
||||
const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
|
||||
const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
|
||||
const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
|
||||
const allInput = totalInput + totalCacheRead + totalCacheWrite
|
||||
const cacheHitPercent = allInput > 0 ? Math.round((totalCacheRead / allInput) * 1000) / 10 : 0
|
||||
|
||||
const dailyMap: Record<string, { cost: number; calls: number }> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const turn of sess.turns) {
|
||||
if (!turn.timestamp) { continue }
|
||||
const day = turn.timestamp.slice(0, 10)
|
||||
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
|
||||
for (const call of turn.assistantCalls) {
|
||||
dailyMap[day].cost += call.costUSD
|
||||
dailyMap[day].calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
|
||||
date,
|
||||
cost: convertCost(d.cost),
|
||||
calls: d.calls,
|
||||
}))
|
||||
|
||||
const projectList = projects.map(p => ({
|
||||
name: p.project,
|
||||
path: p.projectPath,
|
||||
cost: convertCost(p.totalCostUSD),
|
||||
calls: p.totalApiCalls,
|
||||
sessions: p.sessions.length,
|
||||
}))
|
||||
|
||||
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
|
||||
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
|
||||
modelMap[model].calls += d.calls
|
||||
modelMap[model].cost += d.costUSD
|
||||
modelMap[model].inputTokens += d.tokens.inputTokens
|
||||
modelMap[model].outputTokens += d.tokens.outputTokens
|
||||
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
|
||||
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
|
||||
}
|
||||
}
|
||||
const models = Object.entries(modelMap)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
|
||||
|
||||
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
|
||||
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
|
||||
catMap[cat].turns += d.turns
|
||||
catMap[cat].cost += d.costUSD
|
||||
catMap[cat].editTurns += d.editTurns
|
||||
catMap[cat].oneShotTurns += d.oneShotTurns
|
||||
}
|
||||
}
|
||||
const activities = Object.entries(catMap)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({
|
||||
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
|
||||
cost: convertCost(d.cost),
|
||||
turns: d.turns,
|
||||
editTurns: d.editTurns,
|
||||
oneShotTurns: d.oneShotTurns,
|
||||
oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
|
||||
}))
|
||||
|
||||
const toolMap: Record<string, number> = {}
|
||||
const mcpMap: Record<string, number> = {}
|
||||
const bashMap: Record<string, number> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
|
||||
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
|
||||
}
|
||||
for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
|
||||
mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
|
||||
}
|
||||
for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
|
||||
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMap = (m: Record<string, number>) =>
|
||||
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
|
||||
|
||||
const topSessions = projects
|
||||
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp?.slice(0, 10) ?? null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
currency: code,
|
||||
period,
|
||||
periodKey,
|
||||
overview: {
|
||||
cost: convertCost(totalCostUSD),
|
||||
calls: totalCalls,
|
||||
sessions: totalSessions,
|
||||
cacheHitPercent,
|
||||
tokens: {
|
||||
input: totalInput,
|
||||
output: totalOutput,
|
||||
cacheRead: totalCacheRead,
|
||||
cacheWrite: totalCacheWrite,
|
||||
},
|
||||
},
|
||||
daily,
|
||||
projects: projectList,
|
||||
models,
|
||||
activities,
|
||||
tools: sortedMap(toolMap),
|
||||
mcpServers: sortedMap(mcpMap),
|
||||
shellCommands: sortedMap(bashMap),
|
||||
topSessions,
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('report', { isDefault: true })
|
||||
.description('Interactive usage dashboard')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
|
||||
const period = toPeriod(opts.period)
|
||||
if (opts.format === 'json') {
|
||||
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude)
|
||||
})
|
||||
|
||||
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
|
|
@ -122,18 +273,21 @@ program
|
|||
.description('Compact status output (today + week + month)')
|
||||
.option('--format <format>', 'Output format: terminal, menubar, json', 'terminal')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
if (opts.format === 'menubar') {
|
||||
const todayRange = getDateRange('today').range
|
||||
const todayData = buildPeriodData('Today', await parseAllSessions(todayRange, pf))
|
||||
const weekData = buildPeriodData('7 Days', await parseAllSessions(getDateRange('week').range, pf))
|
||||
const thirtyDayData = buildPeriodData('30 Days', await parseAllSessions(getDateRange('30days').range, pf))
|
||||
const monthData = buildPeriodData('Month', await parseAllSessions(getDateRange('month').range, pf))
|
||||
const todayData = buildPeriodData('Today', fp(await parseAllSessions(todayRange, pf)))
|
||||
const weekData = buildPeriodData('7 Days', fp(await parseAllSessions(getDateRange('week').range, pf)))
|
||||
const thirtyDayData = buildPeriodData('30 Days', fp(await parseAllSessions(getDateRange('30days').range, pf)))
|
||||
const monthData = buildPeriodData('Month', fp(await parseAllSessions(getDateRange('month').range, pf)))
|
||||
const todayProviders: ProviderCost[] = []
|
||||
for (const p of await getAllProviders()) {
|
||||
const data = await parseAllSessions(todayRange, p.name)
|
||||
const data = fp(await parseAllSessions(todayRange, p.name))
|
||||
const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
|
||||
if (cost > 0) todayProviders.push({ name: p.displayName, cost })
|
||||
}
|
||||
|
|
@ -142,8 +296,8 @@ program
|
|||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
const todayData = buildPeriodData('today', await parseAllSessions(getDateRange('today').range, pf))
|
||||
const monthData = buildPeriodData('month', await parseAllSessions(getDateRange('month').range, pf))
|
||||
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
|
||||
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
|
||||
const { code, rate } = getCurrency()
|
||||
console.log(JSON.stringify({
|
||||
currency: code,
|
||||
|
|
@ -153,7 +307,7 @@ program
|
|||
return
|
||||
}
|
||||
|
||||
const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
|
||||
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
|
||||
console.log(renderStatusBar(monthProjects))
|
||||
})
|
||||
|
||||
|
|
@ -161,18 +315,32 @@ program
|
|||
.command('today')
|
||||
.description('Today\'s usage dashboard')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard('today', opts.provider, opts.refresh)
|
||||
if (opts.format === 'json') {
|
||||
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
|
||||
})
|
||||
|
||||
program
|
||||
.command('month')
|
||||
.description('This month\'s usage dashboard')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard('month', opts.provider, opts.refresh)
|
||||
if (opts.format === 'json') {
|
||||
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
|
||||
})
|
||||
|
||||
program
|
||||
|
|
@ -181,13 +349,16 @@ program
|
|||
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
|
||||
.option('-o, --output <path>', 'Output file path')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
const periods: PeriodExport[] = [
|
||||
{ label: 'Today', projects: await parseAllSessions(getDateRange('today').range, pf) },
|
||||
{ label: '7 Days', projects: await parseAllSessions(getDateRange('week').range, pf) },
|
||||
{ label: '30 Days', projects: await parseAllSessions(getDateRange('30days').range, pf) },
|
||||
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
|
||||
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
|
||||
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
|
||||
]
|
||||
|
||||
if (periods.every(p => p.projects.length === 0)) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'
|
|||
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { parseAllSessions } from './parser.js'
|
||||
import { parseAllSessions, filterProjectsByName } from './parser.js'
|
||||
import { loadPricing } from './models.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
|
||||
|
|
@ -137,6 +137,8 @@ function HBar({ value, max, width }: { value: number; max: number; width: number
|
|||
)
|
||||
}
|
||||
|
||||
const PANEL_CHROME = 4
|
||||
|
||||
function Panel({ title, color, children, width }: { title: string; color: string; children: React.ReactNode; width: number }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width} overflowX="hidden">
|
||||
|
|
@ -387,7 +389,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
|
|||
}
|
||||
|
||||
const maxCost = top[0].totalCostUSD
|
||||
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1)
|
||||
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
|
||||
|
||||
return (
|
||||
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
|
||||
|
|
@ -572,7 +574,14 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
|
|||
)
|
||||
}
|
||||
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds }: { initialProjects: ProjectSummary[]; initialPeriod: Period; initialProvider: string; refreshSeconds?: number }) {
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds, projectFilter, excludeFilter }: {
|
||||
initialProjects: ProjectSummary[]
|
||||
initialPeriod: Period
|
||||
initialProvider: string
|
||||
refreshSeconds?: number
|
||||
projectFilter?: string[]
|
||||
excludeFilter?: string[]
|
||||
}) {
|
||||
const { exit } = useApp()
|
||||
const [period, setPeriod] = useState<Period>(initialPeriod)
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
|
||||
|
|
@ -632,9 +641,11 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const reloadData = useCallback(async (p: Period, prov: string) => {
|
||||
setLoading(true)
|
||||
setOptimizeResult(null)
|
||||
setProjects(await parseAllSessions(getDateRange(p), prov))
|
||||
const range = getDateRange(p)
|
||||
const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
|
||||
setProjects(data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
}, [projectFilter, excludeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshSeconds || refreshSeconds <= 0) return
|
||||
|
|
@ -708,12 +719,15 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
|
|||
)
|
||||
}
|
||||
|
||||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number): Promise<void> {
|
||||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const projects = await parseAllSessions(getDateRange(period), provider)
|
||||
const range = getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const isTTY = process.stdin.isTTY && process.stdout.isTTY
|
||||
if (isTTY) {
|
||||
const { waitUntilExit } = render(<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} />)
|
||||
const { waitUntilExit } = render(
|
||||
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
|
||||
)
|
||||
await waitUntilExit()
|
||||
} else {
|
||||
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { execSync } from 'child_process'
|
||||
import { execFileSync, execSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { chmod, mkdir, unlink, writeFile } from 'fs/promises'
|
||||
import { homedir, platform } from 'os'
|
||||
|
|
@ -7,6 +7,8 @@ import { formatCost, formatTokens } from './format.js'
|
|||
import { getCurrency } from './currency.js'
|
||||
|
||||
const PLUGIN_REFRESH = '5m'
|
||||
const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
|
||||
const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
|
||||
|
||||
function getSwiftBarPluginDir(): string {
|
||||
return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
|
||||
|
|
@ -16,6 +18,49 @@ function getXbarPluginDir(): string {
|
|||
return join(homedir(), 'Library', 'Application Support', 'xbar', 'plugins')
|
||||
}
|
||||
|
||||
export function parsePluginDirectoryPreference(value: string): string | undefined {
|
||||
const pluginDir = value.trim()
|
||||
if (!pluginDir) return undefined
|
||||
if (pluginDir === '~') return homedir()
|
||||
if (pluginDir.startsWith('~/')) return join(homedir(), pluginDir.slice(2))
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
function getConfiguredSwiftBarPluginDir(): string | undefined {
|
||||
if (platform() !== 'darwin') return undefined
|
||||
|
||||
try {
|
||||
return parsePluginDirectoryPreference(execFileSync('defaults', [
|
||||
'read',
|
||||
SWIFTBAR_PREFERENCES_DOMAIN,
|
||||
SWIFTBAR_PLUGIN_DIRECTORY_KEY,
|
||||
], { encoding: 'utf-8' }))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getSwiftBarPluginDirs(): string[] {
|
||||
const dirs = [getConfiguredSwiftBarPluginDir(), getSwiftBarPluginDir()]
|
||||
return dirs.filter((dir, index): dir is string => dir !== undefined && dirs.indexOf(dir) === index)
|
||||
}
|
||||
|
||||
export function chooseMenubarPluginDir(
|
||||
swiftBarPluginDirs: string[],
|
||||
xbarPluginDir: string,
|
||||
pathExists: (path: string) => boolean,
|
||||
): { pluginDir: string; appName: string } {
|
||||
const preferredSwiftBarDir = swiftBarPluginDirs[0] ?? getSwiftBarPluginDir()
|
||||
|
||||
for (const pluginDir of swiftBarPluginDirs) {
|
||||
if (pathExists(pluginDir)) return { pluginDir, appName: 'SwiftBar' }
|
||||
}
|
||||
|
||||
if (pathExists(xbarPluginDir)) return { pluginDir: xbarPluginDir, appName: 'xbar' }
|
||||
|
||||
return { pluginDir: preferredSwiftBarDir, appName: 'SwiftBar' }
|
||||
}
|
||||
|
||||
function getCodeburnBin(): string {
|
||||
try {
|
||||
return execSync('which codeburn', { encoding: 'utf-8' }).trim()
|
||||
|
|
@ -225,18 +270,9 @@ export async function installMenubar(): Promise<string> {
|
|||
const bin = getCodeburnBin()
|
||||
const pluginContent = generatePlugin(bin)
|
||||
|
||||
let pluginDir: string
|
||||
let appName: string
|
||||
const { pluginDir, appName } = chooseMenubarPluginDir(getSwiftBarPluginDirs(), getXbarPluginDir(), existsSync)
|
||||
|
||||
if (existsSync(getSwiftBarPluginDir())) {
|
||||
pluginDir = getSwiftBarPluginDir()
|
||||
appName = 'SwiftBar'
|
||||
} else if (existsSync(getXbarPluginDir())) {
|
||||
pluginDir = getXbarPluginDir()
|
||||
appName = 'xbar'
|
||||
} else {
|
||||
pluginDir = getSwiftBarPluginDir()
|
||||
appName = 'SwiftBar'
|
||||
if (!existsSync(pluginDir)) {
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
}
|
||||
|
||||
|
|
@ -264,7 +300,7 @@ export async function installMenubar(): Promise<string> {
|
|||
|
||||
export async function uninstallMenubar(): Promise<string> {
|
||||
const paths = [
|
||||
join(getSwiftBarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
|
||||
...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
|
||||
join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|||
const WEB_SEARCH_COST = 0.01
|
||||
|
||||
const FALLBACK_PRICING: Record<string, ModelCosts> = {
|
||||
'claude-opus-4-7': { inputCostPerToken: 5e-6, outputCostPerToken: 25e-6, cacheWriteCostPerToken: 6.25e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 6 },
|
||||
'claude-opus-4-6': { inputCostPerToken: 5e-6, outputCostPerToken: 25e-6, cacheWriteCostPerToken: 6.25e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 6 },
|
||||
'claude-opus-4-5': { inputCostPerToken: 5e-6, outputCostPerToken: 25e-6, cacheWriteCostPerToken: 6.25e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'claude-opus-4-1': { inputCostPerToken: 15e-6, outputCostPerToken: 75e-6, cacheWriteCostPerToken: 18.75e-6, cacheReadCostPerToken: 1.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
|
|
@ -175,6 +176,7 @@ export function calculateCost(
|
|||
export function getShortModelName(model: string): string {
|
||||
const canonical = getCanonicalName(model)
|
||||
const shortNames: Record<string, string> = {
|
||||
'claude-opus-4-7': 'Opus 4.7',
|
||||
'claude-opus-4-6': 'Opus 4.6',
|
||||
'claude-opus-4-5': 'Opus 4.5',
|
||||
'claude-opus-4-1': 'Opus 4.1',
|
||||
|
|
|
|||
|
|
@ -462,6 +462,31 @@ function cachePut(key: string, data: ProjectSummary[]) {
|
|||
sessionCache.set(key, { data, ts: now })
|
||||
}
|
||||
|
||||
export function filterProjectsByName(
|
||||
projects: ProjectSummary[],
|
||||
include?: string[],
|
||||
exclude?: string[],
|
||||
): ProjectSummary[] {
|
||||
let result = projects
|
||||
if (include && include.length > 0) {
|
||||
const patterns = include.map(s => s.toLowerCase())
|
||||
result = result.filter(p => {
|
||||
const name = p.project.toLowerCase()
|
||||
const path = p.projectPath.toLowerCase()
|
||||
return patterns.some(pat => name.includes(pat) || path.includes(pat))
|
||||
})
|
||||
}
|
||||
if (exclude && exclude.length > 0) {
|
||||
const patterns = exclude.map(s => s.toLowerCase())
|
||||
result = result.filter(p => {
|
||||
const name = p.project.toLowerCase()
|
||||
const path = p.projectPath.toLowerCase()
|
||||
return !patterns.some(pat => name.includes(pat) || path.includes(pat))
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
|
||||
const key = cacheKey(dateRange, providerFilter)
|
||||
const cached = sessionCache.get(key)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { homedir } from 'os'
|
|||
import type { Provider, SessionSource, SessionParser } from './types.js'
|
||||
|
||||
const shortNames: Record<string, string> = {
|
||||
'claude-opus-4-7': 'Opus 4.7',
|
||||
'claude-opus-4-6': 'Opus 4.6',
|
||||
'claude-opus-4-5': 'Opus 4.5',
|
||||
'claude-opus-4-1': 'Opus 4.1',
|
||||
|
|
|
|||
57
tests/menubar.test.ts
Normal file
57
tests/menubar.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { chooseMenubarPluginDir, parsePluginDirectoryPreference } from '../src/menubar.js'
|
||||
|
||||
describe('parsePluginDirectoryPreference', () => {
|
||||
it('trims defaults output and preserves spaces in paths', () => {
|
||||
expect(parsePluginDirectoryPreference('/Users/test/Documents/Tech stuff/swiftbar_plugins\n')).toBe('/Users/test/Documents/Tech stuff/swiftbar_plugins')
|
||||
})
|
||||
|
||||
it('expands tilde paths', () => {
|
||||
expect(parsePluginDirectoryPreference('~/swiftbar_plugins')).toBe(join(homedir(), 'swiftbar_plugins'))
|
||||
})
|
||||
|
||||
it('ignores blank preference values', () => {
|
||||
expect(parsePluginDirectoryPreference(' \n')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chooseMenubarPluginDir', () => {
|
||||
const configuredSwiftBarDir = '/Users/test/Documents/Tech stuff/swiftbar_plugins'
|
||||
const defaultSwiftBarDir = '/Users/test/Library/Application Support/SwiftBar/plugins'
|
||||
const xbarDir = '/Users/test/Library/Application Support/xbar/plugins'
|
||||
|
||||
it('uses SwiftBar configured plugin directory before the default directory', () => {
|
||||
const existing = new Set([configuredSwiftBarDir, defaultSwiftBarDir])
|
||||
const result = chooseMenubarPluginDir(
|
||||
[configuredSwiftBarDir, defaultSwiftBarDir],
|
||||
xbarDir,
|
||||
path => existing.has(path),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
|
||||
})
|
||||
|
||||
it('falls back to xbar when no SwiftBar plugin directory exists', () => {
|
||||
const existing = new Set([xbarDir])
|
||||
const result = chooseMenubarPluginDir(
|
||||
[defaultSwiftBarDir],
|
||||
xbarDir,
|
||||
path => existing.has(path),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ pluginDir: xbarDir, appName: 'xbar' })
|
||||
})
|
||||
|
||||
it('creates the preferred SwiftBar directory when no plugin directory exists', () => {
|
||||
const result = chooseMenubarPluginDir(
|
||||
[configuredSwiftBarDir, defaultSwiftBarDir],
|
||||
xbarDir,
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
|
||||
})
|
||||
})
|
||||
82
tests/parser-filter.test.ts
Normal file
82
tests/parser-filter.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { filterProjectsByName } from '../src/parser.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function makeProject(project: string, projectPath = project): ProjectSummary {
|
||||
return {
|
||||
project,
|
||||
projectPath,
|
||||
sessions: [],
|
||||
totalCostUSD: 0,
|
||||
totalApiCalls: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('filterProjectsByName', () => {
|
||||
const projects = [
|
||||
makeProject('codeburn', '/Users/alice/codeburn'),
|
||||
makeProject('AgentSeal', '/Users/alice/projects/AgentSeal'),
|
||||
makeProject('dashboard', '/Users/alice/AgentSeal/dashboard'),
|
||||
makeProject('sandbox', '/tmp/sandbox'),
|
||||
]
|
||||
|
||||
it('returns all projects when no filters given', () => {
|
||||
expect(filterProjectsByName(projects)).toEqual(projects)
|
||||
expect(filterProjectsByName(projects, [], [])).toEqual(projects)
|
||||
expect(filterProjectsByName(projects, undefined, undefined)).toEqual(projects)
|
||||
})
|
||||
|
||||
it('include matches project name (case-insensitive substring)', () => {
|
||||
const result = filterProjectsByName(projects, ['codeburn'])
|
||||
expect(result.map(p => p.project)).toEqual(['codeburn'])
|
||||
})
|
||||
|
||||
it('include is case-insensitive', () => {
|
||||
const result = filterProjectsByName(projects, ['AGENTSEAL'])
|
||||
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
|
||||
})
|
||||
|
||||
it('include matches substring in path when name does not match', () => {
|
||||
const result = filterProjectsByName(projects, ['alice/projects'])
|
||||
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
|
||||
})
|
||||
|
||||
it('include uses OR semantics across patterns', () => {
|
||||
const result = filterProjectsByName(projects, ['codeburn', 'sandbox'])
|
||||
expect(result.map(p => p.project).sort()).toEqual(['codeburn', 'sandbox'])
|
||||
})
|
||||
|
||||
it('exclude removes matching projects (AND-negation across patterns)', () => {
|
||||
const result = filterProjectsByName(projects, undefined, ['codeburn', 'sandbox'])
|
||||
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
|
||||
})
|
||||
|
||||
it('exclude matches path substring', () => {
|
||||
const result = filterProjectsByName(projects, undefined, ['/tmp'])
|
||||
expect(result.map(p => p.project)).not.toContain('sandbox')
|
||||
})
|
||||
|
||||
it('exclude is applied after include', () => {
|
||||
const result = filterProjectsByName(projects, ['AgentSeal'], ['dashboard'])
|
||||
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
|
||||
})
|
||||
|
||||
it('returns empty array when no project matches include', () => {
|
||||
expect(filterProjectsByName(projects, ['does-not-exist'])).toEqual([])
|
||||
})
|
||||
|
||||
it('empty-string pattern matches every project', () => {
|
||||
const resultInclude = filterProjectsByName(projects, [''])
|
||||
expect(resultInclude).toHaveLength(projects.length)
|
||||
const resultExclude = filterProjectsByName(projects, undefined, [''])
|
||||
expect(resultExclude).toEqual([])
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [makeProject('a'), makeProject('b')]
|
||||
const snapshot = [...input]
|
||||
filterProjectsByName(input, ['a'], ['b'])
|
||||
expect(input).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue