SLOPSHOPPER

jr-todo

/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.

newbandcommandprocessnetworktimer
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-todo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-todo
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /jrtodo ⎿ jr-todo: jr todo · usage: /jrtodo <task> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jr-todo

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

Targets

Pick one in /config → jr-todo → Where tasks go.

TargetWhere the task landsWhat it needs
markdown (default)- [ ] task appended to TODO.md at the project root, or the file you setnothing
todoistYour Todoist inbox (API v1)API token
linearA new issue in a Linear teamPersonal API key + team id
notionA new page in a Notion databaseIntegration token + database id (+ title column name, default Name)
githubA new issue in the current repository, via ghgh signed in
thingsThings 3 inbox (macOS)Things installed
remindersApple Reminders, default list (macOS)Allow access the first time
customAny command you set, with {task} where the task goesthe command

Tokens and keys are stored as sensitive settings, never in plain settings files.

Any other tool through custom

The task is handed to your command as one quoted argument, so its text is never run as shell code. For example:

  • Google Tasks (it needs a Google sign-in, which a mod cannot do for you): point custom at a Google Tasks command-line tool or script you already have signed in, such as my-gtasks add {task}
  • A webhook: curl -s -X POST https://example.com/hook -d "task={task}"
  • Your own script: /Users/you/bin/add-task {task}

Commands

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.

Install

/plugin install jr-todo --marketplace jrlprost/claude-code-mods

Access

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.

Source 2 files
hooks/register.ts 63 lines
1import 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}
63
hooks/targets.ts 118 lines
1// 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