/jrtodo <task>: capture a task instantly, with no model call, to TODO.md, Todoist, Linear, Notion, a GitHub issue, Things, Apple Reminders, or your own command.

Capture a task the moment you think of it, without leaving Claude Code and without spending a model call.
/jrtodo renew the domain before Friday
jr todo · ✓ "renew the domain before Friday" → TODO.md
Pick one in /config → jr-todo → Where tasks go.
| Target | Where the task lands | What it needs |
|---|---|---|
markdown (default) | - [ ] task appended to TODO.md at the project root, or the file you set | nothing |
todoist | Your Todoist inbox (API v1) | API token |
linear | A new issue in a Linear team | Personal API key + team id |
notion | A new page in a Notion database | Integration token + database id (+ title column name, default Name) |
github | A new issue in the current repository, via gh | gh signed in |
things | Things 3 inbox (macOS) | Things installed |
reminders | Apple Reminders, default list (macOS) | Allow access the first time |
custom | Any command you set, with {task} where the task goes | the command |
Tokens and keys are stored as sensitive settings, never in plain settings files.
customThe task is handed to your command as one quoted argument, so its text is never run as shell code. For example:
custom at a Google Tasks command-line tool or script you already have signed in, such as my-gtasks add {task}curl -s -X POST https://example.com/hook -d "task={task}"/Users/you/bin/add-task {task}| Command | |
|---|---|
/jrtodo <task> | File the task in the configured target |
When a target refuses the task (bad token, missing team), nothing is saved and the reason is shown.
/plugin install jr-todo --marketplace jrlprost/claude-code-mods
Depending on the target you choose: appends to one markdown file; or sends the task text to Todoist, Linear or Notion with your token; or runs gh issue create, open things:///…, osascript (Reminders) or your own command. Nothing happens until you type /jrtodo.
hooks/register.ts 63 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { appendLine, httpFailure, optionsOf, planFor } from './targets'
4import type { TodoOptions } from './targets'
5
6const commands = { isRegistered: false }
7
8async function ensureCommand($: EngineInterface) {
9 if (commands.isRegistered) return
10 commands.isRegistered = true
11 await $.command.register({ name: 'jrtodo', description: 'Capture a task instantly, no model call: /jrtodo <task>' })
12}
13
14/** Files the task where the settings say; returns the line shown to you. */
15async function capture($: EngineInterface, task: string, o: TodoOptions) {
16 if (!task) return 'jr todo · usage: /jrtodo <task>'
17 const root = await $.session.root()
18 const plan = planFor(task, o, root)
19 if (plan.kind === 'missing') return `jr todo · the ${o.target} target needs ${plan.what}: set it in /config`
20 try {
21 if (plan.kind === 'append') {
22 let current = ''
23 try {
24 current = await $.fs.read(plan.file)
25 } catch {
26 current = ''
27 }
28 await $.fs.write(plan.file, appendLine(current, plan.line))
29 } else if (plan.kind === 'http') {
30 const res = await $.http.fetch(plan.url, plan.init)
31 const failure = httpFailure(res.status, res.ok, res.text)
32 if (failure) return `jr todo · ${plan.describe} refused the task (${failure}); nothing was saved`
33 } else {
34 const res = await $.process.run(plan.argv, { cwd: root, timeoutMs: 20_000 })
35 if (res.exitCode !== 0) return `jr todo · ${plan.describe} failed (exit ${res.exitCode}): ${res.stderr.trim().slice(0, 160)}`
36 }
37 } catch (error) {
38 return `jr todo · could not reach ${plan.describe}: ${String(error).slice(0, 160)}`
39 }
40 return `jr todo · ✓ "${task}" → ${plan.describe}`
41}
42
43export const register: Register = (on, options) => {
44 const o = optionsOf(options)
45
46 on('session.start', async ($, e, next) => {
47 const started = await next(e)
48 await ensureCommand($)
49 return started
50 })
51
52 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
53 if (!commands.isRegistered) {
54 $.clock.after(0, () => {
55 void ensureCommand($)
56 })
57 }
58 return next(e)
59 })
60
61 on('command.run', { command: 'jrtodo' }, async ($, e) => ({ text: await capture($, e.args.trim(), o) }))
62}
63hooks/targets.ts 118 lines1// Where /jrtodo sends a task, and how. Every target is built here as plain data, so it can be tested
2// without the network; register.ts carries it out.
3
4export const TARGETS = ['markdown', 'todoist', 'linear', 'notion', 'github', 'things', 'reminders', 'custom'] as const
5export type Target = (typeof TARGETS)[number]
6
7export type TodoOptions = {
8 target: Target
9 file: string
10 todoistToken: string
11 linearKey: string
12 linearTeamId: string
13 notionToken: string
14 notionDatabaseId: string
15 notionTitleProperty: string
16 command: string
17}
18
19export type Plan =
20 | { kind: 'http'; url: string; init: { method: 'POST'; headers: Record<string, string>; body: string }; describe: string }
21 | { kind: 'process'; argv: string[]; describe: string }
22 | { kind: 'append'; file: string; line: string; describe: string }
23 | { kind: 'missing'; what: string }
24
25export const optionsOf = (raw: Readonly<Record<string, unknown>>): TodoOptions => ({
26 target: TARGETS.includes(raw.target as Target) ? (raw.target as Target) : 'markdown',
27 file: String(raw.file ?? ''),
28 todoistToken: String(raw.todoistToken ?? ''),
29 linearKey: String(raw.linearKey ?? ''),
30 linearTeamId: String(raw.linearTeamId ?? ''),
31 notionToken: String(raw.notionToken ?? ''),
32 notionDatabaseId: String(raw.notionDatabaseId ?? ''),
33 notionTitleProperty: String(raw.notionTitleProperty || 'Name'),
34 command: String(raw.command ?? ''),
35})
36
37/** The plan to file `task` in the configured target. `root` is the project's root folder. */
38export const planFor = (task: string, o: TodoOptions, root: string): Plan => {
39 switch (o.target) {
40 case 'todoist':
41 if (!o.todoistToken) return { kind: 'missing', what: 'a Todoist API token' }
42 return {
43 kind: 'http',
44 url: 'https://api.todoist.com/api/v1/tasks',
45 init: { method: 'POST', headers: { Authorization: `Bearer ${o.todoistToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content: task }) },
46 describe: 'Todoist inbox',
47 }
48 case 'linear':
49 if (!o.linearKey || !o.linearTeamId) return { kind: 'missing', what: 'a Linear API key and team id' }
50 return {
51 kind: 'http',
52 url: 'https://api.linear.app/graphql',
53 init: {
54 method: 'POST',
55 headers: { Authorization: o.linearKey, 'Content-Type': 'application/json' },
56 body: JSON.stringify({
57 query: 'mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier } } }',
58 variables: { input: { teamId: o.linearTeamId, title: task } },
59 }),
60 },
61 describe: 'Linear',
62 }
63 case 'notion':
64 if (!o.notionToken || !o.notionDatabaseId) return { kind: 'missing', what: 'a Notion token and database id' }
65 return {
66 kind: 'http',
67 url: 'https://api.notion.com/v1/pages',
68 init: {
69 method: 'POST',
70 headers: { Authorization: `Bearer ${o.notionToken}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' },
71 body: JSON.stringify({
72 parent: { database_id: o.notionDatabaseId },
73 properties: { [o.notionTitleProperty]: { title: [{ text: { content: task } }] } },
74 }),
75 },
76 describe: 'Notion',
77 }
78 case 'github':
79 return { kind: 'process', argv: ['gh', 'issue', 'create', '--title', task, '--body', 'Captured with /jrtodo'], describe: 'a GitHub issue in this repository' }
80 case 'things':
81 return { kind: 'process', argv: ['open', `things:///add?title=${encodeURIComponent(task)}`], describe: 'Things' }
82 case 'reminders':
83 // The task travels as an argument, never inside the script text.
84 return {
85 kind: 'process',
86 argv: ['osascript', '-e', 'on run argv', '-e', 'tell application "Reminders" to make new reminder with properties {name:(item 1 of argv)}', '-e', 'end run', task],
87 describe: 'Apple Reminders',
88 }
89 case 'custom':
90 if (!o.command.includes('{task}')) return { kind: 'missing', what: 'a custom command containing {task}' }
91 // {task} becomes a quoted shell argument: the task text is never parsed as shell code.
92 return { kind: 'process', argv: ['sh', '-c', o.command.replaceAll('{task}', '"$1"'), 'jrtodo', task], describe: 'your command' }
93 default: {
94 const file = o.file ? (o.file.startsWith('/') ? o.file : `${root}/${o.file}`) : `${root}/TODO.md`
95 return { kind: 'append', file, line: `- [ ] ${task}`, describe: file.split('/').pop() ?? 'TODO.md' }
96 }
97 }
98}
99
100/** The file with the line added at the end, on a line of its own. */
101export const appendLine = (current: string, line: string) => {
102 if (!current) return `${line}\n`
103 return `${current}${current.endsWith('\n') ? '' : '\n'}${line}\n`
104}
105
106/** Linear answers 200 with an errors array when something failed. */
107export const httpFailure = (status: number, ok: boolean, text: string) => {
108 if (!ok) return `HTTP ${status}`
109 try {
110 const body = JSON.parse(text) as { errors?: { message?: string }[]; data?: { issueCreate?: { success?: boolean } } }
111 if (body.errors?.length) return body.errors[0]?.message ?? 'error'
112 if (body.data?.issueCreate && !body.data.issueCreate.success) return 'not created'
113 } catch {
114 // Not JSON: an ok status is enough.
115 }
116 return null
117}
118