ticktick-cli 0.1.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # TickTick CLI
2
+
3
+ A simple TypeScript CLI wrapper for the TickTick Open API documented at:
4
+
5
+ - https://developer.ticktick.com/
6
+ - https://developer.ticktick.com/docs#/openapi
7
+
8
+ This wrapper covers the documented OAuth flow plus every documented task and project endpoint.
9
+
10
+ ## What it covers
11
+
12
+ - OAuth authorization code flow
13
+ - OAuth authorize URL generation
14
+ - Get, create, update, complete, delete, move, list-completed, and filter task endpoints
15
+ - List, get, create, update, delete, and get-data project endpoints
16
+ - Raw authenticated request passthrough
17
+ - Local config storage for client credentials and access tokens
18
+ - `ticktick` and `dida365` service profiles, with manual base URL overrides when needed
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install
24
+ npm run build
25
+ ```
26
+
27
+ Run locally with:
28
+
29
+ ```bash
30
+ node dist/cli.js --help
31
+ ```
32
+
33
+ Or install the built CLI globally from this directory:
34
+
35
+ ```bash
36
+ npm install -g .
37
+ ticktick --help
38
+ ```
39
+
40
+ ## Configure
41
+
42
+ The CLI reads config in this order:
43
+
44
+ 1. Command flags
45
+ 2. Environment variables
46
+ 3. Local config file
47
+ 4. Built-in defaults
48
+
49
+ Useful environment variables:
50
+
51
+ ```bash
52
+ TICKTICK_SERVICE=ticktick
53
+ TICKTICK_CLIENT_ID=...
54
+ TICKTICK_CLIENT_SECRET=...
55
+ TICKTICK_REDIRECT_URI=http://127.0.0.1:18463/callback
56
+ TICKTICK_SCOPES="tasks:read tasks:write"
57
+ TICKTICK_ACCESS_TOKEN=...
58
+ TICKTICK_API_BASE_URL=https://api.ticktick.com
59
+ TICKTICK_AUTH_BASE_URL=https://ticktick.com
60
+ TICKTICK_CONFIG_FILE=/custom/path/config.json
61
+ ```
62
+
63
+ You can also persist config values:
64
+
65
+ ```bash
66
+ ticktick config set clientId YOUR_CLIENT_ID
67
+ ticktick config set clientSecret YOUR_CLIENT_SECRET
68
+ ticktick config set redirectUri http://127.0.0.1:18463/callback
69
+ ticktick config show
70
+ ```
71
+
72
+ ## Auth
73
+
74
+ Interactive login:
75
+
76
+ ```bash
77
+ ticktick auth login
78
+ ```
79
+
80
+ If you already have an authorization code:
81
+
82
+ ```bash
83
+ ticktick auth exchange YOUR_CODE
84
+ ```
85
+
86
+ Print the authorize URL without starting the callback server:
87
+
88
+ ```bash
89
+ ticktick auth url
90
+ ```
91
+
92
+ Check current auth state:
93
+
94
+ ```bash
95
+ ticktick auth status
96
+ ```
97
+
98
+ Clear the stored access token:
99
+
100
+ ```bash
101
+ ticktick auth logout
102
+ ```
103
+
104
+ ## Examples
105
+
106
+ List projects:
107
+
108
+ ```bash
109
+ ticktick project list
110
+ ```
111
+
112
+ Get project details:
113
+
114
+ ```bash
115
+ ticktick project get 6226ff9877acee87727f6bca
116
+ ticktick project data 6226ff9877acee87727f6bca
117
+ ```
118
+
119
+ Create a project:
120
+
121
+ ```bash
122
+ ticktick project create --name "Inbox" --color "#F18181" --view-mode list --kind TASK
123
+ ```
124
+
125
+ Create a task:
126
+
127
+ ```bash
128
+ ticktick task create --project-id 6226ff9877acee87727f6bca --title "Ship CLI"
129
+ ```
130
+
131
+ Update a task:
132
+
133
+ ```bash
134
+ ticktick task update 63b7bebb91c0a5474805fcd4 --project-id 6226ff9877acee87727f6bca --priority 3
135
+ ```
136
+
137
+ Move one task:
138
+
139
+ ```bash
140
+ ticktick task move \
141
+ --from-project-id 69a850ef1c20d2030e148fdd \
142
+ --to-project-id 69a850f41c20d2030e148fdf \
143
+ --task-id 69a850f8b9061f374d54a046
144
+ ```
145
+
146
+ Filter tasks using JSON:
147
+
148
+ ```bash
149
+ ticktick task filter --json '{
150
+ "projectIds": ["69a850f41c20d2030e148fdf"],
151
+ "startDate": "2026-03-01T00:58:20.000+0000",
152
+ "endDate": "2026-03-06T10:58:20.000+0000",
153
+ "priority": [0],
154
+ "tag": ["urgent"],
155
+ "status": [0]
156
+ }'
157
+ ```
158
+
159
+ Move multiple tasks from a file:
160
+
161
+ ```bash
162
+ ticktick task move --json-file ./moves.json
163
+ ```
164
+
165
+ Pipe JSON into a command:
166
+
167
+ ```bash
168
+ echo '{"name":"Planning","kind":"TASK"}' | ticktick project create
169
+ ```
170
+
171
+ Send a raw authenticated request:
172
+
173
+ ```bash
174
+ ticktick request GET /open/v1/project
175
+ ```
176
+
177
+ Send a raw request to a full URL without bearer auth:
178
+
179
+ ```bash
180
+ ticktick request POST https://httpbin.org/post --no-auth --json '{"hello":"world"}'
181
+ ```
182
+
183
+ ## Notes
184
+
185
+ - The docs currently show `api.ticktick.com` for most endpoints, but `api.dida365.com` in the examples for `task/move`, `task/completed`, and `task/filter`. This CLI defaults to the selected service profile and lets you override base URLs explicitly if your account needs something different.
186
+ - The CLI writes config to a local JSON file under the OS-specific app config directory unless `--config-file` or `TICKTICK_CONFIG_FILE` is set.
187
+ - Access tokens and client secrets are stored as plain text in that config file. That keeps the wrapper simple, but it is not a secure keychain integration.
package/dist/auth.js ADDED
@@ -0,0 +1,110 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ export function buildAuthorizationUrl(config, state = randomUUID()) {
7
+ const url = new URL("/oauth/authorize", config.authBaseUrl);
8
+ url.searchParams.set("scope", config.scopes);
9
+ url.searchParams.set("client_id", config.clientId ?? "");
10
+ url.searchParams.set("state", state);
11
+ url.searchParams.set("redirect_uri", config.redirectUri ?? "");
12
+ url.searchParams.set("response_type", "code");
13
+ return { url: url.toString(), state };
14
+ }
15
+ export async function exchangeAuthorizationCode(config, code, fetchImpl = fetch) {
16
+ if (!config.clientId || !config.clientSecret) {
17
+ throw new Error("Client credentials are required. Provide them with flags, env vars, or `ticktick config set`.");
18
+ }
19
+ const tokenUrl = new URL("/oauth/token", config.authBaseUrl);
20
+ const params = new URLSearchParams({
21
+ grant_type: "authorization_code",
22
+ code,
23
+ scope: config.scopes,
24
+ redirect_uri: config.redirectUri ?? "",
25
+ });
26
+ const response = await fetchImpl(tokenUrl, {
27
+ method: "POST",
28
+ headers: {
29
+ Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`,
30
+ "Content-Type": "application/x-www-form-urlencoded",
31
+ },
32
+ body: params,
33
+ });
34
+ const raw = await response.text();
35
+ const parsed = raw.length > 0 ? JSON.parse(raw) : {};
36
+ if (!response.ok) {
37
+ throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(parsed)}`);
38
+ }
39
+ return parsed;
40
+ }
41
+ export function isLoopbackRedirect(redirectUri) {
42
+ if (!redirectUri) {
43
+ return false;
44
+ }
45
+ const parsed = new URL(redirectUri);
46
+ return (parsed.protocol === "http:" &&
47
+ ["127.0.0.1", "localhost"].includes(parsed.hostname));
48
+ }
49
+ export async function waitForOAuthCode(redirectUri, expectedState, timeoutMs = 120_000) {
50
+ const redirect = new URL(redirectUri);
51
+ const hostname = redirect.hostname === "localhost" ? "127.0.0.1" : redirect.hostname;
52
+ const port = Number.parseInt(redirect.port || "80", 10);
53
+ const callbackPath = redirect.pathname || "/";
54
+ return new Promise((resolve, reject) => {
55
+ const timeout = setTimeout(() => {
56
+ server.close();
57
+ reject(new Error("Timed out waiting for the OAuth callback."));
58
+ }, timeoutMs);
59
+ const server = createServer((request, response) => {
60
+ const requestUrl = new URL(request.url ?? "/", redirect.origin);
61
+ if (requestUrl.pathname !== callbackPath) {
62
+ response.statusCode = 404;
63
+ response.end("Not Found");
64
+ return;
65
+ }
66
+ const code = requestUrl.searchParams.get("code");
67
+ const state = requestUrl.searchParams.get("state");
68
+ if (!code) {
69
+ response.statusCode = 400;
70
+ response.end("Missing authorization code.");
71
+ return;
72
+ }
73
+ if (state !== expectedState) {
74
+ response.statusCode = 400;
75
+ response.end("State mismatch.");
76
+ clearTimeout(timeout);
77
+ server.close();
78
+ reject(new Error("OAuth state mismatch."));
79
+ return;
80
+ }
81
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
82
+ response.end("<html><body><h1>TickTick CLI</h1><p>Authorization complete. You can close this window.</p></body></html>");
83
+ clearTimeout(timeout);
84
+ server.close();
85
+ resolve(code);
86
+ });
87
+ server.on("error", (error) => {
88
+ clearTimeout(timeout);
89
+ reject(error);
90
+ });
91
+ server.listen(port, hostname);
92
+ });
93
+ }
94
+ export async function openBrowser(url) {
95
+ try {
96
+ if (process.platform === "win32") {
97
+ await execFileAsync("cmd", ["/c", "start", "", url]);
98
+ return;
99
+ }
100
+ if (process.platform === "darwin") {
101
+ await execFileAsync("open", [url]);
102
+ return;
103
+ }
104
+ await execFileAsync("xdg-open", [url]);
105
+ }
106
+ catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ process.stderr.write(`Could not open a browser automatically: ${message}\n`);
109
+ }
110
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { buildAuthorizationUrl, exchangeAuthorizationCode, isLoopbackRedirect, openBrowser, waitForOAuthCode, } from "./auth.js";
4
+ import { TickTickClient } from "./client.js";
5
+ import { loadStoredConfig, resolveRuntimeConfig, saveStoredConfig, validateService, } from "./config.js";
6
+ import { loadJsonValue, maskSecret, mergeDefined, parseBoolean, parseInteger, printJson, } from "./utils.js";
7
+ const CONFIG_KEYS = new Set([
8
+ "service",
9
+ "clientId",
10
+ "clientSecret",
11
+ "redirectUri",
12
+ "scopes",
13
+ "accessToken",
14
+ "apiBaseUrl",
15
+ "authBaseUrl",
16
+ ]);
17
+ const program = new Command();
18
+ program
19
+ .name("ticktick")
20
+ .description("Simple CLI wrapper for the TickTick Open API")
21
+ .option("--config-file <path>", "Custom config file path")
22
+ .option("--service <service>", 'Service: "ticktick" or "dida365"')
23
+ .option("--api-base-url <url>", "Override the API base URL")
24
+ .option("--auth-base-url <url>", "Override the OAuth base URL")
25
+ .option("--access-token <token>", "Override the access token for a single command")
26
+ .option("--client-id <id>", "Override the OAuth client id")
27
+ .option("--client-secret <secret>", "Override the OAuth client secret")
28
+ .option("--redirect-uri <uri>", "Override the OAuth redirect URI")
29
+ .option("--scopes <scopes>", "Override the OAuth scopes");
30
+ buildAuthCommands(program);
31
+ buildConfigCommands(program);
32
+ buildTaskCommands(program);
33
+ buildProjectCommands(program);
34
+ buildRequestCommand(program);
35
+ program.parseAsync(process.argv).catch(handleError);
36
+ function buildAuthCommands(root) {
37
+ const auth = root.command("auth").description("OAuth and token management");
38
+ auth
39
+ .command("url")
40
+ .description("Print the OAuth authorize URL without opening a browser")
41
+ .option("--state <value>", "Override the OAuth state value")
42
+ .action(async (...args) => {
43
+ const command = args.at(-1);
44
+ const options = command.optsWithGlobals();
45
+ const config = await resolveRuntimeConfig(runtimeOverrides(options));
46
+ requireClientId(config);
47
+ const result = buildAuthorizationUrl(config, options.state);
48
+ printJson(result);
49
+ });
50
+ auth
51
+ .command("login")
52
+ .description("Open the OAuth flow, exchange the code, and store the access token")
53
+ .option("--timeout-ms <number>", "Timeout while waiting for the callback", parseInteger, 120000)
54
+ .action(async (...args) => {
55
+ const command = args.at(-1);
56
+ const options = command.optsWithGlobals();
57
+ const config = await resolveRuntimeConfig(runtimeOverrides(options));
58
+ requireClientCredentials(config);
59
+ const { url, state } = buildAuthorizationUrl(config);
60
+ process.stderr.write(`Authorize URL:\n${url}\n`);
61
+ if (!isLoopbackRedirect(config.redirectUri)) {
62
+ printJson({
63
+ ok: false,
64
+ reason: "redirect_uri is not a local HTTP callback. Open the URL above and then run `ticktick auth exchange <code>`.",
65
+ });
66
+ return;
67
+ }
68
+ const codePromise = waitForOAuthCode(config.redirectUri, state, options.timeoutMs);
69
+ await openBrowser(url);
70
+ const code = await codePromise;
71
+ const token = await exchangeAuthorizationCode(config, code);
72
+ await persistConfig(config, token.access_token);
73
+ printJson(token);
74
+ });
75
+ auth
76
+ .command("exchange <code>")
77
+ .description("Exchange an authorization code for an access token")
78
+ .action(async (...args) => {
79
+ const command = args.at(-1);
80
+ const [code] = args;
81
+ const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
82
+ requireClientCredentials(config);
83
+ const token = await exchangeAuthorizationCode(config, code);
84
+ await persistConfig(config, token.access_token);
85
+ printJson(token);
86
+ });
87
+ auth
88
+ .command("status")
89
+ .description("Show the resolved auth configuration")
90
+ .option("--show-secrets", "Include client secret and access token")
91
+ .action(async (...args) => {
92
+ const command = args.at(-1);
93
+ const options = command.optsWithGlobals();
94
+ const config = await resolveRuntimeConfig(runtimeOverrides(options));
95
+ printJson({
96
+ service: config.service,
97
+ configFile: config.configFile,
98
+ clientId: config.clientId,
99
+ clientSecret: options.showSecrets ? config.clientSecret : maskSecret(config.clientSecret),
100
+ redirectUri: config.redirectUri,
101
+ scopes: config.scopes,
102
+ accessToken: options.showSecrets ? config.accessToken : maskSecret(config.accessToken),
103
+ apiBaseUrl: config.apiBaseUrl,
104
+ authBaseUrl: config.authBaseUrl,
105
+ });
106
+ });
107
+ auth
108
+ .command("logout")
109
+ .description("Remove the stored access token from the config file")
110
+ .action(async (...args) => {
111
+ const command = args.at(-1);
112
+ const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
113
+ const stored = await loadStoredConfig(config.configFile);
114
+ delete stored.accessToken;
115
+ await saveStoredConfig(config.configFile, stored);
116
+ printJson({ ok: true });
117
+ });
118
+ }
119
+ function buildConfigCommands(root) {
120
+ const config = root.command("config").description("Read and write local CLI config");
121
+ config
122
+ .command("show")
123
+ .description("Show the resolved configuration")
124
+ .option("--show-secrets", "Include stored secrets")
125
+ .action(async (...args) => {
126
+ const command = args.at(-1);
127
+ const options = command.optsWithGlobals();
128
+ const resolved = await resolveRuntimeConfig(runtimeOverrides(options));
129
+ printJson({
130
+ service: resolved.service,
131
+ configFile: resolved.configFile,
132
+ clientId: resolved.clientId,
133
+ clientSecret: options.showSecrets ? resolved.clientSecret : maskSecret(resolved.clientSecret),
134
+ redirectUri: resolved.redirectUri,
135
+ scopes: resolved.scopes,
136
+ accessToken: options.showSecrets ? resolved.accessToken : maskSecret(resolved.accessToken),
137
+ apiBaseUrl: resolved.apiBaseUrl,
138
+ authBaseUrl: resolved.authBaseUrl,
139
+ });
140
+ });
141
+ config
142
+ .command("set <key> <value>")
143
+ .description("Set a config value in the local config file")
144
+ .action(async (...args) => {
145
+ const command = args.at(-1);
146
+ const [key, value] = args;
147
+ const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
148
+ if (!CONFIG_KEYS.has(key)) {
149
+ throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
150
+ }
151
+ const stored = await loadStoredConfig(runtime.configFile);
152
+ const normalizedValue = key === "service" ? validateService(value) : value;
153
+ const next = { ...stored, [key]: normalizedValue };
154
+ await saveStoredConfig(runtime.configFile, next);
155
+ printJson(next);
156
+ });
157
+ config
158
+ .command("unset <key>")
159
+ .description("Remove a config value from the local config file")
160
+ .action(async (...args) => {
161
+ const command = args.at(-1);
162
+ const [key] = args;
163
+ const runtime = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
164
+ if (!CONFIG_KEYS.has(key)) {
165
+ throw new Error(`Unsupported config key "${key}". Allowed keys: ${Array.from(CONFIG_KEYS).join(", ")}`);
166
+ }
167
+ const stored = await loadStoredConfig(runtime.configFile);
168
+ delete stored[key];
169
+ await saveStoredConfig(runtime.configFile, stored);
170
+ printJson(stored);
171
+ });
172
+ }
173
+ function buildTaskCommands(root) {
174
+ const task = root.command("task").description("Task endpoints");
175
+ task
176
+ .command("get <projectId> <taskId>")
177
+ .description("Get a task by project id and task id")
178
+ .action(async (...args) => {
179
+ const [projectId, taskId] = args;
180
+ await runClientCommand(args, (client) => client.getTask(projectId, taskId));
181
+ });
182
+ withJsonBody(task
183
+ .command("create")
184
+ .description("Create a task")
185
+ .option("--project-id <id>", "Project id")
186
+ .option("--title <title>", "Task title")
187
+ .option("--content <content>", "Task content")
188
+ .option("--desc <desc>", "Task description")
189
+ .option("--start-date <date>", "Start date in yyyy-MM-dd'T'HH:mm:ssZ format")
190
+ .option("--due-date <date>", "Due date in yyyy-MM-dd'T'HH:mm:ssZ format")
191
+ .option("--time-zone <tz>", "Time zone")
192
+ .option("--repeat-flag <rrule>", "Recurring rule")
193
+ .option("--priority <number>", "Priority", parseInteger)
194
+ .option("--sort-order <number>", "Sort order", parseInteger)
195
+ .option("--all-day <boolean>", "All day", parseBoolean)).action(async (...args) => {
196
+ const command = args.at(-1);
197
+ const options = command.optsWithGlobals();
198
+ const payload = (await loadObjectPayload(options, {
199
+ projectId: options.projectId,
200
+ title: options.title,
201
+ content: options.content,
202
+ desc: options.desc,
203
+ startDate: options.startDate,
204
+ dueDate: options.dueDate,
205
+ timeZone: options.timeZone,
206
+ repeatFlag: options.repeatFlag,
207
+ priority: options.priority,
208
+ sortOrder: options.sortOrder,
209
+ isAllDay: options.allDay,
210
+ }));
211
+ if (!payload.projectId || !payload.title) {
212
+ throw new Error("Task creation requires both projectId and title.");
213
+ }
214
+ await runClientCommand(args, (client) => client.createTask(payload));
215
+ });
216
+ withJsonBody(task
217
+ .command("update <taskId>")
218
+ .description("Update a task")
219
+ .option("--project-id <id>", "Project id")
220
+ .option("--title <title>", "Task title")
221
+ .option("--content <content>", "Task content")
222
+ .option("--desc <desc>", "Task description")
223
+ .option("--start-date <date>", "Start date in yyyy-MM-dd'T'HH:mm:ssZ format")
224
+ .option("--due-date <date>", "Due date in yyyy-MM-dd'T'HH:mm:ssZ format")
225
+ .option("--time-zone <tz>", "Time zone")
226
+ .option("--repeat-flag <rrule>", "Recurring rule")
227
+ .option("--priority <number>", "Priority", parseInteger)
228
+ .option("--sort-order <number>", "Sort order", parseInteger)
229
+ .option("--all-day <boolean>", "All day", parseBoolean)).action(async (...args) => {
230
+ const command = args.at(-1);
231
+ const [taskId] = args;
232
+ const options = command.optsWithGlobals();
233
+ const payload = (await loadObjectPayload(options, {
234
+ id: taskId,
235
+ projectId: options.projectId,
236
+ title: options.title,
237
+ content: options.content,
238
+ desc: options.desc,
239
+ startDate: options.startDate,
240
+ dueDate: options.dueDate,
241
+ timeZone: options.timeZone,
242
+ repeatFlag: options.repeatFlag,
243
+ priority: options.priority,
244
+ sortOrder: options.sortOrder,
245
+ isAllDay: options.allDay,
246
+ }));
247
+ payload.id = taskId;
248
+ if (!payload.projectId) {
249
+ throw new Error("Task update requires projectId.");
250
+ }
251
+ await runClientCommand(args, (client) => client.updateTask(taskId, payload));
252
+ });
253
+ task
254
+ .command("complete <projectId> <taskId>")
255
+ .description("Complete a task")
256
+ .action(async (...args) => {
257
+ const [projectId, taskId] = args;
258
+ await runClientCommand(args, (client) => client.completeTask(projectId, taskId));
259
+ });
260
+ task
261
+ .command("delete <projectId> <taskId>")
262
+ .description("Delete a task")
263
+ .action(async (...args) => {
264
+ const [projectId, taskId] = args;
265
+ await runClientCommand(args, (client) => client.deleteTask(projectId, taskId));
266
+ });
267
+ withJsonBody(task
268
+ .command("move")
269
+ .description("Move one or more tasks between projects")
270
+ .option("--from-project-id <id>", "Source project id")
271
+ .option("--to-project-id <id>", "Destination project id")
272
+ .option("--task-id <id>", "Task id")).action(async (...args) => {
273
+ const command = args.at(-1);
274
+ const options = command.optsWithGlobals();
275
+ let payload = (await loadJsonValue(options.json, options.jsonFile));
276
+ if (!payload) {
277
+ if (!options.fromProjectId || !options.toProjectId || !options.taskId) {
278
+ throw new Error("Provide --json/--json-file or all of --from-project-id, --to-project-id, and --task-id.");
279
+ }
280
+ payload = [
281
+ {
282
+ fromProjectId: options.fromProjectId,
283
+ toProjectId: options.toProjectId,
284
+ taskId: options.taskId,
285
+ },
286
+ ];
287
+ }
288
+ if (!Array.isArray(payload)) {
289
+ throw new Error("Move payload must be a JSON array.");
290
+ }
291
+ await runClientCommand(args, (client) => client.moveTasks(payload));
292
+ });
293
+ withJsonBody(task
294
+ .command("completed")
295
+ .description("List completed tasks")
296
+ .option("--project-id <id>", "Project id", collectString, [])
297
+ .option("--start-date <date>", "Start of completedTime range")
298
+ .option("--end-date <date>", "End of completedTime range")).action(async (...args) => {
299
+ const command = args.at(-1);
300
+ const options = command.optsWithGlobals();
301
+ const payload = (await loadObjectPayload(options, {
302
+ projectIds: options.projectId && options.projectId.length > 0 ? options.projectId : undefined,
303
+ startDate: options.startDate,
304
+ endDate: options.endDate,
305
+ }));
306
+ await runClientCommand(args, (client) => client.listCompletedTasks(payload));
307
+ });
308
+ withJsonBody(task
309
+ .command("filter")
310
+ .description("Filter tasks")
311
+ .option("--project-id <id>", "Project id", collectString, [])
312
+ .option("--start-date <date>", "Start date lower bound")
313
+ .option("--end-date <date>", "Start date upper bound")
314
+ .option("--priority <number>", "Priority filter", collectInteger, [])
315
+ .option("--tag <tag>", "Tag filter", collectString, [])
316
+ .option("--status <number>", "Status filter", collectInteger, [])).action(async (...args) => {
317
+ const command = args.at(-1);
318
+ const options = command.optsWithGlobals();
319
+ const payload = (await loadObjectPayload(options, {
320
+ projectIds: options.projectId && options.projectId.length > 0 ? options.projectId : undefined,
321
+ startDate: options.startDate,
322
+ endDate: options.endDate,
323
+ priority: options.priority && options.priority.length > 0 ? options.priority : undefined,
324
+ tag: options.tag && options.tag.length > 0 ? options.tag : undefined,
325
+ status: options.status && options.status.length > 0 ? options.status : undefined,
326
+ }));
327
+ await runClientCommand(args, (client) => client.filterTasks(payload));
328
+ });
329
+ }
330
+ function buildProjectCommands(root) {
331
+ const project = root.command("project").description("Project endpoints");
332
+ project
333
+ .command("list")
334
+ .description("List user projects")
335
+ .action(async (...args) => {
336
+ await runClientCommand(args, (client) => client.listProjects());
337
+ });
338
+ project
339
+ .command("get <projectId>")
340
+ .description("Get a project by id")
341
+ .action(async (...args) => {
342
+ const [projectId] = args;
343
+ await runClientCommand(args, (client) => client.getProject(projectId));
344
+ });
345
+ project
346
+ .command("data <projectId>")
347
+ .description("Get a project together with tasks and columns")
348
+ .action(async (...args) => {
349
+ const [projectId] = args;
350
+ await runClientCommand(args, (client) => client.getProjectData(projectId));
351
+ });
352
+ withJsonBody(project
353
+ .command("create")
354
+ .description("Create a project")
355
+ .option("--name <name>", "Project name")
356
+ .option("--color <color>", 'Project color, for example "#F18181"')
357
+ .option("--sort-order <number>", "Sort order", parseInteger)
358
+ .option("--view-mode <mode>", 'View mode: "list", "kanban", or "timeline"')
359
+ .option("--kind <kind>", 'Kind: "TASK" or "NOTE"')).action(async (...args) => {
360
+ const command = args.at(-1);
361
+ const options = command.optsWithGlobals();
362
+ const payload = (await loadObjectPayload(options, {
363
+ name: options.name,
364
+ color: options.color,
365
+ sortOrder: options.sortOrder,
366
+ viewMode: options.viewMode,
367
+ kind: options.kind,
368
+ }));
369
+ if (!payload.name) {
370
+ throw new Error("Project creation requires name.");
371
+ }
372
+ await runClientCommand(args, (client) => client.createProject(payload));
373
+ });
374
+ withJsonBody(project
375
+ .command("update <projectId>")
376
+ .description("Update a project")
377
+ .option("--name <name>", "Project name")
378
+ .option("--color <color>", 'Project color, for example "#F18181"')
379
+ .option("--sort-order <number>", "Sort order", parseInteger)
380
+ .option("--view-mode <mode>", 'View mode: "list", "kanban", or "timeline"')
381
+ .option("--kind <kind>", 'Kind: "TASK" or "NOTE"')).action(async (...args) => {
382
+ const command = args.at(-1);
383
+ const [projectId] = args;
384
+ const options = command.optsWithGlobals();
385
+ const payload = (await loadObjectPayload(options, {
386
+ name: options.name,
387
+ color: options.color,
388
+ sortOrder: options.sortOrder,
389
+ viewMode: options.viewMode,
390
+ kind: options.kind,
391
+ }));
392
+ await runClientCommand(args, (client) => client.updateProject(projectId, payload));
393
+ });
394
+ project
395
+ .command("delete <projectId>")
396
+ .description("Delete a project")
397
+ .action(async (...args) => {
398
+ const [projectId] = args;
399
+ await runClientCommand(args, (client) => client.deleteProject(projectId));
400
+ });
401
+ }
402
+ function buildRequestCommand(root) {
403
+ withJsonBody(root
404
+ .command("request <method> <path>")
405
+ .description("Send a raw request to the configured API base URL, or to a full URL")
406
+ .option("--no-auth", "Skip bearer auth on this request")).action(async (...args) => {
407
+ const command = args.at(-1);
408
+ const [method, path] = args;
409
+ const options = command.optsWithGlobals();
410
+ const config = await resolveRuntimeConfig(runtimeOverrides(options));
411
+ const client = new TickTickClient(config);
412
+ const body = await loadJsonValue(options.json, options.jsonFile);
413
+ const result = await client.requestRaw(method.toUpperCase(), path, body, { auth: options.auth });
414
+ printJson(result ?? { ok: true });
415
+ });
416
+ }
417
+ function withJsonBody(command) {
418
+ return command
419
+ .option("--json <json>", "Inline JSON body")
420
+ .option("--json-file <path>", "Path to a JSON body file");
421
+ }
422
+ function runtimeOverrides(options) {
423
+ return {
424
+ configFile: options.configFile,
425
+ service: options.service ? validateService(options.service) : undefined,
426
+ apiBaseUrl: options.apiBaseUrl,
427
+ authBaseUrl: options.authBaseUrl,
428
+ accessToken: options.accessToken,
429
+ clientId: options.clientId,
430
+ clientSecret: options.clientSecret,
431
+ redirectUri: options.redirectUri,
432
+ scopes: options.scopes,
433
+ };
434
+ }
435
+ async function runClientCommand(args, runner) {
436
+ const command = args.at(-1);
437
+ const config = await resolveRuntimeConfig(runtimeOverrides(command.optsWithGlobals()));
438
+ const client = new TickTickClient(config);
439
+ const result = await runner(client, config);
440
+ printJson(result ?? { ok: true });
441
+ }
442
+ async function loadObjectPayload(options, flags) {
443
+ const loaded = await loadJsonValue(options.json, options.jsonFile);
444
+ if (loaded === undefined) {
445
+ return mergeDefined({}, flags);
446
+ }
447
+ if (!isPlainObject(loaded)) {
448
+ throw new Error("Expected a JSON object payload.");
449
+ }
450
+ return mergeDefined(loaded, flags);
451
+ }
452
+ function isPlainObject(value) {
453
+ return typeof value === "object" && value !== null && !Array.isArray(value);
454
+ }
455
+ async function persistConfig(runtime, accessToken) {
456
+ const stored = await loadStoredConfig(runtime.configFile);
457
+ const next = {
458
+ ...stored,
459
+ service: runtime.service,
460
+ clientId: runtime.clientId,
461
+ clientSecret: runtime.clientSecret,
462
+ redirectUri: runtime.redirectUri,
463
+ scopes: runtime.scopes,
464
+ apiBaseUrl: runtime.apiBaseUrl,
465
+ authBaseUrl: runtime.authBaseUrl,
466
+ accessToken,
467
+ };
468
+ await saveStoredConfig(runtime.configFile, next);
469
+ }
470
+ function requireClientCredentials(config) {
471
+ if (!config.clientId || !config.clientSecret) {
472
+ throw new Error("Client credentials are required. Set TICKTICK_CLIENT_ID and TICKTICK_CLIENT_SECRET or use `ticktick config set`.");
473
+ }
474
+ }
475
+ function requireClientId(config) {
476
+ if (!config.clientId) {
477
+ throw new Error("Client id is required. Set TICKTICK_CLIENT_ID or use `ticktick config set clientId ...`.");
478
+ }
479
+ }
480
+ function collectString(value, previous = []) {
481
+ return [...previous, value];
482
+ }
483
+ function collectInteger(value, previous = []) {
484
+ return [...previous, parseInteger(value)];
485
+ }
486
+ function handleError(error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ process.stderr.write(`${message}\n`);
489
+ process.exitCode = 1;
490
+ }
package/dist/client.js ADDED
@@ -0,0 +1,101 @@
1
+ export class TickTickApiError extends Error {
2
+ status;
3
+ payload;
4
+ constructor(status, payload) {
5
+ const summary = typeof payload === "string"
6
+ ? payload
7
+ : payload
8
+ ? JSON.stringify(payload)
9
+ : "No response body";
10
+ super(`TickTick API request failed with status ${status}: ${summary}`);
11
+ this.name = "TickTickApiError";
12
+ this.status = status;
13
+ this.payload = payload;
14
+ }
15
+ }
16
+ export class TickTickClient {
17
+ config;
18
+ fetchImpl;
19
+ constructor(config, fetchImpl = fetch) {
20
+ this.config = config;
21
+ this.fetchImpl = fetchImpl;
22
+ }
23
+ async requestRaw(method, pathOrUrl, body, options = {}) {
24
+ const requiresAuth = options.auth ?? true;
25
+ if (requiresAuth && !this.config.accessToken) {
26
+ throw new Error("No access token available. Run `ticktick auth login` or provide TICKTICK_ACCESS_TOKEN.");
27
+ }
28
+ const url = /^https?:\/\//.test(pathOrUrl)
29
+ ? pathOrUrl
30
+ : `${this.config.apiBaseUrl}${pathOrUrl}`;
31
+ const response = await this.fetchImpl(url, {
32
+ method,
33
+ headers: {
34
+ ...(requiresAuth
35
+ ? { Authorization: `Bearer ${this.config.accessToken}` }
36
+ : {}),
37
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
38
+ },
39
+ body: body !== undefined ? JSON.stringify(body) : undefined,
40
+ });
41
+ if (response.status === 204) {
42
+ return undefined;
43
+ }
44
+ const raw = await response.text();
45
+ const parsed = raw.length > 0 ? tryParseJson(raw) : undefined;
46
+ if (!response.ok) {
47
+ throw new TickTickApiError(response.status, parsed ?? raw);
48
+ }
49
+ return parsed;
50
+ }
51
+ getTask(projectId, taskId) {
52
+ return this.requestRaw("GET", `/open/v1/project/${projectId}/task/${taskId}`);
53
+ }
54
+ createTask(payload) {
55
+ return this.requestRaw("POST", "/open/v1/task", payload);
56
+ }
57
+ updateTask(taskId, payload) {
58
+ return this.requestRaw("POST", `/open/v1/task/${taskId}`, payload);
59
+ }
60
+ completeTask(projectId, taskId) {
61
+ return this.requestRaw("POST", `/open/v1/project/${projectId}/task/${taskId}/complete`);
62
+ }
63
+ deleteTask(projectId, taskId) {
64
+ return this.requestRaw("DELETE", `/open/v1/project/${projectId}/task/${taskId}`);
65
+ }
66
+ moveTasks(payload) {
67
+ return this.requestRaw("POST", "/open/v1/task/move", payload);
68
+ }
69
+ listCompletedTasks(payload) {
70
+ return this.requestRaw("POST", "/open/v1/task/completed", payload);
71
+ }
72
+ filterTasks(payload) {
73
+ return this.requestRaw("POST", "/open/v1/task/filter", payload);
74
+ }
75
+ listProjects() {
76
+ return this.requestRaw("GET", "/open/v1/project");
77
+ }
78
+ getProject(projectId) {
79
+ return this.requestRaw("GET", `/open/v1/project/${projectId}`);
80
+ }
81
+ getProjectData(projectId) {
82
+ return this.requestRaw("GET", `/open/v1/project/${projectId}/data`);
83
+ }
84
+ createProject(payload) {
85
+ return this.requestRaw("POST", "/open/v1/project", payload);
86
+ }
87
+ updateProject(projectId, payload) {
88
+ return this.requestRaw("POST", `/open/v1/project/${projectId}`, payload);
89
+ }
90
+ deleteProject(projectId) {
91
+ return this.requestRaw("DELETE", `/open/v1/project/${projectId}`);
92
+ }
93
+ }
94
+ function tryParseJson(value) {
95
+ try {
96
+ return JSON.parse(value);
97
+ }
98
+ catch {
99
+ return value;
100
+ }
101
+ }
package/dist/config.js ADDED
@@ -0,0 +1,85 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const DEFAULT_SCOPES = "tasks:read tasks:write";
5
+ const DEFAULT_SERVICE = "ticktick";
6
+ const SERVICE_DEFAULTS = {
7
+ ticktick: {
8
+ apiBaseUrl: "https://api.ticktick.com",
9
+ authBaseUrl: "https://ticktick.com",
10
+ },
11
+ dida365: {
12
+ apiBaseUrl: "https://api.dida365.com",
13
+ authBaseUrl: "https://dida365.com",
14
+ },
15
+ };
16
+ export function defaultConfigFilePath() {
17
+ const appData = process.env.APPDATA;
18
+ if (process.platform === "win32" && appData) {
19
+ return path.join(appData, "ticktick-cli", "config.json");
20
+ }
21
+ if (process.platform === "darwin") {
22
+ return path.join(os.homedir(), "Library", "Application Support", "ticktick-cli", "config.json");
23
+ }
24
+ return path.join(os.homedir(), ".config", "ticktick-cli", "config.json");
25
+ }
26
+ export async function loadStoredConfig(configFile) {
27
+ try {
28
+ const raw = await readFile(configFile, "utf8");
29
+ return JSON.parse(raw);
30
+ }
31
+ catch (error) {
32
+ if (error.code === "ENOENT") {
33
+ return {};
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+ export async function saveStoredConfig(configFile, config) {
39
+ await mkdir(path.dirname(configFile), { recursive: true });
40
+ await writeFile(configFile, JSON.stringify(config, null, 2), "utf8");
41
+ }
42
+ export function validateService(service) {
43
+ if (service === "ticktick" || service === "dida365") {
44
+ return service;
45
+ }
46
+ throw new Error(`Unsupported service "${service}". Use "ticktick" or "dida365".`);
47
+ }
48
+ function envConfig(env) {
49
+ return {
50
+ service: env.TICKTICK_SERVICE
51
+ ? validateService(env.TICKTICK_SERVICE)
52
+ : undefined,
53
+ clientId: env.TICKTICK_CLIENT_ID,
54
+ clientSecret: env.TICKTICK_CLIENT_SECRET,
55
+ redirectUri: env.TICKTICK_REDIRECT_URI,
56
+ scopes: env.TICKTICK_SCOPES,
57
+ accessToken: env.TICKTICK_ACCESS_TOKEN,
58
+ apiBaseUrl: env.TICKTICK_API_BASE_URL,
59
+ authBaseUrl: env.TICKTICK_AUTH_BASE_URL,
60
+ };
61
+ }
62
+ export async function resolveRuntimeConfig(overrides = {}, env = process.env) {
63
+ const configFile = overrides.configFile ?? env.TICKTICK_CONFIG_FILE ?? defaultConfigFilePath();
64
+ const stored = await loadStoredConfig(configFile);
65
+ const fromEnv = envConfig(env);
66
+ const service = overrides.service ?? fromEnv.service ?? stored.service ?? DEFAULT_SERVICE;
67
+ const defaults = SERVICE_DEFAULTS[service];
68
+ return {
69
+ service,
70
+ configFile,
71
+ clientId: overrides.clientId ?? fromEnv.clientId ?? stored.clientId,
72
+ clientSecret: overrides.clientSecret ?? fromEnv.clientSecret ?? stored.clientSecret,
73
+ redirectUri: overrides.redirectUri ??
74
+ fromEnv.redirectUri ??
75
+ stored.redirectUri ??
76
+ "http://127.0.0.1:18463/callback",
77
+ scopes: overrides.scopes ?? fromEnv.scopes ?? stored.scopes ?? DEFAULT_SCOPES,
78
+ accessToken: overrides.accessToken ?? fromEnv.accessToken ?? stored.accessToken,
79
+ apiBaseUrl: overrides.apiBaseUrl ?? fromEnv.apiBaseUrl ?? stored.apiBaseUrl ?? defaults.apiBaseUrl,
80
+ authBaseUrl: overrides.authBaseUrl ??
81
+ fromEnv.authBaseUrl ??
82
+ stored.authBaseUrl ??
83
+ defaults.authBaseUrl,
84
+ };
85
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,69 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export function mergeDefined(base, patch) {
3
+ const next = { ...base };
4
+ for (const [key, value] of Object.entries(patch)) {
5
+ if (value !== undefined) {
6
+ next[key] = value;
7
+ }
8
+ }
9
+ return next;
10
+ }
11
+ export function parseBoolean(value) {
12
+ const normalized = value.trim().toLowerCase();
13
+ if (["true", "1", "yes", "y"].includes(normalized)) {
14
+ return true;
15
+ }
16
+ if (["false", "0", "no", "n"].includes(normalized)) {
17
+ return false;
18
+ }
19
+ throw new Error(`Expected a boolean value, received "${value}".`);
20
+ }
21
+ export function parseInteger(value) {
22
+ const parsed = Number.parseInt(value, 10);
23
+ if (Number.isNaN(parsed)) {
24
+ throw new Error(`Expected an integer value, received "${value}".`);
25
+ }
26
+ return parsed;
27
+ }
28
+ export async function maybeReadStdin() {
29
+ if (process.stdin.isTTY) {
30
+ return undefined;
31
+ }
32
+ const chunks = [];
33
+ for await (const chunk of process.stdin) {
34
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
35
+ }
36
+ const text = Buffer.concat(chunks).toString("utf8").trim();
37
+ return text.length > 0 ? text : undefined;
38
+ }
39
+ export async function loadJsonValue(inlineJson, jsonFile) {
40
+ let raw = inlineJson;
41
+ if (jsonFile) {
42
+ raw = await readFile(jsonFile, "utf8");
43
+ }
44
+ if (!raw) {
45
+ raw = await maybeReadStdin();
46
+ }
47
+ if (!raw) {
48
+ return undefined;
49
+ }
50
+ try {
51
+ return JSON.parse(raw);
52
+ }
53
+ catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ throw new Error(`Failed to parse JSON input: ${message}`);
56
+ }
57
+ }
58
+ export function printJson(value) {
59
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
60
+ }
61
+ export function maskSecret(value) {
62
+ if (!value) {
63
+ return value;
64
+ }
65
+ if (value.length <= 6) {
66
+ return "*".repeat(value.length);
67
+ }
68
+ return `${value.slice(0, 3)}***${value.slice(-3)}`;
69
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "ticktick-cli",
3
+ "version": "0.1.1",
4
+ "description": "Simple CLI wrapper for the TickTick Open API",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "bin": {
12
+ "ticktick": "dist/cli.js"
13
+ },
14
+ "scripts": {
15
+ "build": "npm run clean && tsc -p tsconfig.json",
16
+ "check": "npm run build",
17
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
18
+ "dev": "tsx src/cli.ts",
19
+ "prepack": "npm run test",
20
+ "test": "npm run build && node --import tsx --test src/test/*.test.ts"
21
+ },
22
+ "keywords": [
23
+ "ticktick",
24
+ "cli",
25
+ "oauth2",
26
+ "tasks",
27
+ "projects"
28
+ ],
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^14.0.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.6.0",
41
+ "tsx": "^4.20.5",
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }