Skip to content

Commit bdfcb16

Browse files
lmjabreuclaude
andauthored
feat(status): add tdc status show, set and clear (#77)
* feat(status): add tdc status show, set and clear Needs a @doist/comms-sdk release that includes users.setStatus (Doist/comms-sdk-typescript#100); the dependency bump is not in this commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * build(deps): bump @doist/comms-sdk to 3.4.0 Brings in users.setStatus and users.clearStatus. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(skill): sync SKILL.md with tdc status Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: drop the tdc away lines from the README The command no longer exists. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(status): reject empty expiry values and read date-only --until as local Addresses doistbot review: validate --until/--for presence instead of truthiness, skip markdown preload for status, add --full to set/clear, reuse the dates.ts unit constants, and cover the user Status line, the set text output and fake-timer cleanup in tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(status): validate set input, register the user:write scope, report what the server returned From an offline review of the whole PR: reject impossible dates, past expiries, out-of-range messages and empty emoji before the dry-run branch, require user:write through the scope table, print the server's status after set and clear, add --full to show, and fix the stale AGENTS.md pointer. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(status): reject an option-like value for --emoji Commander reads the next token as the value, so --emoji --dry-run ran the real write. An emoji never starts with a dash. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(status): accept fractional seconds, reject DST gaps, exit 1 when the server disagrees Addresses the second doistbot round: parseLocalDate takes what Date.toISOString() produces, a nonexistent local time in a daylight-saving gap is rejected, clear and set exit 1 whenever the server's status does not match the request (JSON output included), the JSON rendering lives in one helper, and the read-only routing is asserted for both status methods. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(status): accept a date whose midnight is skipped, check the year and offset range, fail through CliError From a revision review of 761dc90: the DST-gap rule rejected a date-only --until on the one day a year when a zone's clocks skip midnight, years 0 to 99 round-tripped to 1900 to 1999, and offsets up to +23:59 were accepted. set and clear now throw INVALID_STATE when the server disagrees, so --json prints the usual stderr envelope and no process.exitCode state leaks. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(status): word the past-expiry hint for datetimes, share formatStatus, cover the curated user JSON From the third doistbot round: the midnight hint now appears only for a date with no time, formatStatus moves to src/lib so tdc user no longer imports from the status folder, and a test pins status in the curated tdc user --json output. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs(status): say pause-notifications is Comms-only for now; explain the no-op error From Scott's review: the help, README and skill text now say the pause holds back Comms notifications only, that each app has to read the status, and that held notifications are not sent later. An agent is told to pass the flag only when asked. The set error now says nothing changed, the usual cause, and what to run next. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(status): pin that --json --full redacts session credentials The status commands print the user through formatJson, which since #78 drops token and cometChannel. This guards the call site. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 3629ae5 commit bdfcb16

24 files changed

Lines changed: 1058 additions & 17 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ This is a TypeScript CLI (`tdc`) for Comms messaging, built with Commander.js.
5858

5959
- **Implicit view subcommand**: `tdc thread <ref>` defaults to `tdc thread view <ref>` via Commander's `{ isDefault: true }`. Same for `conversation` and `msg`. Edge case: if a ref matches a subcommand name (e.g., "reply"), the subcommand wins — user must use `tdc thread view reply`
6060
- **Named flag aliases**: Where commands accept positional `[workspace-ref]`, the `--workspace` flag is also accepted. Error if both positional and flag are provided
61-
- **JSON output on mutating commands**: Mutating commands (create, update, delete, archive) should support `--json` output where it provides scripting value. Commands that return an object from the API (create/update) should also support `--full`. Commands where the API returns void should output a minimal status object (e.g. `{ id, deleted: true }` or `{ id, isArchived: true }`). Extend `MutationOptions` in `src/lib/options.ts` (which already includes `json` and `full`) rather than adding these fields ad hoc. Use `formatJson()` from `src/lib/output.ts` for the output. See `src/commands/away.ts` as the reference implementation.
61+
- **JSON output on mutating commands**: Mutating commands (create, update, delete, archive) should support `--json` output where it provides scripting value. Commands that return an object from the API (create/update) should also support `--full`. Commands where the API returns void should output a minimal status object (e.g. `{ id, deleted: true }` or `{ id, isArchived: true }`). Extend `MutationOptions` in `src/lib/options.ts` (which already includes `json` and `full`) rather than adding these fields ad hoc. Use `formatJson()` from `src/lib/output.ts` for the output. See `src/commands/status/set.ts` as the reference implementation.
6262
- **Spinner messages**: When adding new SDK method calls, add a corresponding entry in the `API_SPINNER_MESSAGES` map in `src/lib/api.ts`. Every user-facing API call should have a spinner message so the CLI shows progress feedback.
6363
- **Batch API responses**: When calling `client.batch(...)`, never access `.data` directly on a batch result. Use these helpers from `src/lib/api.ts`:
6464
- `assertBatchData(response, label)` — single result; throws `CliError` on any failure (including `data: null`). Use for primary data the command can't render without (e.g. the thread itself).

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ This opens Todoist OAuth in your browser. The default grant can read Comms data
6868

6969
All group management — `groups create`, `rename`, `delete`, `add-user`, `remove-user` — needs the `workspaces:write` scope, so it requires `tdc auth login --full-access`. Group reads (`groups`, `groups view`) work on a default login.
7070

71+
Setting or clearing your status (`status set`, `status clear`) needs the `user:write` scope, so it also requires `tdc auth login --full-access`. `tdc status` works on a default login.
72+
73+
`--pause-notifications` holds back Comms notifications only for now: each app has to read the status and hold its own notifications back, and Todoist does not yet. Held notifications are not sent later.
74+
7175
By default, the CLI stores the token in your OS credential manager:
7276

7377
- macOS: Keychain
@@ -142,9 +146,9 @@ tdc msg view <ref> # view a conversation message
142146
tdc search "keyword" # search across workspace
143147
tdc search "keyword" --all # fetch all result pages
144148
tdc react thread <ref> 👍 # add reaction
145-
tdc away # show away status
146-
tdc away set vacation 2026-03-20 # set away until date
147-
tdc away clear # clear away status
149+
tdc status # show your status
150+
tdc status set "Deep work" --emoji 🎯 --for 2h --pause-notifications # set it (--until <iso> for a date)
151+
tdc status clear # clear your status
148152
tdc groups # list groups in a workspace
149153
tdc groups view <ref> # show a group with members
150154
tdc groups create "Frontend" # create a group

‎package-lock.json‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
],
5353
"dependencies": {
5454
"@doist/cli-core": "1.6.0",
55-
"@doist/comms-sdk": "3.3.1",
55+
"@doist/comms-sdk": "3.4.0",
5656
"@pnpm/tabtab": "0.5.4",
5757
"chalk": "5.6.2",
5858
"commander": "14.0.3",

‎skills/comms-cli/SKILL.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ tdc changelog # Show recent changelog entries
5050
tdc migrate urls <urls> # Translate old twist.com URLs to Comms URLs (needs a Twist token)
5151
```
5252

53-
OAuth login uses Todoist OAuth for Comms access. The default grant can read Comms data and create/update content or messages. It does not include delete, channel management, or user/workspace write scopes; use `tdc auth login --full-access` only when needed (all `tdc groups` writes require it). Credential storage defaults to `fallback`: use the system credential manager when available, otherwise warn and save to `~/.config/comms-cli/config.json`. Pass `--credential-store system` to fail instead of writing plaintext, or `--credential-store plaintext` to choose config-file storage explicitly. `COMMS_API_TOKEN` always takes priority over the stored token.
53+
OAuth login uses Todoist OAuth for Comms access. The default grant can read Comms data and create/update content or messages. It does not include delete, channel management, or user/workspace write scopes; use `tdc auth login --full-access` only when needed (all `tdc groups` writes and `tdc status set|clear` require it). Credential storage defaults to `fallback`: use the system credential manager when available, otherwise warn and save to `~/.config/comms-cli/config.json`. Pass `--credential-store system` to fail instead of writing plaintext, or `--credential-store plaintext` to choose config-file storage explicitly. `COMMS_API_TOKEN` always takes priority over the stored token.
5454

5555
In read-only mode (`tdc auth login --read-only`), commands that modify Comms data (reply, archive, react, delete, etc.) are blocked by the CLI. Externally provided tokens (`COMMS_API_TOKEN` or `tdc auth token`) are treated as unknown scope and assumed write-capable.
5656

@@ -253,6 +253,13 @@ tdc users # List active workspace users
253253
tdc users --search <text> # Filter by name/email
254254
tdc users --include-removed # Include users removed from the workspace
255255
tdc users --ids-only # Output only user IDs
256+
tdc status # Show your status (default login is enough)
257+
tdc status --json # Show it as { status } JSON (null when unset; --full for the whole user)
258+
tdc status set "Deep work" --emoji 🎯 --for 2h --pause-notifications # Set it (replaces any current status; needs --full-access)
259+
tdc status set "On holiday" --until 2026-10-20T09:00 # End at a date (YYYY-MM-DD, YYYY-MM-DDTHH:MM or a full ISO 8601 timestamp; local time unless it has Z or an offset)
260+
tdc status set "Away" --dry-run # Preview without setting
261+
tdc status set "Away" --json # Set and return { status } as JSON
262+
tdc status clear # Clear your status (--dry-run, --json supported)
256263
tdc channels # List active joined workspace channels (alias of: tdc channel list)
257264
tdc channels --state all # Include archived joined channels too
258265
tdc channels --scope discoverable # Active public channels you can see but have not joined
@@ -311,6 +318,10 @@ tdc groups remove-user <ref> id:123,id:456 # Comma-separated ID refs
311318

312319
All group *writes* (`groups create`, `rename`, `delete`, `add-user`, `remove-user`) need the `workspaces:write` scope, which only `tdc auth login --full-access` grants. Group *reads* (`groups`, `groups view`) work on a default login.
313320

321+
`tdc status set` and `tdc status clear` need the `user:write` scope, which only `tdc auth login --full-access` grants. `tdc status` (show) works on a default login.
322+
323+
Only pass `--pause-notifications` when the user asks for it. It holds back Comms notifications only for now: each app has to read the status and hold its own notifications back, and Todoist does not yet. Held notifications are not sent later.
324+
314325
If a channel is not found in `tdc channels`, widen with broader listings such as `tdc channels --scope public`, then `tdc channels --scope public --state all`. Check `tdc channels --help` for other available filters.
315326

316327
`tdc channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line.

‎src/commands/status/clear.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getCommsClient } from '../../lib/api.js'
2+
import { CliError } from '../../lib/errors.js'
3+
import type { MutationOptions } from '../../lib/options.js'
4+
import { printDryRun } from '../../lib/output.js'
5+
import { formatStatus } from '../../lib/status.js'
6+
import { formatStatusJson } from './helpers.js'
7+
8+
export async function clearStatus(options: MutationOptions): Promise<void> {
9+
if (options.dryRun) {
10+
printDryRun('clear status')
11+
return
12+
}
13+
14+
const client = await getCommsClient()
15+
const user = await client.users.clearStatus()
16+
17+
if (user.status) {
18+
throw new CliError(
19+
'INVALID_STATE',
20+
`Comms still reports a status: ${formatStatus(user.status)}`,
21+
)
22+
}
23+
24+
console.log(options.json ? formatStatusJson(user, options.full) : 'Status cleared.')
25+
}

‎src/commands/status/helpers.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { User } from '@doist/comms-sdk'
2+
import { formatJson } from '../../lib/output.js'
3+
4+
/** The `{ status }` payload, or the whole user with `--full`, as every status command prints it. */
5+
export function formatStatusJson(user: User, full: boolean | undefined): string {
6+
return full ? formatJson(user, 'user', true) : formatJson({ status: user.status ?? null })
7+
}

‎src/commands/status/index.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Command } from 'commander'
2+
import { clearStatus } from './clear.js'
3+
import { setStatus } from './set.js'
4+
import { showStatus } from './show.js'
5+
6+
export function registerStatusCommand(program: Command): void {
7+
const status = program.command('status').description('Show, set or clear your status')
8+
9+
status
10+
.command('show', { isDefault: true })
11+
.description('Show your current status')
12+
.option('--json', 'Output as JSON')
13+
.option('--full', 'Include all user fields in JSON output')
14+
.addHelpText(
15+
'after',
16+
`
17+
Examples:
18+
tdc status
19+
tdc status --json`,
20+
)
21+
.action(showStatus)
22+
23+
status
24+
.command('set <message>')
25+
.description('Set your status, replacing any current one')
26+
.option('--emoji <emoji>', 'Emoji to show next to the message')
27+
.option(
28+
'--until <datetime>',
29+
'When the status ends: YYYY-MM-DD, YYYY-MM-DDTHH:MM or a full ISO 8601 timestamp. Local time unless it has Z or an offset',
30+
)
31+
.option('--for <duration>', 'How long the status lasts, e.g. 30m, 2h, 1d, 1h30m')
32+
.option(
33+
'--pause-notifications',
34+
'Hold back Comms notifications while the status is active. Comms only for now: each app has to read the status to honour it',
35+
)
36+
.option('--dry-run', 'Show what would be set without setting it')
37+
.option('--json', 'Output as JSON')
38+
.option('--full', 'Include all user fields in JSON output')
39+
.addHelpText(
40+
'after',
41+
`
42+
The message is 1 to 80 characters. The status is shared with your Todoist account.
43+
Setting or clearing it needs the user:write scope, so log in with tdc auth login --full-access.
44+
--pause-notifications only affects Comms for now. Each app has to read the status and hold
45+
its own notifications back, and Todoist does not yet. Held notifications are not sent later.
46+
47+
Examples:
48+
tdc status set "On holiday" --emoji 🏝️ --until 2026-10-20T09:00
49+
tdc status set "Deep work" --emoji 🎯 --for 2h --pause-notifications`,
50+
)
51+
.action(setStatus)
52+
53+
status
54+
.command('clear')
55+
.description('Clear your status')
56+
.option('--dry-run', 'Show what would be cleared without clearing it')
57+
.option('--json', 'Output as JSON')
58+
.option('--full', 'Include all user fields in JSON output')
59+
.action(clearStatus)
60+
}

‎src/commands/status/set.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { SetStatusArgs } from '@doist/comms-sdk'
2+
import { getCommsClient } from '../../lib/api.js'
3+
import { formatDateTime, parseLocalDate } from '../../lib/dates.js'
4+
import { parseDuration } from '../../lib/duration.js'
5+
import { CliError } from '../../lib/errors.js'
6+
import type { MutationOptions } from '../../lib/options.js'
7+
import { printDryRun } from '../../lib/output.js'
8+
import { formatStatus } from '../../lib/status.js'
9+
import { formatStatusJson } from './helpers.js'
10+
11+
const MAX_MESSAGE_LENGTH = 80
12+
13+
type SetStatusOptions = MutationOptions & {
14+
emoji?: string
15+
until?: string
16+
for?: string
17+
pauseNotifications?: boolean
18+
}
19+
20+
function resolveMessage(message: string): string {
21+
const trimmed = message.trim()
22+
const length = [...trimmed].length
23+
if (length < 1 || length > MAX_MESSAGE_LENGTH) {
24+
throw new CliError(
25+
'INVALID_VALUE',
26+
`The message must be 1 to ${MAX_MESSAGE_LENGTH} characters`,
27+
)
28+
}
29+
return trimmed
30+
}
31+
32+
function resolveEmoji(emoji: string | undefined): string | undefined {
33+
if (emoji === undefined) {
34+
return undefined
35+
}
36+
const trimmed = emoji.trim()
37+
if (trimmed === '') {
38+
throw new CliError('INVALID_VALUE', '--emoji cannot be empty')
39+
}
40+
if (trimmed.startsWith('-')) {
41+
throw new CliError('INVALID_VALUE', `--emoji needs an emoji, got ${trimmed}`, [
42+
'A flag after --emoji was read as its value; put the emoji right after --emoji',
43+
])
44+
}
45+
return trimmed
46+
}
47+
48+
function resolveExpiry(options: SetStatusOptions): Date | undefined {
49+
if (options.until !== undefined && options.for !== undefined) {
50+
throw new CliError('CONFLICTING_OPTIONS', 'Cannot use --until and --for together')
51+
}
52+
53+
let expiry: Date | undefined
54+
if (options.until !== undefined) {
55+
expiry = parseLocalDate(options.until) ?? undefined
56+
if (!expiry) {
57+
throw new CliError('INVALID_DATE', `Invalid --until date: ${options.until}`, [
58+
'Use a real date as YYYY-MM-DD or YYYY-MM-DDTHH:MM (local time), or add Z or an offset such as +02:00',
59+
])
60+
}
61+
} else if (options.for !== undefined) {
62+
const duration = parseDuration(options.for)
63+
expiry = duration === null ? undefined : new Date(Date.now() + duration)
64+
if (!expiry || Number.isNaN(expiry.getTime())) {
65+
throw new CliError('INVALID_VALUE', `Invalid --for duration: ${options.for}`, [
66+
'Use minutes, hours or days, e.g. 30m, 2h, 1d or 1h30m',
67+
])
68+
}
69+
}
70+
71+
if (expiry && expiry.getTime() <= Date.now()) {
72+
const dateOnly =
73+
options.until !== undefined && /^\d{4}-\d{2}-\d{2}$/.test(options.until.trim())
74+
throw new CliError('INVALID_DATE', `The status must end in the future: ${options.until}`, [
75+
dateOnly
76+
? 'A date with no time means midnight at its start, so add a time or use a later date'
77+
: 'Use a date or time in the future',
78+
])
79+
}
80+
return expiry
81+
}
82+
83+
export async function setStatus(message: string, options: SetStatusOptions): Promise<void> {
84+
const text = resolveMessage(message)
85+
const emoji = resolveEmoji(options.emoji)
86+
const activeUntil = resolveExpiry(options)
87+
88+
const args: SetStatusArgs = {
89+
message: text,
90+
...(emoji ? { emoji } : {}),
91+
...(activeUntil ? { activeUntil } : {}),
92+
...(options.pauseNotifications ? { effects: { pauseNotifications: {} } } : {}),
93+
}
94+
95+
if (options.dryRun) {
96+
printDryRun('set status', {
97+
Message: text,
98+
Emoji: emoji,
99+
Until: activeUntil ? formatDateTime(activeUntil) : undefined,
100+
'Pause notifications': options.pauseNotifications ? 'yes' : undefined,
101+
})
102+
return
103+
}
104+
105+
const client = await getCommsClient()
106+
const user = await client.users.setStatus(args)
107+
108+
// The server accepted the call but reports nothing active, e.g. an expiry that has already passed.
109+
if (!user.status) {
110+
throw new CliError(
111+
'INVALID_STATE',
112+
'Nothing changed: Comms accepted the request but there is no active status, usually because the expiry has already passed',
113+
[
114+
'Run `tdc status` to see the current status',
115+
'To keep a status active, set a later --until or --for',
116+
],
117+
)
118+
}
119+
120+
console.log(
121+
options.json
122+
? formatStatusJson(user, options.full)
123+
: `Status set: ${formatStatus(user.status)}`,
124+
)
125+
}

‎src/commands/status/show.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getSessionUser } from '../../lib/api.js'
2+
import type { ViewOptions } from '../../lib/options.js'
3+
import { formatStatus } from '../../lib/status.js'
4+
import { formatStatusJson } from './helpers.js'
5+
6+
export async function showStatus(options: ViewOptions): Promise<void> {
7+
const user = await getSessionUser()
8+
9+
if (options.json) {
10+
console.log(formatStatusJson(user, options.full))
11+
return
12+
}
13+
14+
console.log(user.status ? formatStatus(user.status) : 'No status set.')
15+
}

0 commit comments

Comments
 (0)