Skip to content

Commit 84568a2

Browse files
committed
✨ feat(cli): search + system subcommands
1 parent f445634 commit 84568a2

5 files changed

Lines changed: 242 additions & 0 deletions

File tree

crates/oversight-cli/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ pub enum Command {
6060
Run(run::RunArgs),
6161
/// Manage scheduled agent runs
6262
Schedules(commands::schedules::SchedulesArgs),
63+
/// Search across tasks, documents, and projects
64+
Search(commands::search::SearchArgs),
65+
/// System status, stats, and activity log
66+
System(commands::system::SystemArgs),
6367
/// Manage tasks
6468
Tasks(commands::tasks::TasksArgs),
6569
/// Manage workflows

crates/oversight-cli/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ pub mod inbox;
99
pub mod milestones;
1010
pub mod projects;
1111
pub mod schedules;
12+
pub mod search;
13+
pub mod system;
1214
pub mod tasks;
1315
pub mod workflows;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
use anyhow::{Context, Result};
2+
use clap::Args;
3+
4+
use crate::cli::{self, GlobalArgs};
5+
use crate::config::Config;
6+
use crate::output::{render_list, resolve_format, TableView};
7+
8+
#[derive(Debug, Args)]
9+
pub struct SearchArgs {
10+
/// Search query
11+
pub query: String,
12+
/// lexical | semantic | hybrid (default hybrid)
13+
#[arg(long)]
14+
pub mode: Option<String>,
15+
/// Comma-separated entity types (task, document, project)
16+
#[arg(long)]
17+
pub types: Option<String>,
18+
/// Max results (1-100, default 20)
19+
#[arg(long)]
20+
pub limit: Option<u32>,
21+
}
22+
23+
pub async fn execute(args: SearchArgs, globals: &GlobalArgs) -> Result<()> {
24+
let config_path = Config::default_path()?;
25+
let mut config = Config::load(&config_path)?;
26+
config.apply_profile_override(globals.profile.as_deref())?;
27+
let client = cli::client_from_globals(&config, globals).await?;
28+
29+
let resp = client
30+
.search(
31+
&args.query,
32+
args.mode.as_deref(),
33+
args.types.as_deref(),
34+
args.limit,
35+
)
36+
.await
37+
.context("search")?;
38+
39+
if let Some(reason) = &resp.degraded_reason {
40+
eprintln!("note: {reason}");
41+
}
42+
43+
let view = TableView {
44+
headers: vec!["TYPE", "ID", "TITLE", "SCORE"],
45+
rows: resp
46+
.results
47+
.iter()
48+
.map(|r| {
49+
vec![
50+
r.entity_type.clone(),
51+
short_id(&r.id),
52+
r.title.clone(),
53+
format!("{:.3}", r.score),
54+
]
55+
})
56+
.collect(),
57+
};
58+
let fmt = resolve_format(globals.format.unwrap_or_default());
59+
let out = render_list(&resp.results, view, fmt)?;
60+
println!("{out}");
61+
Ok(())
62+
}
63+
64+
fn short_id(id: &str) -> String {
65+
if id.len() <= 8 {
66+
id.to_string()
67+
} else {
68+
format!("{}…", &id[..8])
69+
}
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
use clap::Parser;
76+
77+
#[derive(Parser)]
78+
struct Wrapper {
79+
#[command(flatten)]
80+
args: SearchArgs,
81+
}
82+
83+
#[test]
84+
fn parses_query_with_mode_and_limit() {
85+
let w = Wrapper::try_parse_from(["test", "needle", "--mode", "lexical", "--limit", "5"])
86+
.unwrap();
87+
assert_eq!(w.args.query, "needle");
88+
assert_eq!(w.args.mode.as_deref(), Some("lexical"));
89+
assert_eq!(w.args.limit, Some(5));
90+
}
91+
92+
#[test]
93+
fn parses_query_only() {
94+
let w = Wrapper::try_parse_from(["test", "auth"]).unwrap();
95+
assert_eq!(w.args.query, "auth");
96+
assert!(w.args.mode.is_none());
97+
}
98+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
use anyhow::{Context, Result};
2+
use clap::{Args, Subcommand};
3+
4+
use crate::cli::{self, GlobalArgs};
5+
use crate::client::ApiClient;
6+
use crate::config::Config;
7+
use crate::output::{render_describe, render_list, resolve_format, TableView};
8+
9+
#[derive(Debug, Args)]
10+
pub struct SystemArgs {
11+
#[command(subcommand)]
12+
pub command: SystemCommand,
13+
}
14+
15+
#[derive(Debug, Subcommand)]
16+
pub enum SystemCommand {
17+
/// Health check (status, version, uptime)
18+
Status,
19+
/// Global statistics (documents, edges, top labels)
20+
Stats,
21+
/// Recent agent activities
22+
Activities {
23+
#[arg(long)]
24+
agent: Option<String>,
25+
#[arg(long)]
26+
task: Option<String>,
27+
#[arg(long, default_value_t = 50)]
28+
limit: u32,
29+
},
30+
}
31+
32+
pub async fn execute(args: SystemArgs, globals: &GlobalArgs) -> Result<()> {
33+
let config_path = Config::default_path()?;
34+
let mut config = Config::load(&config_path)?;
35+
config.apply_profile_override(globals.profile.as_deref())?;
36+
let client = cli::client_from_globals(&config, globals).await?;
37+
38+
match args.command {
39+
SystemCommand::Status => status(&client, globals).await,
40+
SystemCommand::Stats => stats(&client, globals).await,
41+
SystemCommand::Activities { agent, task, limit } => {
42+
activities(&client, agent.as_deref(), task.as_deref(), limit, globals).await
43+
}
44+
}
45+
}
46+
47+
async fn status(client: &ApiClient, globals: &GlobalArgs) -> Result<()> {
48+
let h = client.health().await.context("get health")?;
49+
let fmt = resolve_format(globals.format.unwrap_or_default());
50+
println!("{}", render_describe(&h, fmt)?);
51+
Ok(())
52+
}
53+
54+
async fn stats(client: &ApiClient, globals: &GlobalArgs) -> Result<()> {
55+
let s = client.stats().await.context("get stats")?;
56+
let fmt = resolve_format(globals.format.unwrap_or_default());
57+
println!("{}", render_describe(&s, fmt)?);
58+
Ok(())
59+
}
60+
61+
async fn activities(
62+
client: &ApiClient,
63+
agent_id: Option<&str>,
64+
task_id: Option<&str>,
65+
limit: u32,
66+
globals: &GlobalArgs,
67+
) -> Result<()> {
68+
let acts = client
69+
.list_activities(agent_id, task_id, Some(limit))
70+
.await
71+
.context("list activities")?;
72+
let view = TableView {
73+
headers: vec!["WHEN", "AGENT", "TYPE", "STATUS", "TASK", "COST"],
74+
rows: acts
75+
.iter()
76+
.map(|a| {
77+
vec![
78+
a.created_at.clone(),
79+
a.agent_id.clone(),
80+
a.activity_type.clone(),
81+
a.status.clone(),
82+
a.task_id
83+
.as_deref()
84+
.map(short_id)
85+
.unwrap_or_else(|| "-".into()),
86+
format!("{:.4}", a.cost_usd),
87+
]
88+
})
89+
.collect(),
90+
};
91+
let fmt = resolve_format(globals.format.unwrap_or_default());
92+
let out = render_list(&acts, view, fmt)?;
93+
println!("{out}");
94+
Ok(())
95+
}
96+
97+
fn short_id(id: &str) -> String {
98+
if id.len() <= 8 {
99+
id.to_string()
100+
} else {
101+
format!("{}…", &id[..8])
102+
}
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
use clap::Parser;
109+
110+
#[derive(Parser)]
111+
struct Wrapper {
112+
#[command(subcommand)]
113+
cmd: SystemCommand,
114+
}
115+
116+
#[test]
117+
fn parses_status() {
118+
let w = Wrapper::try_parse_from(["test", "status"]).unwrap();
119+
assert!(matches!(w.cmd, SystemCommand::Status));
120+
}
121+
122+
#[test]
123+
fn parses_activities_with_filters() {
124+
let w =
125+
Wrapper::try_parse_from(["test", "activities", "--agent", "coder", "--limit", "10"])
126+
.unwrap();
127+
match w.cmd {
128+
SystemCommand::Activities { agent, task, limit } => {
129+
assert_eq!(agent.as_deref(), Some("coder"));
130+
assert!(task.is_none());
131+
assert_eq!(limit, 10);
132+
}
133+
_ => panic!("expected Activities"),
134+
}
135+
}
136+
}

crates/oversight-cli/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ async fn main() -> anyhow::Result<()> {
3636
cli::Command::Projects(args) => commands::projects::execute(args, &globals).await,
3737
cli::Command::Run(args) => run::execute(args).await,
3838
cli::Command::Schedules(args) => commands::schedules::execute(args, &globals).await,
39+
cli::Command::Search(args) => commands::search::execute(args, &globals).await,
40+
cli::Command::System(args) => commands::system::execute(args, &globals).await,
3941
cli::Command::Tasks(args) => commands::tasks::execute(args, &globals).await,
4042
cli::Command::Workflows(args) => commands::workflows::execute(args, &globals).await,
4143
cli::Command::Worker(args) => worker::execute(args).await,

0 commit comments

Comments
 (0)