@krodak/clickup-cli 1.44.0 → 1.45.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.
package/dist/index.js CHANGED
@@ -1,8 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ formatDate,
4
+ formatDateISO,
5
+ formatDuration,
6
+ formatLongDuration,
7
+ formatTimestamp,
8
+ readBundleData,
9
+ renderBundleMarkdown,
10
+ writeBundleData
11
+ } from "./chunk-ISEGUZ6H.js";
2
12
 
3
13
  // src/index.ts
4
14
  import { realpathSync as realpathSync2 } from "fs";
5
- import { basename, resolve } from "path";
15
+ import { basename, resolve as resolve2 } from "path";
6
16
  import { Command } from "commander";
7
17
  import { createRequire } from "module";
8
18
  import { fileURLToPath } from "url";
@@ -81,10 +91,12 @@ function normalizeViewId(input) {
81
91
  var ClickUpClient = class {
82
92
  apiToken;
83
93
  teamId;
94
+ rateLimiter;
84
95
  meCache = null;
85
96
  constructor(config) {
86
97
  this.apiToken = config.apiToken;
87
98
  this.teamId = config.teamId;
99
+ this.rateLimiter = config.rateLimiter;
88
100
  }
89
101
  taskPath(taskId, suffix = "") {
90
102
  const normalized = normalizeTaskId(taskId);
@@ -102,7 +114,7 @@ var ClickUpClient = class {
102
114
  return "";
103
115
  }
104
116
  sleep(ms) {
105
- return new Promise((resolve2) => setTimeout(resolve2, ms));
117
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
106
118
  }
107
119
  retryDelayMs(res, attempt) {
108
120
  const retryAfter = res.headers.get("retry-after");
@@ -118,9 +130,11 @@ var ClickUpClient = class {
118
130
  const maxRetries = 3;
119
131
  let attempt = 0;
120
132
  for (; ; ) {
133
+ await this.rateLimiter?.acquire();
121
134
  const res = await fetch(url, { ...init, signal: AbortSignal.timeout(3e4) });
122
135
  const retryable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
123
136
  if (!retryable || attempt >= maxRetries) return res;
137
+ if (res.status === 429) this.rateLimiter?.penalize();
124
138
  attempt++;
125
139
  const delayMs = this.retryDelayMs(res, attempt);
126
140
  process.stderr.write(
@@ -236,6 +250,7 @@ var ClickUpClient = class {
236
250
  subtasks: String(filters.subtasks ?? true)
237
251
  });
238
252
  if (filters.includeClosed) baseParams.set("include_closed", "true");
253
+ if (filters.archived) baseParams.set("archived", "true");
239
254
  if (!filters.all) {
240
255
  const me = await this.getMe();
241
256
  baseParams.append("assignees[]", String(me.id));
@@ -280,10 +295,40 @@ var ClickUpClient = class {
280
295
  const data = await this.request(this.taskPath(taskId, "/comment"));
281
296
  return readCollectionField(data, "comments", "task comments");
282
297
  }
298
+ /**
299
+ * Every comment on a task. ClickUp returns 25 per call and pages with
300
+ * `start` (the oldest returned comment's date) + `start_id`; the cursor
301
+ * comment is repeated on the next page, so results are deduped by id.
302
+ */
303
+ async getAllTaskComments(taskId) {
304
+ const PAGE_SIZE = 25;
305
+ const seen = /* @__PURE__ */ new Set();
306
+ const all = [];
307
+ let cursor;
308
+ for (; ; ) {
309
+ const qs = cursor ? `?start=${encodeURIComponent(cursor.start)}&start_id=${encodeURIComponent(cursor.startId)}` : "";
310
+ const data = await this.request(
311
+ this.taskPath(taskId, `/comment${qs}`)
312
+ );
313
+ const page = readCollectionField(data, "comments", "task comments");
314
+ let added = 0;
315
+ for (const c of page) {
316
+ if (seen.has(c.id)) continue;
317
+ seen.add(c.id);
318
+ all.push(c);
319
+ added++;
320
+ }
321
+ if (page.length < PAGE_SIZE || added === 0) break;
322
+ const last = page[page.length - 1];
323
+ cursor = { start: last.date, startId: last.id };
324
+ }
325
+ return all;
326
+ }
283
327
  async getTasksFromList(listId, params = {}, options = {}) {
284
328
  return this.paginate((page) => {
285
329
  const base = { subtasks: "true", page: String(page), ...params };
286
330
  if (options.includeClosed) base["include_closed"] = "true";
331
+ if (options.archived) base["archived"] = "true";
287
332
  const qs = new URLSearchParams(base).toString();
288
333
  return `/list/${listId}/task?${qs}`;
289
334
  });
@@ -291,6 +336,12 @@ var ClickUpClient = class {
291
336
  async getTask(taskId) {
292
337
  return this.request(this.taskPath(taskId, "?include_markdown_description=true"));
293
338
  }
339
+ /** Full task for archival: markdown description plus the direct subtask list. */
340
+ async getTaskForExport(taskId) {
341
+ return this.request(
342
+ this.taskPath(taskId, "?include_markdown_description=true&include_subtasks=true")
343
+ );
344
+ }
294
345
  /**
295
346
  * Resolve any accepted task-id form to a native ClickUp task id.
296
347
  * - Task URLs are reduced to their id segment.
@@ -764,6 +815,23 @@ var ClickUpClient = class {
764
815
  const data = await this.requestV3(`/workspaces/${workspaceId}/docs`);
765
816
  return readCollectionField(data, "docs", "docs");
766
817
  }
818
+ /** Every doc in the workspace, following v3 `next_cursor` pagination. */
819
+ async getAllDocs(workspaceId, options = {}) {
820
+ const all = [];
821
+ let cursor;
822
+ for (; ; ) {
823
+ const params = new URLSearchParams({ limit: "50" });
824
+ if (options.archived) params.set("archived", "true");
825
+ if (cursor) params.set("next_cursor", cursor);
826
+ const data = await this.requestV3(
827
+ `/workspaces/${workspaceId}/docs?${params.toString()}`
828
+ );
829
+ all.push(...readCollectionField(data, "docs", "docs"));
830
+ if (!data.next_cursor) break;
831
+ cursor = data.next_cursor;
832
+ }
833
+ return all;
834
+ }
767
835
  async getDocPage(workspaceId, docId, pageId) {
768
836
  return this.requestV3(
769
837
  `/workspaces/${workspaceId}/docs/${docId}/pages/${pageId}?content_format=text/md`
@@ -1562,50 +1630,6 @@ function writeConfig(config, profileName) {
1562
1630
  saveMultiProfileConfig(multi);
1563
1631
  }
1564
1632
 
1565
- // src/date.ts
1566
- function formatDate(ms) {
1567
- return new Date(Number(ms)).toLocaleDateString("en-US", {
1568
- month: "short",
1569
- day: "numeric",
1570
- year: "numeric"
1571
- });
1572
- }
1573
- function formatTimestamp(ms) {
1574
- return new Date(Number(ms)).toLocaleString("en-US", {
1575
- month: "short",
1576
- day: "numeric",
1577
- hour: "numeric",
1578
- minute: "2-digit"
1579
- });
1580
- }
1581
- function formatDuration(ms) {
1582
- const totalMinutes = Math.round(Math.abs(ms) / 6e4);
1583
- const hours = Math.floor(totalMinutes / 60);
1584
- const minutes = totalMinutes % 60;
1585
- if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
1586
- if (hours > 0) return `${hours}h`;
1587
- return `${minutes}m`;
1588
- }
1589
- function formatLongDuration(ms) {
1590
- const totalMinutes = Math.round(Math.abs(ms) / 6e4);
1591
- if (totalMinutes === 0) return "< 1m";
1592
- const days = Math.floor(totalMinutes / 1440);
1593
- const hours = Math.floor(totalMinutes % 1440 / 60);
1594
- const minutes = totalMinutes % 60;
1595
- const parts = [];
1596
- if (days > 0) parts.push(`${days}d`);
1597
- if (hours > 0) parts.push(`${hours}h`);
1598
- if (minutes > 0) parts.push(`${minutes}m`);
1599
- return parts.join(" ");
1600
- }
1601
- function formatDateISO(ms) {
1602
- const d = new Date(Number(ms));
1603
- const year = d.getUTCFullYear();
1604
- const month = String(d.getUTCMonth() + 1).padStart(2, "0");
1605
- const day = String(d.getUTCDate()).padStart(2, "0");
1606
- return `${year}-${month}-${day}`;
1607
- }
1608
-
1609
1633
  // src/output.ts
1610
1634
  import chalk from "chalk";
1611
1635
  function isTTY() {
@@ -4280,6 +4304,42 @@ var commandMetadata = [
4280
4304
  }
4281
4305
  ]
4282
4306
  },
4307
+ {
4308
+ name: "export",
4309
+ description: "Export tasks and docs to a local archive (lossless JSON + rendered markdown)",
4310
+ quickReference: [
4311
+ {
4312
+ section: "read",
4313
+ usage: "export user <userRef>",
4314
+ description: "Export all tasks assigned to a user (me, id, email; incl. closed, archived)"
4315
+ },
4316
+ {
4317
+ section: "read",
4318
+ usage: "export team <spaceRef>",
4319
+ description: "Export every list in a space (id or name), incl. archived"
4320
+ },
4321
+ {
4322
+ section: "read",
4323
+ usage: "export roadmap <listId>",
4324
+ description: "Export a list with initiatives grouped (--item-id) and subtask trees"
4325
+ },
4326
+ {
4327
+ section: "read",
4328
+ usage: "export initiatives <listId>",
4329
+ description: "Export only initiative-typed tasks (--item-id) plus their subtask trees"
4330
+ },
4331
+ {
4332
+ section: "read",
4333
+ usage: "export docs",
4334
+ description: "Export every workspace doc as markdown page trees"
4335
+ },
4336
+ {
4337
+ section: "read",
4338
+ usage: "export all",
4339
+ description: "Export the whole workspace (plans, confirms; --yes for non-interactive)"
4340
+ }
4341
+ ]
4342
+ },
4283
4343
  {
4284
4344
  name: "attach-get",
4285
4345
  description: "Download task attachment(s) by ID or title",
@@ -6371,8 +6431,8 @@ async function installSkillInteractive() {
6371
6431
  const source = skillPath();
6372
6432
  const installed = [];
6373
6433
  if (isTTY()) {
6374
- const { checkbox: checkbox2 } = await import("@inquirer/prompts");
6375
- const selected = await checkbox2({
6434
+ const { checkbox: checkbox3 } = await import("@inquirer/prompts");
6435
+ const selected = await checkbox3({
6376
6436
  message: "Install skill for which agents?",
6377
6437
  choices: targets.map((t) => ({
6378
6438
  name: `${t.name}${t.detected ? chalk8.dim(" (detected)") : ""}`,
@@ -7163,7 +7223,7 @@ async function downloadAttachment(attachment, targetPath, force) {
7163
7223
  return { title: attachment.title, path: targetPath, size: buffer.length };
7164
7224
  }
7165
7225
  async function attachGet(config, taskId, selector, opts) {
7166
- const { resolve: resolve2 } = await import("path");
7226
+ const { resolve: resolve3 } = await import("path");
7167
7227
  const client = new ClickUpClient(config);
7168
7228
  const attachments = await client.getTaskAttachments(taskId);
7169
7229
  const selected = selectAttachments(attachments, selector, opts.all ?? false);
@@ -7171,16 +7231,1066 @@ async function attachGet(config, taskId, selector, opts) {
7171
7231
  for (const att of selected) {
7172
7232
  let targetPath;
7173
7233
  if (opts.output && !opts.all) {
7174
- targetPath = resolve2(opts.output);
7234
+ targetPath = resolve3(opts.output);
7175
7235
  } else {
7176
7236
  const dir = opts.dir ?? ".";
7177
- targetPath = resolve2(dir, sanitizeFilename(att.title));
7237
+ targetPath = resolve3(dir, sanitizeFilename(att.title));
7178
7238
  }
7179
7239
  results.push(await downloadAttachment(att, targetPath, opts.force ?? false));
7180
7240
  }
7181
7241
  return results;
7182
7242
  }
7183
7243
 
7244
+ // src/commands/export.ts
7245
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
7246
+ import { join as join6, resolve } from "path";
7247
+
7248
+ // src/export/discover.ts
7249
+ function slug(name) {
7250
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "unnamed";
7251
+ }
7252
+ async function resolveUserRef(client, teamId, ref) {
7253
+ if (ref === "me") {
7254
+ const me = await client.getMe();
7255
+ return { id: me.id, username: me.username };
7256
+ }
7257
+ const members = await client.getWorkspaceMembers(teamId);
7258
+ const needle = ref.trim().toLowerCase();
7259
+ const match = members.find(
7260
+ (m) => String(m.id) === needle || m.username?.toLowerCase() === needle || m.email?.toLowerCase() === needle
7261
+ );
7262
+ if (!match) {
7263
+ const available = members.map((m) => m.username ?? m.email ?? String(m.id)).join(", ");
7264
+ throw new Error(`User "${ref}" not found. Available: ${available}`);
7265
+ }
7266
+ return { id: match.id, username: match.username ?? match.email ?? String(match.id) };
7267
+ }
7268
+ async function workspaceInfo(client, teamId) {
7269
+ const teams = await client.getTeams();
7270
+ const team = teams.find((t) => t.id === teamId);
7271
+ return { id: teamId, name: team?.name ?? teamId };
7272
+ }
7273
+ function toDiscovered(task) {
7274
+ return {
7275
+ id: task.id,
7276
+ listId: task.list.id,
7277
+ initiative: (task.custom_item_id ?? 0) !== 0
7278
+ };
7279
+ }
7280
+ function dedupe(tasks) {
7281
+ const seen = /* @__PURE__ */ new Set();
7282
+ return tasks.filter((t) => seen.has(t.id) ? false : (seen.add(t.id), true));
7283
+ }
7284
+ async function discoverUserTasks(client, teamId, userRef) {
7285
+ const user = await resolveUserRef(client, teamId, userRef);
7286
+ const base = { all: true, assignees: [user.id], includeClosed: true, subtasks: true };
7287
+ const [active, archived] = await Promise.all([
7288
+ client.getMyTasks(teamId, base),
7289
+ client.getMyTasks(teamId, { ...base, archived: true })
7290
+ ]);
7291
+ const all = dedupe([...active, ...archived]);
7292
+ return {
7293
+ slice: { name: `user-${slug(user.username)}`, kind: "user", scope: String(user.id) },
7294
+ tasks: all.map(toDiscovered),
7295
+ workspace: await workspaceInfo(client, teamId),
7296
+ tasksById: Object.fromEntries(all.map((t) => [t.id, t]))
7297
+ };
7298
+ }
7299
+ async function resolveSpaceRef(client, teamId, ref) {
7300
+ const spaces = await client.getSpaces(teamId);
7301
+ const needle = ref.trim().toLowerCase();
7302
+ const match = spaces.find((s) => s.id === ref.trim() || s.name.toLowerCase() === needle);
7303
+ if (!match) {
7304
+ throw new Error(`Space "${ref}" not found. Available: ${spaces.map((s) => s.name).join(", ")}`);
7305
+ }
7306
+ return { id: match.id, name: match.name };
7307
+ }
7308
+ async function listTasks(client, listId) {
7309
+ const [active, archived] = await Promise.all([
7310
+ client.getTasksFromList(listId, {}, { includeClosed: true }),
7311
+ client.getTasksFromList(listId, {}, { includeClosed: true, archived: true })
7312
+ ]);
7313
+ return dedupe([...active, ...archived]);
7314
+ }
7315
+ async function union(active, archived) {
7316
+ const [a, b] = await Promise.all([active, archived]);
7317
+ const seen = /* @__PURE__ */ new Set();
7318
+ return [...a, ...b].filter((x) => seen.has(x.id) ? false : (seen.add(x.id), true));
7319
+ }
7320
+ async function walkSpace(client, spaceId, spaceName) {
7321
+ const [lists, folders] = await Promise.all([
7322
+ union(client.getLists(spaceId, false), client.getLists(spaceId, true)),
7323
+ union(client.getFolders(spaceId, false), client.getFolders(spaceId, true))
7324
+ ]);
7325
+ const folderEntries = await Promise.all(
7326
+ folders.map(async (f) => ({
7327
+ id: f.id,
7328
+ name: f.name,
7329
+ lists: (await union(client.getFolderLists(f.id, false), client.getFolderLists(f.id, true))).map((l) => ({ id: l.id, name: l.name }))
7330
+ }))
7331
+ );
7332
+ const hierarchy = {
7333
+ space: { id: spaceId, name: spaceName },
7334
+ folders: folderEntries,
7335
+ lists: lists.map((l) => ({ id: l.id, name: l.name }))
7336
+ };
7337
+ return hierarchy;
7338
+ }
7339
+ async function discoverTeamTasks(client, teamId, spaceRef) {
7340
+ const space = await resolveSpaceRef(client, teamId, spaceRef);
7341
+ const hierarchy = await walkSpace(client, space.id, space.name);
7342
+ const listIds = [
7343
+ ...hierarchy.lists.map((l) => l.id),
7344
+ ...hierarchy.folders.flatMap((f) => f.lists.map((l) => l.id))
7345
+ ];
7346
+ const perList = await Promise.all(listIds.map((id) => listTasks(client, id)));
7347
+ const all = dedupe(perList.flat());
7348
+ return {
7349
+ slice: { name: `team-${slug(space.name)}`, kind: "team", scope: space.id },
7350
+ tasks: all.map(toDiscovered),
7351
+ workspace: await workspaceInfo(client, teamId),
7352
+ tasksById: Object.fromEntries(all.map((t) => [t.id, t])),
7353
+ hierarchy
7354
+ };
7355
+ }
7356
+ async function discoverListTasks(client, teamId, listId, opts) {
7357
+ const list = await client.getListWithStatuses(listId);
7358
+ let all = await listTasks(client, listId);
7359
+ if (opts.kind === "initiatives") {
7360
+ const itemId = opts.initiativeItemId;
7361
+ if (itemId === void 0) throw new Error("initiatives export requires an initiative item id");
7362
+ const matching = all.filter((t) => t.custom_item_id === itemId);
7363
+ if (matching.length === 0) {
7364
+ const found = [...new Set(all.map((t) => t.custom_item_id ?? 0).filter((id) => id !== 0))].sort((a, b) => a - b).join(", ");
7365
+ throw new Error(
7366
+ `No tasks with custom item id ${itemId} in list "${list.name}" (custom item ids found: ${found || "none"})`
7367
+ );
7368
+ }
7369
+ all = matching;
7370
+ }
7371
+ return {
7372
+ slice: { name: `${opts.kind}-${slug(list.name)}`, kind: opts.kind, scope: listId },
7373
+ tasks: all.map(toDiscovered),
7374
+ workspace: await workspaceInfo(client, teamId),
7375
+ tasksById: Object.fromEntries(all.map((t) => [t.id, t])),
7376
+ list: { id: list.id, name: list.name }
7377
+ };
7378
+ }
7379
+
7380
+ // src/util/batch.ts
7381
+ async function runInBatches(items, concurrency, fn) {
7382
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
7383
+ throw new Error(`concurrency must be a positive integer, got ${concurrency}`);
7384
+ }
7385
+ const results = [];
7386
+ for (let i = 0; i < items.length; i += concurrency) {
7387
+ const batch = items.slice(i, i + concurrency);
7388
+ const settled = await Promise.allSettled(batch.map(fn));
7389
+ settled.forEach((res, idx) => {
7390
+ const item = batch[idx];
7391
+ if (res.status === "fulfilled") {
7392
+ results.push({ item, ok: true, result: res.value });
7393
+ } else {
7394
+ const error = res.reason instanceof Error ? res.reason : new Error(String(res.reason), { cause: res.reason });
7395
+ results.push({ item, ok: false, error });
7396
+ }
7397
+ });
7398
+ }
7399
+ return results;
7400
+ }
7401
+
7402
+ // src/export/bundle.ts
7403
+ function replyCount(c) {
7404
+ const n = c.reply_count;
7405
+ return typeof n === "number" ? n : 0;
7406
+ }
7407
+ async function fetchTaskBundle(client, taskId) {
7408
+ const [task, rawComments] = await Promise.all([
7409
+ client.getTaskForExport(taskId),
7410
+ client.getAllTaskComments(taskId)
7411
+ ]);
7412
+ const comments = await Promise.all(
7413
+ rawComments.map(async (c) => ({
7414
+ ...c,
7415
+ replies: replyCount(c) > 0 ? await client.getThreadedComments(c.id) : []
7416
+ }))
7417
+ );
7418
+ return {
7419
+ task,
7420
+ comments,
7421
+ attachments: task.attachments ?? [],
7422
+ subtaskIds: (task.subtasks ?? []).map((s) => s.id),
7423
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
7424
+ };
7425
+ }
7426
+
7427
+ // src/export/manifest.ts
7428
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "fs";
7429
+ import { join as join3 } from "path";
7430
+ var MANIFEST_VERSION = 1;
7431
+ var FILE = "manifest.json";
7432
+ function emptyManifest() {
7433
+ return { version: MANIFEST_VERSION, workspace: void 0, tasks: {}, docs: {}, slices: {} };
7434
+ }
7435
+ function loadManifest(root) {
7436
+ const path = join3(root, FILE);
7437
+ if (!existsSync2(path)) return emptyManifest();
7438
+ let parsed;
7439
+ try {
7440
+ parsed = JSON.parse(readFileSync3(path, "utf8"));
7441
+ } catch (err) {
7442
+ throw new Error(`Cannot parse ${path}: ${err.message}`, { cause: err });
7443
+ }
7444
+ const m = parsed;
7445
+ if (m.version !== MANIFEST_VERSION) {
7446
+ throw new Error(
7447
+ `Unsupported manifest version ${String(m.version)} in ${path} (expected ${MANIFEST_VERSION})`
7448
+ );
7449
+ }
7450
+ return {
7451
+ version: MANIFEST_VERSION,
7452
+ workspace: m.workspace ?? void 0,
7453
+ tasks: m.tasks ?? {},
7454
+ docs: m.docs ?? {},
7455
+ slices: m.slices ?? {}
7456
+ };
7457
+ }
7458
+ function saveManifest(root, manifest) {
7459
+ mkdirSync2(root, { recursive: true });
7460
+ const path = join3(root, FILE);
7461
+ const tmp = `${path}.tmp`;
7462
+ writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n");
7463
+ renameSync(tmp, path);
7464
+ }
7465
+
7466
+ // src/export/engine.ts
7467
+ function addSlice(manifest, taskId, slice) {
7468
+ const entry = manifest.tasks[taskId];
7469
+ if (entry && !entry.slices.includes(slice)) entry.slices.push(slice);
7470
+ }
7471
+ async function runExport(client, plan, opts) {
7472
+ const manifest = loadManifest(opts.root);
7473
+ manifest.workspace = plan.workspace;
7474
+ const sliceName = plan.slice.name;
7475
+ const checkpointEvery = opts.checkpointEvery ?? 25;
7476
+ const queue = [];
7477
+ const queued = /* @__PURE__ */ new Set();
7478
+ const enqueue = (id) => {
7479
+ if (!queued.has(id)) {
7480
+ queued.add(id);
7481
+ queue.push(id);
7482
+ }
7483
+ };
7484
+ for (const t of plan.tasks) enqueue(t.id);
7485
+ const summary = {
7486
+ fetched: 0,
7487
+ skipped: 0,
7488
+ failed: [],
7489
+ attachmentsDownloaded: 0,
7490
+ attachmentsFailed: 0
7491
+ };
7492
+ const touched = /* @__PURE__ */ new Set();
7493
+ let sinceCheckpoint = 0;
7494
+ while (queue.length > 0) {
7495
+ const batch = queue.splice(0, opts.concurrency);
7496
+ const toFetch = [];
7497
+ for (const id of batch) {
7498
+ touched.add(id);
7499
+ if (!opts.refresh && manifest.tasks[id]) {
7500
+ summary.skipped++;
7501
+ addSlice(manifest, id, sliceName);
7502
+ try {
7503
+ const cached = await readBundleData(opts.root, id);
7504
+ for (const s of cached.subtaskIds) enqueue(s);
7505
+ } catch {
7506
+ delete manifest.tasks[id];
7507
+ toFetch.push(id);
7508
+ summary.skipped--;
7509
+ }
7510
+ continue;
7511
+ }
7512
+ toFetch.push(id);
7513
+ }
7514
+ const outcomes = await runInBatches(toFetch, opts.concurrency, async (id) => {
7515
+ const bundle = await fetchTaskBundle(client, id);
7516
+ const written = await writeBundleData(opts.root, bundle, {
7517
+ downloadAttachments: opts.downloadAttachments,
7518
+ download: opts.download
7519
+ });
7520
+ return { bundle, written };
7521
+ });
7522
+ for (const o of outcomes) {
7523
+ if (!o.ok) {
7524
+ summary.failed.push({ id: o.item, error: o.error.message });
7525
+ continue;
7526
+ }
7527
+ const { bundle, written } = o.result;
7528
+ summary.fetched++;
7529
+ summary.attachmentsDownloaded += written.attachmentsDownloaded;
7530
+ summary.attachmentsFailed += written.attachmentsFailed.length;
7531
+ const existing = manifest.tasks[bundle.task.id];
7532
+ manifest.tasks[bundle.task.id] = {
7533
+ fetchedAt: bundle.fetchedAt,
7534
+ slices: existing?.slices.includes(sliceName) ? existing.slices : [...existing?.slices ?? [], sliceName],
7535
+ contentHash: written.contentHash
7536
+ };
7537
+ for (const s of bundle.subtaskIds) enqueue(s);
7538
+ sinceCheckpoint++;
7539
+ }
7540
+ const total = touched.size + queue.length;
7541
+ const done = summary.fetched + summary.skipped + summary.failed.length;
7542
+ opts.log(
7543
+ `[${sliceName}] ${done}/${total} tasks (${summary.fetched} fetched, ${summary.skipped} cached, ${summary.failed.length} failed)`
7544
+ );
7545
+ if (sinceCheckpoint >= checkpointEvery) {
7546
+ saveManifest(opts.root, manifest);
7547
+ sinceCheckpoint = 0;
7548
+ }
7549
+ }
7550
+ const known = new Set(Object.keys(manifest.tasks));
7551
+ const hasTask = (id) => known.has(id);
7552
+ const spaceNames = opts.spaceNames;
7553
+ const spaceName = spaceNames ? (id) => spaceNames[id] : void 0;
7554
+ const renderOutcomes = await runInBatches([...known], opts.concurrency * 2, async (id) => {
7555
+ const bundle = await readBundleData(opts.root, id);
7556
+ await renderBundleMarkdown(opts.root, bundle, hasTask, spaceName);
7557
+ });
7558
+ for (const o of renderOutcomes) {
7559
+ if (!o.ok) opts.log(`warning: could not render ${o.item}: ${o.error.message}`);
7560
+ }
7561
+ manifest.slices[sliceName] = {
7562
+ kind: plan.slice.kind,
7563
+ scope: plan.slice.scope,
7564
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
7565
+ taskCount: [...touched].filter((id) => manifest.tasks[id]).length
7566
+ };
7567
+ saveManifest(opts.root, manifest);
7568
+ return summary;
7569
+ }
7570
+
7571
+ // src/export/docs.ts
7572
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
7573
+ import { join as join4 } from "path";
7574
+ var PARENT_TYPES = {
7575
+ 7: "workspace",
7576
+ 4: "space",
7577
+ 6: "folder",
7578
+ 5: "list",
7579
+ 1: "task"
7580
+ };
7581
+ function docDirName(doc) {
7582
+ const s = doc.name ? slug(doc.name) : "";
7583
+ return s && s !== "unnamed" ? `${s}-${doc.id}` : doc.id;
7584
+ }
7585
+ function pageFileName(page) {
7586
+ const s = page.name ? slug(page.name) : "";
7587
+ return s && s !== "unnamed" ? `${s}-${page.id}.md` : `${page.id}.md`;
7588
+ }
7589
+ function pageDirName(page) {
7590
+ return pageFileName(page).replace(/\.md$/, "");
7591
+ }
7592
+ function yamlString(v) {
7593
+ return JSON.stringify(v);
7594
+ }
7595
+ function renderPage(page, docId) {
7596
+ const header = [
7597
+ "---",
7598
+ `id: ${page.id}`,
7599
+ `doc: ${docId}`,
7600
+ `title: ${page.name ? yamlString(page.name).slice(1, -1) : ""}`,
7601
+ ...page.parent_page_id ? [`parent: ${page.parent_page_id}`] : [],
7602
+ ...page.archived ? ["archived: true"] : [],
7603
+ ...page.date_created ? [`created: ${new Date(page.date_created).toISOString()}`] : [],
7604
+ ...page.date_updated ? [`updated: ${new Date(page.date_updated).toISOString()}`] : [],
7605
+ "---"
7606
+ ];
7607
+ return header.join("\n") + "\n\n" + (page.content ?? "") + "\n";
7608
+ }
7609
+ function writePages(dir, docId, pages, prefix = "", depth = 0) {
7610
+ const out = [];
7611
+ for (const page of pages) {
7612
+ const file = pageFileName(page);
7613
+ const relPath = prefix ? `${prefix}/${file}` : file;
7614
+ mkdirSync3(join4(dir, prefix), { recursive: true });
7615
+ writeFileSync2(join4(dir, relPath), renderPage(page, docId));
7616
+ out.push({ page, relPath, depth });
7617
+ if (page.pages?.length) {
7618
+ const childPrefix = prefix ? `${prefix}/${pageDirName(page)}` : pageDirName(page);
7619
+ out.push(...writePages(dir, docId, page.pages, childPrefix, depth + 1));
7620
+ }
7621
+ }
7622
+ return out;
7623
+ }
7624
+ function docTitle(doc) {
7625
+ return doc.name || `(unnamed ${doc.id})`;
7626
+ }
7627
+ function renderDocReadme(doc, written, location) {
7628
+ const lines = [
7629
+ `# ${docTitle(doc)}`,
7630
+ "",
7631
+ `Doc id: ${doc.id} \xB7 Location: ${location} \xB7 ${written.length} pages`,
7632
+ ...doc.date_updated ? [`Last updated: ${new Date(Number(doc.date_updated)).toISOString()}`] : [],
7633
+ "",
7634
+ "## Pages",
7635
+ ""
7636
+ ];
7637
+ for (const w of written) {
7638
+ const name = w.page.name || `(untitled ${w.page.id})`;
7639
+ const flag = w.page.archived ? " (archived)" : "";
7640
+ lines.push(`${" ".repeat(w.depth)}- [${name}](${w.relPath})${flag}`);
7641
+ }
7642
+ lines.push("");
7643
+ return lines.join("\n");
7644
+ }
7645
+ function renderDocsIndex(entries) {
7646
+ const lines = [
7647
+ "# Docs",
7648
+ "",
7649
+ `${entries.length} docs`,
7650
+ "",
7651
+ "| Doc | Location | Pages |",
7652
+ "| --- | --- | --- |"
7653
+ ];
7654
+ for (const e of entries.sort((a, b) => docTitle(a.doc).localeCompare(docTitle(b.doc)))) {
7655
+ lines.push(`| [${docTitle(e.doc)}](${e.dir}/README.md) | ${e.location} | ${e.pages} |`);
7656
+ }
7657
+ lines.push("");
7658
+ return lines.join("\n");
7659
+ }
7660
+ function countPages(pages) {
7661
+ return pages.reduce((n, p) => n + 1 + countPages(p.pages ?? []), 0);
7662
+ }
7663
+ async function exportDocs(client, teamId, opts) {
7664
+ const manifest = loadManifest(opts.root);
7665
+ const docsRoot = join4(opts.root, "docs");
7666
+ mkdirSync3(docsRoot, { recursive: true });
7667
+ const [docs, spaces, teams] = await Promise.all([
7668
+ client.getAllDocs(teamId),
7669
+ client.getSpaces(teamId),
7670
+ client.getTeams()
7671
+ ]);
7672
+ const spaceNames = new Map(spaces.map((s) => [s.id, s.name]));
7673
+ const teamName = teams.find((t) => t.id === teamId)?.name ?? teamId;
7674
+ const locationOf = (doc) => {
7675
+ const t = doc.parent ? PARENT_TYPES[doc.parent.type] ?? `type ${doc.parent.type}` : "unknown";
7676
+ const id = doc.parent?.id ?? "";
7677
+ if (t === "space") return `space ${spaceNames.get(id) ?? id}`;
7678
+ if (t === "workspace") return `workspace ${teamName}`;
7679
+ return `${t} ${id}`;
7680
+ };
7681
+ const summary = { docs: 0, pages: 0, skipped: 0, failed: [] };
7682
+ const entries = [];
7683
+ opts.log(`Plan [docs]: ${docs.length} docs in workspace "${teamName}"`);
7684
+ for (const doc of docs) {
7685
+ const dir = docDirName(doc);
7686
+ const location = locationOf(doc);
7687
+ const cached = manifest.docs[doc.id];
7688
+ if (cached && !opts.refresh) {
7689
+ summary.skipped++;
7690
+ entries.push({ doc, dir, pages: cached.pageCount, location });
7691
+ continue;
7692
+ }
7693
+ try {
7694
+ const pages = await client.getDocPages(teamId, doc.id);
7695
+ const dirPath = join4(docsRoot, dir);
7696
+ mkdirSync3(dirPath, { recursive: true });
7697
+ writeFileSync2(join4(dirPath, "doc.json"), JSON.stringify({ doc, pages }, null, 2) + "\n");
7698
+ const written = writePages(dirPath, doc.id, pages);
7699
+ writeFileSync2(join4(dirPath, "README.md"), renderDocReadme(doc, written, location));
7700
+ const pageCount = countPages(pages);
7701
+ manifest.docs[doc.id] = {
7702
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
7703
+ name: doc.name,
7704
+ pageCount
7705
+ };
7706
+ summary.docs++;
7707
+ summary.pages += pageCount;
7708
+ entries.push({ doc, dir, pages: pageCount, location });
7709
+ opts.log(
7710
+ `[docs] ${summary.docs + summary.skipped + summary.failed.length}/${docs.length} ${docTitle(doc)} (${pageCount} pages)`
7711
+ );
7712
+ } catch (err) {
7713
+ summary.failed.push({ id: doc.id, error: err.message });
7714
+ }
7715
+ }
7716
+ writeFileSync2(join4(docsRoot, "README.md"), renderDocsIndex(entries));
7717
+ manifest.slices["docs"] = {
7718
+ kind: "docs",
7719
+ scope: teamId,
7720
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
7721
+ taskCount: 0
7722
+ };
7723
+ if (!manifest.workspace) manifest.workspace = { id: teamId, name: teamName };
7724
+ saveManifest(opts.root, manifest);
7725
+ return summary;
7726
+ }
7727
+
7728
+ // src/export/index-team.ts
7729
+ function link(task) {
7730
+ return `[${task.name.replace(/\|/g, "\\|")}](../../tasks/${task.id}/task.md)`;
7731
+ }
7732
+ function assignees(task) {
7733
+ return task.assignees.map((a) => a.username).join(", ");
7734
+ }
7735
+ function typeOf(task, initiativeItemId) {
7736
+ const id = task.custom_item_id ?? 0;
7737
+ if (id === 0) return "task";
7738
+ return initiativeItemId !== void 0 && id === initiativeItemId ? "initiative" : `type ${id}`;
7739
+ }
7740
+ function taskTable(tasks, initiativeItemId) {
7741
+ const lines = ["| Task | Status | Assignees | Type |", "| --- | --- | --- | --- |"];
7742
+ for (const t of tasks) {
7743
+ lines.push(
7744
+ `| ${link(t)} | ${t.status.status} | ${assignees(t)} | ${typeOf(t, initiativeItemId)} |`
7745
+ );
7746
+ }
7747
+ return lines;
7748
+ }
7749
+ function renderTeamIndex(hierarchy, tasks, opts) {
7750
+ const topLevel = tasks.filter((t) => !t.parent);
7751
+ const byList = /* @__PURE__ */ new Map();
7752
+ for (const t of topLevel) byList.set(t.list.id, [...byList.get(t.list.id) ?? [], t]);
7753
+ const listCount = hierarchy.lists.length + hierarchy.folders.reduce((n, f) => n + f.lists.length, 0);
7754
+ const folderWord = hierarchy.folders.length === 1 ? "folder" : "folders";
7755
+ const lines = [
7756
+ `# ${hierarchy.space.name} (space)`,
7757
+ "",
7758
+ `Exported ${opts.exportedAt.slice(0, 10)} \xB7 ${listCount} lists \xB7 ${hierarchy.folders.length} ${folderWord} \xB7 ${tasks.length} tasks`,
7759
+ ""
7760
+ ];
7761
+ const renderList = (list, level) => {
7762
+ const group = byList.get(list.id) ?? [];
7763
+ const h = "#".repeat(level);
7764
+ lines.push(`${h} ${list.name} \u2014 ${group.length} tasks`, "");
7765
+ const related = opts.relatedSlices?.filter((s) => s.listId === list.id) ?? [];
7766
+ for (const r of related) {
7767
+ lines.push(`\u2192 see also [${r.name}](../${r.name}/README.md)`, "");
7768
+ }
7769
+ if (group.length > 0) lines.push(...taskTable(group, opts.initiativeItemId), "");
7770
+ };
7771
+ for (const list of hierarchy.lists) renderList(list, 2);
7772
+ for (const folder of hierarchy.folders) {
7773
+ lines.push(`## Folder: ${folder.name}`, "");
7774
+ for (const list of folder.lists) renderList(list, 3);
7775
+ }
7776
+ return lines.join("\n");
7777
+ }
7778
+ function checkbox2(task) {
7779
+ return isDoneStatus(task.status.status) ? "[x]" : "[ ]";
7780
+ }
7781
+ function renderRoadmapIndex(list, tasks, opts) {
7782
+ const children = /* @__PURE__ */ new Map();
7783
+ for (const t of tasks) {
7784
+ if (t.parent) children.set(t.parent, [...children.get(t.parent) ?? [], t]);
7785
+ }
7786
+ const isInitiative = (t) => opts.initiativeItemId !== void 0 && t.custom_item_id === opts.initiativeItemId;
7787
+ const initiatives = tasks.filter((t) => !t.parent && isInitiative(t));
7788
+ const ungrouped = tasks.filter((t) => !t.parent && !isInitiative(t));
7789
+ const lines = [
7790
+ `# ${list.name}`,
7791
+ "",
7792
+ `Exported ${opts.exportedAt.slice(0, 10)} \xB7 ${initiatives.length} initiatives \xB7 ${tasks.length} tasks`,
7793
+ ""
7794
+ ];
7795
+ const renderTree = (parentId, depth) => {
7796
+ for (const c of children.get(parentId) ?? []) {
7797
+ lines.push(`${" ".repeat(depth)}- ${checkbox2(c)} ${link(c)}`);
7798
+ renderTree(c.id, depth + 1);
7799
+ }
7800
+ };
7801
+ if (initiatives.length > 0) {
7802
+ lines.push("## Initiatives", "");
7803
+ for (const init of initiatives) {
7804
+ lines.push(`### ${link(init)} \u2014 ${init.status.status}`);
7805
+ const meta = [];
7806
+ if (init.assignees.length > 0) meta.push(`Owner: ${assignees(init)}`);
7807
+ if (init.tags?.length) meta.push(`Tags: ${init.tags.map((t) => t.name).join(", ")}`);
7808
+ if (init.start_date || init.due_date) {
7809
+ const from = init.start_date ? formatDateISO(init.start_date) : "?";
7810
+ const to = init.due_date ? formatDateISO(init.due_date) : "?";
7811
+ meta.push(`${from} \u2192 ${to}`);
7812
+ }
7813
+ if (meta.length > 0) lines.push(meta.join(" \xB7 "));
7814
+ lines.push("");
7815
+ if (children.has(init.id)) {
7816
+ renderTree(init.id, 0);
7817
+ lines.push("");
7818
+ }
7819
+ }
7820
+ }
7821
+ lines.push(`## Ungrouped tasks (${ungrouped.length})`, "");
7822
+ if (ungrouped.length > 0) {
7823
+ lines.push("| Task | Status | Assignees |", "| --- | --- | --- |");
7824
+ for (const t of ungrouped) lines.push(`| ${link(t)} | ${t.status.status} | ${assignees(t)} |`);
7825
+ lines.push("");
7826
+ }
7827
+ return lines.join("\n");
7828
+ }
7829
+
7830
+ // src/export/index-user.ts
7831
+ function link2(task) {
7832
+ return `[${task.name.replace(/\|/g, "\\|")}](../../tasks/${task.id}/task.md)`;
7833
+ }
7834
+ function cap(s) {
7835
+ return s.charAt(0).toUpperCase() + s.slice(1);
7836
+ }
7837
+ function closedAt(task) {
7838
+ return task.date_closed ?? task.date_done ?? void 0;
7839
+ }
7840
+ function monthOf(ms) {
7841
+ return formatDateISO(ms).slice(0, 7);
7842
+ }
7843
+ function renderUserIndex(user, tasks, opts) {
7844
+ const lines = [
7845
+ `# ${user.username} \u2014 tasks`,
7846
+ "",
7847
+ `Exported ${opts.exportedAt.slice(0, 10)} \xB7 ${tasks.length} tasks assigned \xB7 user id ${user.id}`,
7848
+ ""
7849
+ ];
7850
+ const open = tasks.filter((t) => !isDoneStatus(t.status.status));
7851
+ const done = tasks.filter((t) => isDoneStatus(t.status.status));
7852
+ const byStatus = /* @__PURE__ */ new Map();
7853
+ for (const t of open) {
7854
+ const key = t.status.status.toLowerCase();
7855
+ byStatus.set(key, [...byStatus.get(key) ?? [], t]);
7856
+ }
7857
+ const order = (s) => /progress|review|active/.test(s) ? 0 : /to ?do|open|backlog/.test(s) ? 2 : 1;
7858
+ for (const [status, group] of [...byStatus.entries()].sort(
7859
+ (a, b) => order(a[0]) - order(b[0]) || a[0].localeCompare(b[0])
7860
+ )) {
7861
+ lines.push(
7862
+ `## ${cap(status)} (${group.length})`,
7863
+ "",
7864
+ "| Task | List | Due |",
7865
+ "| --- | --- | --- |"
7866
+ );
7867
+ for (const t of group) {
7868
+ lines.push(`| ${link2(t)} | ${t.list.name} | ${t.due_date ? formatDateISO(t.due_date) : ""} |`);
7869
+ }
7870
+ lines.push("");
7871
+ }
7872
+ if (done.length > 0) {
7873
+ lines.push(`## Done (${done.length})`, "");
7874
+ const byMonth = /* @__PURE__ */ new Map();
7875
+ for (const t of done) {
7876
+ const when = closedAt(t);
7877
+ const key = when ? monthOf(when) : "unknown";
7878
+ byMonth.set(key, [...byMonth.get(key) ?? [], t]);
7879
+ }
7880
+ for (const [month, group] of [...byMonth.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
7881
+ lines.push(`### ${month}`, "", "| Task | List | Closed |", "| --- | --- | --- |");
7882
+ for (const t of group) {
7883
+ const when = closedAt(t);
7884
+ lines.push(`| ${link2(t)} | ${t.list.name} | ${when ? formatDateISO(when) : ""} |`);
7885
+ }
7886
+ lines.push("");
7887
+ }
7888
+ }
7889
+ const where = /* @__PURE__ */ new Map();
7890
+ for (const t of tasks) {
7891
+ const space = t.space?.id ? opts.spaceNames[t.space.id] ?? t.space.id : "Unknown space";
7892
+ const key = `${space} / ${t.list.name}`;
7893
+ where.set(key, (where.get(key) ?? 0) + 1);
7894
+ }
7895
+ lines.push("## Where these live", "");
7896
+ for (const [key, n] of [...where.entries()].sort((a, b) => b[1] - a[1])) {
7897
+ lines.push(`- ${key}: ${n}`);
7898
+ }
7899
+ lines.push("");
7900
+ return lines.join("\n");
7901
+ }
7902
+
7903
+ // src/export/root-readme.ts
7904
+ import { writeFileSync as writeFileSync3 } from "fs";
7905
+ import { join as join5 } from "path";
7906
+ function renderRootReadme(manifest) {
7907
+ const ws = manifest.workspace;
7908
+ const taskCount = Object.keys(manifest.tasks).length;
7909
+ const docCount = Object.keys(manifest.docs).length;
7910
+ const lines = [
7911
+ `# ClickUp export${ws ? `: ${ws.name}` : ""}`,
7912
+ "",
7913
+ `${taskCount} tasks \xB7 ${docCount} docs \xB7 generated by \`cup export\``,
7914
+ "",
7915
+ "## Slices",
7916
+ "",
7917
+ "Each slice is an index into the shared task store. Start from whichever matches what you are looking for.",
7918
+ "",
7919
+ "| Slice | Kind | Scope | Tasks | Exported |",
7920
+ "| --- | --- | --- | --- | --- |"
7921
+ ];
7922
+ const slices = Object.entries(manifest.slices).filter(([, s]) => s.kind !== "docs").sort((a, b) => a[0].localeCompare(b[0]));
7923
+ for (const [name, s] of slices) {
7924
+ lines.push(
7925
+ `| [${name}](slices/${name}/README.md) | ${s.kind} | ${s.scope} | ${s.taskCount} | ${s.exportedAt.slice(0, 10)} |`
7926
+ );
7927
+ }
7928
+ if (docCount > 0) lines.push("", "Docs: [docs/](docs/README.md)");
7929
+ lines.push(
7930
+ "",
7931
+ "## Layout",
7932
+ "",
7933
+ "- `tasks/<id>/` \u2014 one directory per task: `task.json` (lossless API payload), `task.md` (rendered), `comments.json` / `comments.md`, `attachments/`",
7934
+ "- `slices/<name>/README.md` \u2014 navigable index for one export scope",
7935
+ "- `manifest.json` \u2014 what has been exported and when; drives incremental re-runs",
7936
+ "",
7937
+ "Task ids match ClickUp URLs: `https://app.clickup.com/t/<id>` \u2192 `tasks/<id>/`.",
7938
+ ""
7939
+ );
7940
+ return lines.join("\n");
7941
+ }
7942
+ function writeRootReadme(root, manifest) {
7943
+ writeFileSync3(join5(root, "README.md"), renderRootReadme(manifest));
7944
+ }
7945
+
7946
+ // src/util/rate-limit.ts
7947
+ function createRateLimiter(requestsPerMinute) {
7948
+ if (!(requestsPerMinute > 0)) {
7949
+ throw new Error(`requestsPerMinute must be a positive number, got ${requestsPerMinute}`);
7950
+ }
7951
+ const capacity = Math.max(5, Math.floor(requestsPerMinute / 10));
7952
+ const refillPerMs = requestsPerMinute / 6e4;
7953
+ let tokens = capacity;
7954
+ let lastRefill = Date.now();
7955
+ const queue = [];
7956
+ let timer;
7957
+ function refill() {
7958
+ const now = Date.now();
7959
+ tokens = Math.min(capacity, tokens + (now - lastRefill) * refillPerMs);
7960
+ lastRefill = now;
7961
+ }
7962
+ function drain() {
7963
+ timer = void 0;
7964
+ refill();
7965
+ while (queue.length > 0 && tokens >= 1) {
7966
+ tokens -= 1;
7967
+ queue.shift()();
7968
+ }
7969
+ if (queue.length > 0) {
7970
+ const waitMs = Math.ceil((1 - tokens) / refillPerMs);
7971
+ timer = setTimeout(drain, waitMs);
7972
+ }
7973
+ }
7974
+ return {
7975
+ acquire() {
7976
+ return new Promise((resolve3) => {
7977
+ queue.push(resolve3);
7978
+ if (!timer) drain();
7979
+ });
7980
+ },
7981
+ penalize() {
7982
+ refill();
7983
+ tokens = 0;
7984
+ }
7985
+ };
7986
+ }
7987
+
7988
+ // src/commands/export.ts
7989
+ var CONCURRENCY = 4;
7990
+ function requireTeam(config) {
7991
+ if (!config.teamId) {
7992
+ throw new Error("Export requires a teamId in your config. Run `cup init` to set one.");
7993
+ }
7994
+ return config.teamId;
7995
+ }
7996
+ function makeClient(config, rpm) {
7997
+ return new ClickUpClient({ ...config, rateLimiter: createRateLimiter(rpm) });
7998
+ }
7999
+ function emptySummary(plan, opts) {
8000
+ return {
8001
+ slice: plan.slice.name,
8002
+ planned: plan.tasks.length,
8003
+ out: resolve(opts.out),
8004
+ dryRun: opts.dryRun,
8005
+ fetched: 0,
8006
+ skipped: 0,
8007
+ failed: [],
8008
+ attachmentsDownloaded: 0,
8009
+ attachmentsFailed: 0
8010
+ };
8011
+ }
8012
+ function describePlan(plan) {
8013
+ const initiatives = plan.tasks.filter((t) => t.initiative).length;
8014
+ return `Plan [${plan.slice.name}]: ${plan.tasks.length} tasks (${initiatives} initiatives) in workspace "${plan.workspace.name}"`;
8015
+ }
8016
+ async function execute(client, teamId, plan, opts, writeIndex) {
8017
+ opts.log(describePlan(plan));
8018
+ const summary = emptySummary(plan, opts);
8019
+ if (opts.dryRun) return summary;
8020
+ const root = resolve(opts.out);
8021
+ const spaces = await client.getSpaces(teamId);
8022
+ const spaceNames = Object.fromEntries(spaces.map((s) => [s.id, s.name]));
8023
+ const run2 = await runExport(client, plan, {
8024
+ root,
8025
+ refresh: opts.refresh,
8026
+ downloadAttachments: opts.attachments,
8027
+ concurrency: CONCURRENCY,
8028
+ log: opts.log,
8029
+ spaceNames
8030
+ });
8031
+ await writeIndex(root, spaceNames);
8032
+ writeRootReadme(root, loadManifest(root));
8033
+ return { ...summary, ...run2 };
8034
+ }
8035
+ async function exportUser(config, userRef, opts) {
8036
+ const teamId = requireTeam(config);
8037
+ const client = makeClient(config, opts.rpm);
8038
+ const user = await resolveUserRef(client, teamId, userRef);
8039
+ const plan = await discoverUserTasks(client, teamId, userRef);
8040
+ return execute(client, teamId, plan, opts, async (root, spaceNames) => {
8041
+ const tasks = Object.values(plan.tasksById ?? {});
8042
+ const dir = join6(root, "slices", plan.slice.name);
8043
+ mkdirSync4(dir, { recursive: true });
8044
+ writeFileSync4(
8045
+ join6(dir, "README.md"),
8046
+ renderUserIndex(user, tasks, { exportedAt: (/* @__PURE__ */ new Date()).toISOString(), spaceNames })
8047
+ );
8048
+ writeFileSync4(
8049
+ join6(dir, "tasks.json"),
8050
+ JSON.stringify({ user, taskIds: plan.tasks.map((t) => t.id) }, null, 2) + "\n"
8051
+ );
8052
+ });
8053
+ }
8054
+ function formatExportSummary(s) {
8055
+ if (s.dryRun) return `Dry run: ${s.planned} tasks would be exported to ${s.out}`;
8056
+ const lines = [
8057
+ `Exported slice "${s.slice}" to ${s.out}`,
8058
+ ` tasks: ${s.fetched} fetched, ${s.skipped} already present, ${s.failed.length} failed`,
8059
+ ` attachments: ${s.attachmentsDownloaded} downloaded, ${s.attachmentsFailed} failed`
8060
+ ];
8061
+ for (const f of s.failed.slice(0, 10)) lines.push(` failed ${f.id}: ${f.error}`);
8062
+ if (s.failed.length > 10) lines.push(` ... and ${s.failed.length - 10} more`);
8063
+ return lines.join("\n");
8064
+ }
8065
+ function writeSliceFiles(root, sliceName, readme, meta) {
8066
+ const dir = join6(root, "slices", sliceName);
8067
+ mkdirSync4(dir, { recursive: true });
8068
+ writeFileSync4(join6(dir, "README.md"), readme);
8069
+ writeFileSync4(join6(dir, "tasks.json"), JSON.stringify(meta, null, 2) + "\n");
8070
+ }
8071
+ async function exportTeam(config, spaceRef, opts) {
8072
+ const teamId = requireTeam(config);
8073
+ const client = makeClient(config, opts.rpm);
8074
+ const plan = await discoverTeamTasks(client, teamId, spaceRef);
8075
+ return execute(client, teamId, plan, opts, async (root) => {
8076
+ const manifest = loadManifest(root);
8077
+ const relatedSlices = Object.entries(manifest.slices).filter(([, s]) => s.kind === "roadmap" || s.kind === "initiatives").map(([name, s]) => ({ name, listId: s.scope }));
8078
+ const tasks = Object.values(plan.tasksById ?? {});
8079
+ writeSliceFiles(
8080
+ root,
8081
+ plan.slice.name,
8082
+ renderTeamIndex(plan.hierarchy, tasks, {
8083
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8084
+ relatedSlices,
8085
+ initiativeItemId: opts.initiativeItemId
8086
+ }),
8087
+ { hierarchy: plan.hierarchy, taskIds: plan.tasks.map((t) => t.id) }
8088
+ );
8089
+ });
8090
+ }
8091
+ async function exportRoadmap(config, listId, opts) {
8092
+ const teamId = requireTeam(config);
8093
+ const client = makeClient(config, opts.rpm);
8094
+ const plan = await discoverListTasks(client, teamId, listId, { kind: "roadmap" });
8095
+ return execute(client, teamId, plan, opts, async (root) => {
8096
+ const tasks = Object.values(plan.tasksById ?? {});
8097
+ writeSliceFiles(
8098
+ root,
8099
+ plan.slice.name,
8100
+ renderRoadmapIndex(plan.list, tasks, {
8101
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8102
+ initiativeItemId: opts.initiativeItemId
8103
+ }),
8104
+ {
8105
+ list: plan.list,
8106
+ initiativeItemId: opts.initiativeItemId ?? null,
8107
+ taskIds: plan.tasks.map((t) => t.id)
8108
+ }
8109
+ );
8110
+ });
8111
+ }
8112
+ async function exportInitiatives(config, listId, opts) {
8113
+ const teamId = requireTeam(config);
8114
+ if (opts.initiativeItemId === void 0) {
8115
+ throw new Error(
8116
+ "initiatives export needs --item-id <n>: the custom_item_id your workspace uses for initiatives (see `cup task-types`)"
8117
+ );
8118
+ }
8119
+ const client = makeClient(config, opts.rpm);
8120
+ const plan = await discoverListTasks(client, teamId, listId, {
8121
+ kind: "initiatives",
8122
+ initiativeItemId: opts.initiativeItemId
8123
+ });
8124
+ return execute(client, teamId, plan, opts, async (root) => {
8125
+ const { readBundleData: readBundleData2 } = await import("./writer-EYKGCPNB.js");
8126
+ const manifest = loadManifest(root);
8127
+ const wanted = /* @__PURE__ */ new Set();
8128
+ const queue = plan.tasks.map((t) => t.id);
8129
+ while (queue.length > 0) {
8130
+ const id = queue.pop();
8131
+ if (wanted.has(id) || !manifest.tasks[id]) continue;
8132
+ wanted.add(id);
8133
+ const b = await readBundleData2(root, id);
8134
+ queue.push(...b.subtaskIds);
8135
+ }
8136
+ const tasks = await Promise.all(
8137
+ [...wanted].map((id) => readBundleData2(root, id).then((b) => b.task))
8138
+ );
8139
+ writeSliceFiles(
8140
+ root,
8141
+ plan.slice.name,
8142
+ renderRoadmapIndex(plan.list, tasks, {
8143
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8144
+ initiativeItemId: opts.initiativeItemId
8145
+ }),
8146
+ {
8147
+ list: plan.list,
8148
+ initiativeItemId: opts.initiativeItemId,
8149
+ taskIds: plan.tasks.map((t) => t.id)
8150
+ }
8151
+ );
8152
+ });
8153
+ }
8154
+ async function exportDocs2(config, opts) {
8155
+ const teamId = requireTeam(config);
8156
+ const client = makeClient(config, opts.rpm);
8157
+ const root = resolve(opts.out);
8158
+ if (opts.dryRun) {
8159
+ const docs = await client.getAllDocs(teamId);
8160
+ opts.log(`Plan [docs]: ${docs.length} docs`);
8161
+ return {
8162
+ slice: "docs",
8163
+ out: root,
8164
+ dryRun: true,
8165
+ docs: docs.length,
8166
+ pages: 0,
8167
+ skipped: 0,
8168
+ failed: []
8169
+ };
8170
+ }
8171
+ const summary = await exportDocs(client, teamId, {
8172
+ root,
8173
+ refresh: opts.refresh,
8174
+ log: opts.log
8175
+ });
8176
+ writeRootReadme(root, loadManifest(root));
8177
+ return { slice: "docs", out: root, dryRun: false, ...summary };
8178
+ }
8179
+ function formatDocsSummary(s) {
8180
+ if (s.dryRun) return `Dry run: ${s.docs} docs would be exported to ${s.out}`;
8181
+ const lines = [
8182
+ `Exported docs to ${s.out}/docs`,
8183
+ ` docs: ${s.docs} fetched (${s.pages} pages), ${s.skipped} already present, ${s.failed.length} failed`
8184
+ ];
8185
+ for (const f of s.failed.slice(0, 10)) lines.push(` failed ${f.id}: ${f.error}`);
8186
+ return lines.join("\n");
8187
+ }
8188
+ var REQUESTS_PER_TASK = 3;
8189
+ async function exportAll(config, opts) {
8190
+ const teamId = requireTeam(config);
8191
+ const client = makeClient(config, opts.rpm);
8192
+ const root = resolve(opts.out);
8193
+ const [spaces, docs, teams] = await Promise.all([
8194
+ client.getSpaces(teamId),
8195
+ client.getAllDocs(teamId),
8196
+ client.getTeams()
8197
+ ]);
8198
+ const workspaceName = teams.find((t) => t.id === teamId)?.name ?? teamId;
8199
+ const plans = [];
8200
+ for (const space of spaces) plans.push(await discoverTeamTasks(client, teamId, space.id));
8201
+ const manifest = loadManifest(root);
8202
+ const allTaskIds = new Set(plans.flatMap((p) => p.tasks.map((t) => t.id)));
8203
+ const alreadyExported = [...allTaskIds].filter((id) => manifest.tasks[id]).length;
8204
+ const toFetch = allTaskIds.size - alreadyExported;
8205
+ const listCount = plans.reduce(
8206
+ (n, p) => n + (p.hierarchy?.lists.length ?? 0) + (p.hierarchy?.folders.reduce((m, f) => m + f.lists.length, 0) ?? 0),
8207
+ 0
8208
+ );
8209
+ const estRequests = toFetch * REQUESTS_PER_TASK + docs.length;
8210
+ const estMinutes = Math.ceil(estRequests / opts.rpm);
8211
+ opts.log(`Workspace export plan for "${workspaceName}":`);
8212
+ opts.log(
8213
+ ` ${spaces.length} spaces, ${listCount} lists, ${allTaskIds.size} tasks, ${docs.length} docs`
8214
+ );
8215
+ opts.log(` Already exported: ${alreadyExported} tasks (will be skipped)`);
8216
+ opts.log(` Estimated requests: ~${estRequests}`);
8217
+ opts.log(
8218
+ ` Estimated time at ${opts.rpm} req/min: ~${estMinutes < 60 ? `${estMinutes}m` : `${Math.floor(estMinutes / 60)}h ${estMinutes % 60}m`}`
8219
+ );
8220
+ if (opts.attachments) opts.log(" Attachments: downloaded (size unknown until fetched)");
8221
+ const empty = {
8222
+ slice: "all",
8223
+ out: root,
8224
+ dryRun: opts.dryRun,
8225
+ spaces: plans.map((p) => p.slice.name),
8226
+ planned: allTaskIds.size,
8227
+ fetched: 0,
8228
+ skipped: 0,
8229
+ failed: [],
8230
+ attachmentsDownloaded: 0,
8231
+ attachmentsFailed: 0,
8232
+ docs: { docs: docs.length, pages: 0, skipped: 0, failed: [] }
8233
+ };
8234
+ if (opts.dryRun) return empty;
8235
+ if (!opts.yes) {
8236
+ if (!isTTY()) {
8237
+ throw new Error(
8238
+ "This is a long-running operation. Re-run with --yes to confirm in non-interactive mode."
8239
+ );
8240
+ }
8241
+ const { confirm: confirm3 } = await import("@inquirer/prompts");
8242
+ const ok = await confirm3({
8243
+ message: `Export the whole workspace to ${root}?`,
8244
+ default: false
8245
+ });
8246
+ if (!ok) throw new Error("Cancelled");
8247
+ }
8248
+ const spaceNames = Object.fromEntries(spaces.map((s) => [s.id, s.name]));
8249
+ const summary = { ...empty };
8250
+ for (const plan of plans) {
8251
+ const run2 = await runExport(client, plan, {
8252
+ root,
8253
+ refresh: opts.refresh,
8254
+ downloadAttachments: opts.attachments,
8255
+ concurrency: CONCURRENCY,
8256
+ log: opts.log,
8257
+ spaceNames
8258
+ });
8259
+ summary.fetched += run2.fetched;
8260
+ summary.skipped += run2.skipped;
8261
+ summary.failed.push(...run2.failed);
8262
+ summary.attachmentsDownloaded += run2.attachmentsDownloaded;
8263
+ summary.attachmentsFailed += run2.attachmentsFailed;
8264
+ const tasks = Object.values(plan.tasksById ?? {});
8265
+ writeSliceFiles(
8266
+ root,
8267
+ plan.slice.name,
8268
+ renderTeamIndex(plan.hierarchy, tasks, {
8269
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8270
+ initiativeItemId: opts.initiativeItemId
8271
+ }),
8272
+ { hierarchy: plan.hierarchy, taskIds: plan.tasks.map((t) => t.id) }
8273
+ );
8274
+ }
8275
+ summary.docs = await exportDocs(client, teamId, { root, refresh: opts.refresh, log: opts.log });
8276
+ writeRootReadme(root, loadManifest(root));
8277
+ return summary;
8278
+ }
8279
+ function formatAllSummary(s) {
8280
+ if (s.dryRun)
8281
+ return `Dry run: ${s.planned} tasks across ${s.spaces.length} spaces would be exported to ${s.out}`;
8282
+ const lines = [
8283
+ `Exported workspace to ${s.out}`,
8284
+ ` spaces: ${s.spaces.length} (${s.spaces.join(", ")})`,
8285
+ ` tasks: ${s.fetched} fetched, ${s.skipped} already present, ${s.failed.length} failed`,
8286
+ ` attachments: ${s.attachmentsDownloaded} downloaded, ${s.attachmentsFailed} failed`,
8287
+ ` docs: ${s.docs.docs} fetched (${s.docs.pages} pages), ${s.docs.skipped} already present, ${s.docs.failed.length} failed`
8288
+ ];
8289
+ for (const f of s.failed.slice(0, 10)) lines.push(` failed ${f.id}: ${f.error}`);
8290
+ if (s.failed.length > 10) lines.push(` ... and ${s.failed.length - 10} more`);
8291
+ return lines.join("\n");
8292
+ }
8293
+
7184
8294
  // src/commands/docs.ts
7185
8295
  var DOC_COLUMNS = [
7186
8296
  { key: "id", label: "ID", maxWidth: 15 },
@@ -7606,28 +8716,6 @@ function formatFieldsMarkdown(fields) {
7606
8716
  }).join("\n");
7607
8717
  }
7608
8718
 
7609
- // src/util/batch.ts
7610
- async function runInBatches(items, concurrency, fn) {
7611
- if (!Number.isInteger(concurrency) || concurrency < 1) {
7612
- throw new Error(`concurrency must be a positive integer, got ${concurrency}`);
7613
- }
7614
- const results = [];
7615
- for (let i = 0; i < items.length; i += concurrency) {
7616
- const batch = items.slice(i, i + concurrency);
7617
- const settled = await Promise.allSettled(batch.map(fn));
7618
- settled.forEach((res, idx) => {
7619
- const item = batch[idx];
7620
- if (res.status === "fulfilled") {
7621
- results.push({ item, ok: true, result: res.value });
7622
- } else {
7623
- const error = res.reason instanceof Error ? res.reason : new Error(String(res.reason), { cause: res.reason });
7624
- results.push({ item, ok: false, error });
7625
- }
7626
- });
7627
- }
7628
- return results;
7629
- }
7630
-
7631
8719
  // src/commands/field-create.ts
7632
8720
  var FIELD_CREATE_CONCURRENCY = 5;
7633
8721
  var VALID_FIELD_TYPES = [
@@ -8744,10 +9832,10 @@ function resolveRequiredMessage(opts) {
8744
9832
  }
8745
9833
  async function resolveMentions(client, teamId, mentions) {
8746
9834
  if (mentions.length === 0) return [];
8747
- const resolve2 = createCachedMemberResolver(client, teamId);
9835
+ const resolve3 = createCachedMemberResolver(client, teamId);
8748
9836
  const ids = [];
8749
9837
  for (const mention of mentions) {
8750
- ids.push(await resolve2(mention));
9838
+ ids.push(await resolve3(mention));
8751
9839
  }
8752
9840
  return ids;
8753
9841
  }
@@ -9040,6 +10128,120 @@ function buildProgram(programName = basename(process.argv[1] ?? "cup")) {
9040
10128
  }
9041
10129
  )
9042
10130
  );
10131
+ const exportCmd = program.command("export").description("Export tasks and docs to a local archive (lossless JSON + rendered markdown)");
10132
+ function withExportOptions(cmd) {
10133
+ return cmd.option(
10134
+ "--out <dir>",
10135
+ "Archive directory (slices compose into the same dir)",
10136
+ "./clickup-export"
10137
+ ).option("--refresh", "Re-fetch tasks already present in the archive").option("--no-attachments", "Skip downloading attachment binaries (metadata still written)").option("--dry-run", "Discover and print the plan without fetching or writing").option("--rpm <n>", "Request throttle per minute (ClickUp Business limit is 100)", "90").option("--yes", "Skip confirmation prompts (required for non-interactive `export all`)").option(
10138
+ "--item-id <n>",
10139
+ "custom_item_id that marks an initiative in your workspace (see `cup task-types`)"
10140
+ ).option("--json", "Force JSON output even in terminal");
10141
+ }
10142
+ function toExportOptions(opts) {
10143
+ const rpm = Number(opts.rpm);
10144
+ if (!Number.isFinite(rpm) || rpm <= 0) throw new Error("--rpm must be a positive number");
10145
+ let initiativeItemId;
10146
+ if (opts.itemId !== void 0) {
10147
+ initiativeItemId = Number(opts.itemId);
10148
+ if (!Number.isInteger(initiativeItemId) || initiativeItemId <= 0) {
10149
+ throw new Error("--item-id must be a positive integer");
10150
+ }
10151
+ }
10152
+ return {
10153
+ out: opts.out,
10154
+ refresh: opts.refresh ?? false,
10155
+ attachments: opts.attachments,
10156
+ dryRun: opts.dryRun ?? false,
10157
+ rpm,
10158
+ log: (line) => process.stderr.write(line + "\n"),
10159
+ ...initiativeItemId !== void 0 ? { initiativeItemId } : {},
10160
+ yes: opts.yes ?? false
10161
+ };
10162
+ }
10163
+ function printExportSummary(summary, json) {
10164
+ if (shouldOutputJson(json)) {
10165
+ console.log(JSON.stringify(summary, null, 2));
10166
+ } else {
10167
+ console.log(formatExportSummary(summary));
10168
+ }
10169
+ if (summary.failed.length > 0) process.exitCode = 1;
10170
+ }
10171
+ withExportOptions(
10172
+ exportCmd.command("user <userRef>").description(
10173
+ "Export every task assigned to a user (me, id, email, or username), incl. closed and archived"
10174
+ )
10175
+ ).action(
10176
+ wrapAction(async (userRef, opts) => {
10177
+ const config = loadConfig(getProfileName());
10178
+ const summary = await exportUser(config, userRef, toExportOptions(opts));
10179
+ printExportSummary(summary, opts.json ?? false);
10180
+ })
10181
+ );
10182
+ withExportOptions(
10183
+ exportCmd.command("team <spaceRef>").description("Export every list in a space (by id or name), incl. archived lists and tasks")
10184
+ ).action(
10185
+ wrapAction(async (spaceRef, opts) => {
10186
+ const config = loadConfig(getProfileName());
10187
+ const summary = await exportTeam(config, spaceRef, toExportOptions(opts));
10188
+ printExportSummary(summary, opts.json ?? false);
10189
+ })
10190
+ );
10191
+ withExportOptions(
10192
+ exportCmd.command("roadmap <listId>").description(
10193
+ "Export a list with initiatives grouped and their subtask trees (use --item-id)"
10194
+ )
10195
+ ).action(
10196
+ wrapAction(async (listId, opts) => {
10197
+ const config = loadConfig(getProfileName());
10198
+ const summary = await exportRoadmap(config, listId, toExportOptions(opts));
10199
+ printExportSummary(summary, opts.json ?? false);
10200
+ })
10201
+ );
10202
+ withExportOptions(
10203
+ exportCmd.command("docs").description(
10204
+ "Export every workspace doc as markdown page trees (ClickUp has no bulk doc export)"
10205
+ )
10206
+ ).action(
10207
+ wrapAction(async (opts) => {
10208
+ const config = loadConfig(getProfileName());
10209
+ const summary = await exportDocs2(config, toExportOptions(opts));
10210
+ if (shouldOutputJson(opts.json ?? false)) {
10211
+ console.log(JSON.stringify(summary, null, 2));
10212
+ } else {
10213
+ console.log(formatDocsSummary(summary));
10214
+ }
10215
+ if (summary.failed.length > 0) process.exitCode = 1;
10216
+ })
10217
+ );
10218
+ withExportOptions(
10219
+ exportCmd.command("all").description(
10220
+ "Export the whole workspace: every space as a team slice, plus all docs. Plans first and asks for confirmation"
10221
+ )
10222
+ ).action(
10223
+ wrapAction(async (opts) => {
10224
+ const config = loadConfig(getProfileName());
10225
+ const summary = await exportAll(config, toExportOptions(opts));
10226
+ if (shouldOutputJson(opts.json ?? false)) {
10227
+ console.log(JSON.stringify(summary, null, 2));
10228
+ } else {
10229
+ console.log(formatAllSummary(summary));
10230
+ }
10231
+ if (summary.failed.length > 0 || summary.docs.failed.length > 0) process.exitCode = 1;
10232
+ })
10233
+ );
10234
+ withExportOptions(
10235
+ exportCmd.command("initiatives <listId>").description(
10236
+ "Export only initiative-typed tasks in a list plus their subtask trees (requires --item-id)"
10237
+ )
10238
+ ).action(
10239
+ wrapAction(async (listId, opts) => {
10240
+ const config = loadConfig(getProfileName());
10241
+ const summary = await exportInitiatives(config, listId, toExportOptions(opts));
10242
+ printExportSummary(summary, opts.json ?? false);
10243
+ })
10244
+ );
9043
10245
  program.command("sprint").description("List my tasks in the current active sprint (auto-detected)").option("--status <status>", "Filter by status").option("--space <nameOrId>", "Narrow sprint search to a specific space (partial name or ID)").option("--folder <folderId>", "Sprint folder ID (overrides config and auto-detection)").option("--include-closed", "Include done/closed tasks").option("--json", "Force JSON output even in terminal").action(
9044
10246
  wrapAction(
9045
10247
  async (opts) => {
@@ -11216,7 +12418,7 @@ process.on("SIGINT", () => {
11216
12418
  });
11217
12419
  function checkDirectExecution() {
11218
12420
  try {
11219
- return process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === realpathSync2(resolve(process.argv[1]));
12421
+ return process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === realpathSync2(resolve2(process.argv[1]));
11220
12422
  } catch {
11221
12423
  return false;
11222
12424
  }