Skip to content

Commit 9e29856

Browse files
authored
Merge pull request #12 from osodevops/codex/cli-agent-project-workflows
Add agent project workflows to CLI
2 parents 7c30be5 + e6686fa commit 9e29856

20 files changed

Lines changed: 1564 additions & 31 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.1.4] - 2026-05-12
9+
10+
### Added
11+
12+
- Client discovery and creation commands for agent setup workflows.
13+
- Project creation from the CLI, including client filtering, billable defaults, explicit task IDs, and conflict handling.
14+
- Agent session recording fields for source metadata, duration-based logging, and setup wizard support.
15+
16+
### Fixed
17+
18+
- Integration tests now write mock config to the Windows `%APPDATA%` path as well as Unix/macOS paths.
19+
820
## [0.1.3] - 2026-05-05
921

1022
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "keito-cli"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
edition = "2021"
55
description = "CLI for AI agents and humans to track billable time against the Keito platform"
66
license = "MIT"

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ Configuration file:
128128
api_key = "kto_..."
129129
account_id = "co_abc123"
130130
workspace_id = "co_abc123" # legacy alias, kept for compatibility
131+
api_url = "https://app.keito.ai"
131132
```
132133

133134
### Credential Precedence
@@ -150,6 +151,7 @@ Find the Company ID in Keito under Settings > API & Developers > Company ID.
150151
| Variable | Description |
151152
|---|---|
152153
| `KEITO_API_KEY` | API key — takes precedence over config |
154+
| `KEITO_API_URL` | API base URL override; defaults to `https://app.keito.ai` |
153155
| `KEITO_ACCOUNT_ID` | Company/account ID sent as `Keito-Account-Id` |
154156
| `KEITO_WORKSPACE_ID` | Legacy alias for `KEITO_ACCOUNT_ID` |
155157

src/api/client.rs

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use chrono::Utc;
22
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
3-
use reqwest::Client;
3+
use reqwest::Client as HttpClient;
44
use std::time::Duration;
55

66
use crate::api::error::map_status_to_error;
@@ -9,7 +9,7 @@ use crate::config::ResolvedAuth;
99
use crate::error::AppError;
1010

1111
pub struct KeitorClient {
12-
client: Client,
12+
client: HttpClient,
1313
base_url: String,
1414
}
1515

@@ -27,7 +27,7 @@ impl KeitorClient {
2727
.map_err(|_| AppError::Auth("Invalid workspace ID format".into()))?,
2828
);
2929

30-
let client = Client::builder()
30+
let client = HttpClient::builder()
3131
.default_headers(headers.clone())
3232
.timeout(Duration::from_secs(30))
3333
.build()
@@ -146,24 +146,63 @@ impl KeitorClient {
146146
.await
147147
}
148148

149+
pub async fn list_clients(&self) -> Result<Vec<Client>, AppError> {
150+
let path = path_with_query(
151+
"/api/v2/clients",
152+
&[("is_active", "true"), ("per_page", "200")],
153+
);
154+
let resp: ClientsResponse = self
155+
.request_with_retry(reqwest::Method::GET, &path, None::<&()>)
156+
.await?;
157+
Ok(resp.clients)
158+
}
159+
160+
pub async fn create_client(&self, req: &CreateClientRequest) -> Result<Client, AppError> {
161+
self.request_with_retry(reqwest::Method::POST, "/api/v2/clients", Some(req))
162+
.await
163+
}
164+
149165
pub async fn list_projects(&self) -> Result<Vec<Project>, AppError> {
150-
let resp: ProjectsResponse = self
151-
.request_with_retry(
152-
reqwest::Method::GET,
153-
"/api/v2/projects?is_active=true&per_page=200",
154-
None::<&()>,
166+
self.list_projects_for_client(None).await
167+
}
168+
169+
pub async fn list_projects_for_client(
170+
&self,
171+
client_id: Option<&str>,
172+
) -> Result<Vec<Project>, AppError> {
173+
let path = if let Some(client_id) = client_id {
174+
path_with_query(
175+
"/api/v2/projects",
176+
&[
177+
("is_active", "true"),
178+
("per_page", "200"),
179+
("client_id", client_id),
180+
],
181+
)
182+
} else {
183+
path_with_query(
184+
"/api/v2/projects",
185+
&[("is_active", "true"), ("per_page", "200")],
155186
)
187+
};
188+
let resp: ProjectsResponse = self
189+
.request_with_retry(reqwest::Method::GET, &path, None::<&()>)
156190
.await?;
157191
Ok(resp.projects)
158192
}
159193

194+
pub async fn create_project(&self, req: &CreateProjectRequest) -> Result<Project, AppError> {
195+
self.request_with_retry(reqwest::Method::POST, "/api/v2/projects", Some(req))
196+
.await
197+
}
198+
160199
pub async fn list_tasks(&self) -> Result<Vec<Task>, AppError> {
200+
let path = path_with_query(
201+
"/api/v2/tasks",
202+
&[("is_active", "true"), ("per_page", "200")],
203+
);
161204
let resp: TasksResponse = self
162-
.request_with_retry(
163-
reqwest::Method::GET,
164-
"/api/v2/tasks?is_active=true&per_page=200",
165-
None::<&()>,
166-
)
205+
.request_with_retry(reqwest::Method::GET, &path, None::<&()>)
167206
.await?;
168207
Ok(resp.tasks)
169208
}
@@ -276,6 +315,8 @@ impl KeitorClient {
276315
}),
277316
billable: Some(timer.billable),
278317
is_running: false,
318+
started_time: timer.started_time.clone(),
319+
ended_time: timer.ended_time.clone(),
279320
source: Some("cli".into()),
280321
metadata: timer.metadata.clone(),
281322
})
@@ -295,6 +336,16 @@ impl KeitorClient {
295336
}
296337
}
297338

339+
fn path_with_query(path: &str, query: &[(&str, &str)]) -> String {
340+
let url = reqwest::Url::parse_with_params(&format!("https://keito.local{path}"), query)
341+
.expect("static API path should be a valid URL");
342+
343+
match url.query() {
344+
Some(query) => format!("{}?{}", url.path(), query),
345+
None => url.path().to_string(),
346+
}
347+
}
348+
298349
fn is_missing_stop_route(err: &AppError) -> bool {
299350
matches!(
300351
err,

src/api/models.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ impl MeResponse {
4747
}
4848
}
4949

50+
// -- Clients --
51+
52+
#[derive(Debug, Clone, Serialize, Deserialize)]
53+
pub struct Client {
54+
pub id: String,
55+
pub name: String,
56+
#[serde(default)]
57+
pub currency: Option<String>,
58+
#[serde(default)]
59+
pub address: Option<String>,
60+
#[serde(default)]
61+
pub is_active: bool,
62+
#[serde(default)]
63+
pub created_at: Option<DateTime<Utc>>,
64+
#[serde(default)]
65+
pub updated_at: Option<DateTime<Utc>>,
66+
}
67+
68+
#[derive(Debug, Serialize)]
69+
pub struct CreateClientRequest {
70+
pub name: String,
71+
#[serde(skip_serializing_if = "Option::is_none")]
72+
pub address: Option<String>,
73+
#[serde(skip_serializing_if = "Option::is_none")]
74+
pub currency: Option<String>,
75+
}
76+
5077
// -- Projects --
5178

5279
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -83,6 +110,24 @@ impl Project {
83110
}
84111
}
85112

113+
#[derive(Debug, Serialize)]
114+
pub struct CreateProjectRequest {
115+
pub client_id: String,
116+
pub name: String,
117+
#[serde(skip_serializing_if = "Option::is_none")]
118+
pub code: Option<String>,
119+
#[serde(skip_serializing_if = "Option::is_none")]
120+
pub notes: Option<String>,
121+
#[serde(skip_serializing_if = "Option::is_none")]
122+
pub is_billable: Option<bool>,
123+
#[serde(skip_serializing_if = "Option::is_none")]
124+
pub bill_by: Option<String>,
125+
#[serde(skip_serializing_if = "Option::is_none")]
126+
pub budget_by: Option<String>,
127+
#[serde(skip_serializing_if = "Option::is_none")]
128+
pub task_ids: Option<Vec<String>>,
129+
}
130+
86131
// -- Tasks --
87132

88133
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -181,6 +226,10 @@ pub struct CreateTimeEntryRequest {
181226
pub billable: Option<bool>,
182227
pub is_running: bool,
183228
#[serde(skip_serializing_if = "Option::is_none")]
229+
pub started_time: Option<String>,
230+
#[serde(skip_serializing_if = "Option::is_none")]
231+
pub ended_time: Option<String>,
232+
#[serde(skip_serializing_if = "Option::is_none")]
184233
pub source: Option<String>,
185234
#[serde(skip_serializing_if = "Option::is_none")]
186235
pub metadata: Option<serde_json::Value>,
@@ -189,6 +238,12 @@ pub struct CreateTimeEntryRequest {
189238
#[derive(Debug, Serialize)]
190239
#[allow(dead_code)]
191240
pub struct UpdateTimeEntryRequest {
241+
#[serde(skip_serializing_if = "Option::is_none")]
242+
pub project_id: Option<String>,
243+
#[serde(skip_serializing_if = "Option::is_none")]
244+
pub task_id: Option<String>,
245+
#[serde(skip_serializing_if = "Option::is_none")]
246+
pub spent_date: Option<String>,
192247
#[serde(skip_serializing_if = "Option::is_none")]
193248
pub is_running: Option<bool>,
194249
#[serde(skip_serializing_if = "Option::is_none")]
@@ -198,6 +253,10 @@ pub struct UpdateTimeEntryRequest {
198253
#[serde(skip_serializing_if = "Option::is_none")]
199254
pub billable: Option<bool>,
200255
#[serde(skip_serializing_if = "Option::is_none")]
256+
pub started_time: Option<String>,
257+
#[serde(skip_serializing_if = "Option::is_none")]
258+
pub ended_time: Option<String>,
259+
#[serde(skip_serializing_if = "Option::is_none")]
201260
pub metadata: Option<serde_json::Value>,
202261
}
203262

@@ -233,6 +292,21 @@ pub struct ProjectsResponse {
233292
pub links: Option<PaginationLinks>,
234293
}
235294

295+
#[derive(Debug, Clone, Serialize, Deserialize)]
296+
pub struct ClientsResponse {
297+
pub clients: Vec<Client>,
298+
#[serde(default)]
299+
pub per_page: Option<u64>,
300+
#[serde(default)]
301+
pub total_pages: Option<u64>,
302+
#[serde(default)]
303+
pub total_entries: Option<u64>,
304+
#[serde(default)]
305+
pub page: Option<u64>,
306+
#[serde(default)]
307+
pub links: Option<PaginationLinks>,
308+
}
309+
236310
#[derive(Debug, Clone, Serialize, Deserialize)]
237311
pub struct TasksResponse {
238312
pub tasks: Vec<Task>,
@@ -297,13 +371,17 @@ mod tests {
297371
notes: Some("test".into()),
298372
billable: Some(true),
299373
is_running: false,
374+
started_time: Some("09:00".into()),
375+
ended_time: Some("10:30".into()),
300376
source: Some("cli".into()),
301377
metadata: None,
302378
};
303379

304380
let value = serde_json::to_value(req).unwrap();
305381
assert_eq!(value["spent_date"], "2026-03-04");
306382
assert_eq!(value["billable"], true);
383+
assert_eq!(value["started_time"], "09:00");
384+
assert_eq!(value["ended_time"], "10:30");
307385
assert!(value.get("date").is_none());
308386
assert!(value.get("is_billable").is_none());
309387
}

src/cli/clients.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use clap::{Args, Subcommand};
2+
3+
#[derive(Args)]
4+
pub struct ClientsCommand {
5+
#[command(subcommand)]
6+
pub command: ClientsSubcommand,
7+
}
8+
9+
#[derive(Subcommand)]
10+
pub enum ClientsSubcommand {
11+
/// List active clients
12+
#[command(long_about = "\
13+
List active clients available to the authenticated Keito account.
14+
15+
Use this before project discovery when an agent needs to map user-provided \
16+
client context to Keito's project names and IDs.
17+
18+
API EFFECT:
19+
GET /api/v2/clients?is_active=true&per_page=200
20+
21+
EXAMPLE:
22+
keito clients list --json")]
23+
List {
24+
/// Max clients to display
25+
#[arg(long)]
26+
limit: Option<usize>,
27+
},
28+
29+
/// Create a client
30+
#[command(long_about = "\
31+
Create a client in Keito.
32+
33+
Requires a Keito API key with manager permissions.
34+
35+
API EFFECT:
36+
POST /api/v2/clients
37+
38+
EXAMPLE:
39+
keito clients create \"Acme Ltd\" --currency GBP --json")]
40+
Create {
41+
/// Client name
42+
name: String,
43+
44+
/// Client billing currency, such as USD or GBP
45+
#[arg(long)]
46+
currency: Option<String>,
47+
48+
/// Client postal address
49+
#[arg(long)]
50+
address: Option<String>,
51+
},
52+
}

src/cli/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod auth;
2+
pub mod clients;
23
pub mod projects;
34
pub mod time;
45

@@ -19,6 +20,7 @@ and returns structured exit codes for programmatic error handling.",
1920
after_long_help = "\
2021
ENVIRONMENT VARIABLES:
2122
KEITO_API_KEY API key (takes precedence over config)
23+
KEITO_API_URL API base URL override
2224
KEITO_ACCOUNT_ID Company/account ID for Keito-Account-Id
2325
KEITO_WORKSPACE_ID Legacy alias for KEITO_ACCOUNT_ID
2426
@@ -92,9 +94,12 @@ this value in Keito under Settings > API & Developers > Company ID."
9294
}
9395

9496
#[derive(Subcommand)]
97+
#[allow(clippy::large_enum_variant)]
9598
pub enum Command {
9699
/// Manage authentication (login, logout, status, whoami)
97100
Auth(auth::AuthCommand),
101+
/// Browse clients
102+
Clients(clients::ClientsCommand),
98103
/// Track time entries (start, stop, log, list, running)
99104
Time(time::TimeCommand),
100105
/// Browse projects and tasks

0 commit comments

Comments
 (0)