Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ The SDK follows a resource-based architecture. Each API resource is accessed as

| Resource | Methods | Description |
|----------|---------|-------------|
| `client.timeEntries` | `list` `create` `update` `delete` | Log and manage billable time |
| `client.timeEntries` | `list` `create` `startTimer` `stopTimer` `restartTimer` `update` `delete` | Log and manage billable time |
| `client.expenses` | `list` `create` | Track expenses and compute costs |
| `client.projects` | `list` | Browse projects |
| `client.clients` | `list` `get` `create` `update` | Manage client records |
Expand Down Expand Up @@ -124,6 +124,20 @@ const entry = await client.timeEntries.create({
billable: true,
});

// Start a running timer. Omit started_time to start at the server's current time.
const timer = await client.timeEntries.startTimer({
project_id: 'proj_123',
task_id: 'task_456',
spent_date: new Date().toISOString().split('T')[0],
source: 'agent',
replace_running: true,
});

// Stop a running timer. The API calculates elapsed duration server-side.
await client.timeEntries.stopTimer(timer.id, {
notes: 'Finished implementation',
});

// Update a time entry
await client.timeEntries.update('te_abc', {
hours: 2.5,
Expand Down
95 changes: 92 additions & 3 deletions openapi/openapi-v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ paths:
schema: { type: string, format: date-time }
- name: source
in: query
schema: { type: string, enum: [web, cli, api, agent] }
schema: { type: string, enum: [web, cli, api, agent, calendar, desktop] }
description: Filter by entry source. Comma-separated for multiple (e.g. `agent,cli`).
responses:
"200":
Expand Down Expand Up @@ -118,6 +118,12 @@ paths:
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: Conflict — another timer is already running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/api/v2/time_entries/{id}:
parameters:
Expand Down Expand Up @@ -169,6 +175,72 @@ paths:
schema:
$ref: "#/components/schemas/Error"

/api/v2/time_entries/{id}/stop:
parameters:
- name: id
in: path
required: true
schema: { type: string }
description: Time entry CUID

patch:
tags: [Time Entries]
summary: Stop a running time entry
operationId: stopTimeEntry
description: Stops a running timer and calculates elapsed duration server-side.
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/TimeEntryStop"
responses:
"200":
description: Stopped time entry
content:
application/json:
schema:
$ref: "#/components/schemas/TimeEntry"
"400": { $ref: "#/components/responses/BadRequest" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }

/api/v2/time_entries/{id}/restart:
parameters:
- name: id
in: path
required: true
schema: { type: string }
description: Time entry CUID

patch:
tags: [Time Entries]
summary: Restart a stopped time entry
operationId: restartTimeEntry
description: Restarts a stopped entry as the active timer. Pass `replace_running` to intentionally switch timers.
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/TimeEntryRestart"
responses:
"200":
description: Restarted time entry
content:
application/json:
schema:
$ref: "#/components/schemas/TimeEntry"
"400": { $ref: "#/components/responses/BadRequest" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: Conflict — another timer is already running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

# ─── EXPENSES ──────────────────────────────────────────────────
/api/v2/expenses:
get:
Expand Down Expand Up @@ -720,6 +792,7 @@ components:
# ── Common ─────────────────────────────────────────────────
Error:
type: object
additionalProperties: true
properties:
error:
type: string
Expand Down Expand Up @@ -784,7 +857,7 @@ components:
cost_rate: { type: number, nullable: true }
source:
type: string
enum: [web, cli, api, agent]
enum: [web, cli, api, agent, calendar, desktop]
metadata: { $ref: "#/components/schemas/Metadata" }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Expand All @@ -801,13 +874,29 @@ components:
notes: { type: string }
billable: { type: boolean }
is_running: { type: boolean }
replace_running:
type: boolean
description: When true with is_running, stop any current running timer before creating this one.
started_time: { type: string, description: "HH:mm" }
ended_time: { type: string, description: "HH:mm" }
source:
type: string
enum: [web, cli, api, agent]
enum: [web, cli, api, agent, calendar, desktop]
metadata: { $ref: "#/components/schemas/Metadata" }

TimeEntryStop:
type: object
properties:
notes:
type: string
nullable: true

TimeEntryRestart:
type: object
properties:
replace_running:
type: boolean

TimeEntryUpdate:
type: object
properties:
Expand Down
42 changes: 33 additions & 9 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,46 +10,69 @@ export class KeitoApiError extends KeitoError {
readonly error: string;
readonly error_description: string;
readonly headers: Headers;
readonly body: Record<string, unknown> | null;

constructor(
status: number,
error: string,
error_description: string,
headers: Headers,
body: Record<string, unknown> | null = null,
) {
super(`${status} ${error}: ${error_description}`);
this.name = 'KeitoApiError';
this.status = status;
this.error = error;
this.error_description = error_description;
this.headers = headers;
this.body = body;
}
}

export class KeitoAuthError extends KeitoApiError {
constructor(error: string, error_description: string, headers: Headers) {
super(401, error, error_description, headers);
constructor(
error: string,
error_description: string,
headers: Headers,
body: Record<string, unknown> | null = null,
) {
super(401, error, error_description, headers, body);
this.name = 'KeitoAuthError';
}
}

export class KeitoForbiddenError extends KeitoApiError {
constructor(error: string, error_description: string, headers: Headers) {
super(403, error, error_description, headers);
constructor(
error: string,
error_description: string,
headers: Headers,
body: Record<string, unknown> | null = null,
) {
super(403, error, error_description, headers, body);
this.name = 'KeitoForbiddenError';
}
}

export class KeitoNotFoundError extends KeitoApiError {
constructor(error: string, error_description: string, headers: Headers) {
super(404, error, error_description, headers);
constructor(
error: string,
error_description: string,
headers: Headers,
body: Record<string, unknown> | null = null,
) {
super(404, error, error_description, headers, body);
this.name = 'KeitoNotFoundError';
}
}

export class KeitoConflictError extends KeitoApiError {
constructor(error: string, error_description: string, headers: Headers) {
super(409, error, error_description, headers);
constructor(
error: string,
error_description: string,
headers: Headers,
body: Record<string, unknown> | null = null,
) {
super(409, error, error_description, headers, body);
this.name = 'KeitoConflictError';
}
}
Expand All @@ -61,8 +84,9 @@ export class KeitoRateLimitError extends KeitoApiError {
error: string,
error_description: string,
headers: Headers,
body: Record<string, unknown> | null = null,
) {
super(429, error, error_description, headers);
super(429, error, error_description, headers, body);
this.name = 'KeitoRateLimitError';
const ra = headers.get('retry-after');
this.retryAfter = ra ? parseInt(ra, 10) : null;
Expand Down
26 changes: 17 additions & 9 deletions src/core/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,19 @@ export class HttpClient {
private async parseError(response: Response): Promise<KeitoApiError> {
let error = 'unknown_error';
let error_description = '';
let body: Record<string, unknown> | null = null;

try {
const body = await response.json();
error = body.error ?? error;
error_description = body.error_description ?? '';
const parsed = await response.json();
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
body = parsed as Record<string, unknown>;
}
if (typeof body?.error === 'string') {
error = body.error;
}
if (typeof body?.error_description === 'string') {
error_description = body.error_description;
}
} catch {
// response body not JSON
}
Expand All @@ -141,17 +149,17 @@ export class HttpClient {

switch (status) {
case 401:
return new KeitoAuthError(error, error_description, headers);
return new KeitoAuthError(error, error_description, headers, body);
case 403:
return new KeitoForbiddenError(error, error_description, headers);
return new KeitoForbiddenError(error, error_description, headers, body);
case 404:
return new KeitoNotFoundError(error, error_description, headers);
return new KeitoNotFoundError(error, error_description, headers, body);
case 409:
return new KeitoConflictError(error, error_description, headers);
return new KeitoConflictError(error, error_description, headers, body);
case 429:
return new KeitoRateLimitError(error, error_description, headers);
return new KeitoRateLimitError(error, error_description, headers, body);
default:
return new KeitoApiError(status, error, error_description, headers);
return new KeitoApiError(status, error, error_description, headers, body);
}
}

Expand Down
Loading
Loading