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
4 changes: 3 additions & 1 deletion CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ New subcommand? Copy a sibling in the target group, wire it in that group's
- **`markdown.ts`** — `preloadMarkdown`, markdown → terminal renderer
- **`errors.ts`** — `CliError(code, message, hints?)`, `ErrorType` union
- **`collaborators.ts`** — `CollaboratorCache`, `formatAssignee`,
`resolveAssigneeId`
`resolveAssigneeId`, `fetchCollaboratorsForProject`, `resolveNotifyIds`
- **`comment-recipients.ts`** — `getDefaultCommentRecipients`: who a new
comment notifies when the caller does not say
- **`global-args.ts`** — `isJsonMode`, `isNdjsonMode`, `isRawMode`,
`isQuiet`, `isAccessible`, progress-jsonl target
- **`logger.ts`** — verbose levels 0–4, `initializeLogger`
Expand Down
4 changes: 4 additions & 0 deletions skills/todoist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ td comment list "Plan sprint"
td comment list "Roadmap" --project
td comment add "Plan sprint" --content "See attached" --file ./report.pdf
td comment add "Plan sprint" --content "See attached" --file ./report.pdf --file-name "Quarterly report.pdf"
td comment add "Plan sprint" --content "@Ana could you review?" --notify "Ana"
td comment add "Plan sprint" --content "Note to self" --no-notify
td comment update id:123 --content "Updated text"
td comment delete id:123 --yes
td comment browse id:123
Expand Down Expand Up @@ -297,6 +299,8 @@ td reminder location get id:456

`td attachment view` prints text attachments directly and encodes binary content as base64. Use `--json` for metadata plus content. Prefer this over `curl` + `Read` on Todoist file URLs — for images in particular, `Read` will try to decode the file through the vision pipeline, and if that fails the image stays pinned in conversation context and every retry hits the same error.

Comments notify only the people `comment add` is handed. Writing "@Ana" in the text notifies nobody — name her with `--notify`. Omit `--notify` to notify whoever the Todoist apps would (the task's assignee, assigner and creator on a first comment, or the previous comment's participants on a reply), or pass `--no-notify` to stay silent. Notification cannot be sent when editing a comment, only when adding one.

`td comment view` flags image attachments with a `Hint` line pointing at `td attachment view`. In `--json` mode the hint is written to stderr so stdout stays parseable — watch the tool output, not just the JSON body.

### Help Center
Expand Down
73 changes: 72 additions & 1 deletion src/commands/comment/add.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import chalk from 'chalk'
import { getApi } from '../../lib/api/core.js'
import { getApi, getCurrentUserId } from '../../lib/api/core.js'
import {
type CollaboratorInfo,
fetchCollaboratorsForProject,
formatUserShortName,
resolveNotifyIds,
} from '../../lib/collaborators.js'
import { getDefaultCommentRecipients } from '../../lib/comment-recipients.js'
import { CliError } from '../../lib/errors.js'
import { isQuiet } from '../../lib/global-args.js'
import { openLocalFileAsBlob } from '../../lib/local-file.js'
Expand All @@ -13,6 +20,9 @@ interface AddOptions {
file?: string
fileName?: string
project?: boolean
// Commander sets this to false for --no-notify, and to the raw
// comma-separated string for --notify.
notify?: string | false
json?: boolean
dryRun?: boolean
}
Expand Down Expand Up @@ -51,6 +61,9 @@ export async function addComment(ref: string, options: AddOptions): Promise<void
'Target type': options.project ? 'project' : 'task',
Content: content.length > 80 ? `${content.slice(0, 80)}...` : content,
File: options.file,
// Printed unresolved: the dry-run deliberately runs before getApi(),
// so no lookup has happened at this point.
Notify: describeNotifyOption(options.notify),
})
return
}
Expand All @@ -59,14 +72,35 @@ export async function addComment(ref: string, options: AddOptions): Promise<void

let targetArgs: { taskId: string } | { projectId: string }
let targetName: string
let targetProjectId: string
if (options.project) {
const project = await resolveProjectRef(api, ref)
targetArgs = { projectId: project.id }
targetName = project.name
targetProjectId = project.id
} else {
const task = await resolveTaskRef(api, ref)
targetArgs = { taskId: task.id }
targetName = task.content
targetProjectId = task.projectId
}

// Held so the confirmation line can name people without a second fetch.
let notifyCollaborators: CollaboratorInfo[] | undefined
let uidsToNotify: string[]
if (options.notify === false) {
uidsToNotify = []
} else if (options.notify) {
const project = await api.getProject(targetProjectId)
notifyCollaborators = await fetchCollaboratorsForProject(api, project)
uidsToNotify = resolveNotifyIds({
refs: options.notify.split(','),
collaborators: notifyCollaborators,
currentUserId: await getCurrentUserId(),
projectName: project.name,
})
} else {
uidsToNotify = await getDefaultCommentRecipients(api, targetArgs, await getCurrentUserId())
}

let attachment:
Expand Down Expand Up @@ -98,6 +132,7 @@ export async function addComment(ref: string, options: AddOptions): Promise<void
...targetArgs,
content,
...(attachment && { attachment }),
...(uidsToNotify.length > 0 && { uidsToNotify }),
})

if (options.json) {
Expand All @@ -114,5 +149,41 @@ export async function addComment(ref: string, options: AddOptions): Promise<void
if (attachment) {
console.log(chalk.dim(`Attached: ${attachment.fileName}`))
}
if (uidsToNotify.length > 0) {
const names = await describeRecipients(
api,
uidsToNotify,
targetProjectId,
notifyCollaborators,
)
console.log(chalk.dim(`Notified: ${names}`))
}
console.log(chalk.dim(`ID: ${comment.id}`))
}

function describeNotifyOption(notify: string | false | undefined): string | undefined {
if (notify === false) return '(nobody)'
if (notify) return notify
return undefined
}

/**
* Render recipients as short names, falling back to the raw ID for anyone the
* collaborator list does not cover — the same fallback `formatAssignee` makes.
*/
async function describeRecipients(
api: Awaited<ReturnType<typeof getApi>>,
userIds: string[],
projectId: string,
known: CollaboratorInfo[] | undefined,
): Promise<string> {
try {
const collaborators =
known ?? (await fetchCollaboratorsForProject(api, await api.getProject(projectId)))
const names = new Map(collaborators.map((c) => [c.id, formatUserShortName(c.name)]))
return userIds.map((id) => names.get(id) ?? id).join(', ')
} catch {
// Naming people is a nicety; never fail a posted comment over it.
return userIds.join(', ')
}
}
Loading
Loading