Skip to content

Commit d0cb0ca

Browse files
ktamas77claude
andcommitted
feat(entries): add edit + delete via CLI and MCP
CLI: timebook entries edit <id> [-d desc] [-t 1h30m] [--start iso] [--end iso] [-p project] [-r rate] timebook entries delete <id> MCP tools: update_entry (id + any combination of fields), delete_entry (id only). delete_entry annotated destructiveHint=true; update_entry destructiveHint=false (overwrite of mutable fields, not data loss). Both surfaces hit the existing PUT /api/time-entries/:id and DELETE /:id, which already enforce the per-token authorship rule (token must have created the entry, or be admin, or be a JWT session). 403s and 404s get friendly messages instead of stack traces. Partial-update flows through naturally: the API wrapper sends only the fields you pass; the backend's conditional spread leaves unset fields untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fbb4ce2 commit d0cb0ca

5 files changed

Lines changed: 264 additions & 0 deletions

File tree

src/commands/delete.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { c } from '../lib/colors.js';
2+
import { api, ApiError } from '../lib/api.js';
3+
4+
/**
5+
* Delete a single time entry. Server enforces:
6+
* - entry isn't locked (i.e., not on a sent invoice)
7+
* - the API token created this entry (or session/admin)
8+
*/
9+
export async function deleteCommand(id: string): Promise<void> {
10+
try {
11+
await api.deleteEntry(id);
12+
console.log(c.green('✓ ') + `Deleted entry ${c.bold(id)}.`);
13+
} catch (err) {
14+
if (err instanceof ApiError && err.status === 403) {
15+
console.error(c.red('✗ ') + err.message);
16+
process.exitCode = 1;
17+
return;
18+
}
19+
if (err instanceof ApiError && err.status === 404) {
20+
console.error(c.red('✗ ') + `Entry ${id} not found.`);
21+
process.exitCode = 1;
22+
return;
23+
}
24+
throw err;
25+
}
26+
}

src/commands/edit.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { c } from '../lib/colors.js';
2+
import { api, ApiError } from '../lib/api.js';
3+
import { parseDuration, formatDuration } from '../lib/format.js';
4+
import { resolveProject } from '../lib/resolve.js';
5+
6+
interface EditOptions {
7+
description?: string;
8+
duration?: string;
9+
start?: string;
10+
end?: string;
11+
project?: string;
12+
rate?: string;
13+
}
14+
15+
/**
16+
* Edit one or more fields on an existing entry. Any combination of flags is
17+
* accepted — fields you don't pass are left as-is by the backend.
18+
*
19+
* Authorship rule (server-enforced): the API token must have created the
20+
* entry, OR the caller is an admin, OR the request is JWT-session (web UI).
21+
* If denied, the backend returns 403 with a helpful hint.
22+
*/
23+
export async function editCommand(id: string, opts: EditOptions): Promise<void> {
24+
if (
25+
opts.description === undefined &&
26+
!opts.duration &&
27+
!opts.start &&
28+
!opts.end &&
29+
!opts.project &&
30+
!opts.rate
31+
) {
32+
throw new Error(
33+
'Nothing to update. Pass at least one of --description, --duration, --start, --end, --project, --rate.',
34+
);
35+
}
36+
37+
const payload: Record<string, unknown> = {};
38+
if (opts.description !== undefined) payload.description = opts.description;
39+
if (opts.duration) payload.duration = parseDuration(opts.duration);
40+
if (opts.start) payload.startTime = new Date(opts.start).toISOString();
41+
if (opts.end) payload.endTime = new Date(opts.end).toISOString();
42+
if (opts.project) {
43+
const project = await resolveProject(opts.project);
44+
payload.projectId = project.id;
45+
}
46+
if (opts.rate) {
47+
const { rates } = await api.listRates();
48+
const found = rates.find((r) => r.id === opts.rate || r.name === opts.rate);
49+
if (!found) throw new Error(`Rate not found: ${opts.rate}`);
50+
payload.rateId = found.id;
51+
}
52+
53+
try {
54+
const { entry } = await api.updateEntry(id, payload);
55+
const minutes = entry.duration ?? 0;
56+
const projectName = entry.project?.name ?? entry.projectId;
57+
console.log(c.green('✓ ') + `Updated ${c.bold(projectName)}${formatDuration(minutes)}`);
58+
if (entry.description) console.log(c.dim(` Note: ${entry.description}`));
59+
} catch (err) {
60+
if (err instanceof ApiError && err.status === 403) {
61+
console.error(c.red('✗ ') + err.message);
62+
process.exitCode = 1;
63+
return;
64+
}
65+
if (err instanceof ApiError && err.status === 404) {
66+
console.error(c.red('✗ ') + `Entry ${id} not found.`);
67+
process.exitCode = 1;
68+
return;
69+
}
70+
throw err;
71+
}
72+
}

src/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { startCommand } from './commands/start.js';
1313
import { stopCommand } from './commands/stop.js';
1414
import { logCommand } from './commands/log.js';
1515
import { listClientsCommand, listEntriesCommand, listProjectsCommand } from './commands/list.js';
16+
import { editCommand } from './commands/edit.js';
17+
import { deleteCommand } from './commands/delete.js';
1618
import { runMcpServer } from './mcp/server.js';
1719

1820
async function readVersion(): Promise<string> {
@@ -197,6 +199,48 @@ async function main(): Promise<void> {
197199
},
198200
);
199201

202+
entries
203+
.command('edit <id>')
204+
.description(
205+
'Edit one or more fields on an entry. Any combination is valid; unset fields are left as-is.',
206+
)
207+
.option('-d, --description <text>', 'Description / note (pass empty string to clear)')
208+
.option('-t, --duration <e.g. 1h30m>', 'Duration')
209+
.option('--start <iso>', 'Start time (ISO-8601)')
210+
.option('--end <iso>', 'End time (ISO-8601)')
211+
.option('-p, --project <idOrName>', 'Reassign to another project')
212+
.option('-r, --rate <idOrName>', 'Switch billable rate')
213+
.action(
214+
async (
215+
id: string,
216+
opts: {
217+
description?: string;
218+
duration?: string;
219+
start?: string;
220+
end?: string;
221+
project?: string;
222+
rate?: string;
223+
},
224+
) => {
225+
try {
226+
await editCommand(id, opts);
227+
} catch (err) {
228+
fail(err);
229+
}
230+
},
231+
);
232+
233+
entries
234+
.command('delete <id>')
235+
.description('Delete a time entry. Token must own it (or use the web UI / admin).')
236+
.action(async (id: string) => {
237+
try {
238+
await deleteCommand(id);
239+
} catch (err) {
240+
fail(err);
241+
}
242+
});
243+
200244
program
201245
.command('mcp')
202246
.description('Run as an MCP server (stdio) for AI agents like Claude or Codex.')

src/lib/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ export const api = {
157157
invoiced?: boolean;
158158
} = {},
159159
) => request<{ entries: TimeEntry[] }>('/api/time-entries', { query }),
160+
updateEntry: (
161+
id: string,
162+
input: Partial<{
163+
projectId: string;
164+
description: string | null;
165+
startTime: string;
166+
endTime: string;
167+
duration: number;
168+
rateId: string | null;
169+
}>,
170+
) =>
171+
request<{ entry: TimeEntry }>(`/api/time-entries/${encodeURIComponent(id)}`, {
172+
method: 'PUT',
173+
body: input,
174+
}),
175+
deleteEntry: (id: string) =>
176+
request<{ message: string }>(`/api/time-entries/${encodeURIComponent(id)}`, {
177+
method: 'DELETE',
178+
}),
160179
};
161180

162181
// For login flow: validate a freshly-obtained token before storing it.

src/mcp/server.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,29 @@ const listEntriesInput = z.object({
4646
limit: z.number().int().min(1).max(500).optional(),
4747
});
4848

49+
const updateEntryInput = z.object({
50+
id: z.string().describe('Entry id (uuid) returned by list_entries.'),
51+
description: z
52+
.string()
53+
.nullable()
54+
.optional()
55+
.describe('Set the entry note. Pass an empty string or null to clear.'),
56+
duration: z
57+
.string()
58+
.optional()
59+
.describe(
60+
'New duration, e.g. "1h30m" or "45m". If set without start/end, only duration moves.',
61+
),
62+
startTime: z.string().optional().describe('New start time (ISO-8601).'),
63+
endTime: z.string().optional().describe('New end time (ISO-8601).'),
64+
project: z.string().optional().describe('Reassign the entry to another project (id or name).'),
65+
rate: z.string().optional().describe('Switch billable rate (id or name).'),
66+
});
67+
68+
const deleteEntryInput = z.object({
69+
id: z.string().describe('Entry id (uuid) to delete.'),
70+
});
71+
4972
const TOOLS: Tool[] = [
5073
{
5174
name: 'whoami',
@@ -188,6 +211,56 @@ const TOOLS: Tool[] = [
188211
},
189212
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
190213
},
214+
{
215+
name: 'update_entry',
216+
description:
217+
'Edit one or more fields on an existing time entry. Any combination is valid; unset fields are left as-is. Server-enforced authorship rule: this token can only edit entries it created itself (sessions and admins bypass).',
218+
inputSchema: {
219+
type: 'object',
220+
properties: {
221+
id: { type: 'string', description: 'Entry id (uuid).' },
222+
description: {
223+
type: ['string', 'null'],
224+
description: 'New description / note. Pass empty string or null to clear.',
225+
},
226+
duration: {
227+
type: 'string',
228+
description: 'New duration, e.g. "1h30m" or "45m".',
229+
},
230+
startTime: { type: 'string', description: 'ISO-8601 start time.' },
231+
endTime: { type: 'string', description: 'ISO-8601 end time.' },
232+
project: { type: 'string', description: 'Reassign — project id or name.' },
233+
rate: { type: 'string', description: 'New rate — id or name.' },
234+
},
235+
required: ['id'],
236+
additionalProperties: false,
237+
},
238+
annotations: {
239+
readOnlyHint: false,
240+
destructiveHint: false,
241+
idempotentHint: false,
242+
openWorldHint: true,
243+
},
244+
},
245+
{
246+
name: 'delete_entry',
247+
description:
248+
'Delete a time entry. Server enforces: not invoiced, and either this token created it or the caller is an admin / web session.',
249+
inputSchema: {
250+
type: 'object',
251+
properties: {
252+
id: { type: 'string', description: 'Entry id (uuid).' },
253+
},
254+
required: ['id'],
255+
additionalProperties: false,
256+
},
257+
annotations: {
258+
readOnlyHint: false,
259+
destructiveHint: true,
260+
idempotentHint: true,
261+
openWorldHint: true,
262+
},
263+
},
191264
];
192265

193266
type ToolResult = CallToolResult;
@@ -303,6 +376,36 @@ async function handleTool(name: string, args: unknown): Promise<ToolResult> {
303376
const limit = input.limit ?? DEFAULT_ENTRY_LIMIT;
304377
return ok(entries.slice(0, limit));
305378
}
379+
case 'update_entry': {
380+
const input = updateEntryInput.parse(args ?? {});
381+
const payload: Record<string, unknown> = {};
382+
if (input.description !== undefined) payload.description = input.description;
383+
if (input.duration) payload.duration = parseDuration(input.duration);
384+
if (input.startTime) payload.startTime = new Date(input.startTime).toISOString();
385+
if (input.endTime) payload.endTime = new Date(input.endTime).toISOString();
386+
if (input.project) {
387+
const project = await resolveProject(input.project);
388+
payload.projectId = project.id;
389+
}
390+
if (input.rate) {
391+
const { rates } = await api.listRates();
392+
const found = rates.find((r) => r.id === input.rate || r.name === input.rate);
393+
if (!found) return err(`Rate not found: ${input.rate}`);
394+
payload.rateId = found.id;
395+
}
396+
if (Object.keys(payload).length === 0) {
397+
return err(
398+
'Nothing to update. Pass at least one of description, duration, startTime, endTime, project, rate.',
399+
);
400+
}
401+
const result = await api.updateEntry(input.id, payload);
402+
return ok(result);
403+
}
404+
case 'delete_entry': {
405+
const input = deleteEntryInput.parse(args ?? {});
406+
const result = await api.deleteEntry(input.id);
407+
return ok(result);
408+
}
306409
default:
307410
return err(`Unknown tool: ${name}`);
308411
}

0 commit comments

Comments
 (0)