@wangjs-jacky/ticktick-cli 0.1.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 (40) hide show
  1. package/.github/workflows/npm-publish.yml +26 -0
  2. package/CLAUDE.md +34 -0
  3. package/README.md +62 -0
  4. package/README_CN.md +62 -0
  5. package/bin/cli.ts +2 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +1490 -0
  8. package/dist/index.js.map +1 -0
  9. package/docs/oauth-credential-pre-validation.md +253 -0
  10. package/docs/reference/cli-usage-guide.md +587 -0
  11. package/docs/reference/dida365-open-api-zh.md +999 -0
  12. package/docs/reference/dida365-open-api.md +999 -0
  13. package/docs/reference/project-guide.md +63 -0
  14. package/docs/superpowers/plans/2026-04-03-tt-cli-auth.md +1110 -0
  15. package/docs/superpowers/specs/2026-04-03-tt-cli-design.md +142 -0
  16. package/package.json +45 -0
  17. package/skills/tt-cli-guide/SKILL.md +152 -0
  18. package/skills/tt-cli-guide/references/intent-mapping.md +169 -0
  19. package/src/api/client.ts +61 -0
  20. package/src/api/oauth.ts +146 -0
  21. package/src/api/resources.ts +291 -0
  22. package/src/commands/auth.ts +218 -0
  23. package/src/commands/project.ts +303 -0
  24. package/src/commands/task.ts +806 -0
  25. package/src/commands/user.ts +43 -0
  26. package/src/index.ts +46 -0
  27. package/src/types.ts +211 -0
  28. package/src/utils/config.ts +88 -0
  29. package/src/utils/endpoints.ts +22 -0
  30. package/src/utils/format.ts +71 -0
  31. package/src/utils/server.ts +81 -0
  32. package/tests/config.test.ts +87 -0
  33. package/tests/format.test.ts +56 -0
  34. package/tests/oauth.test.ts +42 -0
  35. package/tests/parity-fields.test.ts +89 -0
  36. package/tests/parity-map.ts +184 -0
  37. package/tests/parity.test.ts +101 -0
  38. package/tsconfig.json +22 -0
  39. package/tsup.config.ts +12 -0
  40. package/vitest.config.ts +7 -0
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatTaskTime } from '../src/utils/format.js';
3
+
4
+ describe('formatTaskTime', () => {
5
+ it('全天任务应返回 "全天"', () => {
6
+ expect(
7
+ formatTaskTime({ startDate: '2026-04-04T00:00:00+0000', isAllDay: true })
8
+ ).toBe('全天');
9
+ });
10
+
11
+ it('有起止时间应返回 "HH:mm-HH:mm"', () => {
12
+ expect(
13
+ formatTaskTime({
14
+ startDate: '2026-04-04T14:15:00+0000',
15
+ dueDate: '2026-04-04T14:45:00+0000',
16
+ isAllDay: false,
17
+ })
18
+ ).toBe('14:15-14:45');
19
+ });
20
+
21
+ it('仅有开始时间应返回 "HH:mm"', () => {
22
+ expect(formatTaskTime({ startDate: '2026-04-04T09:00:00+0000' })).toBe(
23
+ '09:00'
24
+ );
25
+ });
26
+
27
+ it('无时间字段应返回空字符串', () => {
28
+ expect(formatTaskTime({})).toBe('');
29
+ });
30
+
31
+ it('仅有截止日期无开始时间应返回空字符串', () => {
32
+ expect(formatTaskTime({ dueDate: '2026-04-04T14:45:00+0000' })).toBe('');
33
+ });
34
+
35
+ it('全天任务无 startDate 应返回空字符串', () => {
36
+ expect(formatTaskTime({ isAllDay: true })).toBe('');
37
+ });
38
+
39
+ it('起止时间相同时只显示一个', () => {
40
+ expect(
41
+ formatTaskTime({
42
+ startDate: '2026-04-04T10:00:00+0000',
43
+ dueDate: '2026-04-04T10:00:00+0000',
44
+ })
45
+ ).toBe('10:00');
46
+ });
47
+
48
+ it('应处理 +08:00 时区格式', () => {
49
+ expect(
50
+ formatTaskTime({
51
+ startDate: '2026-04-04T14:15:00+08:00',
52
+ dueDate: '2026-04-04T14:45:00+08:00',
53
+ })
54
+ ).toBe('14:15-14:45');
55
+ });
56
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateState, buildAuthUrl } from '../src/api/oauth.js';
3
+ import type { OAuthConfig } from '../src/types.js';
4
+
5
+ describe('OAuth utilities', () => {
6
+ it('generateState 应返回 32 位十六进制字符串', () => {
7
+ const state = generateState();
8
+ expect(state).toMatch(/^[0-9a-f]{32}$/);
9
+ });
10
+
11
+ it('generateState 每次应返回不同的值', () => {
12
+ const a = generateState();
13
+ const b = generateState();
14
+ expect(a).not.toBe(b);
15
+ });
16
+
17
+ it('buildAuthUrl 国内版应使用 dida365 域名', () => {
18
+ const config: OAuthConfig = { clientId: 'my-client-id', clientSecret: 'my-secret' };
19
+ const url = buildAuthUrl(config, 'test-state', 3000, 'cn');
20
+
21
+ expect(url).toContain('https://dida365.com/oauth/authorize');
22
+ expect(url).toContain('client_id=my-client-id');
23
+ expect(url).toContain('state=test-state');
24
+ });
25
+
26
+ it('buildAuthUrl 国际版应使用 ticktick 域名', () => {
27
+ const config: OAuthConfig = { clientId: 'my-client-id', clientSecret: 'my-secret' };
28
+ const url = buildAuthUrl(config, 'test-state', 3000, 'global');
29
+
30
+ expect(url).toContain('https://ticktick.com/oauth/authorize');
31
+ expect(url).toContain('client_id=my-client-id');
32
+ });
33
+
34
+ it('buildAuthUrl 不同端口应生成不同的 redirect_uri', () => {
35
+ const config: OAuthConfig = { clientId: 'id', clientSecret: 'secret' };
36
+ const url3000 = buildAuthUrl(config, 's', 3000, 'cn');
37
+ const url8080 = buildAuthUrl(config, 's', 8080, 'cn');
38
+
39
+ expect(url3000).toContain('localhost%3A3000');
40
+ expect(url8080).toContain('localhost%3A8080');
41
+ });
42
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { Task } from '../src/types.js';
3
+
4
+ /**
5
+ * MCP OpenTask schema 定义的所有字段
6
+ * 来源:dida365 MCP server 的 OpenTask 类型定义
7
+ */
8
+ const MCP_OPEN_TASK_FIELDS = [
9
+ 'id',
10
+ 'projectId',
11
+ 'sortOrder',
12
+ 'title',
13
+ 'content',
14
+ 'desc',
15
+ 'startDate',
16
+ 'dueDate',
17
+ 'timeZone',
18
+ 'isAllDay',
19
+ 'priority',
20
+ 'reminders',
21
+ 'repeatFlag',
22
+ 'completedTime',
23
+ 'status',
24
+ 'items',
25
+ 'tags',
26
+ 'columnId',
27
+ 'parentId',
28
+ 'childIds',
29
+ 'columnName',
30
+ 'assignor',
31
+ 'etag',
32
+ 'kind',
33
+ ] as const;
34
+
35
+ /**
36
+ * CLI Task 接口的所有字段
37
+ */
38
+ const CLI_TASK_FIELDS: (keyof Task)[] = [
39
+ 'id',
40
+ 'projectId',
41
+ 'title',
42
+ 'isAllDay',
43
+ 'completedTime',
44
+ 'content',
45
+ 'desc',
46
+ 'dueDate',
47
+ 'items',
48
+ 'priority',
49
+ 'reminders',
50
+ 'repeatFlag',
51
+ 'sortOrder',
52
+ 'startDate',
53
+ 'status',
54
+ 'timeZone',
55
+ 'kind',
56
+ 'tags',
57
+ 'etag',
58
+ 'columnId',
59
+ 'parentId',
60
+ 'childIds',
61
+ 'columnName',
62
+ 'assignor',
63
+ ];
64
+
65
+ describe('MCP-CLI 等价校验:Task 字段覆盖', () => {
66
+ it('CLI Task 类型应覆盖 MCP OpenTask 的所有核心字段', () => {
67
+ const cliSet = new Set<string>(CLI_TASK_FIELDS);
68
+ const missing = MCP_OPEN_TASK_FIELDS.filter((f) => !cliSet.has(f));
69
+
70
+ expect(missing, `CLI Task 缺少 MCP 字段: ${missing.join(', ')}`).toEqual([]);
71
+ });
72
+
73
+ it('CLI Task 字段数应 >= MCP OpenTask 字段数', () => {
74
+ const cliSet = new Set<string>(CLI_TASK_FIELDS);
75
+ const mcpSet = new Set<string>(MCP_OPEN_TASK_FIELDS);
76
+
77
+ expect(cliSet.size).toBeGreaterThanOrEqual(mcpSet.size);
78
+ });
79
+
80
+ it('CLI 可有 MCP 无的扩展字段(仅做信息性输出)', () => {
81
+ const mcpSet = new Set<string>(MCP_OPEN_TASK_FIELDS);
82
+ const cliExtra = CLI_TASK_FIELDS.filter((f) => !mcpSet.has(f));
83
+
84
+ // CLI 扩展字段是允许的,仅做日志输出
85
+ if (cliExtra.length > 0) {
86
+ console.log(` CLI 扩展字段(MCP 无对应): ${cliExtra.join(', ')}`);
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * MCP 工具与 CLI API 函数的等价映射表
3
+ *
4
+ * 用于自动化校验 MCP 工具 ↔ CLI API 函数的输入输出一致性。
5
+ * 每次新增 MCP 工具或 CLI API 函数时,需同步更新此表。
6
+ */
7
+
8
+ export interface ParityEntry {
9
+ /** MCP 工具名称 */
10
+ mcpTool: string;
11
+ /** CLI API 函数名称 (在 src/api/resources.ts 中) */
12
+ cliApiFn: string;
13
+ /** CLI 命令名称 (tt <command>) */
14
+ cliCommand: string;
15
+ /** API HTTP 方法 */
16
+ httpMethod: string;
17
+ /** API 路径模板 */
18
+ apiPath: string;
19
+ /** 备注 */
20
+ notes?: string;
21
+ }
22
+
23
+ /**
24
+ * 完整的 MCP 工具 → CLI 映射表
25
+ */
26
+ export const PARITY_MAP: ParityEntry[] = [
27
+ // ─── 项目 (Project) ────────────────────────────────
28
+ {
29
+ mcpTool: 'list_projects',
30
+ cliApiFn: 'getProjects',
31
+ cliCommand: 'project-list',
32
+ httpMethod: 'GET',
33
+ apiPath: 'project',
34
+ },
35
+ {
36
+ mcpTool: 'get_project_by_id',
37
+ cliApiFn: 'getProject',
38
+ cliCommand: 'project-get <id>',
39
+ httpMethod: 'GET',
40
+ apiPath: 'project/{projectId}',
41
+ },
42
+ {
43
+ mcpTool: 'get_project_with_undone_tasks',
44
+ cliApiFn: 'getProjectData',
45
+ cliCommand: 'project-tasks <id>',
46
+ httpMethod: 'GET',
47
+ apiPath: 'project/{projectId}/data',
48
+ },
49
+ {
50
+ mcpTool: 'create_project',
51
+ cliApiFn: 'createProject',
52
+ cliCommand: 'project-create [name]',
53
+ httpMethod: 'POST',
54
+ apiPath: 'project',
55
+ },
56
+ {
57
+ mcpTool: 'update_project',
58
+ cliApiFn: 'updateProject',
59
+ cliCommand: 'project-update <id>',
60
+ httpMethod: 'POST',
61
+ apiPath: 'project/{projectId}',
62
+ },
63
+
64
+ // ─── 任务 (Task) ──────────────────────────────────────
65
+ {
66
+ mcpTool: 'create_task',
67
+ cliApiFn: 'createTask',
68
+ cliCommand: 'task-add [title]',
69
+ httpMethod: 'POST',
70
+ apiPath: 'task',
71
+ },
72
+ {
73
+ mcpTool: 'get_task_in_project',
74
+ cliApiFn: 'getTask',
75
+ cliCommand: 'task-get <projectId> <taskId>',
76
+ httpMethod: 'GET',
77
+ apiPath: 'project/{projectId}/task/{taskId}',
78
+ },
79
+ {
80
+ mcpTool: 'get_task_by_id',
81
+ cliApiFn: 'getTaskById',
82
+ cliCommand: 'task-find <taskId>',
83
+ httpMethod: 'POST',
84
+ apiPath: 'task/filter (客户端过滤)',
85
+ notes: 'CLI 通过 filterTasks + 客户端匹配实现',
86
+ },
87
+ {
88
+ mcpTool: 'update_task',
89
+ cliApiFn: 'updateTask',
90
+ cliCommand: 'task-update <taskId>',
91
+ httpMethod: 'POST',
92
+ apiPath: 'task/{taskId}',
93
+ },
94
+ {
95
+ mcpTool: 'complete_task',
96
+ cliApiFn: 'completeTask',
97
+ cliCommand: 'task-done <projectId> <taskId>',
98
+ httpMethod: 'POST',
99
+ apiPath: 'project/{projectId}/task/{taskId}/complete',
100
+ },
101
+ {
102
+ mcpTool: 'batch_add_tasks',
103
+ cliApiFn: 'batchAddTasks',
104
+ cliCommand: 'task-batch-add [jsonFile]',
105
+ httpMethod: 'POST',
106
+ apiPath: 'task/batch',
107
+ },
108
+ {
109
+ mcpTool: 'batch_update_tasks',
110
+ cliApiFn: 'batchUpdateTasks',
111
+ cliCommand: 'task-batch-update [jsonFile]',
112
+ httpMethod: 'POST',
113
+ apiPath: 'task/batch',
114
+ },
115
+ {
116
+ mcpTool: 'complete_tasks_in_project',
117
+ cliApiFn: 'completeTasksInProject',
118
+ cliCommand: 'task-batch-done <projectId>',
119
+ httpMethod: 'POST',
120
+ apiPath: 'project/{projectId}/task/{taskId}/complete (循环调用)',
121
+ notes: 'CLI 循环调用 completeTask 实现',
122
+ },
123
+ {
124
+ mcpTool: 'filter_tasks',
125
+ cliApiFn: 'filterTasks',
126
+ cliCommand: 'task-list',
127
+ httpMethod: 'POST',
128
+ apiPath: 'task/filter',
129
+ },
130
+ {
131
+ mcpTool: 'list_completed_tasks_by_date',
132
+ cliApiFn: 'getCompletedTasks',
133
+ cliCommand: 'task-completed',
134
+ httpMethod: 'POST',
135
+ apiPath: 'task/completed',
136
+ },
137
+ {
138
+ mcpTool: 'list_undone_tasks_by_date',
139
+ cliApiFn: 'listUndoneTasksByDate',
140
+ cliCommand: 'task-undone --start --end',
141
+ httpMethod: 'POST',
142
+ apiPath: 'task/filter (status:[0])',
143
+ notes: 'CLI 封装 filterTasks 并添加 status:[0]',
144
+ },
145
+ {
146
+ mcpTool: 'list_undone_tasks_by_time_query',
147
+ cliApiFn: 'listUndoneTasksByTimeQuery',
148
+ cliCommand: 'task-undone --query <preset>',
149
+ httpMethod: 'POST',
150
+ apiPath: 'task/filter (计算日期范围 + status:[0])',
151
+ notes: 'CLI 计算本地日期范围后调用 listUndoneTasksByDate',
152
+ },
153
+ {
154
+ mcpTool: 'search_task',
155
+ cliApiFn: 'searchTask',
156
+ cliCommand: 'task-search <keyword>',
157
+ httpMethod: 'POST',
158
+ apiPath: 'task/filter (客户端关键词匹配)',
159
+ },
160
+ {
161
+ mcpTool: 'search',
162
+ cliApiFn: 'searchTask',
163
+ cliCommand: 'task-search <keyword>',
164
+ httpMethod: 'POST',
165
+ apiPath: 'task/filter (客户端关键词匹配)',
166
+ notes: '别名,对应同一个 CLI 函数',
167
+ },
168
+ {
169
+ mcpTool: 'move_task',
170
+ cliApiFn: 'moveTasks',
171
+ cliCommand: 'task-move <taskId>',
172
+ httpMethod: 'POST',
173
+ apiPath: 'task/move',
174
+ },
175
+
176
+ // ─── 用户 (User) ───────────────────────────────────────
177
+ {
178
+ mcpTool: 'get_user_preference',
179
+ cliApiFn: 'getUserPreference',
180
+ cliCommand: 'user-pref',
181
+ httpMethod: 'GET',
182
+ apiPath: 'user/info',
183
+ },
184
+ ];
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { PARITY_MAP } from './parity-map.js';
3
+
4
+ /**
5
+ * resources.ts 中实际导出的全部 API 函数名
6
+ * (每次新增 API 函数时需同步更新)
7
+ */
8
+ const RESOURCES_EXPORTED_FNS = [
9
+ // Project API
10
+ 'getProjects',
11
+ 'getProject',
12
+ 'getProjectData',
13
+ 'createProject',
14
+ 'updateProject',
15
+ 'deleteProject',
16
+ // Task API
17
+ 'getTask',
18
+ 'createTask',
19
+ 'updateTask',
20
+ 'completeTask',
21
+ 'deleteTask',
22
+ 'moveTasks',
23
+ 'getCompletedTasks',
24
+ 'filterTasks',
25
+ // Batch
26
+ 'batchAddTasks',
27
+ 'batchUpdateTasks',
28
+ 'completeTasksInProject',
29
+ // Query
30
+ 'getTaskById',
31
+ 'getUserPreference',
32
+ 'listUndoneTasksByDate',
33
+ 'listUndoneTasksByTimeQuery',
34
+ 'searchTask',
35
+ ] as const;
36
+
37
+ describe('MCP-CLI 等价校验:API 函数覆盖', () => {
38
+ it('映射表中每个 CLI API 函数名应存在于 resources.ts 导出列表中', () => {
39
+ const exportedSet = new Set(RESOURCES_EXPORTED_FNS);
40
+ const missing: string[] = [];
41
+
42
+ for (const entry of PARITY_MAP) {
43
+ if (!exportedSet.has(entry.cliApiFn)) {
44
+ missing.push(`${entry.mcpTool} -> resources.${entry.cliApiFn}`);
45
+ }
46
+ }
47
+
48
+ expect(missing, `以下 CLI API 函数在 resources.ts 中不存在:\n${missing.join('\n')}`).toEqual([]);
49
+ });
50
+
51
+ it('映射表应覆盖所有已知的 MCP 工具', () => {
52
+ const knownTools = [
53
+ 'list_projects',
54
+ 'get_project_by_id',
55
+ 'get_project_with_undone_tasks',
56
+ 'create_project',
57
+ 'update_project',
58
+ 'create_task',
59
+ 'get_task_in_project',
60
+ 'get_task_by_id',
61
+ 'update_task',
62
+ 'complete_task',
63
+ 'batch_add_tasks',
64
+ 'batch_update_tasks',
65
+ 'complete_tasks_in_project',
66
+ 'filter_tasks',
67
+ 'list_completed_tasks_by_date',
68
+ 'list_undone_tasks_by_date',
69
+ 'list_undone_tasks_by_time_query',
70
+ 'search_task',
71
+ 'search',
72
+ 'move_task',
73
+ 'get_user_preference',
74
+ ];
75
+
76
+ const mappedTools = new Set(PARITY_MAP.map((e) => e.mcpTool));
77
+ const unmapped = knownTools.filter((t) => !mappedTools.has(t));
78
+
79
+ expect(unmapped, `以下 MCP 工具未在映射表中:\n${unmapped.join('\n')}`).toEqual([]);
80
+ });
81
+
82
+ it('非别名条目不应有重复的 CLI API 函数', () => {
83
+ const nonAliasEntries = PARITY_MAP.filter(
84
+ (e) => !e.notes?.includes('别名')
85
+ );
86
+ const fns = nonAliasEntries.map((e) => e.cliApiFn);
87
+ const uniqueFns = new Set(fns);
88
+
89
+ expect(uniqueFns.size, `存在重复的 CLI API 函数映射`).toBe(fns.length);
90
+ });
91
+
92
+ it('每个映射条目都包含必要字段', () => {
93
+ for (const entry of PARITY_MAP) {
94
+ expect(entry.mcpTool, 'mcpTool 不能为空').toBeTruthy();
95
+ expect(entry.cliApiFn, 'cliApiFn 不能为空').toBeTruthy();
96
+ expect(entry.cliCommand, 'cliCommand 不能为空').toBeTruthy();
97
+ expect(entry.httpMethod, 'httpMethod 不能为空').toBeTruthy();
98
+ expect(entry.apiPath, 'apiPath 不能为空').toBeTruthy();
99
+ }
100
+ });
101
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "ignoreDeprecations": "6.0",
4
+ "target": "ES2022",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "lib": ["ES2022"],
8
+ "types": ["node"],
9
+ "outDir": "dist",
10
+ "rootDir": ".",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "declaration": true,
17
+ "declarationMap": true,
18
+ "sourceMap": true
19
+ },
20
+ "include": ["src/**/*", "bin/**/*", "tests/**/*"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ target: 'node18',
7
+ clean: true,
8
+ splitting: false,
9
+ sourcemap: true,
10
+ dts: true,
11
+ banner: { js: '#!/usr/bin/env node' },
12
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ },
7
+ });