Skill: manuelhettich/ticktick (version 1.0.0)

--- name: ticktick description: Manage TickTick tasks and projects from the command line with OAuth2 auth, batch operations, and rate limit handling. --- # TickTick CLI Skill Manage TickTick tasks and projects from the command line. ## Setup ### 1. Register a TickTick Developer App 1. Go to [TickTick Developer Center](https://developer.ticktick.com/manage) 2. Create a new application 3. Set the redirect URI to `http://localhost:8080` 4. Note your `Client ID` and `Client Secret` ### 2. Authenticate ```bash # Set credentials and start OAuth flow bun run scripts/ticktick.ts auth --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET # Check authentication status bun run scripts/ticktick.ts auth --status # Logout (clear tokens, keep credentials) bun run scripts/ticktick.ts auth --logout ``` ### Headless / Manual Authentication ```bash # Use manual mode on headless servers bun run scripts/ticktick.ts auth --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET --manual ``` This prints an authorization URL. Open it in a browser, approve access, then copy the full redirect URL (it looks like `http://localhost:8080/?code=XXXXX&state=STATE`) and paste it back into the CLI. The CLI will open your browser to authorize access. After approving, tokens are stored in `~/.clawdbot/credentials/ticktick-cli/config.json`. ## Commands ### List Tasks ```bash # List all tasks bun run scripts/ticktick.ts tasks # List tasks from a specific project bun run scripts/ticktick.ts tasks --list "Work" # Filter by status bun run scripts/ticktick.ts tasks --status pending bun run scripts/ticktick.ts tasks --status completed # JSON output bun run scripts/ticktick.ts tasks --json ``` ### Create Task ```bash # Basic task creation bun run scripts/ticktick.ts task "Buy groceries" --list "Personal" # With description and priority bun run scripts/ticktick.ts task "Review PR" --list "Work" --content "Check the new auth changes" --priority high # With due date bun run scripts/ticktick.ts task "Submit report" --list "Work" --due tomorrow bun run scripts/ticktick.ts task "Plan vacation" --list "Personal" --due "in 7 days" bun run scripts/ticktick.ts task "Meeting" --list "Work" --due "2024-12-25" # With tags bun run scripts/ticktick.ts task "Research" --list "Work" --tag research important ``` ### Update Task ```bash # Update by task name or ID bun run scripts/ticktick.ts task "Buy groceries" --update --priority medium bun run scripts/ticktick.ts task "abc123" --update --due tomorrow --content "Updated notes" # Limit search to specific project bun run scripts/ticktick.ts task "Review PR" --update --list "Work" --priority low ``` ### Complete Task ```bash # Mark task as complete bun run scripts/ticktick.ts complete "Buy groceries" # Complete with project filter bun run scripts/ticktick.ts complete "Review PR" --list "Work" ``` ### Abandon Task (Won't Do) ```bash # Mark task as won't do bun run scripts/ticktick.ts abandon "Old task" # Abandon with project filter bun run scripts/ticktick.ts abandon "Obsolete item" --list "Do" ``` ### Batch Abandon (Multiple Tasks) ```bash # Abandon multiple tasks in a single API call bun run scripts/ticktick.ts batch-abandon <taskId1> <taskId2> <taskId3> # With JSON output bun run scripts/ticktick.ts batch-abandon abc123def456... xyz789... --json ``` Note: `batch-abandon` requires task IDs (24-character hex strings), not task names. Use `tasks --json` to get task IDs first. ### List Projects ```bash # List all projects bun run scripts/ticktick.ts lists # JSON output bun run scripts/ticktick.ts lists --json ``` ### Create Project ```bash # Create new project bun run scripts/ticktick.ts list "New Project" # With color bun run scripts/ticktick.ts list "Work Tasks" --color "#FF5733" ``` ### Update Project ```bash # Rename project bun run scripts/ticktick.ts list "Old Name" --update --name "New Name" # Change color bun run scripts/ticktick.ts list "Work" --update --color "#00FF00" ``` ## Options Reference ### Priority Levels - `none` - No priority (default) - `low` - Low priority - `medium` - Medium priority - `high` - High priority ### Due Date Formats - `today` - Due today - `tomorrow` - Due tomorrow - `in N days` - Due in N days (e.g., "in 3 days") - `next monday` - Next occurrence of weekday - ISO date - `YYYY-MM-DD` or full ISO format ### Global Options - `--json` - Output results in JSON format (useful for scripting) - `--help` - Show help for any command ## Agent Usage Tips When using this skill as an AI agent: 1. **Always use `--json` flag** for machine-readable output 2. **List projects first** with `lists --json` to get valid project IDs 3. **Use project IDs** rather than names when possible for reliability 4. **Check task status** before completing to avoid errors Example agent workflow: ```bash # 1. Get available projects bun run scripts/ticktick.ts lists --json # 2. Create a task in a specific project bun run scripts/ticktick.ts task "Agent task" --list "PROJECT_ID" --priority high --json # 3. Later, mark it complete bun run scripts/ticktick.ts complete "Agent task" --list "PROJECT_ID" --json ``` ## Configuration Tokens are stored in `~/.clawdbot/credentials/ticktick-cli/config.json`: ```json { "clientId": "YOUR_CLIENT_ID", "clientSecret": "YOUR_CLIENT_SECRET", "accessToken": "...", "refreshToken": "...", "tokenExpiry": 1234567890000, "redirectUri": "http://localhost:8080" } ``` Note: Credentials are stored in plaintext. The CLI attempts to set file permissions to 700/600; treat this file as sensitive. The CLI automatically refreshes tokens when they expire. ## Troubleshooting ### "Not authenticated" error Run `bun run scripts/ticktick.ts auth` to authenticate. ### "Project not found" error Use `bun run scripts/ticktick.ts lists` to see available projects and their IDs. ### "Task not found" error - Check the task title matches exactly (case-insensitive) - Try using the task ID instead - Use `--list` to narrow the search to a specific project ### Token expired errors The CLI should auto-refresh tokens. If issues persist, run `bun run scripts/ticktick.ts auth` again. ## API Notes This CLI uses the [TickTick Open API v1](https://developer.ticktick.com/api). ### Rate Limits - **100 requests per minute** - **300 requests per 5 minutes** The CLI makes multiple API calls per operation (listing projects to find task), so bulk operations can hit limits quickly. ### Batch Endpoint The CLI supports TickTick's batch endpoint for bulk operations: ``` POST https://api.ticktick.com/open/v1/batch/task { "add": [...], // CreateTaskInput[] "update": [...], // UpdateTaskInput[] "delete": [...] // { taskId, projectId }[] } ``` Use `batch-abandon` to abandon multiple tasks in one API call. The batch API method is also exposed for programmatic use. ### Other Limitations - Maximum 500 tasks per project - Some advanced features (focus time, habits) not supported by the API

path: package.json
content: {
  "name": "ticktick-cli",
  "version": "0.1.0",
  "description": "TickTick CLI for task and project management",
  "type": "module",
  "scripts": {
    "ticktick": "bun run scripts/ticktick.ts"
  },
  "bin": {
    "ticktick": "./scripts/ticktick.ts"
  },
  "dependencies": {
    "commander": "^12.0.0",
    "open": "^10.0.0"
  },
  "devDependencies": {
    "@types/bun": "^1.0.0"
  }
}


path: scripts/api.ts
content: import { getValidToken } from "./auth";

const API_BASE = "https://api.ticktick.com/open/v1";

export interface Project {
  id: string;
  name: string;
  color?: string;
  sortOrder?: number;
  closed?: boolean;
  groupId?: string;
  viewMode?: string;
  permission?: string;
  kind?: string;
}

export interface Task {
  id: string;
  projectId: string;
  title: string;
  content?: string;
  desc?: string;
  priority: number;
  status: number;
  dueDate?: string;
  startDate?: string;
  isAllDay?: boolean;
  timeZone?: string;
  reminders?: string[];
  tags?: string[];
  items?: ChecklistItem[];
  sortOrder?: number;
  completedTime?: string;
}

export interface ChecklistItem {
  id: string;
  title: string;
  status: number;
  sortOrder: number;
}

export interface ProjectData {
  project: Project;
  tasks: Task[];
}

export interface CreateTaskInput {
  title: string;
  projectId: string;
  content?: string;
  priority?: number;
  dueDate?: string;
  startDate?: string;
  isAllDay?: boolean;
  tags?: string[];
}

export interface UpdateTaskInput {
  id: string;
  projectId: string;
  title?: string;
  content?: string;
  priority?: number;
  status?: number;
  dueDate?: string;
  startDate?: string;
  isAllDay?: boolean;
  tags?: string[];
}

export interface BatchDeleteItem {
  taskId: string;
  projectId: string;
}

export interface BatchRequest {
  add?: CreateTaskInput[];
  update?: UpdateTaskInput[];
  delete?: BatchDeleteItem[];
}

export interface BatchResponse {
  id2etag?: Record<string, string>;
  id2error?: Record<string, string>;
}

export interface CreateProjectInput {
  name: string;
  color?: string;
}

export interface UpdateProjectInput {
  id: string;
  name?: string;
  color?: string;
}

// Priority mapping
export const PRIORITY_MAP: Record<string, number> = {
  none: 0,
  low: 1,
  medium: 3,
  high: 5,
};

export const PRIORITY_REVERSE: Record<number, string> = {
  0: "none",
  1: "low",
  3: "medium",
  5: "high",
};

class TickTickAPI {
  private static readonly RETRY_DELAYS = [5000, 15000, 30000, 60000];
  private static readonly MAX_RETRIES = 4;

  private async request<T>(
    endpoint: string,
    options: RequestInit = {},
    retryCount = 0
  ): Promise<T> {
    const token = await getValidToken();

    const response = await fetch(`${API_BASE}${endpoint}`, {
      ...options,
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
        ...options.headers,
      },
    });

    if (!response.ok) {
      const errorText = await response.text();

      // Handle rate limit errors (returned as 500 with exceed_query_limit)
      if (response.status === 500 && errorText.includes("exceed_query_limit")) {
        if (retryCount < TickTickAPI.MAX_RETRIES) {
          const waitMs = TickTickAPI.RETRY_DELAYS[retryCount];
          console.error(
            `Rate limited, waiting ${waitMs / 1000}s before retry ${retryCount + 1}/${TickTickAPI.MAX_RETRIES}...`
          );
          await new Promise((r) => setTimeout(r, waitMs));
          return this.request(endpoint, options, retryCount + 1);
        }
        throw new Error(
          "Rate limit exceeded. Maximum retries reached. Please wait a few minutes and try again."
        );
      }

      if (response.status === 401) {
        throw new Error(
          "Authentication expired. Please run 'ticktick auth' to re-authenticate."
        );
      }
      if (response.status === 404) {
        throw new Error(`Not found: ${endpoint}`);
      }
      if (response.status === 429) {
        // Also handle standard 429 rate limit with retry
        if (retryCount < TickTickAPI.MAX_RETRIES) {
          const waitMs = TickTickAPI.RETRY_DELAYS[retryCount];
          console.error(
            `Rate limited (429), waiting ${waitMs / 1000}s before retry ${retryCount + 1}/${TickTickAPI.MAX_RETRIES}...`
          );
          await new Promise((r) => setTimeout(r, waitMs));
          return this.request(endpoint, options, retryCount + 1);
        }
        throw new Error(
          "Rate limit exceeded. Maximum retries reached. Please wait a few minutes and try again."
        );
      }
      throw new Error(
        `API error ${response.status}: ${errorText || response.statusText}`
      );
    }

    // Handle empty responses
    const text = await response.text();
    if (!text) {
      return {} as T;
    }

    return JSON.parse(text);
  }

  // Projects
  async listProjects(): Promise<Project[]> {
    return this.request<Project[]>("/project");
  }

  async createProject(input: CreateProjectInput): Promise<Project> {
    return this.request<Project>("/project", {
      method: "POST",
      body: JSON.stringify(input),
    });
  }

  async updateProject(input: UpdateProjectInput): Promise<Project> {
    return this.request<Project>(`/project/${input.id}`, {
      method: "POST",
      body: JSON.stringify(input),
    });
  }

  async getProjectData(projectId: string): Promise<ProjectData> {
    return this.request<ProjectData>(`/project/${projectId}/data`);
  }

  async getInboxData(): Promise<ProjectData> {
    // Inbox is a special project
    const projects = await this.listProjects();
    const inbox = projects.find(
      (p) => p.name.toLowerCase() === "inbox" || p.kind === "INBOX"
    );
    if (!inbox) {
      // Fallback - try the inbox endpoint
      return this.request<ProjectData>("/project/inbox/data");
    }
    return this.getProjectData(inbox.id);
  }

  // Tasks
  async createTask(input: CreateTaskInput): Promise<Task> {
    return this.request<Task>("/task", {
      method: "POST",
      body: JSON.stringify(input),
    });
  }

  async updateTask(input: UpdateTaskInput): Promise<Task> {
    return this.request<Task>(`/task/${input.id}`, {
      method: "POST",
      body: JSON.stringify(input),
    });
  }

  async completeTask(projectId: string, taskId: string): Promise<void> {
    await this.request<void>(`/project/${projectId}/task/${taskId}/complete`, {
      method: "POST",
    });
  }

  async deleteTask(projectId: string, taskId: string): Promise<void> {
    await this.request<void>(`/project/${projectId}/task/${taskId}`, {
      method: "DELETE",
    });
  }

  async batchTasks(batch: BatchRequest): Promise<BatchResponse> {
    return this.request<BatchResponse>("/batch/task", {
      method: "POST",
      body: JSON.stringify(batch),
    });
  }

  // Utility functions
  async findProjectByName(name: string): Promise<Project | undefined> {
    const projects = await this.listProjects();
    const lowerName = name.toLowerCase();
    return projects.find(
      (p) => p.name.toLowerCase() === lowerName || p.id === name
    );
  }

  async findTaskById(taskId: string): Promise<{ task: Task; projectId: string } | undefined> {
    const projects = await this.listProjects();

    for (const project of projects) {
      try {
        const data = await this.getProjectData(project.id);
        const task = data.tasks?.find((t) => t.id === taskId);
        if (task) {
          return { task, projectId: project.id };
        }
      } catch {
        // Project might not have tasks accessible
        continue;
      }
    }
    return undefined;
  }

  async findTaskByTitle(
    title: string,
    projectId?: string
  ): Promise<{ task: Task; projectId: string } | undefined> {
    const projects = await this.listProjects();
    const searchProjects = projectId
      ? projects.filter((p) => p.id === projectId || p.name.toLowerCase() === projectId.toLowerCase())
      : projects;

    // Check if searching by ID (24-char hex string)
    const isIdSearch = /^[a-f0-9]{24}$/i.test(title);

    const matches: Array<{ task: Task; projectId: string; projectName: string }> = [];

    for (const project of searchProjects) {
      try {
        const data = await this.getProjectData(project.id);
        const matchingTasks = data.tasks?.filter(
          (t) => t.title.toLowerCase() === title.toLowerCase() || t.id === title
        ) || [];

        for (const task of matchingTasks) {
          matches.push({ task, projectId: project.id, projectName: project.name });
        }
      } catch {
        // Project might not have tasks accessible
        continue;
      }
    }

    if (matches.length === 0) {
      return undefined;
    }

    // If searching by ID or only one match, return it
    if (isIdSearch || matches.length === 1) {
      return { task: matches[0].task, projectId: matches[0].projectId };
    }

    // Multiple matches found - throw error with details
    const matchList = matches
      .map((m) => `  [${m.task.id.slice(0, 8)}] "${m.task.title}" in project "${m.projectName}"`)
      .join("\n");
    throw new Error(
      `Multiple tasks found with name "${title}":\n${matchList}\n\nPlease use the task ID instead of the name to specify which task.`
    );
  }

  async getAllTasks(projectId?: string): Promise<Task[]> {
    const projects = await this.listProjects();
    const searchProjects = projectId
      ? projects.filter((p) => p.id === projectId || p.name.toLowerCase() === projectId.toLowerCase())
      : projects;

    const allTasks: Task[] = [];

    for (const project of searchProjects) {
      try {
        const data = await this.getProjectData(project.id);
        if (data.tasks) {
          allTasks.push(...data.tasks);
        }
      } catch {
        // Project might not have tasks accessible
        continue;
      }
    }

    return allTasks;
  }
}

export const api = new TickTickAPI();


path: scripts/auth.ts
content: import { createServer } from "http";
import { homedir } from "os";
import { join } from "path";
import { chmod, mkdir, readFile, writeFile } from "fs/promises";
import { randomBytes } from "crypto";
import { existsSync } from "fs";
import open from "open";

const CONFIG_DIR = join(homedir(), ".clawdbot", "credentials", "ticktick-cli");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");

const OAUTH_BASE = "https://ticktick.com/oauth";
const API_BASE = "https://api.ticktick.com/open/v1";
const DEFAULT_REDIRECT_PORT = 8080;
const DEFAULT_REDIRECT_URI = `http://localhost:${DEFAULT_REDIRECT_PORT}`;

function generateState(): string {
  return `ticktick-cli-${randomBytes(16).toString("hex")}`;
}


export interface TickTickConfig {
  clientId: string;
  clientSecret: string;
  accessToken?: string;
  refreshToken?: string;
  tokenExpiry?: number;
  redirectUri?: string;
}

export async function ensureConfigDir(): Promise<void> {
  if (!existsSync(CONFIG_DIR)) {
    await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
  }
}

export async function loadConfig(): Promise<TickTickConfig | null> {
  try {
    const data = await readFile(CONFIG_FILE, "utf-8");
    return JSON.parse(data);
  } catch {
    return null;
  }
}

export async function saveConfig(config: TickTickConfig): Promise<void> {
  await ensureConfigDir();
  await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
  await chmod(CONFIG_DIR, 0o700).catch(() => {});
  await chmod(CONFIG_FILE, 0o600).catch(() => {});
}

export async function getValidToken(): Promise<string> {
  const config = await loadConfig();
  if (!config) {
    throw new Error(
      "Not authenticated. Run 'ticktick auth' to set up credentials."
    );
  }

  if (!config.accessToken) {
    throw new Error(
      "No access token found. Run 'ticktick auth' to authenticate."
    );
  }

  // Check if token is expired (with 5 min buffer)
  if (config.tokenExpiry && Date.now() > config.tokenExpiry - 300000) {
    if (config.refreshToken) {
      return await refreshAccessToken(config);
    }
    throw new Error("Token expired. Run 'ticktick auth' to re-authenticate.");
  }

  return config.accessToken;
}

async function refreshAccessToken(config: TickTickConfig): Promise<string> {
  const credentials = Buffer.from(
    `${config.clientId}:${config.clientSecret}`
  ).toString("base64");

  const response = await fetch(`${OAUTH_BASE}/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${credentials}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: config.refreshToken!,
    }),
  });

  if (!response.ok) {
    throw new Error(
      `Failed to refresh [REDACTED_SECRET] ${response.statusText}`
    );
  }

  const data = await response.json();

  config.accessToken = data.access_token;
  if (data.refresh_token) {
    config.refreshToken = data.refresh_token;
  }
  config.tokenExpiry = Date.now() + data.expires_in * 1000;

  await saveConfig(config);
  return config.accessToken;
}

export async function setupCredentials(
  clientId: string,
  clientSecret: string
): Promise<void> {
  const config = (await loadConfig()) || ({} as TickTickConfig);
  config.clientId = clientId;
  config.clientSecret = clientSecret;
  config.redirectUri = DEFAULT_REDIRECT_URI;
  await saveConfig(config);
  console.log("Credentials saved successfully.");
}

export async function authenticate(): Promise<void> {
  const config = await loadConfig();
  if (!config || !config.clientId || !config.clientSecret) {
    throw new Error(
      "No credentials found. Run 'ticktick auth --client-id <id> --client-secret <secret>' first."
    );
  }

  const redirectUri = config.redirectUri || DEFAULT_REDIRECT_URI;
  const port = new URL(redirectUri).port || DEFAULT_REDIRECT_PORT;
  const state = generateState();

  // Create a one-time local server to handle the OAuth callback
  const authCode = await new Promise<string>((resolve, reject) => {
    const server = createServer((req, res) => {
      const url = new URL(req.url!, `http://localhost:${port}`);

      if (url.pathname === "/") {
        const code = url.searchParams.get("code");
        const error = url.searchParams.get("error");
        const returnedState = url.searchParams.get("state");

        if (!returnedState || returnedState !== state) {
          res.writeHead(400, { "Content-Type": "text/html" });
          res.end(
            "<html><body><h1>Authentication Failed</h1><p>Invalid OAuth state.</p><p>You can close this window.</p></body></html>"
          );
          server.close();
          reject(new Error("Invalid OAuth state."));
          return;
        }

        if (error) {
          res.writeHead(400, { "Content-Type": "text/html" });
          res.end(
            `<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`
          );
          server.close();
          reject(new Error(`OAuth error: ${error}`));
          return;
        }

        if (code) {
          res.writeHead(200, { "Content-Type": "text/html" });
          res.end(
            "<html><body><h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>"
          );
          server.close();
          resolve(code);
          return;
        }
      }

      res.writeHead(404);
      res.end("Not found");
    });

    server.listen(port, "127.0.0.1", () => {
      const authUrl = `${OAUTH_BASE}/authorize?scope=tasks:read%20tasks:write&client_id=${encodeURIComponent(config.clientId)}&state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`;

      console.log("\nOpening browser for authentication...");
      console.log(`If browser doesn't open, visit:\n${authUrl}\n`);

      open(authUrl).catch(() => {
        console.log(
          "Could not open browser automatically. Please open the URL above manually."
        );
      });
    });

    // Timeout after 5 minutes
    setTimeout(() => {
      server.close();
      reject(new Error("Authentication timed out. Please try again."));
    }, 300000);
  });

  // Exchange the code for tokens
  const credentials = Buffer.from(
    `${config.clientId}:${config.clientSecret}`
  ).toString("base64");

  const tokenResponse = await fetch(`${OAUTH_BASE}/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${credentials}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: authCode,
      redirect_uri: redirectUri,
    }),
  });

  if (!tokenResponse.ok) {
    const errorText = await tokenResponse.text();
    throw new Error(
      `Failed to exchange code for [REDACTED_SECRET] - ${errorText}`
    );
  }

  const tokenData = await tokenResponse.json();

  config.accessToken = tokenData.access_token;
  config.refreshToken = tokenData.refresh_token;
  config.tokenExpiry = Date.now() + tokenData.expires_in * 1000;

  await saveConfig(config);
  console.log("Authentication successful! Tokens saved.");
}

export async function checkAuth(): Promise<boolean> {
  try {
    await getValidToken();
    return true;
  } catch {
    return false;
  }
}

export async function logout(): Promise<void> {
  const config = await loadConfig();
  if (config) {
    delete config.accessToken;
    delete config.refreshToken;
    delete config.tokenExpiry;
    await saveConfig(config);
    console.log("Logged out successfully. Credentials preserved.");
  } else {
    console.log("No configuration found.");
  }
}

export async function authenticateManual(): Promise<void> {
  const config = await loadConfig();
  if (!config || !config.clientId || !config.clientSecret) {
    throw new Error(
      "No credentials found. Run 'ticktick auth --client-id <id> --client-secret <secret> --manual' first."
    );
  }

  const redirectUri = config.redirectUri || DEFAULT_REDIRECT_URI;
  const state = generateState();

  const authUrl = `${OAUTH_BASE}/authorize?scope=tasks:read%20tasks:write&client_id=${encodeURIComponent(config.clientId)}&state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`;

  console.log("\n=== Manual Authentication ===\n");
  console.log("1. Open this URL in your browser:\n");
  console.log(authUrl);
  console.log("\n2. Authorize the app");
  console.log("3. You'll be redirected to a URL like: http://localhost:8080/?code=XXXXX&state=STATE");
  console.log("4. Copy that ENTIRE redirect URL and paste it below:\n");

  // Read from stdin
  const reader = Bun.stdin.stream().getReader();
  const decoder = new TextDecoder();
  
  process.stdout.write("Paste redirect URL: ");
  
  let input = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    input += decoder.decode(value);
    if (input.includes("\n")) break;
  }
  reader.releaseLock();

  const redirectUrl = input.trim();
  
  if (!redirectUrl) {
    throw new Error("No URL provided.");
  }

  // Extract the code from the URL
  let authCode: string;
  try {
    const url = new URL(redirectUrl);
    const returnedState = url.searchParams.get("state");
    if (!returnedState) {
      throw new Error("Missing state in redirect URL. Paste the full redirect URL.");
    }
    if (returnedState !== state) {
      throw new Error("State mismatch. Please restart auth.");
    }
    authCode = url.searchParams.get("code") || "";
    if (!authCode) {
      throw new Error("No code found in URL");
    }
  } catch (e) {
    throw new Error(`Invalid redirect URL: ${redirectUrl}`);
  }

  console.log("\nExchanging code for tokens...");

  // Exchange the code for tokens
  const credentials = Buffer.from(
    `${config.clientId}:${config.clientSecret}`
  ).toString("base64");

  const tokenResponse = await fetch(`${OAUTH_BASE}/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${credentials}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: authCode,
      redirect_uri: redirectUri,
    }),
  });

  if (!tokenResponse.ok) {
    const errorText = await tokenResponse.text();
    throw new Error(
      `Failed to exchange code for [REDACTED_SECRET] - ${errorText}`
    );
  }

  const tokenData = await tokenResponse.json();

  config.accessToken = tokenData.access_token;
  config.refreshToken = tokenData.refresh_token;
  config.tokenExpiry = Date.now() + tokenData.expires_in * 1000;

  await saveConfig(config);
  console.log("\n✓ Authentication successful! Tokens saved.");
}


path: scripts/commands/abandon.ts
content: import { api } from "../api";

interface AbandonOptions {
  list?: string;
  json?: boolean;
}

// Check if string looks like a task ID (24-char hex)
function isTaskId(str: string): boolean {
  return /^[a-f0-9]{24}$/i.test(str);
}

export async function abandonCommand(
  taskNameOrId: string,
  options: AbandonOptions
): Promise<void> {
  try {
    let found: { task: { id: string; title: string }; projectId: string } | undefined;

    // If it looks like an ID and no --list specified, use findTaskById
    if (isTaskId(taskNameOrId) && !options.list) {
      found = await api.findTaskById(taskNameOrId);
    } else {
      // Search by title (with optional project filter)
      found = await api.findTaskByTitle(taskNameOrId, options.list);
    }

    if (!found) {
      console.error(`Task not found: ${taskNameOrId}`);
      process.exit(1);
    }

    const { task, projectId } = found;

    // Status -1 = won't do / abandoned in TickTick
    const result = await api.updateTask({
      id: task.id,
      projectId: projectId,
      status: -1,
    });

    if (options.json) {
      console.log(JSON.stringify(result, null, 2));
      return;
    }

    console.log(`✓ Abandoned: "${task.title}"`);
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/commands/batch-abandon.ts
content: import { api, UpdateTaskInput } from "../api";

interface BatchAbandonOptions {
  json?: boolean;
}

// Check if string looks like a task ID (24-char hex)
function isTaskId(str: string): boolean {
  return /^[a-f0-9]{24}$/i.test(str);
}

export async function batchAbandonCommand(
  taskIds: string[],
  options: BatchAbandonOptions
): Promise<void> {
  if (taskIds.length === 0) {
    console.error("Error: At least one task ID is required");
    process.exit(1);
  }

  // Validate all inputs are task IDs
  const invalidIds = taskIds.filter((id) => !isTaskId(id));
  if (invalidIds.length > 0) {
    console.error(
      `Error: Invalid task ID format: ${invalidIds.join(", ")}\n` +
        "Task IDs must be 24-character hex strings."
    );
    process.exit(1);
  }

  try {
    // Look up each task to get projectId
    const updates: UpdateTaskInput[] = [];
    const notFound: string[] = [];

    for (const taskId of taskIds) {
      const found = await api.findTaskById(taskId);
      if (found) {
        updates.push({
          id: found.task.id,
          projectId: found.projectId,
          status: -1, // -1 = won't do / abandoned
        });
      } else {
        notFound.push(taskId);
      }
    }

    if (notFound.length > 0) {
      console.error(`Warning: Tasks not found: ${notFound.join(", ")}`);
    }

    if (updates.length === 0) {
      console.error("Error: No valid tasks to abandon");
      process.exit(1);
    }

    // Send batch request
    const result = await api.batchTasks({ update: updates });

    if (options.json) {
      console.log(
        JSON.stringify(
          {
            abandoned: updates.map((u) => u.id),
            notFound,
            response: result,
          },
          null,
          2
        )
      );
      return;
    }

    // Report results
    const successCount = updates.length - (result.id2error ? Object.keys(result.id2error).length : 0);
    console.log(`✓ Abandoned ${successCount} task(s)`);

    if (result.id2error && Object.keys(result.id2error).length > 0) {
      console.error("Errors:");
      for (const [id, error] of Object.entries(result.id2error)) {
        console.error(`  ${id}: ${error}`);
      }
    }

    if (notFound.length > 0) {
      console.log(`Skipped ${notFound.length} task(s) not found`);
    }
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/commands/complete.ts
content: import { api } from "../api";

interface CompleteOptions {
  list?: string;
  json?: boolean;
}

// Check if string looks like a task ID (24-char hex)
function isTaskId(str: string): boolean {
  return /^[a-f0-9]{24}$/i.test(str);
}

export async function completeCommand(
  taskNameOrId: string,
  options: CompleteOptions
): Promise<void> {
  try {
    let found: { task: { id: string; title: string }; projectId: string } | undefined;

    // If it looks like an ID and no --list specified, use findTaskById
    if (isTaskId(taskNameOrId) && !options.list) {
      found = await api.findTaskById(taskNameOrId);
    } else {
      // Search by title (with optional project filter)
      found = await api.findTaskByTitle(taskNameOrId, options.list);
    }

    if (!found) {
      console.error(`Task not found: ${taskNameOrId}`);
      process.exit(1);
    }

    const { task, projectId } = found;

    await api.completeTask(projectId, task.id);

    if (options.json) {
      console.log(
        JSON.stringify(
          {
            success: true,
            task: {
              id: task.id,
              title: task.title,
              projectId: projectId,
              status: "completed",
            },
          },
          null,
          2
        )
      );
      return;
    }

    console.log(`✓ Completed: "${task.title}"`);
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/commands/list.ts
content: import { api, type CreateProjectInput, type UpdateProjectInput } from "../api";

interface ListCreateOptions {
  color?: string;
  json?: boolean;
}

interface ListUpdateOptions {
  update: boolean;
  name?: string;
  color?: string;
  json?: boolean;
}

function normalizeColor(color: string): string {
  // TickTick expects colors in hex format
  if (color.startsWith("#")) {
    return color;
  }
  return `#${color}`;
}

export async function listCreateCommand(
  name: string,
  options: ListCreateOptions
): Promise<void> {
  try {
    const input: CreateProjectInput = { name };

    if (options.color) {
      input.color = normalizeColor(options.color);
    }

    const project = await api.createProject(input);

    if (options.json) {
      console.log(JSON.stringify(project, null, 2));
      return;
    }

    console.log(`✓ Project created: "${project.name}"`);
    console.log(`  ID: ${project.id}`);
    if (project.color) {
      console.log(`  Color: ${project.color}`);
    }
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}

export async function listUpdateCommand(
  nameOrId: string,
  options: ListUpdateOptions
): Promise<void> {
  try {
    // Find the project
    const project = await api.findProjectByName(nameOrId);
    if (!project) {
      console.error(`Project not found: ${nameOrId}`);
      process.exit(1);
    }

    const input: UpdateProjectInput = { id: project.id };

    if (options.name) {
      input.name = options.name;
    }

    if (options.color) {
      input.color = normalizeColor(options.color);
    }

    const updated = await api.updateProject(input);

    if (options.json) {
      console.log(JSON.stringify(updated, null, 2));
      return;
    }

    console.log(`✓ Project updated: "${updated.name}"`);
    console.log(`  ID: ${updated.id}`);
    if (updated.color) {
      console.log(`  Color: ${updated.color}`);
    }
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/commands/lists.ts
content: import { api, type Project } from "../api";

interface ListsOptions {
  json?: boolean;
}

function formatProject(project: Project): string {
  const color = project.color ? ` (${project.color})` : "";
  const closed = project.closed ? " [closed]" : "";
  return `• ${project.name}${color}${closed}\n  id: ${project.id}`;
}

export async function listsCommand(options: ListsOptions): Promise<void> {
  try {
    const projects = await api.listProjects();

    if (options.json) {
      console.log(JSON.stringify(projects, null, 2));
      return;
    }

    if (projects.length === 0) {
      console.log("No projects found.");
      return;
    }

    console.log(`\nProjects (${projects.length}):\n`);
    for (const project of projects) {
      console.log(formatProject(project));
      console.log();
    }
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/commands/task.ts
content: import { api, PRIORITY_MAP, type CreateTaskInput, type UpdateTaskInput } from "../api";

interface TaskCreateOptions {
  list: string;
  content?: string;
  priority?: string;
  due?: string;
  tag?: string[];
  json?: boolean;
}

interface TaskUpdateOptions {
  update: boolean;
  list?: string;
  content?: string;
  priority?: string;
  due?: string;
  tag?: string[];
  json?: boolean;
}

// Check if string looks like a task ID (24-char hex)
function isTaskId(str: string): boolean {
  return /^[a-f0-9]{24}$/i.test(str);
}

function parseDueDate(dueStr: string): string {
  // Handle relative dates
  // TickTick expects full ISO datetime like "2026-01-07T23:00:00.000+0000"
  const now = new Date();
  const lowerDue = dueStr.toLowerCase();

  // Helper to format as end-of-day ISO string (TickTick needs +0000 not Z)
  const toEndOfDay = (d: Date): string => {
    d.setHours(23, 59, 59, 0);
    return d.toISOString().replace("Z", "+0000");
  };

  if (lowerDue === "today") {
    return toEndOfDay(now);
  }
  if (lowerDue === "tomorrow") {
    now.setDate(now.getDate() + 1);
    return toEndOfDay(now);
  }
  if (lowerDue.match(/^in (\d+) days?$/i)) {
    const match = lowerDue.match(/^in (\d+) days?$/i);
    if (match) {
      now.setDate(now.getDate() + parseInt(match[1], 10));
      return toEndOfDay(now);
    }
  }
  if (lowerDue.match(/^next (monday|tuesday|wednesday|thursday|friday|saturday|sunday)$/i)) {
    const match = lowerDue.match(/^next (monday|tuesday|wednesday|thursday|friday|saturday|sunday)$/i);
    if (match) {
      const days = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
      const targetDay = days.indexOf(match[1].toLowerCase());
      const currentDay = now.getDay();
      let daysUntil = targetDay - currentDay;
      if (daysUntil <= 0) daysUntil += 7;
      now.setDate(now.getDate() + daysUntil);
      return toEndOfDay(now);
    }
  }

  // Try parsing as ISO date
  const parsed = new Date(dueStr);
  if (!isNaN(parsed.getTime())) {
    return parsed.toISOString().replace("Z", "+0000");
  }

  throw new Error(`Invalid date format: ${dueStr}. Try 'today', 'tomorrow', 'in 3 days', or ISO date.`);
}

export async function taskCreateCommand(
  title: string,
  options: TaskCreateOptions
): Promise<void> {
  try {
    // Find the project
    const project = await api.findProjectByName(options.list);
    if (!project) {
      console.error(`Project not found: ${options.list}`);
      process.exit(1);
    }

    const input: CreateTaskInput = {
      title,
      projectId: project.id,
    };

    if (options.content) {
      input.content = options.content;
    }

    if (options.priority) {
      const priority = PRIORITY_MAP[options.priority.toLowerCase()];
      if (priority === undefined) {
        console.error(
          `Invalid priority: ${options.priority}. Use none, low, medium, or high.`
        );
        process.exit(1);
      }
      input.priority = priority;
    }

    if (options.due) {
      input.dueDate = parseDueDate(options.due);
    }

    if (options.tag && options.tag.length > 0) {
      input.tags = options.tag;
    }

    const task = await api.createTask(input);

    if (options.json) {
      console.log(JSON.stringify(task, null, 2));
      return;
    }

    console.log(`✓ Task created: "${task.title}"`);
    console.log(`  ID: ${task.id}`);
    console.log(`  Project: ${project.name}`);
    if (task.dueDate) {
      console.log(`  Due: ${new Date(task.dueDate).toLocaleDateString()}`);
    }
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}

export async function taskUpdateCommand(
  taskNameOrId: string,
  options: TaskUpdateOptions
): Promise<void> {
  try {
    let found: { task: { id: string; title: string }; projectId: string } | undefined;

    // If it looks like an ID and no --list specified, use findTaskById
    if (isTaskId(taskNameOrId) && !options.list) {
      found = await api.findTaskById(taskNameOrId);
    } else {
      // Search by title (with optional project filter)
      found = await api.findTaskByTitle(taskNameOrId, options.list);
    }

    if (!found) {
      console.error(`Task not found: ${taskNameOrId}`);
      process.exit(1);
    }

    const { task, projectId } = found;

    const input: UpdateTaskInput = {
      id: task.id,
      projectId: projectId,
    };

    // Only include fields that are explicitly set
    if (options.content !== undefined) {
      input.content = options.content;
    }

    if (options.priority) {
      const priority = PRIORITY_MAP[options.priority.toLowerCase()];
      if (priority === undefined) {
        console.error(
          `Invalid priority: ${options.priority}. Use none, low, medium, or high.`
        );
        process.exit(1);
      }
      input.priority = priority;
    }

    if (options.due) {
      input.dueDate = parseDueDate(options.due);
    }

    if (options.tag && options.tag.length > 0) {
      input.tags = options.tag;
    }

    const updated = await api.updateTask(input);

    if (options.json) {
      console.log(JSON.stringify(updated, null, 2));
      return;
    }

    console.log(`✓ Task updated: "${updated.title}"`);
    console.log(`  ID: ${updated.id}`);
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/commands/tasks.ts
content: import { api, PRIORITY_REVERSE, type Task } from "../api";

interface TaskWithProject extends Task {
  projectName?: string;
}

interface TasksOptions {
  list?: string;
  status?: "pending" | "completed";
  json?: boolean;
}

function formatDueDate(dateStr: string): string {
  const date = new Date(dateStr);
  const now = new Date();
  const tomorrow = new Date(now);
  tomorrow.setDate(tomorrow.getDate() + 1);

  // Check if it's today or tomorrow
  if (date.toDateString() === now.toDateString()) {
    return "today";
  }
  if (date.toDateString() === tomorrow.toDateString()) {
    return "tomorrow";
  }

  // Format as "Jan 7" style
  return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}

function formatTask(task: TaskWithProject, showProject = false): string {
  const status = task.status === 2 ? "✓" : "○";
  const priorityIndicator =
    task.priority === 5
      ? "!!!"
      : task.priority === 3
        ? "!!"
        : task.priority === 1
          ? "!"
          : "";
  const dueStr = task.dueDate ? ` - due ${formatDueDate(task.dueDate)}` : "";
  const projectStr = showProject && task.projectName ? ` (${task.projectName})` : "";
  const shortId = task.id.slice(0, 8);

  return `${status} [${shortId}] ${priorityIndicator}${task.title}${projectStr}${dueStr}`;
}

export async function tasksCommand(options: TasksOptions): Promise<void> {
  try {
    const projects = await api.listProjects();
    const projectMap = new Map(projects.map((p) => [p.id, p.name]));

    let searchProjects = projects;
    if (options.list) {
      const project = await api.findProjectByName(options.list);
      if (!project) {
        console.error(`Project not found: ${options.list}`);
        process.exit(1);
      }
      searchProjects = [project];
    }

    const tasksWithProjects: TaskWithProject[] = [];
    for (const project of searchProjects) {
      try {
        const data = await api.getProjectData(project.id);
        if (data.tasks) {
          for (const task of data.tasks) {
            tasksWithProjects.push({
              ...task,
              projectName: projectMap.get(task.projectId) || task.projectId,
            });
          }
        }
      } catch {
        continue;
      }
    }

    // Filter by status if specified
    let filteredTasks = tasksWithProjects;
    if (options.status === "pending") {
      filteredTasks = tasksWithProjects.filter((t) => t.status !== 2);
    } else if (options.status === "completed") {
      filteredTasks = tasksWithProjects.filter((t) => t.status === 2);
    }

    if (options.json) {
      console.log(JSON.stringify(filteredTasks, null, 2));
      return;
    }

    if (filteredTasks.length === 0) {
      console.log("No tasks found.");
      return;
    }

    console.log(`\nTasks (${filteredTasks.length}):\n`);
    for (const task of filteredTasks) {
      console.log(formatTask(task, !options.list));
    }
  } catch (error) {
    console.error(
      `Error: ${error instanceof Error ? error.message : String(error)}`
    );
    process.exit(1);
  }
}


path: scripts/ticktick.ts
content: #!/usr/bin/env bun

import { Command } from "commander";
import {
  authenticate,
  authenticateManual,
  checkAuth,
  logout,
  setupCredentials,
} from "./auth";
import { tasksCommand } from "./commands/tasks";
import { taskCreateCommand, taskUpdateCommand } from "./commands/task";
import { completeCommand } from "./commands/complete";
import { abandonCommand } from "./commands/abandon";
import { batchAbandonCommand } from "./commands/batch-abandon";
import { listsCommand } from "./commands/lists";
import { listCreateCommand, listUpdateCommand } from "./commands/list";

const program = new Command();

program
  .name("ticktick")
  .description("CLI for TickTick task and project management")
  .version("0.1.0");

// Auth command
const authCmd = program
  .command("auth")
  .description("Authenticate with TickTick");

authCmd
  .option("--client-id <id>", "TickTick OAuth client ID")
  .option("--client-secret <secret>", "TickTick OAuth client secret")
  .option("--manual", "Manual auth flow for headless servers (paste redirect URL)")
  .option("--logout", "Clear authentication tokens")
  .option("--status", "Check authentication status")
  .action(async (options) => {
    if (options.status) {
      const isAuthed = await checkAuth();
      if (isAuthed) {
        console.log("✓ Authenticated with TickTick");
      } else {
        console.log("✗ Not authenticated. Run 'ticktick auth' to set up.");
      }
      return;
    }

    if (options.logout) {
      await logout();
      return;
    }

    if (options.clientId && options.clientSecret) {
      await setupCredentials(options.clientId, options.clientSecret);
    }

    // Start OAuth flow
    if (options.manual) {
      await authenticateManual();
    } else {
      await authenticate();
    }
  });

// Tasks command - list tasks
program
  .command("tasks")
  .description("List tasks")
  .option("-l, --list <name>", "Filter by project name or ID")
  .option(
    "-s, --status <status>",
    "Filter by status: pending or completed"
  )
  .option("--json", "Output as JSON")
  .action(tasksCommand);

// Task command - create or update task
program
  .command("task <title>")
  .description("Create or update a task")
  .option("-l, --list <name>", "Project name or ID (required for create)")
  .option("-c, --content <description>", "Task description/content")
  .option(
    "-p, --priority <level>",
    "Priority: none, low, medium, high"
  )
  .option(
    "-d, --due <date>",
    "Due date: today, tomorrow, 'in N days', or ISO date"
  )
  .option("-t, --tag <tags...>", "Tags for the task")
  .option("-u, --update", "Update existing task instead of creating")
  .option("--json", "Output as JSON")
  .action(async (title, options) => {
    if (options.update) {
      await taskUpdateCommand(title, options);
    } else {
      if (!options.list) {
        console.error("Error: --list is required when creating a task");
        process.exit(1);
      }
      await taskCreateCommand(title, options);
    }
  });

// Complete command - mark task as done
program
  .command("complete <task>")
  .description("Mark a task as complete")
  .option("-l, --list <name>", "Project name or ID to search in")
  .option("--json", "Output as JSON")
  .action(completeCommand);

// Abandon command - mark task as won't do
program
  .command("abandon <task>")
  .description("Mark a task as won't do")
  .option("-l, --list <name>", "Project name or ID to search in")
  .option("--json", "Output as JSON")
  .action(abandonCommand);

// Batch abandon command - abandon multiple tasks at once
program
  .command("batch-abandon <taskIds...>")
  .description("Abandon multiple tasks in a single API call")
  .option("--json", "Output as JSON")
  .action(batchAbandonCommand);

// Lists command - list all projects
program
  .command("lists")
  .description("List all projects")
  .option("--json", "Output as JSON")
  .action(listsCommand);

// List command - create or update project
program
  .command("list <name>")
  .description("Create or update a project")
  .option("-c, --color <hex>", "Project color in hex format")
  .option("-u, --update", "Update existing project instead of creating")
  .option("-n, --name <newName>", "New name (for update)")
  .option("--json", "Output as JSON")
  .action(async (name, options) => {
    if (options.update) {
      await listUpdateCommand(name, options);
    } else {
      await listCreateCommand(name, options);
    }
  });

program.parse();


path: scripts/tt.sh
content: #!/usr/bin/env bash
# Convenience wrapper for ticktick CLI
cd "$(dirname "$0")/.." && bun run scripts/ticktick.ts "$@"


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)