diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a64291..8ea0319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.1.8] - 2026-09-01 + +### Added + +- Add project-scoped task discovery through `keito projects tasks [PROJECT]` and use embedded/project-filtered tasks for all tracking commands. +- Preserve current API v2 user, company, capability, client, project, task, actual-duration, rounded-duration, internal-note, billability, and external-reference response fields in JSON output. +- Send CLI identity headers and a unique idempotency key for every API mutation, reusing the key across transient retries. +- Include production lifecycle metadata (`skill=keito-time-track` and `duration_seconds`) for agent session records. +- Surface `Retry-After` seconds in structured rate-limit errors. + +### Changed + +- Reject Personal read-only sync keys during login/status with a clear credential-scope error; `auth whoami` can still display their limited identity response. +- Paginate client, project, and task reference discovery instead of silently stopping after the first page. +- Respect server `Retry-After` delays when retrying transient 5xx responses. +- Use the production safe-delete intent when discarding a running timer and rely exclusively on the server-side timer stop endpoint. +- Accept the current `mobile` and `integration` time-entry source values. + +### Fixed + +- Parse `/api/v2/users/me` responses that intentionally omit `company` for Personal read-only sync credentials instead of returning a server/serde error. +- Stop sending the removed `is_running` field to the strict time-entry update endpoint during agent session upserts. +- Map production `400`, `413`, and `415` responses to invalid input, and `412`/`428` responses to conflict, while preserving stable CLI exit codes. +- Resolve tasks within the selected project so the CLI no longer offers workspace tasks that production rejects for that project. +- Preserve exact actual seconds and rounded hours in timer/log/session output when returned by the API. +- Return a stable JSON object from `time running` whether or not a timer is active. +- Update `h2` and `quinn-proto` to versions containing their current security fixes. + ## [0.1.7] - 2026-06-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 4f1db71..a8b4ae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -252,7 +252,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -327,7 +327,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -360,7 +360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -535,9 +535,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -872,7 +872,7 @@ dependencies = [ [[package]] name = "keito-cli" -version = "0.1.7" +version = "0.1.8" dependencies = [ "assert_cmd", "chrono", @@ -1170,9 +1170,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -1376,7 +1376,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1543,7 +1543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1629,7 +1629,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 03e2482..e4273b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "keito-cli" -version = "0.1.7" +version = "0.1.8" edition = "2021" description = "AI agent time tracking CLI for Keito: capture billable human and agent work for client billing, agency projects, and AI-native services" license = "MIT" diff --git a/README.md b/README.md index f3d63b5..f6a2c43 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ keito auth login This prompts for your API key (`kto_...`) and account/company ID, validates them against the production v2 API, and stores them in the platform config file. On macOS this is `~/Library/Application Support/keito/config.toml`; on Linux this is typically `~/.config/keito/config.toml`. Find the Company ID in Keito under Settings > API & Developers > Company ID. +The tracking CLI requires a CLI-compatible full-access Personal Access Token bound to an identity with the relevant Keito permissions. The **Personal read-only sync key** shown to members is deliberately limited to read-only sync endpoints: it cannot list tasks or create, update, stop, or discard time. `keito auth login` and `keito auth status` reject that key with exit code `1` instead of reporting a response parsing error. If that is the only credential your workspace exposes, the workspace must provision a supported CLI credential before time tracking can work. + For agent / CI use, set environment variables instead: ```sh @@ -77,7 +79,7 @@ keito auth whoami # Browse projects and tasks keito projects list -keito projects tasks +keito projects tasks "Acme Website" # Start a timer keito time start --project "Acme Website" --task dev @@ -102,7 +104,7 @@ keito auth status --json # Discover projects and tasks keito projects list --json -keito projects tasks --json +keito projects tasks "Acme Website" --json # Start → work → stop keito time start --project "Acme Website" --task dev --json @@ -281,7 +283,7 @@ keito skill install --skip-skills-add - **Exit codes 0–8** — every failure mode has a unique code for programmatic handling - **Name resolution** — use project names, codes, or IDs interchangeably (case-insensitive) - **Config-backed auth** — long-lived API keys are stored in a local `config.toml` for agent-friendly execution -- **Retry logic** — 3× exponential backoff for network and server errors +- **Safe retries** — mutation idempotency keys, exponential backoff for transient server/network errors, and `Retry-After` handling - **Cross-platform** — macOS, Linux, Windows ## Commands @@ -299,7 +301,7 @@ keito skill install --skip-skills-add | `keito time running` | Show the currently running timer | | `keito projects list` | List available projects in the workspace | | `keito projects show` | Show project details by name, code, or ID | -| `keito projects tasks` | List tasks (global, not per-project) | +| `keito projects tasks [PROJECT]` | List workspace tasks or tasks assigned to a project | Run `keito --help` for detailed usage, examples, and exit codes. @@ -360,10 +362,12 @@ $ keito time list --limit 2 [ { "id": "te_abc123", - "project": "Acme Website", - "task": "Development", - "duration": 1.5, - "date": "2025-01-15", + "project": { "id": "prj_abc", "name": "Acme Website" }, + "task": { "id": "tsk_dev", "name": "Development" }, + "spent_date": "2025-01-15", + "hours": 1.5, + "duration_seconds": 5400, + "rounded_hours": 1.5, "billable": true } ] @@ -374,11 +378,11 @@ $ keito time list --limit 2 | Code | Meaning | |---|---| | 0 | Success | -| 1 | Authentication error (missing or invalid API key) | +| 1 | Authentication/permission error (including a non-CLI-compatible sync key) | | 2 | Invalid input (bad arguments, malformed duration) | | 3 | Conflict (e.g. timer already running) | | 4 | Not found (project, task, or entry does not exist) | -| 5 | Rate limited (retry after a moment) | +| 5 | Rate limited (`details.retry_after_seconds` is included when supplied) | | 6 | Server error (Keito API 5xx) | | 7 | Network error (connection failed, timeout) | | 8 | Configuration error (missing config, bad TOML) | diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 12e87c9..e823e72 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -11,6 +11,10 @@ export KEITO_API_KEY=kto_your_api_key_here export KEITO_ACCOUNT_ID=co_your_company_id ``` +Use a CLI-compatible full-access Personal Access Token. A Personal read-only +sync key cannot list project tasks or mutate time entries and therefore fails +the CLI preflight with exit code `1`. + Verify credentials: ```sh @@ -22,7 +26,11 @@ keito auth status --json "authenticated": true, "api_key_source": "environment variable", "account_id": "co_abc123", - "workspace_id": "co_abc123" + "workspace_id": "co_abc123", + "api_key_valid": true, + "credential_type": "full_access", + "cli_compatible": true, + "can_track_time": true } ``` @@ -91,10 +99,12 @@ keito projects list --json ### List Tasks -Tasks are **global** — they are not scoped to a project. Any task can be used with any project. +Task availability is project-aware. Always pass the selected project so the +CLI returns its assigned tasks and production can enforce project/task and +member restrictions consistently. ```sh -keito projects tasks --json +keito projects tasks "Acme Website" --json ``` ```json @@ -115,7 +125,8 @@ Always check first — only one timer can be active at a time: keito time running --json ``` -Exit code `4` = no timer running (safe to start). Exit code `0` = timer already active. +The command exits `0` in both states. Inspect `running`: `false` means it is +safe to start; a returned entry with `running: true` means a timer is active. ### 2. Start Timer @@ -125,11 +136,12 @@ keito time start --project "Acme Website" --task dev --json ```json { - "id": "te_abc123", + "status": "started", + "entry_id": "te_abc123", "project": "Acme Website", "task": "Development", "started_at": "2025-01-15T09:00:00Z", - "is_running": true + "source": "cli" } ``` @@ -143,11 +155,13 @@ keito time stop --json ```json { - "id": "te_abc123", + "status": "stopped", + "entry_id": "te_abc123", "project": "Acme Website", "task": "Development", - "duration": 1.5, - "is_running": false + "duration_hours": 1.5, + "duration_seconds": 5400, + "source": "cli" } ``` @@ -190,11 +204,11 @@ Every error returns a structured JSON response with recovery hints: | Exit Code | Meaning | Recovery Action | |---|---|---| | 0 | Success | — | -| 1 | Auth error | Check `KEITO_API_KEY` is set and valid | +| 1 | Auth/permission error | Check the key is valid, full-access, and permitted; read-only sync keys cannot track time | | 2 | Invalid input | Fix arguments (bad duration, missing flags) | | 3 | Conflict | Stop the existing timer first: `keito time stop` | | 4 | Not found | Check project/task names: `keito projects list --json` | -| 5 | Rate limited | Wait a moment, then retry | +| 5 | Rate limited | Wait for `details.retry_after_seconds` when present, then retry | | 6 | Server error | Retry (automatic 3× backoff is built in) | | 7 | Network error | Check connectivity, retry | | 8 | Config error | Run `keito auth login` or set env vars | @@ -227,10 +241,11 @@ When finishing work, run: ``` 1. keito auth status --json # verify credentials (exit 0 = ok) -2. keito time running --json # check for active timer (exit 4 = none) -3. keito time start --project X --task Y --json # start timer -4. ... perform work ... -5. keito time stop --json # stop timer +2. keito projects tasks X --json # choose a task valid for project X +3. keito time running --json # inspect running=false before starting +4. keito time start --project X --task Y --json # start timer +5. ... perform work ... +6. keito time stop --json # stop timer ``` ### Environment Variables Reference diff --git a/src/api/client.rs b/src/api/client.rs index 55c9c4d..0ed9355 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,7 +1,8 @@ -use chrono::Utc; -use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, RETRY_AFTER, USER_AGENT}; use reqwest::Client as HttpClient; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::api::error::map_status_to_error; use crate::api::models::*; @@ -13,6 +14,9 @@ pub struct KeitorClient { base_url: String, } +const MAX_ATTEMPTS: usize = 3; +static IDEMPOTENCY_SEQUENCE: AtomicU64 = AtomicU64::new(1); + impl KeitorClient { pub fn new(auth: &ResolvedAuth, base_url: &str) -> Result { let mut headers = HeaderMap::new(); @@ -26,6 +30,15 @@ impl KeitorClient { HeaderValue::from_str(&auth.workspace_id) .map_err(|_| AppError::Auth("Invalid workspace ID format".into()))?, ); + headers.insert( + USER_AGENT, + HeaderValue::from_static(concat!("keito-cli/", env!("CARGO_PKG_VERSION"))), + ); + headers.insert("X-Keito-Client", HeaderValue::from_static("cli")); + headers.insert( + "X-Keito-Client-Version", + HeaderValue::from_static(env!("CARGO_PKG_VERSION")), + ); let client = HttpClient::builder() .default_headers(headers.clone()) @@ -46,16 +59,22 @@ impl KeitorClient { body: Option<&impl serde::Serialize>, ) -> Result { let url = format!("{}{}", self.base_url, path); - let max_retries = 3; + let idempotency_key = idempotency_key_for(&method); let mut last_error = None; + let mut next_delay = None; - for attempt in 0..max_retries { + for attempt in 0..MAX_ATTEMPTS { if attempt > 0 { - let delay = Duration::from_secs(1 << attempt); + let delay = next_delay + .take() + .unwrap_or_else(|| exponential_delay(attempt)); tokio::time::sleep(delay).await; } let mut req = self.client.request(method.clone(), &url); + if let Some(key) = idempotency_key.as_deref() { + req = req.header("Idempotency-Key", key); + } if let Some(b) = body { req = req.json(b); } @@ -69,27 +88,33 @@ impl KeitorClient { }; let status = resp.status().as_u16(); + let retry_after = retry_after_seconds(resp.headers()); if (200..300).contains(&status) { let text = resp .text() .await .map_err(|e| AppError::Network(e.to_string()))?; - let parsed: T = serde_json::from_str(&text) - .map_err(|e| AppError::ServerError(format!("Failed to parse response: {e}")))?; + let parsed: T = serde_json::from_str(&text).map_err(|e| { + AppError::ServerError(format!( + "Failed to parse successful {method} {path} response: {e}" + )) + })?; return Ok(parsed); } let resp_body = resp.text().await.unwrap_or_default(); - // Only retry on 5xx or network errors - if status >= 500 { - last_error = Some(map_status_to_error(status, &resp_body)); + // Mutations carry a stable idempotency key across attempts, so a + // transient server failure is safe to retry just like a GET. + if status >= 500 && attempt + 1 < MAX_ATTEMPTS { + next_delay = retry_after.map(Duration::from_secs); + last_error = Some(map_status_to_error(status, &resp_body, retry_after)); continue; } // Client errors are not retried - return Err(map_status_to_error(status, &resp_body)); + return Err(map_status_to_error(status, &resp_body, retry_after)); } Err(last_error.unwrap_or_else(|| AppError::Network("Request failed after retries".into()))) @@ -99,18 +124,28 @@ impl KeitorClient { &self, method: reqwest::Method, path: &str, + extra_headers: Option<&HeaderMap>, ) -> Result<(), AppError> { let url = format!("{}{}", self.base_url, path); - let max_retries = 3; + let idempotency_key = idempotency_key_for(&method); let mut last_error = None; + let mut next_delay = None; - for attempt in 0..max_retries { + for attempt in 0..MAX_ATTEMPTS { if attempt > 0 { - let delay = Duration::from_secs(1 << attempt); + let delay = next_delay + .take() + .unwrap_or_else(|| exponential_delay(attempt)); tokio::time::sleep(delay).await; } - let req = self.client.request(method.clone(), &url); + let mut req = self.client.request(method.clone(), &url); + if let Some(key) = idempotency_key.as_deref() { + req = req.header("Idempotency-Key", key); + } + if let Some(headers) = extra_headers { + req = req.headers(headers.clone()); + } let resp = match req.send().await { Ok(r) => r, @@ -121,6 +156,7 @@ impl KeitorClient { }; let status = resp.status().as_u16(); + let retry_after = retry_after_seconds(resp.headers()); if (200..300).contains(&status) { return Ok(()); @@ -128,12 +164,13 @@ impl KeitorClient { let resp_body = resp.text().await.unwrap_or_default(); - if status >= 500 { - last_error = Some(map_status_to_error(status, &resp_body)); + if status >= 500 && attempt + 1 < MAX_ATTEMPTS { + next_delay = retry_after.map(Duration::from_secs); + last_error = Some(map_status_to_error(status, &resp_body, retry_after)); continue; } - return Err(map_status_to_error(status, &resp_body)); + return Err(map_status_to_error(status, &resp_body, retry_after)); } Err(last_error.unwrap_or_else(|| AppError::Network("Request failed after retries".into()))) @@ -147,14 +184,28 @@ impl KeitorClient { } pub async fn list_clients(&self) -> Result, AppError> { - let path = path_with_query( - "/api/v2/clients", - &[("is_active", "true"), ("per_page", "200")], - ); - let resp: ClientsResponse = self - .request_with_retry(reqwest::Method::GET, &path, None::<&()>) - .await?; - Ok(resp.clients) + let mut page = 1_u64; + let mut clients = Vec::new(); + loop { + let page_value = page.to_string(); + let path = path_with_query( + "/api/v2/clients", + &[ + ("is_active", "true"), + ("per_page", "200"), + ("page", &page_value), + ], + ); + let resp: ClientsResponse = self + .request_with_retry(reqwest::Method::GET, &path, None::<&()>) + .await?; + let has_next = response_has_next_page(resp.page, resp.total_pages, &resp.links); + clients.extend(resp.clients); + if !has_next { + return Ok(clients); + } + page += 1; + } } pub async fn create_client(&self, req: &CreateClientRequest) -> Result { @@ -170,25 +221,29 @@ impl KeitorClient { &self, client_id: Option<&str>, ) -> Result, AppError> { - let path = if let Some(client_id) = client_id { - path_with_query( - "/api/v2/projects", - &[ - ("is_active", "true"), - ("per_page", "200"), - ("client_id", client_id), - ], - ) - } else { - path_with_query( - "/api/v2/projects", - &[("is_active", "true"), ("per_page", "200")], - ) - }; - let resp: ProjectsResponse = self - .request_with_retry(reqwest::Method::GET, &path, None::<&()>) - .await?; - Ok(resp.projects) + let mut page = 1_u64; + let mut projects = Vec::new(); + loop { + let page_value = page.to_string(); + let mut query = vec![ + ("is_active", "true"), + ("per_page", "200"), + ("page", page_value.as_str()), + ]; + if let Some(client_id) = client_id { + query.push(("client_id", client_id)); + } + let path = path_with_query("/api/v2/projects", &query); + let resp: ProjectsResponse = self + .request_with_retry(reqwest::Method::GET, &path, None::<&()>) + .await?; + let has_next = response_has_next_page(resp.page, resp.total_pages, &resp.links); + projects.extend(resp.projects); + if !has_next { + return Ok(projects); + } + page += 1; + } } pub async fn create_project(&self, req: &CreateProjectRequest) -> Result { @@ -197,14 +252,36 @@ impl KeitorClient { } pub async fn list_tasks(&self) -> Result, AppError> { - let path = path_with_query( - "/api/v2/tasks", - &[("is_active", "true"), ("per_page", "200")], - ); - let resp: TasksResponse = self - .request_with_retry(reqwest::Method::GET, &path, None::<&()>) - .await?; - Ok(resp.tasks) + self.list_tasks_for_project(None).await + } + + pub async fn list_tasks_for_project( + &self, + project_id: Option<&str>, + ) -> Result, AppError> { + let mut page = 1_u64; + let mut tasks = Vec::new(); + loop { + let page_value = page.to_string(); + let mut query = vec![ + ("is_active", "true"), + ("per_page", "200"), + ("page", page_value.as_str()), + ]; + if let Some(project_id) = project_id { + query.push(("project_id", project_id)); + } + let path = path_with_query("/api/v2/tasks", &query); + let resp: TasksResponse = self + .request_with_retry(reqwest::Method::GET, &path, None::<&()>) + .await?; + let has_next = response_has_next_page(resp.page, resp.total_pages, &resp.links); + tasks.extend(resp.tasks); + if !has_next { + return Ok(tasks); + } + page += 1; + } } pub async fn list_time_entries(&self, params: &str) -> Result, AppError> { @@ -261,76 +338,16 @@ impl KeitorClient { } } - pub async fn stop_time_entry_compat( - &self, - timer: &TimeEntry, - notes: Option<&str>, - ) -> Result { - match self.stop_time_entry(&timer.id, notes).await { - Ok(entry) => Ok(entry), - Err(err) if is_missing_stop_route(&err) => { - self.emulate_stop_time_entry(timer, notes).await - } - Err(err) => Err(err), - } - } - - async fn emulate_stop_time_entry( - &self, - timer: &TimeEntry, - notes: Option<&str>, - ) -> Result { - let project_id = timer.project_id.clone().ok_or_else(|| { - AppError::ServerError("Running timer response did not include project_id".into()) - })?; - let task_id = timer.task_id.clone().ok_or_else(|| { - AppError::ServerError("Running timer response did not include task_id".into()) - })?; - - let started_at = timer.timer_started_at.or(timer.created_at).ok_or_else(|| { - AppError::ServerError("Running timer response did not include a start time".into()) - })?; - let elapsed_seconds = (Utc::now() - started_at).num_seconds().max(60); - let elapsed_hours = - ((timer.hours.unwrap_or(0.0) + elapsed_seconds as f64 / 3600.0) * 100.0).round() - / 100.0; - - let spent_date = timer - .spent_date - .unwrap_or_else(|| Utc::now().date_naive()) - .to_string(); - - let stopped = self - .create_time_entry(&CreateTimeEntryRequest { - project_id, - task_id, - spent_date, - hours: Some(elapsed_hours), - notes: notes.map(str::to_string).or_else(|| { - timer - .notes - .as_ref() - .filter(|note| !note.is_empty()) - .cloned() - }), - billable: Some(timer.billable), - is_running: false, - started_time: timer.started_time.clone(), - ended_time: timer.ended_time.clone(), - source: Some("cli".into()), - metadata: timer.metadata.clone(), - }) - .await?; - - self.delete_time_entry(&timer.id).await?; - - Ok(stopped) - } - pub async fn delete_time_entry(&self, id: &str) -> Result<(), AppError> { + let mut headers = HeaderMap::new(); + headers.insert( + "X-Keito-Time-Entry-Delete-Intent", + HeaderValue::from_static("discard-running"), + ); self.request_with_retry_no_body( reqwest::Method::DELETE, &format!("/api/v2/time_entries/{id}"), + Some(&headers), ) .await } @@ -346,11 +363,43 @@ fn path_with_query(path: &str, query: &[(&str, &str)]) -> String { } } -fn is_missing_stop_route(err: &AppError) -> bool { - matches!( - err, - AppError::NotFound(message) - if message.contains("This page could not be found") - || message.contains(", + total_pages: Option, + links: &Option, +) -> bool { + if let Some(links) = links { + return links.next.is_some(); + } + matches!((page, total_pages), (Some(page), Some(total)) if page < total) +} + +fn exponential_delay(retry_number: usize) -> Duration { + Duration::from_secs(1_u64 << retry_number.saturating_sub(1).min(5)) +} + +fn retry_after_seconds(headers: &HeaderMap) -> Option { + headers + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) +} + +fn idempotency_key_for(method: &reqwest::Method) -> Option { + if !matches!( + *method, + reqwest::Method::POST | reqwest::Method::PATCH | reqwest::Method::DELETE + ) { + return None; + } + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = IDEMPOTENCY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + Some(format!( + "keito-cli-{}-{nanos}-{sequence}", + std::process::id() + )) } diff --git a/src/api/error.rs b/src/api/error.rs index 079a394..bdaa9cf 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -12,7 +12,7 @@ pub struct ApiErrorResponse { pub message: Option, } -pub fn map_status_to_error(status: u16, body: &str) -> AppError { +pub fn map_status_to_error(status: u16, body: &str, retry_after_seconds: Option) -> AppError { let detail = if let Ok(api_err) = serde_json::from_str::(body) { api_err .error_description @@ -25,11 +25,30 @@ pub fn map_status_to_error(status: u16, body: &str) -> AppError { match status { 401 | 403 => AppError::Auth(detail), + 400 | 413 | 415 | 422 => AppError::InvalidInput(detail), 404 => AppError::NotFound(detail), - 409 => AppError::Conflict(detail), - 422 => AppError::InvalidInput(detail), - 429 => AppError::RateLimited, + 409 | 412 | 428 => AppError::Conflict(detail), + 429 => AppError::RateLimited { + retry_after_seconds, + }, 500..=599 => AppError::ServerError(detail), _ => AppError::ServerError(format!("HTTP {status}: {detail}")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_current_validation_and_concurrency_statuses() { + assert_eq!( + map_status_to_error(400, r#"{"message":"bad field"}"#, None).exit_code(), + 2 + ); + assert_eq!(map_status_to_error(413, "too large", None).exit_code(), 2); + assert_eq!(map_status_to_error(415, "json only", None).exit_code(), 2); + assert_eq!(map_status_to_error(412, "stale", None).exit_code(), 3); + assert_eq!(map_status_to_error(428, "required", None).exit_code(), 3); + } +} diff --git a/src/api/models.rs b/src/api/models.rs index 536ef92..0529a5a 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -6,12 +6,65 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamedReference { pub id: String, - pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, } // -- User / Me -- -pub type Company = NamedReference; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Company { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timezone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_rounding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_past_timer_starts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_future_expense_dates: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub internal_time_notes_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub internal_time_notes_visibility: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_entry_notes_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_billability_overrides_enabled: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MeCapabilities { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_track_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_track_expenses: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_edit_time_billability: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_create_field_clients: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_view_team_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_view_invoices: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_send_invoices: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_record_invoice_payments: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_read_own_time_entries: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_read_users: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_read_own_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_read_clients: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_read_projects: Option, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeResponse { @@ -22,15 +75,38 @@ pub struct MeResponse { pub last_name: Option, #[serde(default)] pub email: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub telephone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub timezone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_access_to_all_future_projects: Option, + #[serde(default)] + pub is_contractor: bool, #[serde(default)] pub is_active: bool, #[serde(default)] pub roles: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub user_type: Option, - pub company: Company, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weekly_capacity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_hourly_rate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub membership_role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub company: Option, + #[serde(default)] + pub capabilities: MeCapabilities, } impl MeResponse { @@ -45,6 +121,22 @@ impl MeResponse { self.email.clone() } } + + pub fn is_personal_read_only_sync(&self) -> bool { + self.company.is_none() + && self.capabilities.can_read_own_time_entries == Some(true) + && self.capabilities.can_read_own_profile == Some(true) + } + + pub fn credential_type(&self) -> &'static str { + if self.is_personal_read_only_sync() { + "personal_read_only_sync" + } else if self.company.is_some() { + "full_access" + } else { + "unknown" + } + } } // -- Clients -- @@ -57,6 +149,18 @@ pub struct Client { pub currency: Option, #[serde(default)] pub address: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment_terms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment_days: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tax: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tax2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statement_key: Option, #[serde(default)] pub is_active: bool, #[serde(default)] @@ -81,9 +185,19 @@ pub struct Project { pub id: String, #[serde(default)] pub client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, pub name: String, #[serde(default)] pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_code: Option, #[serde(default)] pub is_active: bool, #[serde(default)] @@ -100,13 +214,45 @@ pub struct Project { pub budget_by: Option, #[serde(default)] pub budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget_is_monthly: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget_counts_billable_hours_only: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notify_when_over_budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub over_budget_notification_percentage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_budget_to_all: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_budget_include_expenses: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub starts_on: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ends_on: Option, #[serde(default)] pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tasks: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnerReference { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub email: String, } impl Project { pub fn client_name(&self) -> Option<&str> { - self.client.as_ref().map(|client| client.name.as_str()) + self.client.as_ref()?.name.as_deref() } } @@ -140,10 +286,20 @@ pub struct Task { pub billable_by_default: bool, #[serde(default)] pub default_hourly_rate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_billable_rate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget: Option, #[serde(default)] pub is_default: bool, #[serde(default)] pub parent_task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, } // -- Time Entries -- @@ -163,14 +319,24 @@ pub struct TimeEntry { pub project_id: Option, #[serde(default)] pub task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_assignment: Option, #[serde(default, rename = "spent_date", alias = "date")] pub spent_date: Option, #[serde(default)] pub hours: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rounded_hours: Option, #[serde(default)] pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub internal_notes: Option, #[serde(default, alias = "is_billable")] pub billable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_edit_billable: Option, #[serde(default)] pub is_running: bool, #[serde(default)] @@ -195,19 +361,49 @@ pub struct TimeEntry { pub source: Option, #[serde(default)] pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_reference: Option, #[serde(default)] pub created_at: Option>, #[serde(default)] pub updated_at: Option>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncTaskAssignment { + pub id: String, + pub billable: bool, + pub is_active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalReference { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permalink: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_name: Option, +} + impl TimeEntry { pub fn project_name(&self) -> Option<&str> { - self.project.as_ref().map(|project| project.name.as_str()) + self.project.as_ref()?.name.as_deref() } pub fn task_name(&self) -> Option<&str> { - self.task.as_ref().map(|task| task.name.as_str()) + self.task.as_ref()?.name.as_deref() + } + + pub fn actual_hours(&self) -> Option { + self.duration_seconds + .map(|seconds| seconds as f64 / 3600.0) + .or(self.hours) } } @@ -245,8 +441,6 @@ pub struct UpdateTimeEntryRequest { #[serde(skip_serializing_if = "Option::is_none")] pub spent_date: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub is_running: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hours: Option, diff --git a/src/cli/auth.rs b/src/cli/auth.rs index ff837c0..ff2597f 100644 --- a/src/cli/auth.rs +++ b/src/cli/auth.rs @@ -21,6 +21,9 @@ Store API key and configure account ID (interactive, one-time setup). This command prompts for an API key and account/company ID, validates them \ against the production v2 API, and stores them in the platform config file. \ +The tracking CLI requires a full-access personal access token bound to a \ +permitted identity. A Personal read-only sync key cannot list tasks or mutate \ +time, so login rejects it. \ For non-interactive / agent use, set the KEITO_API_KEY and KEITO_ACCOUNT_ID \ environment variables instead. @@ -40,8 +43,9 @@ EXAMPLE: #[command(long_about = "\ Check authentication status and credential source. -Returns which credential source is active and whether it is valid. \ -Useful as a health check before starting a session. +Returns which credential source is active, whether it is valid, and whether it \ +can be used by the tracking CLI. Personal read-only sync credentials and invalid \ +credentials return exit code 1. Useful as a health check before a session. EXAMPLE (JSON): $ keito auth status --json @@ -49,12 +53,16 @@ EXAMPLE (JSON): \"authenticated\": true, \"api_key_source\": \"environment variable\", \"account_id\": \"co_abc123\", - \"workspace_id\": \"co_abc123\" + \"workspace_id\": \"co_abc123\", + \"api_key_valid\": true, + \"credential_type\": \"full_access\", + \"cli_compatible\": true, + \"can_track_time\": true } EXIT CODES: 0 Authenticated - 1 Not authenticated (no valid credentials found)")] + 1 Missing, invalid, or non-CLI-compatible credentials")] Status, /// Show current user identity and account info diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9143324..b6e8f64 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -42,7 +42,7 @@ EXIT CODES: QUICK START (AGENT): keito auth status --json # verify credentials keito projects list --json # discover project IDs - keito projects tasks --json # discover task IDs + keito projects tasks --json # discover valid task IDs keito time start --project --task --json keito time running --json # check active timer keito time stop --json # stop when done diff --git a/src/cli/projects.rs b/src/cli/projects.rs index d0f187e..6dbffd1 100644 --- a/src/cli/projects.rs +++ b/src/cli/projects.rs @@ -2,8 +2,8 @@ use clap::{Args, Subcommand}; #[derive(Args)] #[command(after_long_help = "\ -NOTE: Tasks in Keito are workspace-global, not scoped to a project. \ -Use `keito projects tasks` to list all available tasks.")] +NOTE: Task availability can be scoped to a project. Use \ +`keito projects tasks ` before starting or logging time.")] pub struct ProjectsCommand { #[command(subcommand)] pub command: ProjectsSubcommand, @@ -87,21 +87,26 @@ Project name, code, or numeric ID. Resolution is case-insensitive.")] project: String, }, - /// List tasks (global — not filtered by project) + /// List workspace tasks or tasks assigned to a project #[command(long_about = "\ -List tasks available in the current workspace. +List active tasks available in the current workspace or assigned to a project. -IMPORTANT: Tasks are workspace-global in Keito, not scoped to any \ -particular project. Every task returned here can be used with any project. +Pass a project name, code, or ID to return only tasks assigned to that project. \ +This is the correct discovery path before tracking time because Keito validates \ +project/task assignments and may also enforce member-specific task restrictions. \ +Without a project, the command returns active workspace-level tasks only. EXAMPLE: - $ keito projects tasks --json + $ keito projects tasks \"Acme Website\" --json [ {\"id\": \"tsk_001\", \"name\": \"Development\", ...}, {\"id\": \"tsk_002\", \"name\": \"Design\", ...}, ... ]")] Tasks { + /// Project name, code, or ID + project: Option, + /// Max results to return #[arg(long)] limit: Option, diff --git a/src/cli/time.rs b/src/cli/time.rs index 2c2ab8b..112a169 100644 --- a/src/cli/time.rs +++ b/src/cli/time.rs @@ -61,7 +61,8 @@ Use `keito projects list --json` to discover available values." long, long_help = "\ Task name or numeric ID. Resolution is case-insensitive. Tasks are \ -global (not per-project). Use `keito projects tasks --json` to list." +resolved within the selected project. Use `keito projects tasks --json` \ +to list valid choices." )] task: String, @@ -194,7 +195,7 @@ Duration of the time entry. Accepts two formats: #[arg(long)] billable: Option, - /// Source to store on the time entry: web, cli, api, agent, calendar, or desktop + /// Source: web, cli, api, agent, calendar, desktop, mobile, or integration #[arg(long, default_value = "cli")] source: String, @@ -214,7 +215,7 @@ Duration of the time entry. Accepts two formats: #[arg(long = "agent-type")] agent_type: Option, - /// Skill name to store in metadata.skill + /// Lifecycle skill; source=agent sessions require keito-time-track #[arg(long)] skill: Option, }, @@ -232,7 +233,10 @@ EXAMPLE: keito time session-record --project acme --task dev \\ --session-id codex-123 --duration-seconds 5400 \\ --started-at 2026-05-11T09:00:00Z --ended-at 2026-05-11T10:30:00Z \\ - --skill keito-agent --json")] + --skill keito-time-track --json + +When source=agent, this command emits lifecycle-session metadata with \ +skill=keito-time-track and duration_seconds. Other --skill values are rejected.")] SessionRecord { /// Project name, code, or ID #[arg(long)] @@ -270,7 +274,7 @@ EXAMPLE: #[arg(long)] billable: Option, - /// Source to store on the time entry: web, cli, api, agent, calendar, or desktop + /// Source: web, cli, api, agent, calendar, desktop, mobile, or integration #[arg(long, default_value = "agent")] source: String, @@ -286,8 +290,8 @@ EXAMPLE: #[arg(long = "agent-type")] agent_type: Option, - /// Skill name to store in metadata.skill - #[arg(long)] + /// Skill name; source=agent requires keito-time-track + #[arg(long, default_value = "keito-time-track")] skill: Option, }, @@ -328,7 +332,7 @@ EXAMPLES: #[arg(long)] task: Option, - /// Filter by source: web, cli, api, agent, calendar, or desktop + /// Filter by source: web, cli, api, agent, calendar, desktop, mobile, or integration #[arg(long)] source: Option, @@ -353,20 +357,18 @@ API EFFECT: EXAMPLE: $ keito time running --json - [ - { - \"running\": true, - \"entry_id\": \"te_abc123\", - \"project\": \"Acme Website\", - \"task\": \"Development\", - \"spent_date\": \"2026-03-04\", - \"billable\": true, - \"source\": \"cli\", - \"started_at\": \"2026-03-04T09:00:00Z\", - \"elapsed_hours\": 1.5, - \"elapsed\": \"1:30\" - } - ] + { + \"running\": true, + \"entry_id\": \"te_abc123\", + \"project\": \"Acme Website\", + \"task\": \"Development\", + \"spent_date\": \"2026-03-04\", + \"billable\": true, + \"source\": \"cli\", + \"started_at\": \"2026-03-04T09:00:00Z\", + \"elapsed_hours\": 1.5, + \"elapsed\": \"1:30\" + } EXIT CODES: 0 Command succeeded; inspect JSON running field")] diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 85b7dc5..639e671 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -10,6 +10,8 @@ use crate::config::{AppConfig, ResolvedAuth}; use crate::error::AppError; use crate::output::{self, OutputMode}; +const PERSONAL_SYNC_KEY_ERROR: &str = "This is a personal read-only sync key. It can read a limited set of API resources, but it cannot list tracking tasks or create, update, stop, or discard time entries, so it cannot authenticate the Keito tracking CLI."; + pub async fn run(cmd: AuthCommand, global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { match cmd.command { AuthSubcommand::Login => login(global, mode).await, @@ -35,7 +37,7 @@ async fn login(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { let mut config = AppConfig::load()?; let temp_auth = ResolvedAuth { api_key: api_key.clone(), - workspace_id: account_id, + workspace_id: account_id.clone(), api_key_source: "login".into(), }; @@ -44,21 +46,25 @@ async fn login(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { AppError::Auth(_) => AppError::Auth("Invalid API key or account ID".into()), other => other, })?; + let company = require_cli_company(&me)?; // Store the long-lived API key in the config file. Agents can also use // KEITO_API_KEY for stateless execution. config.api_key = Some(api_key.clone()); - config.account_id = Some(me.company.id.clone()); - config.workspace_id = Some(me.company.id.clone()); + config.account_id = Some(company.id.clone()); + config.workspace_id = Some(company.id.clone()); config.save()?; if mode == OutputMode::Json { let out = serde_json::json!({ "status": "authenticated", "user": me.display_name(), - "company": me.company.name.clone(), - "account_id": me.company.id.clone(), - "workspace_id": me.company.id.clone(), + "company": company.name.clone(), + "account_id": company.id.clone(), + "workspace_id": company.id.clone(), + "credential_type": me.credential_type(), + "cli_compatible": true, + "can_track_time": me.capabilities.can_track_time, "api_key_source": "config file", }); println!("{}", serde_json::to_string_pretty(&out).unwrap()); @@ -67,7 +73,7 @@ async fn login(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { "{} Logged in as {} ({})", "Success!".green().bold(), me.display_name(), - me.company.name + company.name ); println!( "Credentials saved to {}.", @@ -153,43 +159,42 @@ async fn logout(_global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> } async fn status(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { - match ResolvedAuth::resolve(global) { - Ok(auth) => { - let config = AppConfig::load()?; - let client = KeitorClient::new(&auth, &config.api_base_url())?; - let valid = client.get_me().await.is_ok(); - - if mode == OutputMode::Json { - let out = serde_json::json!({ - "authenticated": true, - "api_key_source": auth.api_key_source, - "account_id": auth.workspace_id.clone(), - "workspace_id": auth.workspace_id.clone(), - "api_key_valid": valid, - }); - println!("{}", serde_json::to_string_pretty(&out).unwrap()); - } else { - println!("Authenticated: yes"); - println!("API key source: {}", auth.api_key_source); - println!("Account ID: {}", auth.workspace_id); - println!( - "API key valid: {}", - if valid { - "yes".green().to_string() - } else { - "no".red().to_string() - } - ); - } - } - Err(_) => { - if mode == OutputMode::Json { - println!(r#"{{"authenticated": false}}"#); - } else { - println!( - "Not authenticated. Run 'keito auth login' or set KEITO_API_KEY and KEITO_ACCOUNT_ID." - ); - } + let auth = ResolvedAuth::resolve(global).map_err(|error| match error { + AppError::Auth(message) | AppError::Config(message) => AppError::Auth(message), + other => other, + })?; + let config = AppConfig::load()?; + let client = KeitorClient::new(&auth, &config.api_base_url())?; + let me = client.get_me().await?; + require_cli_company(&me)?; + + if mode == OutputMode::Json { + let out = serde_json::json!({ + "authenticated": true, + "api_key_source": auth.api_key_source, + "account_id": auth.workspace_id.clone(), + "workspace_id": auth.workspace_id.clone(), + "api_key_valid": true, + "credential_type": me.credential_type(), + "cli_compatible": true, + "can_track_time": me.capabilities.can_track_time, + }); + println!("{}", serde_json::to_string_pretty(&out).unwrap()); + } else { + println!("Authenticated: yes"); + println!("API key source: {}", auth.api_key_source); + println!("Account ID: {}", auth.workspace_id); + println!("API key valid: {}", "yes".green()); + println!("CLI compatible: {}", "yes".green()); + if let Some(can_track_time) = me.capabilities.can_track_time { + println!( + "Can track time: {}", + if can_track_time { + "yes".green().to_string() + } else { + "no".red().to_string() + } + ); } } Ok(()) @@ -203,3 +208,16 @@ async fn whoami(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> output::render_single(&me, mode, global.quiet) } + +fn require_cli_company( + me: &crate::api::models::MeResponse, +) -> Result<&crate::api::models::Company, AppError> { + if me.is_personal_read_only_sync() { + return Err(AppError::Auth(PERSONAL_SYNC_KEY_ERROR.into())); + } + me.company.as_ref().ok_or_else(|| { + AppError::ServerError( + "The users/me response did not identify a company or a known credential type".into(), + ) + }) +} diff --git a/src/commands/projects.rs b/src/commands/projects.rs index dda122b..a88c042 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -23,7 +23,9 @@ pub async fn run( tasks, } => create(global, mode, name, client, code, notes, billable, tasks).await, ProjectsSubcommand::Show { project } => show(global, mode, &project).await, - ProjectsSubcommand::Tasks { limit } => tasks(global, mode, limit).await, + ProjectsSubcommand::Tasks { project, limit } => { + tasks(global, mode, project.as_deref(), limit).await + } } } @@ -106,12 +108,40 @@ async fn show(global: &GlobalFlags, mode: OutputMode, query: &str) -> Result<(), output::render_single(&project, mode, global.quiet) } -async fn tasks(global: &GlobalFlags, mode: OutputMode, limit: Option) -> Result<(), AppError> { +async fn tasks( + global: &GlobalFlags, + mode: OutputMode, + project_query: Option<&str>, + limit: Option, +) -> Result<(), AppError> { let auth = ResolvedAuth::resolve(global)?; let config = AppConfig::load()?; let client = KeitorClient::new(&auth, &config.api_base_url())?; - let mut tasks = client.list_tasks().await?; + let mut tasks = if let Some(project_query) = project_query { + let projects = client.list_projects().await?; + let items: Vec<(String, String, Option)> = projects + .iter() + .map(|project| { + ( + project.id.clone(), + project.name.clone(), + project.code.clone(), + ) + }) + .collect(); + let project_id = resolve_name_to_id(project_query, &items, "Project")?.to_string(); + match projects + .iter() + .find(|project| project.id == project_id) + .and_then(|project| project.tasks.clone()) + { + Some(tasks) => tasks, + None => client.list_tasks_for_project(Some(&project_id)).await?, + } + } else { + client.list_tasks().await? + }; if let Some(limit) = limit { tasks.truncate(limit as usize); } diff --git a/src/commands/time.rs b/src/commands/time.rs index dec1175..cce66a0 100644 --- a/src/commands/time.rs +++ b/src/commands/time.rs @@ -2,7 +2,9 @@ use chrono::{DateTime, Local, Utc}; use colored::Colorize; use serde_json::{Map, Value}; -use crate::api::models::{CreateTimeEntryRequest, TimeEntry, UpdateTimeEntryRequest}; +use crate::api::models::{ + CreateTimeEntryRequest, Project, Task, TimeEntry, UpdateTimeEntryRequest, +}; use crate::api::KeitorClient; use crate::cli::time::{TimeCommand, TimeSubcommand}; use crate::cli::GlobalFlags; @@ -142,7 +144,7 @@ async fn start( let project_id = resolve_name_to_id(project_query, &project_items, "Project")?.to_string(); // Resolve task - let tasks = client.list_tasks().await?; + let tasks = tasks_for_resolved_project(&client, &projects, &project_id).await?; let task_items: Vec<(String, String, Option)> = tasks .iter() .map(|t| (t.id.clone(), t.name.clone(), None)) @@ -229,9 +231,7 @@ async fn stop( return Ok(()); } - let entry = client - .stop_time_entry_compat(&timer, notes.as_deref()) - .await?; + let entry = client.stop_time_entry(&timer.id, notes.as_deref()).await?; if mode == OutputMode::Json { let out = serde_json::json!({ @@ -239,8 +239,10 @@ async fn stop( "entry_id": entry.id, "project": entry.project_name(), "task": entry.task_name(), - "duration_hours": entry.hours, - "duration": entry.hours.map(format_duration), + "duration_hours": entry.actual_hours(), + "duration_seconds": entry.duration_seconds, + "rounded_hours": entry.rounded_hours, + "duration": entry.actual_hours().map(format_duration), "spent_date": entry.spent_date, "billable": entry.billable, "source": entry.source, @@ -252,7 +254,10 @@ async fn stop( println!( "{} Timer stopped — {} for {} / {}", "Stopped!".green().bold(), - entry.hours.map(format_duration).unwrap_or_default(), + entry + .actual_hours() + .map(format_duration) + .unwrap_or_default(), entry.project_name().unwrap_or("?"), entry.task_name().unwrap_or("?"), ); @@ -282,14 +287,20 @@ async fn log_entry( skill: Option, ) -> Result<(), AppError> { let hours = hours_from_duration_inputs(duration_str.as_deref(), duration_seconds)?; + let metadata_duration_seconds = + duration_seconds.unwrap_or_else(|| (hours * 3600.0).round() as u64); let source = normalize_source(&source)?; - let metadata = build_metadata(MetadataInput { - metadata, - session_id, - agent_id, - agent_type, - skill, - })?; + let metadata = prepare_agent_log_metadata( + build_metadata(MetadataInput { + metadata, + session_id, + agent_id, + agent_type, + skill, + })?, + &source, + metadata_duration_seconds, + )?; let auth = ResolvedAuth::resolve(global)?; let config = AppConfig::load()?; @@ -304,7 +315,7 @@ async fn log_entry( let project_id = resolve_name_to_id(project_query, &project_items, "Project")?.to_string(); // Resolve task - let tasks = client.list_tasks().await?; + let tasks = tasks_for_resolved_project(&client, &projects, &project_id).await?; let task_items: Vec<(String, String, Option)> = tasks .iter() .map(|t| (t.id.clone(), t.name.clone(), None)) @@ -335,8 +346,10 @@ async fn log_entry( "entry_id": entry.id, "project": entry.project_name(), "task": entry.task_name(), - "duration_hours": entry.hours, - "duration": entry.hours.map(format_duration), + "duration_hours": entry.actual_hours(), + "duration_seconds": entry.duration_seconds, + "rounded_hours": entry.rounded_hours, + "duration": entry.actual_hours().map(format_duration), "spent_date": entry.spent_date, "date": entry.spent_date, "billable": entry.billable, @@ -347,7 +360,10 @@ async fn log_entry( println!( "{} Logged {} for {} / {}", "Logged!".green().bold(), - entry.hours.map(format_duration).unwrap_or_default(), + entry + .actual_hours() + .map(format_duration) + .unwrap_or_default(), entry.project_name().unwrap_or("?"), entry.task_name().unwrap_or("?"), ); @@ -386,14 +402,17 @@ async fn session_record( .as_deref() .map(local_time_from_rfc3339) .transpose()?; - let metadata = build_metadata(MetadataInput { - metadata, - session_id: Some(session_id.clone()), - agent_id, - agent_type, - skill, - })? - .unwrap_or_else(|| serde_json::json!({ "session_id": session_id.clone() })); + let metadata = build_session_metadata( + MetadataInput { + metadata, + session_id: Some(session_id.clone()), + agent_id, + agent_type, + skill, + }, + duration_seconds, + &source, + )?; let auth = ResolvedAuth::resolve(global)?; let config = AppConfig::load()?; @@ -406,7 +425,7 @@ async fn session_record( .collect(); let project_id = resolve_name_to_id(project_query, &project_items, "Project")?.to_string(); - let tasks = client.list_tasks().await?; + let tasks = tasks_for_resolved_project(&client, &projects, &project_id).await?; let task_items: Vec<(String, String, Option)> = tasks .iter() .map(|t| (t.id.clone(), t.name.clone(), None)) @@ -428,7 +447,6 @@ async fn session_record( project_id: Some(project_id), task_id: Some(task_id), spent_date: Some(spent_date.clone()), - is_running: Some(false), notes, hours: Some(hours), billable, @@ -464,8 +482,10 @@ async fn session_record( "entry_id": entry.id, "project": entry.project_name(), "task": entry.task_name(), - "duration_hours": entry.hours, - "duration": entry.hours.map(format_duration), + "duration_hours": entry.actual_hours(), + "duration_seconds": entry.duration_seconds, + "rounded_hours": entry.rounded_hours, + "duration": entry.actual_hours().map(format_duration), "spent_date": entry.spent_date, "billable": entry.billable, "source": entry.source, @@ -481,7 +501,10 @@ async fn session_record( println!( "{} {} for {} / {}", label.green().bold(), - entry.hours.map(format_duration).unwrap_or_default(), + entry + .actual_hours() + .map(format_duration) + .unwrap_or_default(), entry.project_name().unwrap_or("?"), entry.task_name().unwrap_or("?"), ); @@ -503,6 +526,17 @@ async fn list( limit: u32, page: u32, ) -> Result<(), AppError> { + if !(1..=2000).contains(&limit) { + return Err(AppError::InvalidInput( + "--limit must be between 1 and 2000".into(), + )); + } + if page == 0 { + return Err(AppError::InvalidInput( + "--page must be greater than zero".into(), + )); + } + let auth = ResolvedAuth::resolve(global)?; let config = AppConfig::load()?; let client = KeitorClient::new(&auth, &config.api_base_url())?; @@ -531,6 +565,8 @@ async fn list( params.push(format!("source={}", normalize_source(&source)?)); } + let mut project_tasks = None; + // Resolve project ID if provided if let Some(ref project_query) = project { let projects = client.list_projects().await?; @@ -538,13 +574,17 @@ async fn list( .iter() .map(|p| (p.id.clone(), p.name.clone(), p.code.clone())) .collect(); - let project_id = resolve_name_to_id(project_query, &project_items, "Project")?; + let project_id = resolve_name_to_id(project_query, &project_items, "Project")?.to_string(); + project_tasks = Some(tasks_for_resolved_project(&client, &projects, &project_id).await?); params.push(format!("project_id={project_id}")); } // Resolve task ID if provided if let Some(ref task_query) = task { - let tasks = client.list_tasks().await?; + let tasks = match project_tasks { + Some(tasks) => tasks, + None => client.list_tasks().await?, + }; let task_items: Vec<(String, String, Option)> = tasks .iter() .map(|t| (t.id.clone(), t.name.clone(), None)) @@ -576,26 +616,27 @@ async fn running(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> } if mode == OutputMode::Json { - let mut entries: Vec = Vec::new(); - for entry in &running { - let elapsed = entry.timer_started_at.map(|started| { - let elapsed = Utc::now() - started; - elapsed.num_seconds() as f64 / 3600.0 - }); - entries.push(serde_json::json!({ - "running": true, - "entry_id": entry.id, - "project": entry.project_name(), - "task": entry.task_name(), - "started_at": entry.timer_started_at, - "spent_date": entry.spent_date, - "billable": entry.billable, - "source": entry.source, - "elapsed_hours": elapsed, - "elapsed": elapsed.map(format_duration), - })); - } - println!("{}", serde_json::to_string_pretty(&entries).unwrap()); + // Production enforces one running timer per user. Keep the JSON shape + // stable between the running and not-running states by returning one + // object in both cases. + let entry = &running[0]; + let elapsed = entry.timer_started_at.map(|started| { + let elapsed = Utc::now() - started; + elapsed.num_seconds() as f64 / 3600.0 + }); + let out = serde_json::json!({ + "running": true, + "entry_id": entry.id, + "project": entry.project_name(), + "task": entry.task_name(), + "started_at": entry.timer_started_at, + "spent_date": entry.spent_date, + "billable": entry.billable, + "source": entry.source, + "elapsed_hours": elapsed, + "elapsed": elapsed.map(format_duration), + }); + println!("{}", serde_json::to_string_pretty(&out).unwrap()); } else { for entry in &running { let elapsed = entry.timer_started_at.map(|started| { @@ -626,13 +667,30 @@ struct MetadataInput { fn normalize_source(source: &str) -> Result { let normalized = source.trim().to_ascii_lowercase(); match normalized.as_str() { - "web" | "cli" | "api" | "agent" | "calendar" | "desktop" => Ok(normalized), + "web" | "cli" | "api" | "agent" | "calendar" | "desktop" | "mobile" + | "integration" => Ok(normalized), _ => Err(AppError::InvalidInput(format!( - "source must be one of: web, cli, api, agent, calendar, desktop (got '{source}')" + "source must be one of: web, cli, api, agent, calendar, desktop, mobile, integration (got '{source}')" ))), } } +async fn tasks_for_resolved_project( + client: &KeitorClient, + projects: &[Project], + project_id: &str, +) -> Result, AppError> { + if let Some(tasks) = projects + .iter() + .find(|project| project.id == project_id) + .and_then(|project| project.tasks.clone()) + { + return Ok(tasks); + } + + client.list_tasks_for_project(Some(project_id)).await +} + fn hours_from_duration_inputs( duration: Option<&str>, duration_seconds: Option, @@ -701,6 +759,83 @@ fn build_metadata(input: MetadataInput) -> Result, AppError> { Ok(Some(value)) } +fn build_session_metadata( + input: MetadataInput, + duration_seconds: u64, + source: &str, +) -> Result { + let mut metadata = match build_metadata(input)? { + Some(Value::Object(map)) => map, + _ => Map::new(), + }; + + metadata.insert( + "duration_seconds".into(), + Value::Number(duration_seconds.into()), + ); + if source == "agent" { + match metadata.get("skill") { + Some(Value::String(skill)) if skill == "keito-time-track" => {} + Some(_) => { + return Err(AppError::InvalidInput( + "source=agent session records require metadata.skill=keito-time-track".into(), + )) + } + None => { + metadata.insert("skill".into(), Value::String("keito-time-track".into())); + } + } + } + + let value = Value::Object(metadata); + let size = serde_json::to_string(&value) + .map_err(|err| AppError::InvalidInput(format!("failed to serialize metadata: {err}")))? + .len(); + if size > 4096 { + return Err(AppError::InvalidInput( + "--metadata payload must be 4KB or smaller".into(), + )); + } + Ok(value) +} + +fn prepare_agent_log_metadata( + metadata: Option, + source: &str, + duration_seconds: u64, +) -> Result, AppError> { + if source != "agent" { + return Ok(metadata); + } + + let mut map = match metadata { + Some(Value::Object(map)) => map, + other => return Ok(other), + }; + if !map.contains_key("session_id") { + // Strict public-build metadata is a separate valid source=agent shape; + // leave it untouched for production to validate against the target user. + return Ok(Some(Value::Object(map))); + } + + match map.get("skill") { + Some(Value::String(skill)) if skill == "keito-time-track" => {} + Some(_) => { + return Err(AppError::InvalidInput( + "source=agent lifecycle metadata requires skill=keito-time-track".into(), + )) + } + None => { + map.insert("skill".into(), Value::String("keito-time-track".into())); + } + } + map.insert( + "duration_seconds".into(), + Value::Number(duration_seconds.into()), + ); + Ok(Some(Value::Object(map))) +} + fn insert_string_metadata(map: &mut Map, key: &str, value: Option) { if let Some(value) = value { let value = value.trim(); @@ -753,7 +888,9 @@ mod tests { assert_eq!(normalize_source("Agent").unwrap(), "agent"); assert_eq!(normalize_source("desktop").unwrap(), "desktop"); assert_eq!(normalize_source("Calendar").unwrap(), "calendar"); - assert!(normalize_source("mobile").is_err()); + assert_eq!(normalize_source("mobile").unwrap(), "mobile"); + assert_eq!(normalize_source("integration").unwrap(), "integration"); + assert!(normalize_source("unknown").is_err()); } #[test] @@ -788,4 +925,51 @@ mod tests { }) .is_err()); } + + #[test] + fn session_metadata_matches_production_agent_lifecycle_contract() { + let metadata = build_session_metadata( + MetadataInput { + metadata: None, + session_id: Some("session-123".into()), + agent_id: Some("codex".into()), + agent_type: Some("codex".into()), + skill: None, + }, + 5400, + "agent", + ) + .unwrap(); + + assert_eq!(metadata["skill"], "keito-time-track"); + assert_eq!(metadata["session_id"], "session-123"); + assert_eq!(metadata["duration_seconds"], 5400); + + assert!(build_session_metadata( + MetadataInput { + metadata: None, + session_id: Some("session-123".into()), + agent_id: None, + agent_type: None, + skill: Some("another-skill".into()), + }, + 5400, + "agent", + ) + .is_err()); + } + + #[test] + fn agent_log_lifecycle_metadata_gets_skill_and_duration() { + let metadata = prepare_agent_log_metadata( + Some(serde_json::json!({"session_id": "session-123"})), + "agent", + 900, + ) + .unwrap() + .unwrap(); + + assert_eq!(metadata["skill"], "keito-time-track"); + assert_eq!(metadata["duration_seconds"], 900); + } } diff --git a/src/error.rs b/src/error.rs index 6adb425..2049e39 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,7 +16,7 @@ pub enum AppError { NotFound(String), #[error("Rate limited — please retry after a moment")] - RateLimited, + RateLimited { retry_after_seconds: Option }, #[error("Server error: {0}")] ServerError(String), @@ -35,7 +35,7 @@ impl AppError { AppError::InvalidInput(_) => 2, AppError::Conflict(_) => 3, AppError::NotFound(_) => 4, - AppError::RateLimited => 5, + AppError::RateLimited { .. } => 5, AppError::ServerError(_) => 6, AppError::Network(_) => 7, AppError::Config(_) => 8, @@ -51,10 +51,22 @@ impl AppError { AppError::NotFound(msg) if msg.contains("Project") => { Some("keito projects list --json") } - AppError::NotFound(msg) if msg.contains("Task") => Some("keito projects tasks --json"), + AppError::NotFound(msg) if msg.contains("Task") => { + Some("keito projects tasks --json") + } + AppError::Auth(msg) + if msg.contains("read-only sync") + || msg.contains("scope does not allow") + || msg.contains("Insufficient permissions") + || msg.contains("permission") => + { + Some( + "Use a CLI-compatible key with the required permission; personal read-only sync keys cannot track time", + ) + } AppError::Auth(_) => Some("Set KEITO_API_KEY env var or run 'keito auth login'"), AppError::Config(_) => Some("Run 'keito auth login' to configure"), - AppError::RateLimited => Some("Retry after a moment"), + AppError::RateLimited { .. } => Some("Retry after the server's Retry-After delay"), _ => None, } } @@ -77,6 +89,9 @@ impl AppError { } None } + AppError::RateLimited { + retry_after_seconds: Some(seconds), + } => Some(json!({"retry_after_seconds": seconds})), _ => None, } } @@ -108,7 +123,13 @@ mod tests { assert_eq!(AppError::InvalidInput("x".into()).exit_code(), 2); assert_eq!(AppError::Conflict("x".into()).exit_code(), 3); assert_eq!(AppError::NotFound("x".into()).exit_code(), 4); - assert_eq!(AppError::RateLimited.exit_code(), 5); + assert_eq!( + AppError::RateLimited { + retry_after_seconds: None + } + .exit_code(), + 5 + ); assert_eq!(AppError::ServerError("x".into()).exit_code(), 6); assert_eq!(AppError::Network("x".into()).exit_code(), 7); assert_eq!(AppError::Config("x".into()).exit_code(), 8); @@ -144,6 +165,23 @@ mod tests { ); } + #[test] + fn read_only_scope_error_has_credential_recovery() { + let err = AppError::Auth( + "API key scope does not allow GET /api/v2/tasks (personal read-only sync key)".into(), + ); + assert!(err.suggestion().unwrap().contains("CLI-compatible key")); + } + + #[test] + fn rate_limit_json_includes_retry_after() { + let err = AppError::RateLimited { + retry_after_seconds: Some(60), + }; + let json: serde_json::Value = serde_json::from_str(&err.to_json()).unwrap(); + assert_eq!(json["details"]["retry_after_seconds"], 60); + } + #[test] fn to_json_includes_suggestion() { let err = AppError::Conflict("A timer is already running.".into()); diff --git a/src/output/table.rs b/src/output/table.rs index 5668c11..aaf9909 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -239,7 +239,7 @@ fn try_as_time_entries(arr: &[serde_json::Value]) -> Option> { fn try_as_me(arr: &[serde_json::Value]) -> Option> { let first = arr.first()?.as_object()?; - if first.contains_key("id") && first.contains_key("email") && first.contains_key("company") { + if first.contains_key("id") && first.contains_key("email") && first.contains_key("roles") { serde_json::from_value(serde_json::Value::Array(arr.to_vec())).ok() } else { None @@ -294,7 +294,7 @@ fn format_time_entry_table(entries: &[TimeEntry]) -> String { date: e.spent_date.map(|d| d.to_string()).unwrap_or_default(), project: e.project_name().unwrap_or_default().to_string(), task: e.task_name().unwrap_or_default().to_string(), - duration: e.hours.map(format_duration).unwrap_or_else(|| { + duration: e.actual_hours().map(format_duration).unwrap_or_else(|| { if e.is_running { "running...".into() } else { @@ -310,7 +310,7 @@ fn format_time_entry_table(entries: &[TimeEntry]) -> String { fn format_me_table(me_list: &[MeResponse]) -> String { if let Some(me) = me_list.first() { - let rows = vec![ + let mut rows = vec![ TableRow { key: "User ID".into(), value: me.id.clone(), @@ -324,14 +324,26 @@ fn format_me_table(me_list: &[MeResponse]) -> String { value: me.email.clone(), }, TableRow { - key: "Company".into(), - value: me.company.name.clone(), - }, - TableRow { - key: "Company ID".into(), - value: me.company.id.clone(), + key: "Credential".into(), + value: me.credential_type().replace('_', " "), }, ]; + if let Some(company) = &me.company { + rows.push(TableRow { + key: "Company".into(), + value: company.name.clone(), + }); + rows.push(TableRow { + key: "Company ID".into(), + value: company.id.clone(), + }); + } + if let Some(can_track_time) = me.capabilities.can_track_time { + rows.push(TableRow { + key: "Can track time".into(), + value: if can_track_time { "Yes" } else { "No" }.into(), + }); + } Table::new(rows).with(Style::rounded()).to_string() } else { "No data.".into() diff --git a/tests/api_mock.rs b/tests/api_mock.rs index b1e64ce..04775ef 100644 --- a/tests/api_mock.rs +++ b/tests/api_mock.rs @@ -1,5 +1,6 @@ -use chrono::{Duration as ChronoDuration, Utc}; -use wiremock::matchers::{body_json, header, method, path, query_param}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use wiremock::matchers::{body_json, header, header_regex, method, path, query_param}; use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; use keito_cli::api::client::KeitorClient; @@ -16,6 +17,9 @@ fn test_auth(workspace_id: &str) -> ResolvedAuth { fn fixture(name: &str) -> serde_json::Value { let contents = match name { "users_me" => include_str!("fixtures/api_v2/users_me.json"), + "users_me_personal_sync" => { + include_str!("fixtures/api_v2/users_me_personal_sync.json") + } "clients_list" => include_str!("fixtures/api_v2/clients_list.json"), "projects_list" => include_str!("fixtures/api_v2/projects_list.json"), "tasks_list" => include_str!("fixtures/api_v2/tasks_list.json"), @@ -58,7 +62,29 @@ async fn get_me_success() { assert_eq!(me.display_name(), "Test User"); assert_eq!(me.email, "test@test.com"); - assert_eq!(me.company.name, "Test Co"); + assert_eq!(me.company.as_ref().unwrap().name, "Test Co"); + assert_eq!(me.credential_type(), "full_access"); + assert!(!me.is_personal_read_only_sync()); +} + +#[tokio::test] +async fn get_me_accepts_personal_sync_shape_without_company() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v2/users/me")) + .respond_with(ResponseTemplate::new(200).set_body_json(fixture("users_me_personal_sync"))) + .mount(&server) + .await; + + let auth = test_auth("co_test"); + let client = KeitorClient::new(&auth, &server.uri()).unwrap(); + let me = client.get_me().await.unwrap(); + + assert_eq!(me.email, "support@keito.ai"); + assert!(me.company.is_none()); + assert!(me.is_personal_read_only_sync()); + assert_eq!(me.credential_type(), "personal_read_only_sync"); } #[tokio::test] @@ -313,6 +339,27 @@ async fn list_tasks_success() { assert!(tasks[0].billable_by_default); } +#[tokio::test] +async fn list_tasks_can_be_scoped_to_project() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v2/tasks")) + .and(query_param("project_id", "p1")) + .and(query_param("is_active", "true")) + .and(query_param("per_page", "200")) + .respond_with(ResponseTemplate::new(200).set_body_json(fixture("tasks_list"))) + .expect(1) + .mount(&server) + .await; + + let auth = test_auth("co_test"); + let client = KeitorClient::new(&auth, &server.uri()).unwrap(); + let tasks = client.list_tasks_for_project(Some("p1")).await.unwrap(); + + assert_eq!(tasks.len(), 2); +} + #[tokio::test] async fn list_time_entries_success() { let server = MockServer::start().await; @@ -347,6 +394,10 @@ async fn create_time_entry_success() { .and(path("/api/v2/time_entries")) .and(header("Authorization", "Bearer kto_test_key")) .and(header("Keito-Account-Id", "co_test")) + .and(header("X-Keito-Client", "cli")) + .and(header("X-Keito-Client-Version", env!("CARGO_PKG_VERSION"))) + .and(header_regex("User-Agent", r"^keito-cli/\d+\.\d+\.\d+$")) + .and(header_regex("Idempotency-Key", r"^keito-cli-\d+-\d+-\d+$")) .and(body_json(serde_json::json!({ "project_id": "p1", "task_id": "t1", @@ -388,6 +439,65 @@ async fn create_time_entry_success() { assert!(entry.billable); } +#[tokio::test] +async fn mutation_retry_reuses_idempotency_key_and_retry_after() { + let server = MockServer::start().await; + let attempts = Arc::new(AtomicUsize::new(0)); + let keys = Arc::new(Mutex::new(Vec::new())); + let response_body = fixture("time_entry_create"); + let responder_attempts = Arc::clone(&attempts); + let responder_keys = Arc::clone(&keys); + + Mock::given(method("POST")) + .and(path("/api/v2/time_entries")) + .respond_with(move |request: &Request| { + let key = request + .headers + .get("Idempotency-Key") + .unwrap() + .to_str() + .unwrap() + .to_string(); + responder_keys.lock().unwrap().push(key); + if responder_attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(503) + .insert_header("Retry-After", "0") + .set_body_json(serde_json::json!({ + "error": "service unavailable", + "message": "Task reference data is temporarily at capacity." + })) + } else { + ResponseTemplate::new(200).set_body_json(response_body.clone()) + } + }) + .expect(2) + .mount(&server) + .await; + + let auth = test_auth("co_test"); + let client = KeitorClient::new(&auth, &server.uri()).unwrap(); + let request = keito_cli::api::models::CreateTimeEntryRequest { + project_id: "p1".into(), + task_id: "t1".into(), + spent_date: "2026-03-04".into(), + hours: Some(1.5), + notes: Some("test".into()), + billable: Some(true), + is_running: false, + started_time: None, + ended_time: None, + source: Some("cli".into()), + metadata: None, + }; + + let entry = client.create_time_entry(&request).await.unwrap(); + assert_eq!(entry.id, "te_1"); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + let keys = keys.lock().unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], keys[1]); +} + #[tokio::test] async fn update_time_entry_success() { let server = MockServer::start().await; @@ -397,7 +507,6 @@ async fn update_time_entry_success() { .and(header("Authorization", "Bearer kto_test_key")) .and(header("Keito-Account-Id", "co_test")) .and(body_json(serde_json::json!({ - "is_running": false, "hours": 1.5 }))) .respond_with(ResponseTemplate::new(200).set_body_json(fixture("time_entry_create"))) @@ -411,7 +520,6 @@ async fn update_time_entry_success() { project_id: None, task_id: None, spent_date: None, - is_running: Some(false), notes: None, hours: Some(1.5), billable: None, @@ -493,7 +601,7 @@ async fn stop_time_entry_not_running_conflict() { } #[tokio::test] -async fn stop_time_entry_compat_falls_back_when_stop_route_is_missing() { +async fn stop_time_entry_does_not_emulate_a_missing_production_route() { let server = MockServer::start().await; Mock::given(method("PATCH")) @@ -506,51 +614,11 @@ async fn stop_time_entry_compat_falls_back_when_stop_route_is_missing() { .mount(&server) .await; - Mock::given(method("POST")) - .and(path("/api/v2/time_entries")) - .and(header("Authorization", "Bearer kto_test_key")) - .and(header("Keito-Account-Id", "co_test")) - .and(body_json(serde_json::json!({ - "project_id": "p1", - "task_id": "t1", - "spent_date": "2026-03-04", - "hours": 1.5, - "notes": "done", - "billable": true, - "is_running": false, - "started_time": "09:00", - "source": "cli", - "metadata": {"tool": "keito-cli"} - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(fixture("time_entry_create"))) - .expect(1) - .mount(&server) - .await; - - Mock::given(method("DELETE")) - .and(path("/api/v2/time_entries/te_running")) - .and(header("Authorization", "Bearer kto_test_key")) - .and(header("Keito-Account-Id", "co_test")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - let auth = test_auth("co_test"); let client = KeitorClient::new(&auth, &server.uri()).unwrap(); - let mut timer: keito_cli::api::models::TimeEntry = - serde_json::from_value(fixture("time_entry_running")["time_entries"][0].clone()).unwrap(); - let started_at = Utc::now() - ChronoDuration::minutes(90); - timer.timer_started_at = Some(started_at); - timer.created_at = Some(started_at); - - let entry = client - .stop_time_entry_compat(&timer, Some("done")) - .await - .unwrap(); + let result = client.stop_time_entry("te_running", Some("done")).await; - assert_eq!(entry.id, "te_1"); - assert!(!entry.is_running); + assert_eq!(result.unwrap_err().exit_code(), 4); } #[tokio::test] @@ -577,6 +645,11 @@ async fn delete_time_entry_success() { Mock::given(method("DELETE")) .and(path("/api/v2/time_entries/te_1")) + .and(header( + "X-Keito-Time-Entry-Delete-Intent", + "discard-running", + )) + .and(header_regex("Idempotency-Key", r"^keito-cli-\d+-\d+-\d+$")) .respond_with(ResponseTemplate::new(204)) .mount(&server) .await; @@ -594,7 +667,14 @@ async fn rate_limited_error() { Mock::given(method("GET")) .and(path("/api/v2/users/me")) - .respond_with(ResponseTemplate::new(429)) + .respond_with( + ResponseTemplate::new(429) + .insert_header("Retry-After", "60") + .set_body_json(serde_json::json!({ + "error": "rate limited", + "message": "Too many API requests. Retry in 60 seconds." + })), + ) .mount(&server) .await; @@ -602,6 +682,7 @@ async fn rate_limited_error() { let client = KeitorClient::new(&auth, &server.uri()).unwrap(); let result = client.get_me().await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().exit_code(), 5); + let err = result.unwrap_err(); + assert_eq!(err.exit_code(), 5); + assert_eq!(err.details().unwrap()["retry_after_seconds"], 60); } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 801c7c9..e8bc7e2 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1,5 +1,6 @@ #![allow(deprecated)] use assert_cmd::Command; +use chrono::Local; use predicates::prelude::*; use std::fs; use std::path::Path; @@ -476,6 +477,7 @@ async fn time_session_record_creates_agent_entry_against_mock_api() { Mock::given(method("GET")) .and(path("/api/v2/tasks")) + .and(query_param("project_id", "p1")) .and(header("Authorization", "Bearer kto_test_key")) .and(header("Keito-Account-Id", "co_test")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ @@ -522,7 +524,8 @@ async fn time_session_record_creates_agent_entry_against_mock_api() { "session_id": "sess_123", "agent_id": "codex", "agent_type": "codex", - "skill": "keito-agent" + "skill": "keito-time-track", + "duration_seconds": 5400 } }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ @@ -567,7 +570,7 @@ async fn time_session_record_creates_agent_entry_against_mock_api() { "--agent-type", "codex", "--skill", - "keito-agent", + "keito-time-track", ]) .assert() .success() @@ -575,6 +578,321 @@ async fn time_session_record_creates_agent_entry_against_mock_api() { .stdout(predicate::str::contains(r#""session_id": "sess_123""#)); } +#[tokio::test] +async fn time_start_uses_tasks_embedded_for_selected_project() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().unwrap(); + write_test_config(temp_dir.path(), &server.uri()); + let spent_date = Local::now().format("%Y-%m-%d").to_string(); + + Mock::given(method("GET")) + .and(path("/api/v2/time_entries")) + .and(query_param("is_running", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "time_entries": [] + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/api/v2/projects")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "projects": [{ + "id": "p1", + "client": { "id": "c1", "name": "Client A" }, + "name": "Project A", + "code": "PA", + "is_active": true, + "is_billable": true, + "tasks": [{ + "id": "t1", + "name": "Development", + "billable_by_default": true, + "is_active": true + }] + }] + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v2/time_entries")) + .and(body_json(serde_json::json!({ + "project_id": "p1", + "task_id": "t1", + "spent_date": spent_date, + "is_running": true, + "source": "cli" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "te_running", + "project": { "id": "p1", "name": "Project A" }, + "task": { "id": "t1", "name": "Development" }, + "project_id": "p1", + "task_id": "t1", + "spent_date": spent_date, + "hours": 0, + "duration_seconds": 0, + "rounded_hours": 0, + "billable": true, + "is_running": true, + "source": "cli", + "timer_started_at": "2026-09-01T09:00:00Z" + }))) + .expect(1) + .mount(&server) + .await; + + command_with_mock_config(temp_dir.path(), &server.uri()) + .args([ + "--json", + "time", + "start", + "--project", + "PA", + "--task", + "Development", + ]) + .assert() + .success() + .stdout(predicate::str::contains(r#""status": "started""#)); +} + +#[tokio::test] +async fn time_running_returns_an_object_when_active() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().unwrap(); + write_test_config(temp_dir.path(), &server.uri()); + + Mock::given(method("GET")) + .and(path("/api/v2/time_entries")) + .and(query_param("is_running", "true")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + include_str!("fixtures/api_v2/time_entry_running.json"), + "application/json", + )) + .expect(1) + .mount(&server) + .await; + + let output = command_with_mock_config(temp_dir.path(), &server.uri()) + .args(["--json", "time", "running"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(value.is_object()); + assert_eq!(value["running"], true); + assert_eq!(value["entry_id"], "te_running"); +} + +#[tokio::test] +async fn time_running_returns_an_object_when_inactive() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().unwrap(); + write_test_config(temp_dir.path(), &server.uri()); + + Mock::given(method("GET")) + .and(path("/api/v2/time_entries")) + .and(query_param("is_running", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "time_entries": [] + }))) + .expect(1) + .mount(&server) + .await; + + let output = command_with_mock_config(temp_dir.path(), &server.uri()) + .args(["--json", "time", "running"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(value.is_object()); + assert_eq!(value["running"], false); +} + +#[tokio::test] +async fn time_session_record_update_omits_removed_is_running_field() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().unwrap(); + write_test_config(temp_dir.path(), &server.uri()); + + Mock::given(method("GET")) + .and(path("/api/v2/projects")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "projects": [{ + "id": "p1", + "client": { "id": "c1", "name": "Client A" }, + "name": "Project A", + "code": "PA", + "is_active": true, + "is_billable": true, + "tasks": [{ + "id": "t1", + "name": "Development", + "billable_by_default": true, + "is_active": true + }] + }] + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/api/v2/time_entries")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "time_entries": [{ + "id": "te_agent", + "project_id": "p1", + "task_id": "t1", + "spent_date": "2026-09-01", + "hours": 1, + "billable": true, + "is_running": false, + "source": "agent", + "metadata": { + "skill": "keito-time-track", + "session_id": "sess_update", + "duration_seconds": 3600 + } + }] + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("PATCH")) + .and(path("/api/v2/time_entries/te_agent")) + .and(body_json(serde_json::json!({ + "project_id": "p1", + "task_id": "t1", + "spent_date": "2026-09-01", + "hours": 1.5, + "metadata": { + "skill": "keito-time-track", + "session_id": "sess_update", + "duration_seconds": 5400 + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "te_agent", + "project": { "id": "p1", "name": "Project A" }, + "task": { "id": "t1", "name": "Development" }, + "project_id": "p1", + "task_id": "t1", + "spent_date": "2026-09-01", + "hours": 1.5, + "duration_seconds": 5400, + "rounded_hours": 1.5, + "billable": true, + "is_running": false, + "source": "agent", + "metadata": { + "skill": "keito-time-track", + "session_id": "sess_update", + "duration_seconds": 5400 + } + }))) + .expect(1) + .mount(&server) + .await; + + command_with_mock_config(temp_dir.path(), &server.uri()) + .args([ + "--json", + "time", + "session-record", + "--project", + "PA", + "--task", + "Development", + "--session-id", + "sess_update", + "--duration-seconds", + "5400", + "--date", + "2026-09-01", + ]) + .assert() + .success() + .stdout(predicate::str::contains(r#""status": "updated""#)); +} + +#[tokio::test] +async fn auth_status_rejects_personal_sync_key_with_actionable_error() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().unwrap(); + write_test_config(temp_dir.path(), &server.uri()); + + Mock::given(method("GET")) + .and(path("/api/v2/users/me")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "usr_support", + "first_name": "Support", + "last_name": "User", + "email": "support@keito.ai", + "is_active": true, + "is_contractor": false, + "roles": ["member"], + "capabilities": { + "can_read_own_time_entries": true, + "can_read_users": true, + "can_read_own_profile": true, + "can_read_clients": true, + "can_read_projects": true + } + }))) + .expect(1) + .mount(&server) + .await; + + command_with_mock_config(temp_dir.path(), &server.uri()) + .args(["--json", "auth", "status"]) + .assert() + .failure() + .code(1) + .stderr(predicate::str::contains("personal read-only sync key")) + .stderr(predicate::str::contains("missing field `company`").not()); +} + +#[tokio::test] +async fn auth_whoami_can_render_personal_sync_identity() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().unwrap(); + write_test_config(temp_dir.path(), &server.uri()); + + Mock::given(method("GET")) + .and(path("/api/v2/users/me")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "usr_support", + "first_name": "Support", + "last_name": "User", + "email": "support@keito.ai", + "is_active": true, + "roles": ["member"], + "capabilities": { + "can_read_own_time_entries": true, + "can_read_own_profile": true + } + }))) + .expect(1) + .mount(&server) + .await; + + command_with_mock_config(temp_dir.path(), &server.uri()) + .args(["--json", "auth", "whoami"]) + .assert() + .success() + .stdout(predicate::str::contains("support@keito.ai")) + .stdout(predicate::str::contains(r#""company""#).not()); +} + #[test] fn version_flag_works() { Command::cargo_bin("keito") diff --git a/tests/fixtures/api_v2/projects_list.json b/tests/fixtures/api_v2/projects_list.json index 7bcefe3..c2d82ae 100644 --- a/tests/fixtures/api_v2/projects_list.json +++ b/tests/fixtures/api_v2/projects_list.json @@ -3,17 +3,48 @@ { "id": "p1", "client": { "id": "c1", "name": "Client A" }, + "owner_id": "usr_1", + "owner": { "id": "usr_1", "name": "Test User", "email": "test@test.com" }, "name": "Project A", "code": "PA", + "project_kind": "standard", + "display_name": "Project A", + "display_code": "PA", "is_active": true, "is_billable": true, "is_fixed_fee": false, - "bill_by": "Project", + "bill_by": "PROJECT", "hourly_rate": 150, "fee": null, - "budget_by": "project", + "budget_by": "PROJECT", "budget": 100, - "notes": "Production-shaped project" + "budget_is_monthly": false, + "budget_counts_billable_hours_only": false, + "notify_when_over_budget": true, + "over_budget_notification_percentage": 80, + "show_budget_to_all": true, + "cost_budget": null, + "cost_budget_include_expenses": false, + "starts_on": "2026-01-01", + "ends_on": null, + "notes": "Production-shaped project", + "tasks": [ + { + "id": "t1", + "name": "Development", + "billable_by_default": true, + "effective_billable_rate": 150, + "currency": "GBP", + "budget": 100, + "is_default": true, + "is_active": true, + "parent_task_id": null, + "created_at": "2026-01-01T09:00:00Z", + "updated_at": "2026-08-31T09:00:00Z" + } + ], + "created_at": "2026-01-01T09:00:00Z", + "updated_at": "2026-08-31T09:00:00Z" }, { "id": "p2", @@ -23,12 +54,13 @@ "is_active": true, "is_billable": false, "is_fixed_fee": false, - "bill_by": "Project", + "bill_by": "NONE", "hourly_rate": null, "fee": null, - "budget_by": "none", + "budget_by": "NONE", "budget": null, - "notes": null + "notes": null, + "tasks": [] } ], "per_page": 200, diff --git a/tests/fixtures/api_v2/tasks_list.json b/tests/fixtures/api_v2/tasks_list.json index 9d1fa88..b4cfc51 100644 --- a/tests/fixtures/api_v2/tasks_list.json +++ b/tests/fixtures/api_v2/tasks_list.json @@ -5,9 +5,14 @@ "name": "Development", "billable_by_default": true, "default_hourly_rate": 150, + "effective_billable_rate": 150, + "currency": "GBP", + "budget": 100, "is_default": true, "is_active": true, - "parent_task_id": null + "parent_task_id": null, + "created_at": "2026-01-01T09:00:00Z", + "updated_at": "2026-08-31T09:00:00Z" }, { "id": "t2", diff --git a/tests/fixtures/api_v2/time_entries_list.json b/tests/fixtures/api_v2/time_entries_list.json index fe9bd57..dc573e9 100644 --- a/tests/fixtures/api_v2/time_entries_list.json +++ b/tests/fixtures/api_v2/time_entries_list.json @@ -10,7 +10,10 @@ "task_id": "t1", "spent_date": "2026-03-04", "hours": 1.5, + "duration_seconds": 5400, + "rounded_hours": 1.5, "notes": "test", + "internal_notes": null, "is_running": false, "timer_started_at": null, "started_time": null, @@ -19,11 +22,13 @@ "is_closed": false, "is_billed": false, "billable": true, + "can_edit_billable": false, "budgeted": true, "billable_rate": 150, "cost_rate": null, "source": "cli", "metadata": { "tool": "keito-cli" }, + "external_reference": null, "created_at": "2026-03-04T10:00:00Z", "updated_at": "2026-03-04T11:30:00Z" } diff --git a/tests/fixtures/api_v2/time_entry_create.json b/tests/fixtures/api_v2/time_entry_create.json index 7ef1322..af0fc40 100644 --- a/tests/fixtures/api_v2/time_entry_create.json +++ b/tests/fixtures/api_v2/time_entry_create.json @@ -8,7 +8,10 @@ "task_id": "t1", "spent_date": "2026-03-04", "hours": 1.5, + "duration_seconds": 5400, + "rounded_hours": 1.5, "notes": "test", + "internal_notes": null, "is_running": false, "timer_started_at": null, "started_time": null, @@ -17,11 +20,13 @@ "is_closed": false, "is_billed": false, "billable": true, + "can_edit_billable": false, "budgeted": true, "billable_rate": 150, "cost_rate": null, "source": "cli", "metadata": { "tool": "keito-cli" }, + "external_reference": null, "created_at": "2026-03-04T10:00:00Z", "updated_at": "2026-03-04T11:30:00Z" } diff --git a/tests/fixtures/api_v2/time_entry_running.json b/tests/fixtures/api_v2/time_entry_running.json index 016dfe8..30a4c3c 100644 --- a/tests/fixtures/api_v2/time_entry_running.json +++ b/tests/fixtures/api_v2/time_entry_running.json @@ -10,7 +10,10 @@ "task_id": "t1", "spent_date": "2026-03-04", "hours": 0, + "duration_seconds": 0, + "rounded_hours": 0, "notes": "in progress", + "internal_notes": null, "is_running": true, "timer_started_at": "2026-03-04T09:00:00Z", "started_time": "09:00", @@ -19,11 +22,13 @@ "is_closed": false, "is_billed": false, "billable": true, + "can_edit_billable": false, "budgeted": true, "billable_rate": 150, "cost_rate": null, "source": "cli", "metadata": { "tool": "keito-cli" }, + "external_reference": null, "created_at": "2026-03-04T09:00:00Z", "updated_at": "2026-03-04T09:00:00Z" } diff --git a/tests/fixtures/api_v2/time_entry_stopped.json b/tests/fixtures/api_v2/time_entry_stopped.json index 886609f..78a0a10 100644 --- a/tests/fixtures/api_v2/time_entry_stopped.json +++ b/tests/fixtures/api_v2/time_entry_stopped.json @@ -8,7 +8,10 @@ "task_id": "t1", "spent_date": "2026-03-04", "hours": 1.5, + "duration_seconds": 5400, + "rounded_hours": 1.5, "notes": "done", + "internal_notes": null, "is_running": false, "timer_started_at": null, "started_time": "09:00", @@ -17,11 +20,13 @@ "is_closed": false, "is_billed": false, "billable": true, + "can_edit_billable": false, "budgeted": true, "billable_rate": 150, "cost_rate": null, "source": "cli", "metadata": { "tool": "keito-cli" }, + "external_reference": null, "created_at": "2026-03-04T09:00:00Z", "updated_at": "2026-03-04T10:30:00Z" } diff --git a/tests/fixtures/api_v2/users_me.json b/tests/fixtures/api_v2/users_me.json index 4939d08..aab3fb4 100644 --- a/tests/fixtures/api_v2/users_me.json +++ b/tests/fixtures/api_v2/users_me.json @@ -3,12 +3,41 @@ "first_name": "Test", "last_name": "User", "email": "test@test.com", + "telephone": null, "timezone": "Europe/London", + "has_access_to_all_future_projects": false, + "is_contractor": false, "is_active": true, + "weekly_capacity": 40, + "default_hourly_rate": 150, + "currency": "GBP", "roles": ["member"], "user_type": "human", + "avatar_url": null, + "created_at": "2026-01-01T09:00:00Z", + "updated_at": "2026-08-31T09:00:00Z", + "membership_role": "member", "company": { "id": "co_test", - "name": "Test Co" + "name": "Test Co", + "currency": "GBP", + "timezone": "Europe/London", + "time_rounding": "none", + "allow_past_timer_starts": true, + "allow_future_expense_dates": false, + "internal_time_notes_enabled": false, + "internal_time_notes_visibility": "project_admins", + "time_entry_notes_mode": "per_entry", + "time_billability_overrides_enabled": false + }, + "capabilities": { + "can_track_time": true, + "can_track_expenses": true, + "can_edit_time_billability": false, + "can_create_field_clients": false, + "can_view_team_time": false, + "can_view_invoices": false, + "can_send_invoices": false, + "can_record_invoice_payments": false } } diff --git a/tests/fixtures/api_v2/users_me_personal_sync.json b/tests/fixtures/api_v2/users_me_personal_sync.json new file mode 100644 index 0000000..6b4cc8f --- /dev/null +++ b/tests/fixtures/api_v2/users_me_personal_sync.json @@ -0,0 +1,16 @@ +{ + "id": "usr_support", + "first_name": "Support", + "last_name": "User", + "email": "support@keito.ai", + "is_active": true, + "is_contractor": false, + "roles": ["member"], + "capabilities": { + "can_read_own_time_entries": true, + "can_read_users": true, + "can_read_own_profile": true, + "can_read_clients": true, + "can_read_projects": true + } +}