From 97cc142208fb8d4e194971568d5ba739e99038e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:11:29 +0000 Subject: [PATCH 1/2] Add Linear and Todoist extensions Linear: your issues by status with a status picker, search, and an issue form. Todoist: today's and overdue tasks to complete with Enter, all tasks by project, and quick add in natural language. Both use a personal API key stored in the keyring. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- extensions/linear/backslash.toml | 34 +++++ extensions/linear/commands/create-issue.ts | 55 ++++++++ extensions/linear/commands/my-issues.ts | 25 ++++ extensions/linear/commands/search-issues.ts | 19 +++ extensions/linear/lib.ts | 149 ++++++++++++++++++++ extensions/linear/lib_test.ts | 42 ++++++ extensions/linear/statuses.ts | 39 +++++ extensions/todoist/backslash.toml | 41 ++++++ extensions/todoist/commands/add.ts | 11 ++ extensions/todoist/commands/tasks.ts | 19 +++ extensions/todoist/commands/today.ts | 18 +++ extensions/todoist/lib.ts | 132 +++++++++++++++++ extensions/todoist/lib_test.ts | 44 ++++++ 13 files changed, 628 insertions(+) create mode 100644 extensions/linear/backslash.toml create mode 100644 extensions/linear/commands/create-issue.ts create mode 100644 extensions/linear/commands/my-issues.ts create mode 100644 extensions/linear/commands/search-issues.ts create mode 100644 extensions/linear/lib.ts create mode 100644 extensions/linear/lib_test.ts create mode 100644 extensions/linear/statuses.ts create mode 100644 extensions/todoist/backslash.toml create mode 100644 extensions/todoist/commands/add.ts create mode 100644 extensions/todoist/commands/tasks.ts create mode 100644 extensions/todoist/commands/today.ts create mode 100644 extensions/todoist/lib.ts create mode 100644 extensions/todoist/lib_test.ts diff --git a/extensions/linear/backslash.toml b/extensions/linear/backslash.toml new file mode 100644 index 0000000..add745f --- /dev/null +++ b/extensions/linear/backslash.toml @@ -0,0 +1,34 @@ +[extension] +id = "linear" +title = "Linear" +description = "Your issues, search and quick issue creation for Linear" +version = "1.0.0" +author = "Backslash" +icon = "📐" +license = "MIT" + +[permissions] +net = ["api.linear.app"] + +[[commands]] +name = "my-issues" +title = "My Linear Issues" +description = "Issues assigned to you, by status" +keywords = ["linear", "issues", "tickets", "tasks", "todo"] + +[[commands]] +name = "search-issues" +title = "Search Linear Issues" +keywords = ["linear", "issues", "find", "tickets"] + +[[commands]] +name = "create-issue" +title = "Create Linear Issue" +keywords = ["linear", "new", "issue", "bug", "ticket"] + +[[preferences]] +name = "apiKey" +title = "Personal API key" +description = "Linear → Settings → Security & access → Personal API keys" +type = "secret" +required = true diff --git a/extensions/linear/commands/create-issue.ts b/extensions/linear/commands/create-issue.ts new file mode 100644 index 0000000..739d603 --- /dev/null +++ b/extensions/linear/commands/create-issue.ts @@ -0,0 +1,55 @@ +import { action, close, type Context, copy, detail, field, form, open } from "@backslash/api"; +import { linear, PRIORITIES } from "../lib.ts"; + +interface Team { + id: string; + key: string; + name: string; +} + +export default async ({ preferences }: Context) => { + const apiKey = preferences.apiKey as string; + const { teams } = await linear<{ teams: { nodes: Team[] } }>(apiKey, `query { teams { nodes { id key name } } }`); + if (!teams.nodes.length) throw new Error("No Linear team found for this API key"); + return form({ + title: "New Linear issue", + submitTitle: "Create Issue", + fields: [ + field.text("title", "Title", { required: true, placeholder: "What needs doing?" }), + field.textArea("description", "Description", { placeholder: "Markdown" }), + field.dropdown("team", "Team", teams.nodes.map((t) => ({ value: t.id, title: `${t.name} (${t.key})` })), { + value: teams.nodes[0].id, + }), + field.dropdown("priority", "Priority", PRIORITIES, { value: "0" }), + field.checkbox("assignToMe", "Assign to me", { value: true }), + ], + async submit(values) { + let assigneeId: string | undefined; + if (values.assignToMe) { + assigneeId = (await linear<{ viewer: { id: string } }>(apiKey, `query { viewer { id } }`)).viewer.id; + } + const { issueCreate } = await linear< + { issueCreate: { success: boolean; issue: { identifier: string; url: string } } } + >( + apiKey, + `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier url } } }`, + { + input: { + teamId: values.team, + title: String(values.title).trim(), + description: String(values.description ?? "") || undefined, + priority: Number(values.priority), + assigneeId, + }, + }, + ); + if (!issueCreate.success) throw new Error("Linear did not create the issue"); + const { identifier, url } = issueCreate.issue; + return detail({ + title: identifier, + markdown: `# ${identifier} created\n\n${String(values.title)}`, + actions: [open(url, "Open in Linear"), copy(url, "Copy URL"), action("Done", () => close())], + }); + }, + }); +}; diff --git a/extensions/linear/commands/my-issues.ts b/extensions/linear/commands/my-issues.ts new file mode 100644 index 0000000..28256b6 --- /dev/null +++ b/extensions/linear/commands/my-issues.ts @@ -0,0 +1,25 @@ +import { type Context, list } from "@backslash/api"; +import { type Issue, ISSUE_FIELDS, issueItem, linear, live, sortIssues } from "../lib.ts"; +import { statusPicker } from "../statuses.ts"; + +const QUERY = `query { viewer { assignedIssues(first: 100, orderBy: updatedAt, + filter: { state: { type: { nin: ["completed", "canceled"] } } }) { nodes { ${ISSUE_FIELDS} } } } }`; + +export default (ctx: Context) => + list({ + placeholder: "Filter your issues…", + empty: { title: "No open issues assigned to you", icon: "🎉" }, + // Reloads after a status change made from this list. + items: ({ preferences, signal }) => + live(async () => { + const data = await linear<{ viewer: { assignedIssues: { nodes: Issue[] } } }>( + preferences.apiKey as string, + QUERY, + {}, + signal, + ); + return sortIssues(data.viewer.assignedIssues.nodes).map((issue) => + issueItem(issue, { section: issue.state.name, moveTo: (i) => statusPicker(i, ctx) }) + ); + }, signal), + }); diff --git a/extensions/linear/commands/search-issues.ts b/extensions/linear/commands/search-issues.ts new file mode 100644 index 0000000..bef4b7c --- /dev/null +++ b/extensions/linear/commands/search-issues.ts @@ -0,0 +1,19 @@ +import { type Context, list } from "@backslash/api"; +import { type Issue, ISSUE_FIELDS, issueItem, linear } from "../lib.ts"; +import { statusPicker } from "../statuses.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Search issues by title, identifier or text…", + empty: { title: "Type to search Linear" }, + async search(query, { preferences, signal }) { + if (query.trim().length < 2) return []; + const data = await linear<{ searchIssues: { nodes: Issue[] } }>( + preferences.apiKey as string, + `query($term: String!) { searchIssues(term: $term, first: 30) { nodes { ${ISSUE_FIELDS} } } }`, + { term: query.trim() }, + signal, + ); + return data.searchIssues.nodes.map((issue) => issueItem(issue, { moveTo: (i) => statusPicker(i, ctx) })); + }, + }); diff --git a/extensions/linear/lib.ts b/extensions/linear/lib.ts new file mode 100644 index 0000000..4f84c76 --- /dev/null +++ b/extensions/linear/lib.ts @@ -0,0 +1,149 @@ +// Linear GraphQL helper and the mapping from issues to launcher items. + +import { action, copy, type Item, open, type View } from "@backslash/api"; + +export const API = "https://api.linear.app/graphql"; + +export async function linear( + apiKey: string, + query: string, + variables: Record = {}, + signal?: AbortSignal, +): Promise { + const res = await fetch(API, { + method: "POST", + signal, + // Personal API keys go as is; OAuth tokens would be "Bearer …". + headers: { "Content-Type": "application/json", Authorization: apiKey.trim() }, + body: JSON.stringify({ query, variables }), + }); + if (res.status === 401) throw new Error("Linear rejected the API key: check it in Settings → Extensions → Linear"); + const body = await res.json().catch(() => ({})) as { data?: T; errors?: { message: string }[] }; + if (body.errors?.length) throw new Error(`Linear: ${body.errors[0].message}`); + if (!res.ok || !body.data) throw new Error(`Linear answered ${res.status}`); + return body.data; +} + +export const ISSUE_FIELDS = ` + id identifier title url priority priorityLabel updatedAt + state { id name type } + team { id key name } + project { name } + assignee { name } +`; + +export interface Issue { + id: string; + identifier: string; + title: string; + url: string; + priority: number; + priorityLabel: string; + updatedAt: string; + state: { id: string; name: string; type: string }; + team: { id: string; key: string; name: string }; + project?: { name: string } | null; + assignee?: { name: string } | null; +} + +export interface WorkflowState { + id: string; + name: string; + type: string; + position: number; +} + +/** Order of the sections in "My Issues": what's moving first. */ +const STATE_ORDER = ["started", "unstarted", "backlog", "triage", "completed", "canceled"]; + +export function stateRank(type: string): number { + const i = STATE_ORDER.indexOf(type); + return i === -1 ? STATE_ORDER.length : i; +} + +/** Urgent, high, medium, low first; no priority (0) last. */ +export function sortIssues(issues: Issue[]): Issue[] { + const p = (n: number) => n === 0 ? 5 : n; + return [...issues].sort((a, b) => + stateRank(a.state.type) - stateRank(b.state.type) || a.state.name.localeCompare(b.state.name) || + p(a.priority) - p(b.priority) || b.updatedAt.localeCompare(a.updatedAt) + ); +} + +const PRIORITY_COLOR: Record = { + 1: "red", + 2: "orange", + 3: "yellow", + 4: "blue", +}; + +const STATE_ICON: Record = { + started: "🟡", + unstarted: "⚪", + backlog: "◌", + triage: "🔶", + completed: "✅", + canceled: "⛔", +}; + +export function issueItem( + issue: Issue, + options: { section?: string; moveTo?: (issue: Issue) => Promise } = {}, +): Item { + const where = [issue.team.key, issue.project?.name].filter(Boolean).join(" · "); + const actions = [ + open(issue.url, "Open in Linear"), + copy(issue.identifier, "Copy Identifier", { shortcut: "period" }), + copy(issue.url, "Copy URL", { shortcut: "c" }), + copy(`[${issue.identifier}: ${issue.title}](${issue.url})`, "Copy as Markdown Link"), + ]; + if (options.moveTo) { + const moveTo = options.moveTo; + actions.splice(1, 0, action("Change Status…", () => moveTo(issue), { icon: "go-next", shortcut: "s" })); + } + return { + id: issue.id, + title: issue.title, + subtitle: where, + icon: STATE_ICON[issue.state.type] ?? "📐", + section: options.section, + keywords: [issue.identifier, issue.state.name, issue.team.name, issue.assignee?.name ?? ""].filter(Boolean), + accessories: [ + ...(issue.priority ? [{ tag: issue.priorityLabel, color: PRIORITY_COLOR[issue.priority] }] : []), + { text: issue.identifier }, + ], + actions, + }; +} + +export const PRIORITIES = [ + { value: "0", title: "No priority" }, + { value: "1", title: "Urgent" }, + { value: "2", title: "High" }, + { value: "3", title: "Medium" }, + { value: "4", title: "Low" }, +]; + +// Views of this extension run in one process: a change made in one view +// (a new status) reloads the lists still open under it. +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/linear/lib_test.ts b/extensions/linear/lib_test.ts new file mode 100644 index 0000000..07bf0cd --- /dev/null +++ b/extensions/linear/lib_test.ts @@ -0,0 +1,42 @@ +import { assertEquals } from "@std/assert"; +import { type Issue, issueItem, sortIssues } from "./lib.ts"; + +const issue = (identifier: string, type: string, name: string, priority: number, updatedAt = "2026-10-01"): Issue => ({ + id: identifier, + identifier, + title: `Title ${identifier}`, + url: `https://linear.app/acme/issue/${identifier}`, + priority, + priorityLabel: ["No priority", "Urgent", "High", "Medium", "Low"][priority], + updatedAt, + state: { id: name, name, type }, + team: { id: "t", key: "ENG", name: "Engineering" }, + project: identifier === "ENG-1" ? { name: "Launch" } : null, +}); + +Deno.test("issues: in progress first, then by priority, no priority last", () => { + const sorted = sortIssues([ + issue("ENG-1", "backlog", "Backlog", 1), + issue("ENG-2", "unstarted", "Todo", 0), + issue("ENG-3", "unstarted", "Todo", 3), + issue("ENG-4", "started", "In Review", 4), + issue("ENG-5", "started", "In Progress", 2, "2026-09-01"), + issue("ENG-6", "started", "In Progress", 2, "2026-10-02"), + ]); + assertEquals(sorted.map((i) => i.identifier), ["ENG-6", "ENG-5", "ENG-4", "ENG-3", "ENG-2", "ENG-1"]); +}); + +Deno.test("issue item", () => { + const item = issueItem(issue("ENG-1", "started", "In Progress", 1), { section: "In Progress" }); + assertEquals(item.subtitle, "ENG · Launch"); + assertEquals(item.accessories, [{ tag: "Urgent", color: "red" }, { text: "ENG-1" }]); + assertEquals(item.actions?.map((a) => a.title), [ + "Open in Linear", + "Copy Identifier", + "Copy URL", + "Copy as Markdown Link", + ]); + const movable = issueItem(issue("ENG-2", "unstarted", "Todo", 0), { moveTo: () => Promise.reject() }); + assertEquals(movable.accessories, [{ text: "ENG-2" }]); + assertEquals(movable.actions?.[1].title, "Change Status…"); +}); diff --git a/extensions/linear/statuses.ts b/extensions/linear/statuses.ts new file mode 100644 index 0000000..12e2264 --- /dev/null +++ b/extensions/linear/statuses.ts @@ -0,0 +1,39 @@ +// "Change Status…": the workflow states of the issue's team. + +import { action, type Context, list, pop, toast, type View } from "@backslash/api"; +import { type Issue, linear, notifyChanged, stateRank, type WorkflowState } from "./lib.ts"; + +export async function statusPicker(issue: Issue, ctx: Context): Promise { + const apiKey = ctx.preferences.apiKey as string; + const data = await linear<{ team: { states: { nodes: WorkflowState[] } } }>( + apiKey, + `query($id: String!) { team(id: $id) { states { nodes { id name type position } } } }`, + { id: issue.team.id }, + ); + const states = data.team.states.nodes.sort((a, b) => + stateRank(a.type) - stateRank(b.type) || a.position - b.position + ); + return list({ + title: `${issue.identifier} → status`, + placeholder: "Move to…", + items: states.map((s) => ({ + id: s.id, + title: s.name, + icon: s.id === issue.state.id ? "object-select" : undefined, + accessories: s.id === issue.state.id ? [{ tag: "Current", color: "gray" as const }] : [], + actions: [ + action(`Move to ${s.name}`, async () => { + const res = await linear<{ issueUpdate: { success: boolean } }>( + apiKey, + `mutation($id: String!, $state: String!) { issueUpdate(id: $id, input: { stateId: $state }) { success } }`, + { id: issue.id, state: s.id }, + ); + if (!res.issueUpdate.success) throw new Error("Linear did not update the issue"); + notifyChanged(); + await toast(`${issue.identifier} → ${s.name}`, { style: "success" }); + return pop(); + }), + ], + })), + }); +} diff --git a/extensions/todoist/backslash.toml b/extensions/todoist/backslash.toml new file mode 100644 index 0000000..081ca90 --- /dev/null +++ b/extensions/todoist/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "todoist" +title = "Todoist" +description = "Today's tasks, search and quick add in natural language" +version = "1.0.0" +author = "Backslash" +icon = "✅" +license = "MIT" + +[permissions] +net = ["api.todoist.com"] + +[[commands]] +name = "today" +title = "Todoist Today" +description = "Overdue and today's tasks; complete them with Enter" +keywords = ["todoist", "tasks", "todo", "today", "tâches", "aujourd'hui"] + +[[commands]] +name = "tasks" +title = "Search Todoist Tasks" +description = "All your tasks, by project" +keywords = ["todoist", "tasks", "todo", "find", "tâches"] + +[[commands]] +name = "add" +title = "Add Todoist Task" +description = "Natural language: “Call Bob tomorrow 5pm #Work p1”" +mode = "no-view" +keywords = ["todoist", "add", "new", "task", "todo", "ajouter", "tâche"] +[[commands.arguments]] +name = "task" +placeholder = "Call Bob tomorrow 5pm #Work p1" +required = true + +[[preferences]] +name = "token" +title = "API token" +description = "Todoist → Settings → Integrations → Developer → API token" +type = "secret" +required = true diff --git a/extensions/todoist/commands/add.ts b/extensions/todoist/commands/add.ts new file mode 100644 index 0000000..2e49288 --- /dev/null +++ b/extensions/todoist/commands/add.ts @@ -0,0 +1,11 @@ +import { run } from "@backslash/api"; +import { dueSection, type Task, todoist } from "../lib.ts"; + +export default run<{ task: string }>(async ({ args, preferences }) => { + const task = await todoist(preferences.token as string, "/tasks/quick", { + method: "POST", + body: { text: args.task.trim() }, + }); + const when = task.due ? ` · ${task.due.string || dueSection(task)}` : ""; + return `Added “${task.content}”${when}`; +}); diff --git a/extensions/todoist/commands/tasks.ts b/extensions/todoist/commands/tasks.ts new file mode 100644 index 0000000..0525f43 --- /dev/null +++ b/extensions/todoist/commands/tasks.ts @@ -0,0 +1,19 @@ +import { list } from "@backslash/api"; +import { all, type Project, sortTasks, type Task, taskItem } from "../lib.ts"; + +export default list({ + placeholder: "Search your tasks…", + empty: { title: "No tasks", icon: "🎉" }, + async items({ preferences, signal }) { + const token = preferences.token as string; + const [tasks, projects] = await Promise.all([ + all(token, "/tasks", signal), + all(token, "/projects", signal), + ]); + const order = new Map(projects.map((p, i) => [p.id, i])); + const names = new Map(projects.map((p) => [p.id, p.name])); + return sortTasks(tasks) + .sort((a, b) => (order.get(a.project_id) ?? 1e9) - (order.get(b.project_id) ?? 1e9)) + .map((t) => taskItem(t, token, { section: names.get(t.project_id) ?? "Other" })); + }, +}); diff --git a/extensions/todoist/commands/today.ts b/extensions/todoist/commands/today.ts new file mode 100644 index 0000000..ba1ae3b --- /dev/null +++ b/extensions/todoist/commands/today.ts @@ -0,0 +1,18 @@ +import { list } from "@backslash/api"; +import { all, dueSection, type Project, sortTasks, type Task, taskItem } from "../lib.ts"; + +export default list({ + placeholder: "Filter today's tasks…", + empty: { title: "Nothing left for today", icon: "🎉" }, + async items({ preferences, signal }) { + const token = preferences.token as string; + const [tasks, projects] = await Promise.all([ + all(token, `/tasks/filter?query=${encodeURIComponent("today | overdue")}`, signal), + all(token, "/projects", signal), + ]); + const names = new Map(projects.map((p) => [p.id, p.name])); + return sortTasks(tasks).map((t) => + taskItem(t, token, { section: dueSection(t), project: names.get(t.project_id) }) + ); + }, +}); diff --git a/extensions/todoist/lib.ts b/extensions/todoist/lib.ts new file mode 100644 index 0000000..af56a4f --- /dev/null +++ b/extensions/todoist/lib.ts @@ -0,0 +1,132 @@ +// Todoist API v1 helper and the mapping from tasks to launcher items. + +import { action, copy, type Item, open, refresh, toast } from "@backslash/api"; + +export const API = "https://api.todoist.com/api/v1"; + +export async function todoist( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token.trim()}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (res.status === 401 || res.status === 403) { + throw new Error("Todoist rejected the token: check it in Settings → Extensions → Todoist"); + } + if (!res.ok) throw new Error(`Todoist answered ${res.status}: ${(await res.text()).slice(0, 200)}`); + return res.status === 204 ? undefined as T : await res.json() as T; +} + +/** Follows `next_cursor` through every page. */ +export async function all(token: string, path: string, signal?: AbortSignal): Promise { + const out: T[] = []; + let cursor: string | null = null; + do { + const sep = path.includes("?") ? "&" : "?"; + const url: string = cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : `${path}${sep}limit=200`; + const page: { results: T[]; next_cursor: string | null } = await todoist(token, url, { signal }); + out.push(...page.results); + cursor = page.next_cursor; + } while (cursor && out.length < 2000); + return out; +} + +export interface Task { + id: string; + content: string; + description: string; + priority: number; + project_id: string; + labels: string[]; + due: { date: string; string: string; is_recurring: boolean; datetime?: string | null } | null; +} + +export interface Project { + id: string; + name: string; +} + +export const taskUrl = (id: string) => `https://app.todoist.com/app/task/${id}`; + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** "Overdue", "Today", "Tomorrow", a weekday this week, or the date. */ +export function dueSection(task: Task, now = new Date()): string { + if (!task.due) return "No date"; + const today = localDate(now); + const date = task.due.date.slice(0, 10); + if (date < today) return "Overdue"; + if (date === today) return "Today"; + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + if (date === localDate(tomorrow)) return "Tomorrow"; + return date; +} + +/** Time of day for tasks due at a precise time. */ +export function dueTime(task: Task): string | undefined { + const at = task.due?.datetime; + if (!at) return undefined; + const d = new Date(at); + return isNaN(d.getTime()) ? undefined : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +/** The API's priority 4 is the app's P1. */ +const PRIORITY: Record = { + 4: { tag: "P1", color: "red" }, + 3: { tag: "P2", color: "orange" }, + 2: { tag: "P3", color: "blue" }, +}; + +export function sortTasks(tasks: Task[]): Task[] { + const due = (t: Task) => t.due?.datetime ?? t.due?.date ?? "9999"; + return [...tasks].sort((a, b) => + due(a).slice(0, 10).localeCompare(due(b).slice(0, 10)) || b.priority - a.priority || due(a).localeCompare(due(b)) + ); +} + +export function taskItem( + task: Task, + token: string, + options: { section?: string; project?: string; now?: Date } = {}, +): Item { + const priority = PRIORITY[task.priority]; + const time = dueTime(task); + const section = options.section ?? dueSection(task, options.now); + return { + id: task.id, + title: task.content, + subtitle: [options.project, task.description.split("\n")[0]].filter(Boolean).join(" · "), + icon: "⭕", + section: options.section, + keywords: [...task.labels, options.project ?? "", task.due?.string ?? ""].filter(Boolean), + accessories: [ + ...(priority ? [priority] : []), + ...task.labels.map((l) => ({ text: `@${l}` })), + ...(time ? [{ text: time }] : task.due && !options.section ? [{ text: section }] : []), + ...(task.due?.is_recurring ? [{ icon: "view-refresh" }] : []), + ], + actions: [ + action("Complete Task", async () => { + await todoist(token, `/tasks/${task.id}/close`, { method: "POST" }); + await toast(task.due?.is_recurring ? "Done, next occurrence scheduled" : "Completed", { style: "success" }); + return refresh(); + }, { icon: "object-select" }), + open(taskUrl(task.id), "Open in Todoist"), + copy(task.content, "Copy Task", { shortcut: "c" }), + copy(taskUrl(task.id), "Copy Link"), + ], + }; +} diff --git a/extensions/todoist/lib_test.ts b/extensions/todoist/lib_test.ts new file mode 100644 index 0000000..9ed0f3d --- /dev/null +++ b/extensions/todoist/lib_test.ts @@ -0,0 +1,44 @@ +import { assertEquals } from "@std/assert"; +import { dueSection, sortTasks, type Task, taskItem } from "./lib.ts"; + +const task = (id: string, date: string | null, priority = 1, extra: Partial = {}): Task => ({ + id, + content: `Task ${id}`, + description: "", + priority, + project_id: "p", + labels: [], + due: date ? { date, string: date, is_recurring: false } : null, + ...extra, +}); + +const NOW = new Date(2026, 9, 27, 10, 0); // 27 October 2026, local time + +Deno.test("due sections", () => { + assertEquals( + ["2026-10-20", "2026-10-27", "2026-10-28", "2026-11-02", null].map((d) => dueSection(task("x", d), NOW)), + ["Overdue", "Today", "Tomorrow", "2026-11-02", "No date"], + ); + assertEquals(dueSection(task("x", "2026-10-27T18:00:00"), NOW), "Today"); +}); + +Deno.test("sorted by day, then priority (4 = P1), then time", () => { + const sorted = sortTasks([ + task("a", "2026-10-27", 1), + task("b", "2026-10-27", 4), + task("c", "2026-10-26", 1), + task("d", null, 4), + task("e", "2026-10-27T09:00:00", 1), + ]); + assertEquals(sorted.map((t) => t.id), ["c", "b", "a", "e", "d"]); +}); + +Deno.test("task item", () => { + const item = taskItem(task("1", "2026-10-30", 4, { labels: ["work"], description: "Details\nmore" }), "tok", { + project: "Inbox", + now: NOW, + }); + assertEquals(item.subtitle, "Inbox · Details"); + assertEquals(item.accessories, [{ tag: "P1", color: "red" }, { text: "@work" }, { text: "2026-10-30" }]); + assertEquals(item.actions?.map((a) => a.title), ["Complete Task", "Open in Todoist", "Copy Task", "Copy Link"]); +}); From 236d7e14b272327f13ce592a383c55af6fce5208 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:11:44 +0000 Subject: [PATCH 2/2] Registry entries for Linear and Todoist Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzDMDJteorsmKRm6FVPX94 --- README.md | 2 ++ registry/linear.toml | 14 ++++++++++++++ registry/todoist.toml | 14 ++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 registry/linear.toml create mode 100644 registry/todoist.toml diff --git a/README.md b/README.md index 634d5d2..ec8ffac 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Anyone can also install straight from a repository, without the registry: | [Ports](extensions/ports) | What listens on each port, with its process and project; stop it | `ss`, `ps`, `readlink`, `kill` | | [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs, VPN and Tailscale | `nmcli`, `bluetoothctl`, `pactl`, `tailscale` | | [AI](extensions/ai) | Ask AI, fix, improve, summarize, translate, explain copied text | Claude, OpenAI, Mistral, Groq, OpenRouter, Ollama | +| [Linear](extensions/linear) | My issues by status, change status, search, create an issue | `api.linear.app` | +| [Todoist](extensions/todoist) | Today and overdue tasks, complete them, search, quick add | `api.todoist.com` | Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged. diff --git a/registry/linear.toml b/registry/linear.toml new file mode 100644 index 0000000..a7c1dab --- /dev/null +++ b/registry/linear.toml @@ -0,0 +1,14 @@ +# registry/linear.toml +id = "linear" +title = "Linear" +description = "Your issues, search and quick issue creation for Linear" +author = "Backslash" +icon = "📐" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/linear" +commit = "97cc142208fb8d4e194971568d5ba739e99038e0" +checksum = "sha256:d30e2cd5d02683ebd2bae8dde4a6dd9a6ffa67bee156200c9cfce523ec7e8c45" +permissions = ["Access api.linear.app"] +commands = ["My Linear Issues", "Search Linear Issues", "Create Linear Issue"] + diff --git a/registry/todoist.toml b/registry/todoist.toml new file mode 100644 index 0000000..7e325a5 --- /dev/null +++ b/registry/todoist.toml @@ -0,0 +1,14 @@ +# registry/todoist.toml +id = "todoist" +title = "Todoist" +description = "Today's tasks, search and quick add in natural language" +author = "Backslash" +icon = "✅" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/todoist" +commit = "97cc142208fb8d4e194971568d5ba739e99038e0" +checksum = "sha256:9e648dc34ea5adb317bc05033378c5a258fa9f35ba3132ff87a6d78ce6f418ae" +permissions = ["Access api.todoist.com"] +commands = ["Todoist Today", "Search Todoist Tasks", "Add Todoist Task"] +