feat(cli): wire defaultProject through init and all commands

Integrates the defaultProject config field across the entire CLI
surface so that omitting `-p` now falls back to the configured default.

Init command:
- New `--default-project` flag on `lore init` (and robot-mode variant)
- InitInputs.default_project: Option<String> passed through to run_init
- Validation in run_init ensures the default matches a configured path
- Interactive mode: when multiple projects are configured, prompts
  whether to set a default and which project to use
- Robot mode: InitOutputJson now includes default_project (omitted when
  null) for downstream automation
- Autocorrect dictionary updated with `--default-project`

Command handlers applying effective_project():
- handle_issues: list filters use config default when -p omitted
- handle_mrs: same cascading resolution for MR listing
- handle_ingest: dry-run and full sync respect the default
- handle_timeline: TimelineParams.project resolved via effective_project
- handle_search: SearchCliFilters.project resolved via effective_project
- handle_generate_docs: project filter cascades
- handle_who: falls back to config.default_project when -p omitted
- handle_count: both count subcommands respect the default
- handle_discussions: discussion count filters respect the default

Robot-docs:
- init command schema updated with --default-project flag and
  response_schema showing default_project as string?
- New config_notes section documents the defaultProject field with
  type, description, and example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-11 11:36:42 -05:00
parent 6ea3108a20
commit 3a1307dcdc
4 changed files with 100 additions and 37 deletions

View File

@@ -205,6 +205,7 @@ async fn main() {
gitlab_url,
token_env_var,
projects,
default_project,
}) => {
handle_init(
cli.config.as_deref(),
@@ -214,6 +215,7 @@ async fn main() {
gitlab_url,
token_env_var,
projects,
default_project,
)
.await
}
@@ -678,13 +680,14 @@ fn handle_issues(
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let project = config.effective_project(args.project.as_deref());
let asc = args.asc && !args.no_asc;
let has_due = args.has_due && !args.no_has_due;
let open = args.open && !args.no_open;
let order = if asc { "asc" } else { "desc" };
if let Some(iid) = args.iid {
let result = run_show_issue(&config, iid, args.project.as_deref())?;
let result = run_show_issue(&config, iid, project)?;
if robot_mode {
print_show_issue_json(&result, start.elapsed().as_millis() as u64);
} else {
@@ -694,7 +697,7 @@ fn handle_issues(
let state_normalized = args.state.as_deref().map(str::to_lowercase);
let filters = ListFilters {
limit: args.limit,
project: args.project.as_deref(),
project,
state: state_normalized.as_deref(),
author: args.author.as_deref(),
assignee: args.assignee.as_deref(),
@@ -733,12 +736,13 @@ fn handle_mrs(
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let project = config.effective_project(args.project.as_deref());
let asc = args.asc && !args.no_asc;
let open = args.open && !args.no_open;
let order = if asc { "asc" } else { "desc" };
if let Some(iid) = args.iid {
let result = run_show_mr(&config, iid, args.project.as_deref())?;
let result = run_show_mr(&config, iid, project)?;
if robot_mode {
print_show_mr_json(&result, start.elapsed().as_millis() as u64);
} else {
@@ -748,7 +752,7 @@ fn handle_mrs(
let state_normalized = args.state.as_deref().map(str::to_lowercase);
let filters = MrListFilters {
limit: args.limit,
project: args.project.as_deref(),
project,
state: state_normalized.as_deref(),
author: args.author.as_deref(),
assignee: args.assignee.as_deref(),
@@ -791,6 +795,7 @@ async fn handle_ingest(
let start = std::time::Instant::now();
let dry_run = args.dry_run && !args.no_dry_run;
let config = Config::load(config_override)?;
let project = config.effective_project(args.project.as_deref());
let force = args.force && !args.no_force;
let full = args.full && !args.no_full;
@@ -799,8 +804,7 @@ async fn handle_ingest(
if dry_run {
match args.entity.as_deref() {
Some(resource_type) => {
let preview =
run_ingest_dry_run(&config, resource_type, args.project.as_deref(), full)?;
let preview = run_ingest_dry_run(&config, resource_type, project, full)?;
if robot_mode {
print_dry_run_preview_json(&preview);
} else {
@@ -808,10 +812,8 @@ async fn handle_ingest(
}
}
None => {
let issues_preview =
run_ingest_dry_run(&config, "issues", args.project.as_deref(), full)?;
let mrs_preview =
run_ingest_dry_run(&config, "mrs", args.project.as_deref(), full)?;
let issues_preview = run_ingest_dry_run(&config, "issues", project, full)?;
let mrs_preview = run_ingest_dry_run(&config, "mrs", project, full)?;
if robot_mode {
print_combined_dry_run_json(&issues_preview, &mrs_preview);
} else {
@@ -854,7 +856,7 @@ async fn handle_ingest(
let result = run_ingest(
&config,
resource_type,
args.project.as_deref(),
project,
force,
full,
false,
@@ -880,28 +882,12 @@ async fn handle_ingest(
}
let issues_result = run_ingest(
&config,
"issues",
args.project.as_deref(),
force,
full,
false,
display,
None,
&signal,
&config, "issues", project, force, full, false, display, None, &signal,
)
.await?;
let mrs_result = run_ingest(
&config,
"mrs",
args.project.as_deref(),
force,
full,
false,
display,
None,
&signal,
&config, "mrs", project, force, full, false, display, None, &signal,
)
.await?;
@@ -1104,6 +1090,8 @@ struct InitOutputData {
data_dir: String,
user: InitOutputUser,
projects: Vec<InitOutputProject>,
#[serde(skip_serializing_if = "Option::is_none")]
default_project: Option<String>,
}
#[derive(Serialize)]
@@ -1136,6 +1124,7 @@ fn print_init_json(result: &InitResult) {
name: p.name.clone(),
})
.collect(),
default_project: result.default_project.clone(),
},
};
println!(
@@ -1146,6 +1135,7 @@ fn print_init_json(result: &InitResult) {
);
}
#[allow(clippy::too_many_arguments)]
async fn handle_init(
config_override: Option<&str>,
force: bool,
@@ -1154,6 +1144,7 @@ async fn handle_init(
gitlab_url_flag: Option<String>,
token_env_var_flag: Option<String>,
projects_flag: Option<String>,
default_project_flag: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode {
let missing: Vec<&str> = [
@@ -1191,6 +1182,7 @@ async fn handle_init(
gitlab_url: gitlab_url_flag.unwrap(),
token_env_var: token_env_var_flag.unwrap(),
project_paths,
default_project: default_project_flag.clone(),
},
InitOptions {
config_path: config_override.map(String::from),
@@ -1285,6 +1277,29 @@ async fn handle_init(
.collect()
};
// Resolve default project: CLI flag, interactive prompt, or None
let default_project = if default_project_flag.is_some() {
default_project_flag
} else if project_paths.len() > 1 && !non_interactive {
let set_default = Confirm::new()
.with_prompt("Set a default project? (used when -p is omitted)")
.default(true)
.interact()?;
if set_default {
let selection = dialoguer::Select::new()
.with_prompt("Default project")
.items(&project_paths)
.default(0)
.interact()?;
Some(project_paths[selection].clone())
} else {
None
}
} else {
None
};
println!("{}", style("\nValidating configuration...").blue());
let result = run_init(
@@ -1292,6 +1307,7 @@ async fn handle_init(
gitlab_url,
token_env_var,
project_paths,
default_project,
},
InitOptions {
config_path: config_override.map(String::from),
@@ -1317,6 +1333,10 @@ async fn handle_init(
);
}
if let Some(ref dp) = result.default_project {
println!("{}", style(format!("✓ Default project: {dp}")).green());
}
println!(
"{}",
style(format!("\n✓ Config written to {}", result.config_path)).green()
@@ -1680,7 +1700,9 @@ fn handle_timeline(
let params = TimelineParams {
query: args.query,
project: args.project,
project: config
.effective_project(args.project.as_deref())
.map(String::from),
since: args.since,
depth: args.depth,
expand_mentions: args.expand_mentions,
@@ -1722,7 +1744,9 @@ async fn handle_search(
let cli_filters = SearchCliFilters {
source_type: args.source_type,
author: args.author,
project: args.project,
project: config
.effective_project(args.project.as_deref())
.map(String::from),
labels: args.label,
path: args.path,
since: args.since,
@@ -1757,7 +1781,8 @@ async fn handle_generate_docs(
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let result = run_generate_docs(&config, args.full, args.project.as_deref(), None)?;
let project = config.effective_project(args.project.as_deref());
let result = run_generate_docs(&config, args.full, project, None)?;
if robot_mode {
print_generate_docs_json(&result, start.elapsed().as_millis() as u64);
} else {
@@ -2031,6 +2056,7 @@ struct RobotDocsData {
clap_error_codes: serde_json::Value,
error_format: String,
workflows: serde_json::Value,
config_notes: serde_json::Value,
}
#[derive(Serialize)]
@@ -2046,12 +2072,12 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
let commands = serde_json::json!({
"init": {
"description": "Initialize configuration and database",
"flags": ["--force", "--non-interactive", "--gitlab-url <URL>", "--token-env-var <VAR>", "--projects <paths>"],
"robot_flags": ["--gitlab-url", "--token-env-var", "--projects"],
"example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project",
"flags": ["--force", "--non-interactive", "--gitlab-url <URL>", "--token-env-var <VAR>", "--projects <paths>", "--default-project <path>"],
"robot_flags": ["--gitlab-url", "--token-env-var", "--projects", "--default-project"],
"example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project,other/repo --default-project group/project",
"response_schema": {
"ok": "bool",
"data": {"config_path": "string", "data_dir": "string", "user": {"username": "string", "name": "string"}, "projects": "[{path:string, name:string}]"},
"data": {"config_path": "string", "data_dir": "string", "user": {"username": "string", "name": "string"}, "projects": "[{path:string, name:string}]", "default_project": "string?"},
"meta": {"elapsed_ms": "int"}
}
},
@@ -2360,6 +2386,14 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"PARSE_ERROR": "General parse error"
});
let config_notes = serde_json::json!({
"defaultProject": {
"type": "string?",
"description": "Fallback project path used when -p/--project is omitted. Must match a configured project path (exact or suffix). CLI -p always overrides.",
"example": "group/project"
}
});
let output = RobotDocsOutput {
ok: true,
data: RobotDocsData {
@@ -2377,6 +2411,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
clap_error_codes,
error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\",\"actions\":[\"...\"]}}".to_string(),
workflows,
config_notes,
},
};
@@ -2391,11 +2426,14 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
fn handle_who(
config_override: Option<&str>,
args: WhoArgs,
mut args: WhoArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
if args.project.is_none() {
args.project = config.default_project.clone();
}
let run = run_who(&config, &args)?;
let elapsed_ms = start.elapsed().as_millis() as u64;
@@ -2433,6 +2471,7 @@ async fn handle_list_compat(
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let project_filter = config.effective_project(project_filter);
let state_normalized = state_filter.map(str::to_lowercase);
match entity {
@@ -2511,6 +2550,7 @@ async fn handle_show_compat(
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let project_filter = config.effective_project(project_filter);
match entity {
"issue" => {