Skip to content

Commit edc8a02

Browse files
fix(cli): preserve unified source configuration semantics
Use unified week starts for source windows and name the source in config errors. Carry Claude project filters and skip unused shared pricing maps. Cover source-aware sections and space-form config, and clarify named pi store settings. Keep existing report failure and section behavior. Co-authored-by: viniciusdsandrade <[email protected]>
1 parent 09302fd commit edc8a02

6 files changed

Lines changed: 388 additions & 29 deletions

File tree

‎docs/guide/config-files.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,12 @@ ccusage antigravity daily
287287
ccusage zcode daily
288288
```
289289

290-
Source-specific settings are also applied when running unified reports such as `ccusage daily`. Each source uses the same merged options as its focused command: global `defaults`, `commands.<report>`, `commands."<source>:<report>"`, `<source>.defaults`, and `<source>.commands.<report>`, with command-line flags taking precedence for every source. Set `offline` and `pricingOverrides` globally to share them across sources, or in a source section to specialize them. Each source groups dates with its resolved timezone before rows are combined, so sources with different timezones can place the same event on adjacent calendar dates.
290+
Source-specific settings are also applied when running unified reports such as `ccusage daily`. Each source uses the same merged options as its focused command: global `defaults`, `commands.<report>`, `commands."<source>:<report>"`, `<source>.defaults`, and `<source>.commands.<report>`, with command-line flags taking precedence for every source. Set `offline` and `pricingOverrides` globally to share them across sources, or in a source section to specialize them. Set `claude.commands.daily.project` to filter Claude rows in unified daily reports and their sections. Unified weekly reports use the unified command's `startOfWeek` for every source and for `--last` calculation; source-level `startOfWeek` values are ignored. Each source groups dates with its resolved timezone before rows are combined, so sources with different timezones can place the same event on adjacent calendar dates.
291291

292292
Use `pi.stores` for additional pi-format session stores, such as tools or forks that write sessions outside `~/.pi/agent/sessions`. Named stores are additive to the default `pi` agent in unified reports, use their own agent name in JSON and tables, and prefix models with `[name]` followed by a space. Store names must match `^[a-z][a-z0-9_-]{0,31}$`, must be unique, and cannot use a built-in agent name. Each store path can be one sessions directory or a comma-separated list; `~` is expanded for store paths, nonexistent paths are treated as empty, and resolved paths that overlap the default `pi` store or another named store — including one path nested inside another — are rejected. They do not create focused commands such as `ccusage omp daily`; `PI_AGENT_DIR`, `--pi-path`, and `pi.defaults.piPath` still affect only the default `pi` agent.
293293

294+
Named stores use the unified report's options and pricing overrides; `pi.defaults` and `pi.commands` apply only to the default `pi` source.
295+
294296
For a source-focused command, or each source within a unified report, options are applied in this order:
295297

296298
1. `defaults`

‎rust/crates/ccusage-adapter-all/src/loader.rs‎

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ fn load_pricing(
170170
let mut cached = Vec::new();
171171
let pricing_by_source = BUILT_IN_AGENT_NAMES
172172
.iter()
173+
.filter(|agent| !["claude", "opencode", "qwen"].contains(agent))
173174
.map(|agent| {
174175
(
175176
*agent,
@@ -235,6 +236,9 @@ fn load_base_rows(
235236
let codex_speed = sources
236237
.get("codex")
237238
.map_or(CodexSpeed::Auto, |source| source.codex_speed);
239+
let claude_project = sources
240+
.get("claude")
241+
.and_then(|source| source.claude_project.as_deref());
238242
let pi_path = sources
239243
.get("pi")
240244
.and_then(|source| source.pi_path.as_deref());
@@ -246,7 +250,9 @@ fn load_base_rows(
246250
index: 0,
247251
agent: BUILT_IN_AGENT_NAMES[0],
248252
progress_agent: crate::progress::UsageLoadAgent("Claude"),
249-
load: Box::new(|| load_claude_rows(load_kind, &source_shared["claude"])),
253+
load: Box::new(|| {
254+
load_claude_rows(load_kind, &source_shared["claude"], claude_project)
255+
}),
250256
},
251257
AgentLoadSpec {
252258
index: 1,
@@ -789,9 +795,13 @@ fn filtered_pi_format_agent_rows(
789795
})
790796
}
791797

792-
fn load_claude_rows(kind: AgentReportKind, shared: &SharedArgs) -> Result<AgentRows> {
798+
fn load_claude_rows(
799+
kind: AgentReportKind,
800+
shared: &SharedArgs,
801+
project_filter: Option<&str>,
802+
) -> Result<AgentRows> {
793803
if kind == AgentReportKind::Session {
794-
let entries = claude::load_entries(shared, None)?;
804+
let entries = claude::load_entries(shared, project_filter)?;
795805
let detected = !entries.is_empty();
796806
let mut summaries = summarize_entry_sessions(&entries)?;
797807
filter_session_summaries(&mut summaries, shared);
@@ -804,7 +814,7 @@ fn load_claude_rows(kind: AgentReportKind, shared: &SharedArgs) -> Result<AgentR
804814
let claude::DailySummaries {
805815
mut summaries,
806816
detected,
807-
} = claude::load_daily_summaries_with_detection(shared, None, false)?;
817+
} = claude::load_daily_summaries_with_detection(shared, project_filter, false)?;
808818
filter_daily_summaries_by_date(&mut summaries, shared);
809819
Ok(AgentRows {
810820
rows: summary_rows("claude", summaries, false),
@@ -1109,6 +1119,89 @@ mod tests {
11091119
summary_rows(agent, vec![summary], false).pop().unwrap()
11101120
}
11111121

1122+
#[test]
1123+
fn pricing_maps_exclude_sources_that_load_their_own_pricing() {
1124+
let shared = SharedArgs {
1125+
offline: true,
1126+
..SharedArgs::default()
1127+
};
1128+
let source_shared = shared_by_source(&BTreeMap::new(), &shared);
1129+
1130+
let (pricing_by_source, unified_pricing) = load_pricing(&shared, &source_shared, false);
1131+
1132+
for agent in ["claude", "opencode", "qwen"] {
1133+
assert!(
1134+
!pricing_by_source.contains_key(agent),
1135+
"{agent} has its own pricing map"
1136+
);
1137+
}
1138+
for agent in BUILT_IN_AGENT_NAMES {
1139+
if !["claude", "opencode", "qwen"].contains(agent) {
1140+
assert!(
1141+
pricing_by_source.contains_key(agent),
1142+
"{agent} needs a pricing map"
1143+
);
1144+
}
1145+
}
1146+
assert!(unified_pricing.is_none());
1147+
}
1148+
1149+
#[test]
1150+
fn source_claude_project_filters_daily_and_session_rows_without_filtering_other_sources() {
1151+
let fixture = fs_fixture!({
1152+
"claude-config/projects/project-a/session-a.jsonl": r#"{"timestamp":"2099-01-02T00:00:00.000Z","sessionId":"session-a","costUSD":0.01,"message":{"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"model":"claude-sonnet-4-20250514","id":"msg-a"}}"#,
1153+
"claude-config/projects/project-b/session-b.jsonl": r#"{"timestamp":"2099-01-02T00:00:00.000Z","sessionId":"session-b","costUSD":0.02,"message":{"usage":{"input_tokens":1000,"output_tokens":500,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"model":"claude-sonnet-4-20250514","id":"msg-b"}}"#,
1154+
"pi/sessions/project-pi/agent_pi-session.jsonl": r#"{"type":"message","timestamp":"2099-01-02T00:00:00.000Z","message":{"role":"assistant","model":"gpt-5","usage":{"input":7,"output":3}}}"#,
1155+
});
1156+
let _environment = isolated_source_environment(&fixture);
1157+
let shared = SharedArgs {
1158+
json: true,
1159+
mode: CostMode::Display,
1160+
offline: true,
1161+
timezone: Some("UTC".to_string()),
1162+
..SharedArgs::default()
1163+
};
1164+
let sources = BTreeMap::from([(
1165+
"claude".to_string(),
1166+
SourceArgs {
1167+
shared: shared.clone(),
1168+
claude_project: Some("project-a".to_string()),
1169+
..SourceArgs::default()
1170+
},
1171+
)]);
1172+
1173+
let daily = load_rows(AgentReportKind::Daily, &shared, &sources, WeekDay::Monday).unwrap();
1174+
1175+
assert_eq!(daily.rows.len(), 1);
1176+
assert_eq!(daily.rows[0].period, "2099-01-02");
1177+
assert_eq!(daily.rows[0].metadata_agents, Some(vec!["claude", "pi"]));
1178+
assert_eq!(daily.rows[0].input_tokens, 17);
1179+
assert_eq!(daily.rows[0].output_tokens, 8);
1180+
assert!((daily.rows[0].total_cost - 0.01).abs() < f64::EPSILON);
1181+
1182+
let sessions =
1183+
load_rows(AgentReportKind::Session, &shared, &sources, WeekDay::Monday).unwrap();
1184+
1185+
assert!(
1186+
sessions
1187+
.rows
1188+
.iter()
1189+
.any(|row| row.agent == "claude" && row.period == "session-a")
1190+
);
1191+
assert!(
1192+
!sessions
1193+
.rows
1194+
.iter()
1195+
.any(|row| row.agent == "claude" && row.period == "session-b")
1196+
);
1197+
assert!(
1198+
sessions
1199+
.rows
1200+
.iter()
1201+
.any(|row| row.agent == "pi" && row.period == "pi-session")
1202+
);
1203+
}
1204+
11121205
fn isolated_source_environment(fixture: &ccusage_test_support::Fixture) -> EnvVarsGuard {
11131206
isolated_source_environment_with_pi_agent_dir(fixture, "pi/sessions")
11141207
}

‎rust/crates/ccusage-adapter-all/src/tests.rs‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use serde_json::{Value, json};
1515
use super::*;
1616
use crate::{
1717
Align, CodexGroup, CodexModelUsage, ModelBreakdown, PricingMap,
18-
cli::{AgentReportKind, CodexSpeed, CostMode, SharedArgs, WeekDay},
18+
cli::{AgentReportKind, CodexSpeed, CostMode, SharedArgs, SourceArgs, WeekDay},
1919
loader::NO_SOURCES,
2020
model_aliases::set_model_aliases_for_tests,
2121
};
@@ -776,6 +776,70 @@ fn multi_section_claude_fixture_matches_standalone_sections_for_daily_and_sessio
776776
assert_daily_family_and_session_sections_match_standalone(&shared);
777777
}
778778

779+
#[test]
780+
fn multi_section_pi_load_uses_source_options_for_daily_and_session_rows() {
781+
let fixture = fs_fixture!({
782+
"custom/pi/sessions/project-a/agent_in-range.jsonl": r#"{"type":"message","timestamp":"2099-01-02T00:00:00.000Z","message":{"role":"assistant","model":"gpt-source-config","usage":{"input":1000,"output":500}}}"#,
783+
"custom/pi/sessions/project-b/agent_out-of-range.jsonl": r#"{"type":"message","timestamp":"2099-01-03T00:00:00.000Z","message":{"role":"assistant","model":"gpt-source-config","usage":{"input":10000,"output":5000}}}"#,
784+
});
785+
let _env = isolated_agent_env(
786+
&fixture,
787+
"PI_AGENT_DIR",
788+
fixture.path("empty-default").into_os_string(),
789+
);
790+
let shared = SharedArgs {
791+
mode: CostMode::Calculate,
792+
..fixture_shared("20990101", "20990104")
793+
};
794+
let sources = std::collections::BTreeMap::from([(
795+
"pi".to_string(),
796+
SourceArgs {
797+
shared: SharedArgs {
798+
since: Some("20990102".to_string()),
799+
until: Some("20990102".to_string()),
800+
pricing_overrides: std::collections::BTreeMap::from([(
801+
"gpt-source-config".to_string(),
802+
crate::cli::PricingOverride {
803+
input_cost_per_token: Some(1e-6),
804+
output_cost_per_token: Some(2e-6),
805+
..Default::default()
806+
},
807+
)]),
808+
..shared.clone()
809+
},
810+
pi_path: Some(fixture.path("custom/pi").to_string_lossy().into_owned()),
811+
..SourceArgs::default()
812+
},
813+
)]);
814+
815+
let sections = load_sections(
816+
&[AgentReportKind::Daily, AgentReportKind::Session],
817+
&shared,
818+
&sources,
819+
WeekDay::Monday,
820+
)
821+
.unwrap();
822+
let daily_rows = &sections.sections[0].1;
823+
assert_eq!(daily_rows.len(), 1);
824+
assert_eq!(daily_rows[0].period, "2099-01-02");
825+
assert_eq!(daily_rows[0].metadata_agents, Some(vec!["pi"]));
826+
assert_eq!(daily_rows[0].input_tokens, 1_000);
827+
assert_eq!(daily_rows[0].output_tokens, 500);
828+
assert!((daily_rows[0].total_cost - 0.002).abs() < f64::EPSILON);
829+
830+
let session_rows = &sections.sections[1].1;
831+
assert_eq!(session_rows.len(), 1);
832+
assert_eq!(session_rows[0].period, "in-range");
833+
assert_eq!(session_rows[0].agent, "pi");
834+
assert_eq!(
835+
session_rows[0].metadata.as_ref().unwrap()["projectPath"],
836+
"project-a"
837+
);
838+
assert_eq!(session_rows[0].input_tokens, 1_000);
839+
assert_eq!(session_rows[0].output_tokens, 500);
840+
assert!((session_rows[0].total_cost - 0.002).abs() < f64::EPSILON);
841+
}
842+
779843
#[test]
780844
fn multi_section_codex_fixture_matches_standalone_sections_for_daily_and_session_invocations() {
781845
let _aliases = set_model_aliases_for_tests([("private-alpha", "gpt-5.2")]);

‎rust/crates/ccusage-cli/src/types.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ pub struct AgentCommandArgs {
192192
#[derive(Clone, Debug, Default)]
193193
pub struct SourceArgs {
194194
pub shared: SharedArgs,
195+
pub claude_project: Option<String>,
195196
pub pi_path: Option<String>,
196197
pub open_claw_path: Option<String>,
197198
pub codex_speed: CodexSpeed,

‎rust/crates/ccusage/src/cli.rs‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,38 @@ pub(crate) fn parse() -> Command {
1717
exit_with_usage(&message);
1818
});
1919
let config = ConfigContext::from_args(&arg_strings);
20-
let mut command = unified_or_command(parse_with(args.clone(), &config));
21-
if let Command::All(unified) = &mut command {
22-
sources::resolve(unified, &args, &config);
20+
let mut command = unified_or_command(
21+
try_parse_with(args.clone(), &config, None)
22+
.unwrap_or_else(|message| exit_with_usage(&message)),
23+
);
24+
if let Command::All(unified) = &mut command
25+
&& let Err(message) = sources::resolve(unified, &args, &config)
26+
{
27+
exit_with_usage(&message);
2328
}
2429
command
2530
}
2631

2732
/// Parses one command line against one view of the config, with the checks
2833
/// and resolutions that need the parsed command.
29-
fn parse_with(args: Vec<OsString>, config: &ConfigContext) -> Cli {
34+
fn try_parse_with(
35+
args: Vec<OsString>,
36+
config: &ConfigContext,
37+
unified_week_start: Option<WeekDay>,
38+
) -> Result<Cli, String> {
3039
let mut cli = Cli::parse_from_with_config(
3140
args,
3241
config,
3342
DEFAULT_SESSION_DURATION_HOURS,
3443
env!("CCUSAGE_VERSION"),
35-
)
36-
.unwrap_or_else(|message| exit_with_usage(&message));
37-
if let Err(message) = timezone::validate(&cli) {
38-
exit_with_usage(&message);
39-
}
40-
if let Err(message) = last_window::resolve(&mut cli) {
41-
exit_with_usage(&message);
44+
)?;
45+
if let (Some(start_of_week), Some(Command::All(args))) = (unified_week_start, &mut cli.command)
46+
{
47+
args.start_of_week = start_of_week;
4248
}
43-
cli
49+
timezone::validate(&cli)?;
50+
last_window::resolve(&mut cli)?;
51+
Ok(cli)
4452
}
4553

4654
/// A bare `ccusage` is the unified daily report over the root options.

0 commit comments

Comments
 (0)