@mindtnv/todoist-cli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/marketplace.json +16 -0
  2. package/package.json +7 -6
  3. package/src/api/activity.ts +8 -0
  4. package/src/api/client.ts +214 -0
  5. package/src/api/comments.ts +18 -0
  6. package/src/api/completed.ts +15 -0
  7. package/src/api/labels.ts +18 -0
  8. package/src/api/projects.ts +22 -0
  9. package/src/api/sections.ts +20 -0
  10. package/src/api/stats.ts +38 -0
  11. package/src/api/tasks.ts +34 -0
  12. package/src/api/types.ts +202 -0
  13. package/src/cli/auth.ts +40 -0
  14. package/src/cli/commands/task/add.ts +328 -0
  15. package/src/cli/commands/task/complete.ts +62 -0
  16. package/src/cli/commands/task/delete.ts +62 -0
  17. package/src/cli/commands/task/helpers.ts +289 -0
  18. package/src/cli/commands/task/index.ts +27 -0
  19. package/src/cli/commands/task/list.ts +151 -0
  20. package/src/cli/commands/task/move.ts +49 -0
  21. package/src/cli/commands/task/reopen.ts +43 -0
  22. package/src/cli/commands/task/show.ts +115 -0
  23. package/src/cli/commands/task/update.ts +122 -0
  24. package/src/cli/comment.ts +83 -0
  25. package/src/cli/completed.ts +87 -0
  26. package/src/cli/completion.ts +360 -0
  27. package/src/cli/filter.ts +115 -0
  28. package/src/cli/index.ts +638 -0
  29. package/src/cli/label.ts +120 -0
  30. package/src/cli/log.ts +57 -0
  31. package/src/cli/matrix.ts +100 -0
  32. package/src/cli/plugin-loader.ts +38 -0
  33. package/src/cli/plugin.ts +289 -0
  34. package/src/cli/project.ts +172 -0
  35. package/src/cli/review.ts +116 -0
  36. package/src/cli/section.ts +98 -0
  37. package/src/cli/stats.ts +62 -0
  38. package/src/cli/template.ts +89 -0
  39. package/src/config/index.ts +229 -0
  40. package/src/plugins/api-proxy.ts +70 -0
  41. package/src/plugins/extension-registry.ts +53 -0
  42. package/src/plugins/hook-registry.ts +36 -0
  43. package/src/plugins/loader.ts +200 -0
  44. package/src/plugins/marketplace-types.ts +55 -0
  45. package/src/plugins/marketplace.ts +576 -0
  46. package/src/plugins/palette-registry.ts +21 -0
  47. package/src/plugins/storage.ts +101 -0
  48. package/src/plugins/types.ts +226 -0
  49. package/src/plugins/view-registry.ts +19 -0
  50. package/src/ui/App.tsx +234 -0
  51. package/src/ui/components/Breadcrumb.tsx +18 -0
  52. package/src/ui/components/CommandPalette.tsx +237 -0
  53. package/src/ui/components/ConfirmDialog.tsx +28 -0
  54. package/src/ui/components/EditTaskModal.tsx +484 -0
  55. package/src/ui/components/HelpOverlay.tsx +195 -0
  56. package/src/ui/components/InputPrompt.tsx +109 -0
  57. package/src/ui/components/LabelPicker.tsx +110 -0
  58. package/src/ui/components/ModalManager.tsx +275 -0
  59. package/src/ui/components/ProjectPicker.tsx +95 -0
  60. package/src/ui/components/Sidebar.tsx +282 -0
  61. package/src/ui/components/SortMenu.tsx +77 -0
  62. package/src/ui/components/StatusBar.tsx +67 -0
  63. package/src/ui/components/TaskList.tsx +258 -0
  64. package/src/ui/components/TaskRow.tsx +105 -0
  65. package/src/ui/hooks/useKeyboardHandler.ts +291 -0
  66. package/src/ui/hooks/useStatusMessage.ts +32 -0
  67. package/src/ui/hooks/useTaskOperations.ts +558 -0
  68. package/src/ui/hooks/useUndoSystem.ts +218 -0
  69. package/src/ui/views/ActivityView.tsx +213 -0
  70. package/src/ui/views/CompletedView.tsx +337 -0
  71. package/src/ui/views/StatsView.tsx +178 -0
  72. package/src/ui/views/TaskDetailView.tsx +438 -0
  73. package/src/ui/views/TasksView.tsx +851 -0
  74. package/src/utils/colors.ts +27 -0
  75. package/src/utils/date-format.ts +54 -0
  76. package/src/utils/errors.ts +159 -0
  77. package/src/utils/exit.ts +11 -0
  78. package/src/utils/format.ts +46 -0
  79. package/src/utils/open-url.ts +9 -0
  80. package/src/utils/output.ts +29 -0
  81. package/src/utils/quick-add.ts +202 -0
  82. package/src/utils/resolve.ts +359 -0
  83. package/src/utils/sorting.ts +27 -0
  84. package/src/utils/validation.ts +88 -0
  85. package/dist/index.js +0 -10989
@@ -0,0 +1,226 @@
1
+ import type { Command as CliCommand } from "commander";
2
+ import type {
3
+ Task, Project, Label, Section, Comment,
4
+ CreateTaskParams, UpdateTaskParams, TaskFilter,
5
+ } from "../api/types.ts";
6
+
7
+ // ── Plugin Storage ──
8
+
9
+ export interface PluginStorage {
10
+ get<T>(key: string): Promise<T | null>;
11
+ set<T>(key: string, value: T): Promise<void>;
12
+ delete(key: string): Promise<void>;
13
+ list(prefix?: string): Promise<string[]>;
14
+ getTaskData<T>(taskId: string, key: string): Promise<T | null>;
15
+ setTaskData<T>(taskId: string, key: string, value: T): Promise<void>;
16
+ }
17
+
18
+ // ── Plugin Logger ──
19
+
20
+ export interface PluginLogger {
21
+ info(message: string): void;
22
+ warn(message: string): void;
23
+ error(message: string): void;
24
+ }
25
+
26
+ // ── Plugin Context ──
27
+
28
+ export interface PluginContext {
29
+ api: PluginApi;
30
+ storage: PluginStorage;
31
+ config: Record<string, unknown>;
32
+ pluginDir: string;
33
+ log: PluginLogger;
34
+ }
35
+
36
+ export interface PluginApi {
37
+ getTasks: (filter?: TaskFilter) => Promise<Task[]>;
38
+ getTask: (id: string) => Promise<Task>;
39
+ createTask: (params: CreateTaskParams) => Promise<Task>;
40
+ updateTask: (id: string, params: UpdateTaskParams) => Promise<Task>;
41
+ closeTask: (id: string) => Promise<void>;
42
+ reopenTask: (id: string) => Promise<void>;
43
+ deleteTask: (id: string) => Promise<void>;
44
+ getProjects: () => Promise<Project[]>;
45
+ getProject: (id: string) => Promise<Project>;
46
+ getLabels: () => Promise<Label[]>;
47
+ getLabel: (id: string) => Promise<Label>;
48
+ getSections: (projectId?: string) => Promise<Section[]>;
49
+ getComments: (taskId: string) => Promise<Comment[]>;
50
+ }
51
+
52
+ // ── Hook Registry ──
53
+
54
+ export type HookEvent =
55
+ | "task.creating"
56
+ | "task.created"
57
+ | "task.completing"
58
+ | "task.completed"
59
+ | "task.updating"
60
+ | "task.updated"
61
+ | "task.deleting"
62
+ | "task.deleted";
63
+
64
+ // ── Per-event hook context types ──
65
+
66
+ export interface TaskCreatingContext { params: CreateTaskParams }
67
+ export interface TaskCreatedContext { task: Task }
68
+ export interface TaskCompletingContext { task: Task }
69
+ export interface TaskCompletedContext { task: Task }
70
+ export interface TaskUpdatingContext { task: Task; changes: UpdateTaskParams }
71
+ export interface TaskUpdatedContext { task: Task; changes: UpdateTaskParams }
72
+ export interface TaskDeletingContext { task: Task }
73
+ export interface TaskDeletedContext { task: Task }
74
+
75
+ export type HookContextMap = {
76
+ "task.creating": TaskCreatingContext;
77
+ "task.created": TaskCreatedContext;
78
+ "task.completing": TaskCompletingContext;
79
+ "task.completed": TaskCompletedContext;
80
+ "task.updating": TaskUpdatingContext;
81
+ "task.updated": TaskUpdatedContext;
82
+ "task.deleting": TaskDeletingContext;
83
+ "task.deleted": TaskDeletedContext;
84
+ };
85
+
86
+ /** Union of all hook context types — kept for backward compatibility */
87
+ export type HookContext = HookContextMap[HookEvent];
88
+
89
+ export type HookHandler<E extends HookEvent = HookEvent> = (ctx: HookContextMap[E]) => Promise<{ message?: string } | void>;
90
+
91
+ export interface HookRegistry {
92
+ on<E extends HookEvent>(event: E, handler: HookHandler<E>): void;
93
+ off<E extends HookEvent>(event: E, handler: HookHandler<E>): void;
94
+ emit<E extends HookEvent>(event: E, ctx: HookContextMap[E]): Promise<string[]>;
95
+ }
96
+
97
+ // ── View Registry ──
98
+
99
+ export interface PluginViewDefinition {
100
+ name: string;
101
+ label: string;
102
+ component: React.ComponentType<PluginViewProps>;
103
+ sidebar?: { icon: string; section: string };
104
+ shortcut?: string;
105
+ }
106
+
107
+ export interface PluginViewProps {
108
+ onBack: () => void;
109
+ onNavigate: (view: string) => void;
110
+ ctx: PluginContext;
111
+ tasks: Task[];
112
+ projects: Project[];
113
+ labels: Label[];
114
+ }
115
+
116
+ export interface ViewRegistry {
117
+ addView(view: PluginViewDefinition): void;
118
+ getViews(): PluginViewDefinition[];
119
+ }
120
+
121
+ // ── Extension Registry ──
122
+
123
+ export interface TaskColumnDefinition {
124
+ id: string;
125
+ label: string;
126
+ width: number;
127
+ position: "after-priority" | "after-due" | "before-content";
128
+ render: (task: Task, ctx: PluginContext) => string;
129
+ color?: (task: Task) => string;
130
+ refreshInterval?: number; // ms — triggers re-render at this interval (e.g. 1000 for live timer)
131
+ }
132
+
133
+ export interface StatusBarItemDefinition {
134
+ id: string;
135
+ render: (ctx: PluginContext) => string;
136
+ color?: (ctx: PluginContext) => string;
137
+ refreshInterval?: number; // ms
138
+ }
139
+
140
+ export interface DetailSectionDefinition {
141
+ id: string;
142
+ label: string;
143
+ position: "after-comments" | "after-subtasks" | "after-labels";
144
+ component: React.ComponentType<{ task: Task; ctx: PluginContext }>;
145
+ }
146
+
147
+ export interface KeybindingDefinition {
148
+ key: string;
149
+ description: string;
150
+ helpSection: string;
151
+ action: (ctx: PluginContext, currentTask: Task | null) => Promise<{ statusMessage?: string } | void>;
152
+ }
153
+
154
+ export interface ExtensionRegistry {
155
+ addTaskColumn(column: TaskColumnDefinition): void;
156
+ addDetailSection(section: DetailSectionDefinition): void;
157
+ addKeybinding(binding: KeybindingDefinition): void;
158
+ addStatusBarItem(item: StatusBarItemDefinition): void;
159
+ getTaskColumns(): TaskColumnDefinition[];
160
+ getDetailSections(): DetailSectionDefinition[];
161
+ getKeybindings(): KeybindingDefinition[];
162
+ getStatusBarItems(): StatusBarItemDefinition[];
163
+ }
164
+
165
+ // ── Palette Registry ──
166
+
167
+ export interface PaletteInputPrompt {
168
+ label: string;
169
+ placeholder?: string;
170
+ formatPreview?: (value: string) => string;
171
+ }
172
+
173
+ export interface PaletteCommandDefinition {
174
+ label: string;
175
+ category: string;
176
+ shortcut?: string;
177
+ inputPrompt?: PaletteInputPrompt;
178
+ action: (
179
+ ctx: PluginContext,
180
+ currentTask: Task | null,
181
+ navigate: (view: string) => void,
182
+ input?: string,
183
+ ) => Promise<void> | void;
184
+ }
185
+
186
+ export interface PaletteRegistry {
187
+ addCommands(commands: PaletteCommandDefinition[]): void;
188
+ getCommands(): PaletteCommandDefinition[];
189
+ }
190
+
191
+ // ── Plugin Interface ──
192
+
193
+ export interface TodoistPlugin {
194
+ name: string;
195
+ version: string;
196
+ description?: string;
197
+ registerCommands?(program: CliCommand, ctx: PluginContext): void;
198
+ registerViews?(registry: ViewRegistry): void;
199
+ registerHooks?(hooks: HookRegistry): void;
200
+ registerExtensions?(extensions: ExtensionRegistry): void;
201
+ registerPaletteCommands?(palette: PaletteRegistry): void;
202
+ onLoad?(ctx: PluginContext): Promise<void>;
203
+ onUnload?(): Promise<void>;
204
+ }
205
+
206
+ // ── Plugin Manifest (plugin.json) ──
207
+
208
+ export interface PluginManifest {
209
+ name: string;
210
+ version: string;
211
+ description?: string;
212
+ main: string;
213
+ author?: string;
214
+ source?: string;
215
+ engines?: { "todoist-cli"?: string };
216
+ permissions?: string[];
217
+ }
218
+
219
+ // ── Plugin Config (in config.toml) ──
220
+
221
+ export interface PluginConfigEntry {
222
+ source: string;
223
+ enabled?: boolean;
224
+ after?: string;
225
+ [key: string]: unknown;
226
+ }
@@ -0,0 +1,19 @@
1
+ import type { PluginViewDefinition, ViewRegistry } from "./types.ts";
2
+
3
+ export function createViewRegistry(): ViewRegistry {
4
+ const views: PluginViewDefinition[] = [];
5
+
6
+ return {
7
+ addView(view: PluginViewDefinition) {
8
+ if (views.some(v => v.name === view.name)) {
9
+ console.warn(`[plugin] View "${view.name}" already registered, skipping`);
10
+ return;
11
+ }
12
+ views.push(view);
13
+ },
14
+
15
+ getViews() {
16
+ return [...views];
17
+ },
18
+ };
19
+ }
package/src/ui/App.tsx ADDED
@@ -0,0 +1,234 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { render, Box, Text, useApp } from "ink";
3
+ import type { Task, Project, Label, Section } from "../api/types.ts";
4
+ import { getTasks } from "../api/tasks.ts";
5
+ import { getProjects } from "../api/projects.ts";
6
+ import { getLabels } from "../api/labels.ts";
7
+ import { getSections } from "../api/sections.ts";
8
+ import { TasksView } from "./views/TasksView.tsx";
9
+ import type { ListViewState } from "./views/TasksView.tsx";
10
+ import { TaskDetailView } from "./views/TaskDetailView.tsx";
11
+ import { StatsView } from "./views/StatsView.tsx";
12
+ import { CompletedView } from "./views/CompletedView.tsx";
13
+ import { ActivityView } from "./views/ActivityView.tsx";
14
+ import { createHookRegistry } from "../plugins/hook-registry.ts";
15
+ import { createViewRegistry } from "../plugins/view-registry.ts";
16
+ import { createExtensionRegistry } from "../plugins/extension-registry.ts";
17
+ import { createPaletteRegistry } from "../plugins/palette-registry.ts";
18
+ import { loadPlugins } from "../plugins/loader.ts";
19
+ import type { LoadedPlugins } from "../plugins/loader.ts";
20
+
21
+ type View =
22
+ | { type: "list" }
23
+ | { type: "detail"; task: Task }
24
+ | { type: "stats" }
25
+ | { type: "completed" }
26
+ | { type: "activity" }
27
+ | { type: "plugin"; name: string };
28
+
29
+ function App() {
30
+ const { exit } = useApp();
31
+ const [tasks, setTasks] = useState<Task[]>([]);
32
+ const [projects, setProjects] = useState<Project[]>([]);
33
+ const [labels, setLabels] = useState<Label[]>([]);
34
+ const [sections, setSections] = useState<Section[]>([]);
35
+ const [loading, setLoading] = useState(true);
36
+ const [error, setError] = useState<string | null>(null);
37
+ const [view, setView] = useState<View>({ type: "list" });
38
+ const [statusMessage, setStatusMessage] = useState("");
39
+ const [loadedPlugins, setLoadedPlugins] = useState<LoadedPlugins | null>(null);
40
+ const listStateRef = useRef<ListViewState | null>(null);
41
+
42
+ useEffect(() => {
43
+ let cancelled = false;
44
+
45
+ async function init() {
46
+ try {
47
+ const [t, p, l, s] = await Promise.all([
48
+ getTasks(),
49
+ getProjects(),
50
+ getLabels(),
51
+ getSections(),
52
+ ]);
53
+ if (!cancelled) {
54
+ setTasks(t);
55
+ setProjects(p);
56
+ setLabels(l);
57
+ setSections(s);
58
+ setLoading(false);
59
+
60
+ // Load plugins
61
+ try {
62
+ const hooks = createHookRegistry();
63
+ const viewReg = createViewRegistry();
64
+ const extReg = createExtensionRegistry();
65
+ const palReg = createPaletteRegistry();
66
+ const lp = await loadPlugins(hooks, viewReg, extReg, palReg);
67
+ if (!cancelled) setLoadedPlugins(lp);
68
+ } catch {
69
+ // Plugin loading failure is non-fatal
70
+ }
71
+ }
72
+ } catch (err) {
73
+ if (!cancelled) {
74
+ setError(err instanceof Error ? err.message : "Failed to load data");
75
+ setLoading(false);
76
+ }
77
+ }
78
+ }
79
+
80
+ init();
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, []);
85
+
86
+ const handleOpenTask = useCallback((task: Task) => {
87
+ setView({ type: "detail", task });
88
+ }, []);
89
+
90
+ const handleBackToList = useCallback(() => {
91
+ setView({ type: "list" });
92
+ }, []);
93
+
94
+ const handleNavigate = useCallback((viewName: string) => {
95
+ switch (viewName) {
96
+ case "stats":
97
+ setView({ type: "stats" });
98
+ break;
99
+ case "completed":
100
+ setView({ type: "completed" });
101
+ break;
102
+ case "activity":
103
+ setView({ type: "activity" });
104
+ break;
105
+ default: {
106
+ const pluginViews = loadedPlugins?.views.getViews() ?? [];
107
+ if (pluginViews.some(v => v.name === viewName)) {
108
+ setView({ type: "plugin", name: viewName });
109
+ } else {
110
+ setView({ type: "list" });
111
+ }
112
+ break;
113
+ }
114
+ }
115
+ }, [loadedPlugins]);
116
+
117
+ const handleTaskChanged = useCallback(async (message?: string) => {
118
+ if (message) setStatusMessage(message);
119
+ try {
120
+ const newTasks = await getTasks();
121
+ setTasks(newTasks);
122
+ } catch {
123
+ setStatusMessage("Failed to refresh tasks after change");
124
+ }
125
+ setView({ type: "list" });
126
+ }, []);
127
+
128
+ if (loading) {
129
+ return (
130
+ <Box flexDirection="column" justifyContent="center" alignItems="center" width="100%" height="100%">
131
+ <Text bold color="cyan">Todoist CLI</Text>
132
+ <Box marginTop={1}>
133
+ <Text color="gray">Loading your tasks...</Text>
134
+ </Box>
135
+ </Box>
136
+ );
137
+ }
138
+
139
+ if (error) {
140
+ return (
141
+ <Box flexDirection="column" justifyContent="center" alignItems="center" width="100%" height="100%">
142
+ <Text color="red">Error: {error}</Text>
143
+ <Text color="gray">Make sure you have configured your API token with: todoist auth login</Text>
144
+ </Box>
145
+ );
146
+ }
147
+
148
+ if (view.type === "detail") {
149
+ return (
150
+ <TaskDetailView
151
+ task={view.task}
152
+ allTasks={tasks}
153
+ projects={projects}
154
+ labels={labels}
155
+ onBack={handleBackToList}
156
+ onTaskChanged={handleTaskChanged}
157
+ pluginSections={loadedPlugins?.extensions.getDetailSections()}
158
+ pluginSectionContextMap={loadedPlugins?.detailSectionContextMap}
159
+ pluginHooks={loadedPlugins?.hooks ?? null}
160
+ />
161
+ );
162
+ }
163
+
164
+ if (view.type === "stats") {
165
+ return <StatsView onBack={handleBackToList} />;
166
+ }
167
+
168
+ if (view.type === "completed") {
169
+ return <CompletedView onBack={handleBackToList} />;
170
+ }
171
+
172
+ if (view.type === "activity") {
173
+ return <ActivityView onBack={handleBackToList} />;
174
+ }
175
+
176
+ if (view.type === "plugin" && loadedPlugins) {
177
+ const pluginView = loadedPlugins.views.getViews().find(v => v.name === view.name);
178
+ if (pluginView) {
179
+ const ctx = loadedPlugins.viewContextMap.get(view.name);
180
+ if (ctx) {
181
+ const Component = pluginView.component;
182
+ return (
183
+ <Component
184
+ onBack={handleBackToList}
185
+ onNavigate={handleNavigate}
186
+ ctx={ctx}
187
+ tasks={tasks}
188
+ projects={projects}
189
+ labels={labels}
190
+ />
191
+ );
192
+ }
193
+ }
194
+ }
195
+
196
+ return (
197
+ <TasksView
198
+ tasks={tasks}
199
+ projects={projects}
200
+ labels={labels}
201
+ onTasksChange={setTasks}
202
+ onProjectsChange={setProjects}
203
+ onLabelsChange={setLabels}
204
+ onQuit={exit}
205
+ onOpenTask={handleOpenTask}
206
+ sections={sections}
207
+ onNavigate={handleNavigate}
208
+ initialStatus={statusMessage}
209
+ onStatusClear={() => setStatusMessage("")}
210
+ savedStateRef={listStateRef}
211
+ pluginExtensions={loadedPlugins?.extensions ?? null}
212
+ pluginPalette={loadedPlugins?.palette ?? null}
213
+ pluginViews={loadedPlugins?.views ?? null}
214
+ pluginKeybindingContextMap={loadedPlugins?.keybindingContextMap}
215
+ pluginColumnContextMap={loadedPlugins?.columnContextMap}
216
+ pluginPaletteContextMap={loadedPlugins?.paletteContextMap}
217
+ pluginStatusBarContextMap={loadedPlugins?.statusBarContextMap}
218
+ pluginHooks={loadedPlugins?.hooks ?? null}
219
+ />
220
+ );
221
+ }
222
+
223
+ export async function launchUI(): Promise<void> {
224
+ // Enter alternate screen buffer (like vim/htop) — clean TUI, restores terminal on exit
225
+ process.stdout.write("\x1b[?1049h");
226
+ process.stdout.write("\x1b[H\x1b[2J");
227
+
228
+ const instance = render(<App />, { exitOnCtrlC: true });
229
+
230
+ await instance.waitUntilExit();
231
+
232
+ // Leave alternate screen buffer — restore previous terminal content
233
+ process.stdout.write("\x1b[?1049l");
234
+ }
@@ -0,0 +1,18 @@
1
+ import { Box, Text } from "ink";
2
+
3
+ interface BreadcrumbProps {
4
+ segments: Array<{ label: string; color?: string }>;
5
+ }
6
+
7
+ export function Breadcrumb({ segments }: BreadcrumbProps) {
8
+ return (
9
+ <Box>
10
+ {segments.map((seg, i) => (
11
+ <Text key={seg.label}>
12
+ {i > 0 && <Text color="gray"> / </Text>}
13
+ <Text color={seg.color ?? "white"} bold={i === segments.length - 1}>{seg.label}</Text>
14
+ </Text>
15
+ ))}
16
+ </Box>
17
+ );
18
+ }