diff --git a/skills/todoist-cli/SKILL.md b/skills/todoist-cli/SKILL.md index 925c62b4..523891aa 100644 --- a/skills/todoist-cli/SKILL.md +++ b/skills/todoist-cli/SKILL.md @@ -275,7 +275,9 @@ td notification read --all --yes td reminder list "Plan sprint" td reminder list --type time td reminder add "Plan sprint" --before 30m +td reminder add "Plan sprint" --at "2026-06-01 09:00" --urgent # iOS full-screen alarm td reminder update id:123 --before 1h +td reminder update id:123 --no-urgent # toggle urgency without changing time td reminder delete id:123 --yes td reminder get id:123 td reminder location add "Plan sprint" --name "Office" --lat 40.7128 --long -74.0060 --trigger on_enter --radius 100 # radius in meters diff --git a/src/commands/reminder/add.ts b/src/commands/reminder/add.ts index 0f4738ed..8a3a4828 100644 --- a/src/commands/reminder/add.ts +++ b/src/commands/reminder/add.ts @@ -10,11 +10,12 @@ import { CliError } from '../../lib/errors.js' import { isQuiet } from '../../lib/global-args.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveTaskRef } from '../../lib/refs.js' -import { parseDateTime } from './helpers.js' +import { formatUrgentBadge, parseDateTime } from './helpers.js' interface AddOptions { before?: string at?: string + urgent?: boolean json?: boolean dryRun?: boolean } @@ -69,6 +70,7 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise Task: task.content, Before: options.before, At: options.at, + Urgent: options.urgent === undefined ? undefined : String(options.urgent), }) return } @@ -77,6 +79,7 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise itemId: task.id, minuteOffset, due, + isUrgent: options.urgent, }) if (options.json) { @@ -86,6 +89,7 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise type: minuteOffset !== undefined ? 'relative' : 'absolute', minuteOffset, due, + isUrgent: options.urgent, isDeleted: false, } console.log(formatJson(reminder, 'reminder')) @@ -97,10 +101,11 @@ export async function addReminder(taskRef: string, options: AddOptions): Promise return } + const urgent = formatUrgentBadge(options.urgent) if (minuteOffset !== undefined) { - console.log(`Added reminder: ${formatDuration(minuteOffset)} before due`) + console.log(`Added reminder: ${formatDuration(minuteOffset)} before due${urgent}`) } else if (due) { - console.log(`Added reminder: at ${due.date.replace('T', ' ')}`) + console.log(`Added reminder: at ${due.date.replace('T', ' ')}${urgent}`) } console.log(chalk.dim(`ID: ${reminderId}`)) } diff --git a/src/commands/reminder/get.ts b/src/commands/reminder/get.ts index bdf2543a..0f45eb01 100644 --- a/src/commands/reminder/get.ts +++ b/src/commands/reminder/get.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import { getReminderById } from '../../lib/api/reminders.js' import { formatJson } from '../../lib/output.js' import { lenientIdRef } from '../../lib/refs.js' -import { formatReminderTime } from './helpers.js' +import { type TimeReminder, formatReminderTime, formatUrgentBadge } from './helpers.js' interface GetOptions { json?: boolean @@ -11,7 +11,7 @@ interface GetOptions { export async function getReminderCmd(reminderId: string, options: GetOptions): Promise { const id = lenientIdRef(reminderId, 'reminder') - const reminder = await getReminderById(id) + const reminder = (await getReminderById(id)) as TimeReminder if (options.json) { console.log(formatJson(reminder, 'reminder', options.full)) @@ -20,13 +20,8 @@ export async function getReminderCmd(reminderId: string, options: GetOptions): P const idStr = chalk.dim(reminder.id) const type = chalk.cyan('[time]') - const time = formatReminderTime( - reminder as { - type: 'relative' | 'absolute' - minuteOffset?: number - due?: { date: string } - }, - ) - console.log(`${idStr} ${type} ${time}`) + const time = formatReminderTime(reminder) + const urgent = formatUrgentBadge(reminder.isUrgent) + console.log(`${idStr} ${type}${urgent} ${time}`) console.log(chalk.dim(`Task: ${reminder.itemId}`)) } diff --git a/src/commands/reminder/helpers.ts b/src/commands/reminder/helpers.ts index a7cac762..acdfd2ac 100644 --- a/src/commands/reminder/helpers.ts +++ b/src/commands/reminder/helpers.ts @@ -4,16 +4,26 @@ import { type LocationTrigger, type Reminder, } from '@doist/todoist-sdk' +import chalk from 'chalk' import type { ReminderDue } from '../../lib/api/reminders.js' import { formatDuration } from '../../lib/duration.js' import { CliError } from '../../lib/errors.js' export type ReminderTypeFilter = 'time' | 'location' +// `td reminder get` and time-only `list` rows never return location reminders, +// so we narrow the SDK union once and reuse it across both commands. +export type TimeReminder = Extract + interface ReminderLike { type: Reminder['type'] minuteOffset?: number due?: { date: string } + isUrgent?: boolean +} + +export function formatUrgentBadge(isUrgent: boolean | undefined): string { + return isUrgent ? ` ${chalk.red('[urgent]')}` : '' } export function formatReminderTime(reminder: ReminderLike): string { diff --git a/src/commands/reminder/index.ts b/src/commands/reminder/index.ts index dc551044..0d010355 100644 --- a/src/commands/reminder/index.ts +++ b/src/commands/reminder/index.ts @@ -57,6 +57,8 @@ export function registerReminderCommand(program: Command): void { .option('--task ', 'Task reference (name or id:xxx)') .option('--before ', 'Time before due (e.g., 30m, 1h)') .option('--at ', 'Specific time (e.g., 2024-01-15 10:00)') + .option('--urgent', 'Mark reminder as urgent (iOS full-screen alarm)') + .option('--no-urgent', 'Mark reminder as not urgent') .option('--json', 'Output the created reminder as JSON') .option('--dry-run', 'Preview what would happen without executing') .action( @@ -65,6 +67,7 @@ export function registerReminderCommand(program: Command): void { options: { before?: string at?: string + urgent?: boolean json?: boolean dryRun?: boolean task?: string @@ -90,6 +93,9 @@ export function registerReminderCommand(program: Command): void { .description('Update a reminder') .option('--before ', 'Time before due (e.g., 30m, 1h)') .option('--at ', 'Specific time (e.g., 2024-01-15 10:00)') + .option('--urgent', 'Mark reminder as urgent (iOS full-screen alarm)') + .option('--no-urgent', 'Mark reminder as not urgent') + .option('--json', 'Output the updated reminder as JSON') .option('--dry-run', 'Preview what would happen without executing') .action((id, options) => { if (!id) { diff --git a/src/commands/reminder/list.ts b/src/commands/reminder/list.ts index b9362650..16c6e701 100644 --- a/src/commands/reminder/list.ts +++ b/src/commands/reminder/list.ts @@ -8,8 +8,10 @@ import { paginate } from '../../lib/pagination.js' import { resolveTaskRef } from '../../lib/refs.js' import { type ReminderTypeFilter, + type TimeReminder, formatLocationReminderRow, formatReminderTime, + formatUrgentBadge, } from './helpers.js' interface ListOptions extends PaginatedViewOptions { @@ -117,12 +119,13 @@ export async function listReminders( const showTask = !taskId - for (const reminder of reminders) { + for (const reminder of reminders as TimeReminder[]) { const id = chalk.dim(reminder.id) const type = chalk.cyan('[time]') const time = formatReminderTime(reminder) + const urgent = formatUrgentBadge(reminder.isUrgent) const task = showTask ? chalk.dim(` (task:${reminder.itemId})`) : '' - console.log(`${id} ${type} ${time}${task}`) + console.log(`${id} ${type}${urgent} ${time}${task}`) } for (const loc of locationReminders) { diff --git a/src/commands/reminder/reminder.test.ts b/src/commands/reminder/reminder.test.ts index bba97248..3396c2d5 100644 --- a/src/commands/reminder/reminder.test.ts +++ b/src/commands/reminder/reminder.test.ts @@ -298,6 +298,32 @@ describe('reminder list', () => { ).rejects.toThrow('--cursor requires --type') }) + it('shows [urgent] badge for urgent reminders', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockApi.getReminders.mockResolvedValue({ + results: [ + { + id: 'rem-1', + notifyUid: 'user-1', + itemId: 'task-1', + type: 'relative', + minuteOffset: 30, + isUrgent: true, + isDeleted: false, + }, + ], + nextCursor: null, + }) + mockApi.getLocationReminders.mockResolvedValue({ results: [], nextCursor: null }) + + await program.parseAsync(['node', 'td', 'reminder', 'list']) + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]')) + consoleSpy.mockRestore() + }) + it('does not show task context when filtered by task', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -616,6 +642,31 @@ describe('reminder add', () => { ]), ).rejects.toThrow('Cannot specify task both as argument and --task flag') }) + + it('passes --urgent through and shows [urgent] in output', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockApi.getTask.mockResolvedValue({ id: 'task-1', content: 'Buy milk' }) + mockAddReminder.mockResolvedValue('rem-new') + + await program.parseAsync([ + 'node', + 'td', + 'reminder', + 'add', + 'id:task-1', + '--at', + '2024-01-15 10:00', + '--urgent', + ]) + + expect(mockAddReminder).toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'task-1', isUrgent: true }), + ) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]')) + consoleSpy.mockRestore() + }) }) describe('reminder update', () => { @@ -678,6 +729,77 @@ describe('reminder update', () => { ]), ).rejects.toHaveProperty('code', 'INVALID_REF') }) + + it('toggles urgency alone without --before or --at', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockUpdateReminder.mockResolvedValue(undefined) + + await program.parseAsync(['node', 'td', 'reminder', 'update', 'id:rem-1', '--no-urgent']) + + expect(mockUpdateReminder).toHaveBeenCalledWith( + 'rem-1', + expect.objectContaining({ isUrgent: false }), + ) + expect(consoleSpy).toHaveBeenCalledWith('Updated reminder: marked not urgent (id:rem-1)') + consoleSpy.mockRestore() + }) + + it('shows [urgent] in confirmation when time and --urgent are combined', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockUpdateReminder.mockResolvedValue(undefined) + + await program.parseAsync([ + 'node', + 'td', + 'reminder', + 'update', + 'id:rem-1', + '--before', + '1h', + '--urgent', + ]) + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1h before due')) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]')) + consoleSpy.mockRestore() + }) + + it('outputs JSON with --json', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockUpdateReminder.mockResolvedValue(undefined) + mockGetReminderById.mockResolvedValue({ + id: 'rem-1', + notifyUid: 'user-1', + itemId: 'task-1', + type: 'relative', + minuteOffset: 60, + isUrgent: true, + isDeleted: false, + // biome-ignore lint/suspicious/noExplicitAny: mock + } as any) + + await program.parseAsync([ + 'node', + 'td', + 'reminder', + 'update', + 'id:rem-1', + '--urgent', + '--json', + ]) + + const firstCall = consoleSpy.mock.calls[0]?.[0] as string + const parsed = JSON.parse(firstCall) + expect(parsed.id).toBe('rem-1') + expect(parsed.isUrgent).toBe(true) + consoleSpy.mockRestore() + }) }) describe('reminder delete', () => { @@ -876,6 +998,49 @@ describe('reminder get', () => { program.parseAsync(['node', 'td', 'reminder', 'get', 'not-a-valid-id!']), ).rejects.toMatchObject({ code: 'INVALID_REF' }) }) + + it('shows [urgent] badge for urgent reminders', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockGetReminderById.mockResolvedValue({ + id: 'rem-1', + notifyUid: 'user-1', + itemId: 'task-1', + type: 'relative', + minuteOffset: 30, + isUrgent: true, + isDeleted: false, + // biome-ignore lint/suspicious/noExplicitAny: mock + } as any) + + await program.parseAsync(['node', 'td', 'reminder', 'get', 'id:rem-1']) + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[urgent]')) + consoleSpy.mockRestore() + }) + + it('includes isUrgent in default --json output', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + mockGetReminderById.mockResolvedValue({ + id: 'rem-1', + notifyUid: 'user-1', + itemId: 'task-1', + type: 'relative', + minuteOffset: 30, + isUrgent: true, + isDeleted: false, + // biome-ignore lint/suspicious/noExplicitAny: mock + } as any) + + await program.parseAsync(['node', 'td', 'reminder', 'get', 'id:rem-1', '--json']) + + const firstCall = consoleSpy.mock.calls[0]?.[0] as string + expect(firstCall).toContain('"isUrgent": true') + consoleSpy.mockRestore() + }) }) describe('reminder location add', () => { diff --git a/src/commands/reminder/update.ts b/src/commands/reminder/update.ts index bf5d9846..07095f87 100644 --- a/src/commands/reminder/update.ts +++ b/src/commands/reminder/update.ts @@ -1,20 +1,26 @@ -import { updateReminder as apiUpdateReminder, type ReminderDue } from '../../lib/api/reminders.js' +import { + updateReminder as apiUpdateReminder, + getReminderById, + type ReminderDue, +} from '../../lib/api/reminders.js' import { formatDuration, parseDuration } from '../../lib/duration.js' import { CliError } from '../../lib/errors.js' import { isQuiet } from '../../lib/global-args.js' -import { printDryRun } from '../../lib/output.js' +import { formatJson, printDryRun } from '../../lib/output.js' import { lenientIdRef } from '../../lib/refs.js' -import { parseDateTime } from './helpers.js' +import { formatUrgentBadge, parseDateTime } from './helpers.js' interface UpdateOptions { before?: string at?: string + urgent?: boolean + json?: boolean dryRun?: boolean } export async function updateReminderCmd(reminderId: string, options: UpdateOptions): Promise { - if (!options.before && !options.at) { - throw new CliError('MISSING_TIME', 'Must specify --before or --at') + if (!options.before && !options.at && options.urgent === undefined) { + throw new CliError('MISSING_TIME', 'Must specify --before, --at, --urgent, or --no-urgent') } if (options.before && options.at) { @@ -28,6 +34,7 @@ export async function updateReminderCmd(reminderId: string, options: UpdateOptio ID: id, Before: options.before, At: options.at, + Urgent: options.urgent === undefined ? undefined : String(options.urgent), }) return } @@ -49,13 +56,25 @@ export async function updateReminderCmd(reminderId: string, options: UpdateOptio due = parseDateTime(options.at) } - await apiUpdateReminder(id, { minuteOffset, due }) + await apiUpdateReminder(id, { minuteOffset, due, isUrgent: options.urgent }) + + if (options.json) { + const reminder = await getReminderById(id) + console.log(formatJson(reminder, 'reminder')) + return + } if (!isQuiet()) { + const urgent = formatUrgentBadge(options.urgent) if (minuteOffset !== undefined) { - console.log(`Updated reminder: ${formatDuration(minuteOffset)} before due (id:${id})`) + console.log( + `Updated reminder: ${formatDuration(minuteOffset)} before due${urgent} (id:${id})`, + ) } else if (due) { - console.log(`Updated reminder: at ${due.date.replace('T', ' ')} (id:${id})`) + console.log(`Updated reminder: at ${due.date.replace('T', ' ')}${urgent} (id:${id})`) + } else if (options.urgent !== undefined) { + const state = options.urgent ? 'urgent' : 'not urgent' + console.log(`Updated reminder: marked ${state} (id:${id})`) } } } diff --git a/src/lib/api/reminders.ts b/src/lib/api/reminders.ts index d13eac98..73c9750a 100644 --- a/src/lib/api/reminders.ts +++ b/src/lib/api/reminders.ts @@ -20,6 +20,7 @@ export interface Reminder { type: 'absolute' | 'relative' | 'location' due?: ReminderDue minuteOffset?: number + isUrgent?: boolean isDeleted: boolean } @@ -31,6 +32,7 @@ function toReminder(r: SdkReminder): Reminder { due: 'due' in r && r.due ? (r.due as ReminderDue) : undefined, minuteOffset: 'minuteOffset' in r ? (r as { minuteOffset: number }).minuteOffset : undefined, + isUrgent: 'isUrgent' in r ? (r.isUrgent as boolean | undefined) : undefined, isDeleted: r.isDeleted, } } @@ -53,6 +55,7 @@ export interface AddReminderArgs { itemId: string minuteOffset?: number due?: ReminderDue + isUrgent?: boolean } export async function addReminder(args: AddReminderArgs): Promise { @@ -70,6 +73,7 @@ export async function addReminder(args: AddReminderArgs): Promise { ...pickDefined({ minuteOffset: args.minuteOffset, due: args.due, + isUrgent: args.isUrgent, }), }, tempId, @@ -82,11 +86,26 @@ export async function addReminder(args: AddReminderArgs): Promise { export interface UpdateReminderArgs { minuteOffset?: number due?: ReminderDue + isUrgent?: boolean } export async function updateReminder(id: string, args: UpdateReminderArgs): Promise { const api = await getApi() - const type = args.minuteOffset !== undefined ? ('relative' as const) : ('absolute' as const) + // The sync command's `type` is a discriminated literal, so for urgency-only + // patches we read the existing reminder to preserve its type — otherwise a + // relative reminder would silently be re-tagged as absolute. + let type: 'absolute' | 'relative' + if (args.minuteOffset !== undefined) { + type = 'relative' + } else if (args.due !== undefined) { + type = 'absolute' + } else { + const existing = await api.getReminder(id) + if (existing.type !== 'absolute' && existing.type !== 'relative') { + throw new Error(`Cannot update non-time reminder ${id} via 'reminder update'`) + } + type = existing.type + } await api.sync({ commands: [ createCommand('reminder_update', { @@ -95,6 +114,7 @@ export async function updateReminder(id: string, args: UpdateReminderArgs): Prom ...pickDefined({ minuteOffset: args.minuteOffset, due: args.due, + isUrgent: args.isUrgent, }), }), ], diff --git a/src/lib/output.ts b/src/lib/output.ts index 73554d7c..b85d8eb3 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -207,7 +207,14 @@ const COMMENT_ESSENTIAL_FIELDS = [ 'fileAttachment', 'hasAttachment', ] as const -const REMINDER_ESSENTIAL_FIELDS = ['id', 'itemId', 'type', 'due', 'minuteOffset'] as const +const REMINDER_ESSENTIAL_FIELDS = [ + 'id', + 'itemId', + 'type', + 'due', + 'minuteOffset', + 'isUrgent', +] as const const LOCATION_REMINDER_ESSENTIAL_FIELDS = [ 'id', 'itemId', diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 33885154..39a005c7 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -271,7 +271,9 @@ td notification read --all --yes td reminder list "Plan sprint" td reminder list --type time td reminder add "Plan sprint" --before 30m +td reminder add "Plan sprint" --at "2026-06-01 09:00" --urgent # iOS full-screen alarm td reminder update id:123 --before 1h +td reminder update id:123 --no-urgent # toggle urgency without changing time td reminder delete id:123 --yes td reminder get id:123 td reminder location add "Plan sprint" --name "Office" --lat 40.7128 --long -74.0060 --trigger on_enter --radius 100 # radius in meters