clickup-agent-cli 0.2.1 → 0.3.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.
@@ -10,8 +10,8 @@
10
10
  {
11
11
  "name": "clickup",
12
12
  "source": "./",
13
- "description": "ClickUp CLI with 23 agent skills covering the full API -- token-efficient alternative to MCP with chat, time tracking, docs, and project management workflows",
14
- "version": "0.2.1",
13
+ "description": "ClickUp CLI with 25 agent skills covering the full API -- token-efficient alternative to MCP with chat, time tracking, docs, and project management workflows",
14
+ "version": "0.3.0",
15
15
  "homepage": "https://github.com/henryreith/clickup-cli",
16
16
  "keywords": ["clickup", "project-management", "tasks", "time-tracking"],
17
17
  "category": "productivity"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clickup",
3
- "description": "ClickUp CLI with 23 agent skills covering the full API -- token-efficient alternative to MCP with chat, time tracking, docs, and project management workflows",
4
- "version": "0.2.1",
3
+ "description": "ClickUp CLI with 25 agent skills covering the full API -- token-efficient alternative to MCP with chat, time tracking, docs, and project management workflows",
4
+ "version": "0.3.0",
5
5
  "author": {
6
6
  "name": "Henry Reith"
7
7
  },
package/dist/clickup.js CHANGED
@@ -47,7 +47,7 @@ function mapToExitCode(error) {
47
47
  return 1;
48
48
  }
49
49
  }
50
- var ClickUpError, ECODE_MESSAGES, STATUS_MESSAGES, EXIT_CODES;
50
+ var ClickUpError, ECODE_MESSAGES, STATUS_MESSAGES, EXIT_CODES, DryRunComplete;
51
51
  var init_errors = __esm({
52
52
  "src/errors.ts"() {
53
53
  "use strict";
@@ -85,6 +85,12 @@ var init_errors = __esm({
85
85
  RATE_LIMITED: 6,
86
86
  NETWORK_ERROR: 7
87
87
  };
88
+ DryRunComplete = class extends Error {
89
+ constructor() {
90
+ super("Dry run complete");
91
+ this.name = "DryRunComplete";
92
+ }
93
+ };
88
94
  }
89
95
  });
90
96
 
@@ -140,6 +146,31 @@ var init_client = __esm({
140
146
  async delete(path, body) {
141
147
  return this.request("DELETE", path, body);
142
148
  }
149
+ async downloadUrl(url) {
150
+ const controller = new AbortController();
151
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
152
+ try {
153
+ const response = await fetch(url, {
154
+ headers: { Authorization: this.token },
155
+ signal: controller.signal
156
+ });
157
+ clearTimeout(timeoutId);
158
+ if (!response.ok) {
159
+ const body = await this.safeJson(response);
160
+ throw parseApiError(body, response.status);
161
+ }
162
+ return response.arrayBuffer();
163
+ } catch (error) {
164
+ clearTimeout(timeoutId);
165
+ if (error instanceof ClickUpError) throw error;
166
+ throw new ClickUpError(
167
+ `Network error: ${error instanceof Error ? error.message : "Unknown error"}`,
168
+ 0,
169
+ void 0,
170
+ void 0
171
+ );
172
+ }
173
+ }
143
174
  async upload(path, filePath, filename) {
144
175
  const fileBuffer = readFileSync(filePath);
145
176
  const resolvedFilename = filename ?? basename(filePath);
@@ -148,7 +179,7 @@ var init_client = __esm({
148
179
  const url = `${this.baseUrl}${path}`;
149
180
  if (this.dryRun) {
150
181
  this.logDryRun("POST", url, "[multipart form data]");
151
- return {};
182
+ throw new DryRunComplete();
152
183
  }
153
184
  const controller = new AbortController();
154
185
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
@@ -193,7 +224,7 @@ var init_client = __esm({
193
224
  }
194
225
  if (this.dryRun) {
195
226
  this.logDryRun(method, url, body);
196
- return {};
227
+ throw new DryRunComplete();
197
228
  }
198
229
  let lastError;
199
230
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -328,35 +359,111 @@ Headers: ${JSON.stringify(redactedHeaders)}
328
359
  });
329
360
 
330
361
  // src/config.ts
362
+ import { readFileSync as readFileSync2 } from "fs";
331
363
  import Conf from "conf";
332
364
  function isValidConfigKey(key) {
333
- return CONFIG_KEYS.includes(key);
334
- }
335
- function resolveString(key, flagValue) {
336
- if (flagValue !== void 0) return flagValue;
337
- const envVal = process.env[ENV_MAP[key]];
338
- if (envVal !== void 0) return envVal;
339
- const stored = config.get(key);
340
- if (typeof stored === "string") return stored;
341
- return void 0;
365
+ return ALL_CONFIG_KEYS.includes(key);
366
+ }
367
+ function setProfileOverride(nameOrKey) {
368
+ _profileOverride = nameOrKey ? findProfileKey(nameOrKey) : void 0;
369
+ }
370
+ function getActiveProfileKey() {
371
+ if (_profileOverride) return _profileOverride;
372
+ const active = config.get("active_profile");
373
+ return active ?? "default";
374
+ }
375
+ function getProfiles() {
376
+ const stored = config.get("profiles");
377
+ return stored ?? {};
378
+ }
379
+ function setProfile(key, profile) {
380
+ const profiles = getProfiles();
381
+ profiles[key] = profile;
382
+ config.set("profiles", profiles);
342
383
  }
343
- function resolveToken(flagValue) {
344
- return resolveString("token", flagValue);
384
+ function deleteProfile(key) {
385
+ const profiles = getProfiles();
386
+ delete profiles[key];
387
+ config.set("profiles", profiles);
388
+ }
389
+ function slugifyWorkspaceName(name) {
390
+ return name.toLowerCase().replace(/'/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
391
+ }
392
+ function findProfileKey(nameOrKey) {
393
+ const profiles = getProfiles();
394
+ if (profiles[nameOrKey] !== void 0) return nameOrKey;
395
+ const lower = nameOrKey.toLowerCase();
396
+ for (const [key, profile] of Object.entries(profiles)) {
397
+ if (profile.workspace_name?.toLowerCase() === lower) return key;
398
+ if (profile.nickname?.toLowerCase() === lower) return key;
399
+ }
400
+ for (const [key, profile] of Object.entries(profiles)) {
401
+ if (profile.workspace_name?.toLowerCase().includes(lower)) return key;
402
+ if (profile.nickname?.toLowerCase().includes(lower)) return key;
403
+ }
404
+ return nameOrKey;
405
+ }
406
+ function migrateConfig() {
407
+ const legacyToken = config.get("token");
408
+ const legacyWorkspaceId = config.get("workspace_id");
409
+ if (!legacyToken && !legacyWorkspaceId) return;
410
+ const profiles = getProfiles();
411
+ if (!profiles["default"]) {
412
+ const defaultProfile = {};
413
+ if (legacyToken) defaultProfile.token = legacyToken;
414
+ if (legacyWorkspaceId) defaultProfile.workspace_id = legacyWorkspaceId;
415
+ setProfile("default", defaultProfile);
416
+ config.delete("token");
417
+ config.delete("workspace_id");
418
+ }
419
+ }
420
+ function resolveToken(flagValue, tokenFilePath) {
421
+ if (flagValue) return flagValue;
422
+ if (tokenFilePath) {
423
+ try {
424
+ const content = readFileSync2(tokenFilePath, "utf8").trim();
425
+ if (content) return content;
426
+ } catch {
427
+ }
428
+ }
429
+ const envVal = process.env["CLICKUP_API_TOKEN"];
430
+ if (envVal) return envVal;
431
+ const profileKey = getActiveProfileKey();
432
+ const profiles = getProfiles();
433
+ const activeProfile = profiles[profileKey];
434
+ if (activeProfile?.token) return activeProfile.token;
435
+ const legacyToken = config.get("token");
436
+ if (legacyToken) return legacyToken;
437
+ return void 0;
345
438
  }
346
439
  function resolveWorkspaceId(flagValue) {
347
- return resolveString("workspace_id", flagValue);
440
+ if (flagValue) return flagValue;
441
+ const envVal = process.env["CLICKUP_WORKSPACE_ID"];
442
+ if (envVal) return envVal;
443
+ const profileKey = getActiveProfileKey();
444
+ const profiles = getProfiles();
445
+ const activeProfile = profiles[profileKey];
446
+ if (activeProfile?.workspace_id) return activeProfile.workspace_id;
447
+ const withWorkspace = Object.values(profiles).filter((p) => p.workspace_id);
448
+ if (withWorkspace.length === 1) return withWorkspace[0].workspace_id;
449
+ const legacyId = config.get("workspace_id");
450
+ if (legacyId) return legacyId;
451
+ return void 0;
348
452
  }
349
453
  function resolveOutputFormat(flagValue) {
350
- const val = resolveString("output_format", flagValue);
351
- if (val) return val;
454
+ if (flagValue) return flagValue;
455
+ const envVal = process.env["CLICKUP_OUTPUT_FORMAT"];
456
+ if (envVal) return envVal;
457
+ const stored = config.get("output_format");
458
+ if (stored) return stored;
352
459
  return process.stdout.isTTY ? "table" : "json";
353
460
  }
354
- var CONFIG_KEYS, VALID_CONFIG_KEYS, config, ENV_MAP;
461
+ var ALL_CONFIG_KEYS, VALID_CONFIG_KEYS, config, _profileOverride;
355
462
  var init_config = __esm({
356
463
  "src/config.ts"() {
357
464
  "use strict";
358
- CONFIG_KEYS = ["token", "workspace_id", "output_format", "color", "page_size", "timezone"];
359
- VALID_CONFIG_KEYS = CONFIG_KEYS;
465
+ ALL_CONFIG_KEYS = ["token", "workspace_id", "output_format", "color", "page_size", "timezone", "active_profile"];
466
+ VALID_CONFIG_KEYS = ALL_CONFIG_KEYS;
360
467
  config = new Conf({
361
468
  projectName: "clickup-cli",
362
469
  schema: {
@@ -365,17 +472,15 @@ var init_config = __esm({
365
472
  output_format: { type: "string", enum: ["table", "json", "csv", "tsv", "quiet", "id"] },
366
473
  color: { type: "boolean", default: true },
367
474
  page_size: { type: "number", default: 100 },
368
- timezone: { type: "string" }
475
+ timezone: { type: "string" },
476
+ active_profile: { type: "string" },
477
+ profiles: { type: "object" }
369
478
  }
370
479
  });
371
- ENV_MAP = {
372
- token: "CLICKUP_API_TOKEN",
373
- workspace_id: "CLICKUP_WORKSPACE_ID",
374
- output_format: "CLICKUP_OUTPUT_FORMAT",
375
- color: "CLICKUP_COLOR",
376
- page_size: "CLICKUP_PAGE_SIZE",
377
- timezone: "CLICKUP_TIMEZONE"
378
- };
480
+ try {
481
+ migrateConfig();
482
+ } catch {
483
+ }
379
484
  }
380
485
  });
381
486
 
@@ -532,9 +637,10 @@ import { Command } from "commander";
532
637
 
533
638
  // src/commands/auth-cmd.ts
534
639
  init_config();
640
+ import { readFileSync as readFileSync3 } from "fs";
535
641
  function registerAuthCommands(program, getClient) {
536
642
  const auth = program.command("auth").description("Manage authentication");
537
- auth.command("login").description("Authenticate with ClickUp").option("--token <token>", "Personal API token").option("--oauth", "Use OAuth2 PKCE browser flow (requires CLICKUP_CLIENT_ID and CLICKUP_CLIENT_SECRET)").action(async (opts) => {
643
+ auth.command("login").description("Authenticate with ClickUp").option("--token <token>", "Personal API token").option("--token-file <path>", "Read token from this file path").option("--oauth", "Use OAuth2 PKCE browser flow (requires CLICKUP_CLIENT_ID and CLICKUP_CLIENT_SECRET)").action(async (opts) => {
538
644
  if (opts.oauth) {
539
645
  const { oauthLogin: oauthLogin2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
540
646
  const token2 = await oauthLogin2();
@@ -550,9 +656,19 @@ function registerAuthCommands(program, getClient) {
550
656
  return;
551
657
  }
552
658
  let token = opts.token;
659
+ if (!token && opts.tokenFile) {
660
+ try {
661
+ token = readFileSync3(opts.tokenFile, "utf8").trim();
662
+ } catch {
663
+ process.stderr.write(`Error: Could not read token file: ${opts.tokenFile}
664
+ `);
665
+ process.exit(2);
666
+ return;
667
+ }
668
+ }
553
669
  if (!token) {
554
670
  if (!process.stdin.isTTY) {
555
- process.stderr.write("Error: No token provided. Use --token <token> or --oauth in non-interactive mode.\n");
671
+ process.stderr.write("Error: No token provided. Use --token <token>, --token-file <path>, or --oauth in non-interactive mode.\n");
556
672
  process.exit(2);
557
673
  return;
558
674
  }
@@ -571,15 +687,29 @@ function registerAuthCommands(program, getClient) {
571
687
  const client = new ClientClass({ token });
572
688
  try {
573
689
  const userData = await client.get("/user");
574
- config.set("token", token);
690
+ const profileKey = getActiveProfileKey();
691
+ const profiles = getProfiles();
692
+ const existing = profiles[profileKey] ?? {};
693
+ setProfile(profileKey, { ...existing, token });
575
694
  process.stdout.write(`Authenticated as ${userData.user.username} (${userData.user.email})
576
695
  `);
696
+ const profile = getProfiles()[profileKey];
697
+ if (!profile?.workspace_id) {
698
+ process.stdout.write("Run: clickup workspace setup -- to configure your workspace.\n");
699
+ }
577
700
  } catch {
578
701
  process.stderr.write("Error: Invalid token. Authentication failed.\n");
579
702
  process.exit(3);
580
703
  }
581
704
  });
582
705
  auth.command("logout").description("Remove stored credentials").action(() => {
706
+ const profileKey = getActiveProfileKey();
707
+ const profiles = getProfiles();
708
+ const existing = profiles[profileKey];
709
+ if (existing) {
710
+ const { token: _removed, ...profileWithoutToken } = existing;
711
+ setProfile(profileKey, profileWithoutToken);
712
+ }
583
713
  config.delete("token");
584
714
  process.stdout.write("Logged out. Token removed.\n");
585
715
  });
@@ -592,9 +722,17 @@ function registerAuthCommands(program, getClient) {
592
722
  const prefix = token.slice(0, 8);
593
723
  process.stdout.write(`Token: ${prefix}...
594
724
  `);
595
- const workspaceId = config.get("workspace_id");
596
- if (workspaceId) {
597
- process.stdout.write(`Workspace: ${workspaceId}
725
+ const profileKey = getActiveProfileKey();
726
+ process.stdout.write(`Profile: ${profileKey}
727
+ `);
728
+ const profiles = getProfiles();
729
+ const activeProfile = profiles[profileKey];
730
+ if (activeProfile?.workspace_id) {
731
+ process.stdout.write(`Workspace: ${activeProfile.workspace_name ?? activeProfile.workspace_id}
732
+ `);
733
+ } else {
734
+ const legacyId = config.get("workspace_id");
735
+ if (legacyId) process.stdout.write(`Workspace: ${legacyId}
598
736
  `);
599
737
  }
600
738
  try {
@@ -620,7 +758,7 @@ function registerAuthCommands(program, getClient) {
620
758
 
621
759
  // src/commands/config-cmd.ts
622
760
  init_config();
623
- function registerConfigCommands(program) {
761
+ function registerConfigCommands(program, getClient) {
624
762
  const configCmd = program.command("config").description("Manage CLI configuration");
625
763
  configCmd.command("set").description("Set a configuration value").argument("<key>", "Config key").argument("<value>", "Config value").action((key, value) => {
626
764
  if (!isValidConfigKey(key)) {
@@ -694,6 +832,141 @@ function registerConfigCommands(program) {
694
832
  configCmd.command("path").description("Print the config file path").action(() => {
695
833
  process.stdout.write(config.path + "\n");
696
834
  });
835
+ configCmd.command("validate").description("Verify the stored token works and show current identity").action(async () => {
836
+ const client = getClient();
837
+ let user;
838
+ try {
839
+ const userData = await client.get("/user");
840
+ user = userData.user;
841
+ } catch (err) {
842
+ process.stderr.write(`Error: Token validation failed. Run: clickup auth login
843
+ `);
844
+ process.exit(3);
845
+ return;
846
+ }
847
+ let workspaceName = "(none)";
848
+ let workspaceId = "(none)";
849
+ try {
850
+ const teamsData = await client.get("/team");
851
+ const activeKey = getActiveProfileKey();
852
+ const profiles = getProfiles();
853
+ const activeProfile = profiles[activeKey];
854
+ if (activeProfile?.workspace_id) {
855
+ const match = teamsData.teams.find((t) => t.id === activeProfile.workspace_id);
856
+ workspaceName = match?.name ?? activeProfile.workspace_name ?? activeProfile.workspace_id;
857
+ workspaceId = activeProfile.workspace_id;
858
+ } else if (teamsData.teams.length > 0) {
859
+ workspaceName = teamsData.teams.map((t) => t.name).join(", ");
860
+ workspaceId = teamsData.teams.map((t) => t.id).join(", ");
861
+ }
862
+ } catch {
863
+ }
864
+ process.stdout.write(`User: ${user.username} (${user.email})
865
+ `);
866
+ process.stdout.write(`Active profile: ${getActiveProfileKey()}
867
+ `);
868
+ process.stdout.write(`Workspace: ${workspaceName}
869
+ `);
870
+ process.stdout.write(`Workspace ID: ${workspaceId}
871
+ `);
872
+ process.stdout.write(`Token status: Valid
873
+ `);
874
+ });
875
+ const profileCmd = configCmd.command("profile").description("Manage named profiles");
876
+ profileCmd.command("list").description("List all profiles").action(() => {
877
+ const profiles = getProfiles();
878
+ const activeKey = getActiveProfileKey();
879
+ const keys = Object.keys(profiles);
880
+ if (keys.length === 0) {
881
+ process.stdout.write("No profiles configured. Run: clickup auth login\n");
882
+ return;
883
+ }
884
+ const col = (s, w) => s.padEnd(w).slice(0, w);
885
+ process.stdout.write(
886
+ `${col("Key", 20)} ${col("Workspace Name", 30)} ${col("Nickname", 15)} ${col("Workspace ID", 16)} Active
887
+ `
888
+ );
889
+ process.stdout.write(`${"-".repeat(20)} ${"-".repeat(30)} ${"-".repeat(15)} ${"-".repeat(16)} ------
890
+ `);
891
+ for (const key of keys) {
892
+ const p = profiles[key];
893
+ const active = key === activeKey ? "*" : "";
894
+ process.stdout.write(
895
+ `${col(key, 20)} ${col(p.workspace_name ?? "", 30)} ${col(p.nickname ?? "", 15)} ${col(p.workspace_id ?? "", 16)} ${active}
896
+ `
897
+ );
898
+ }
899
+ });
900
+ profileCmd.command("create").description("Create an empty profile").argument("<key>", "Profile key").action((key) => {
901
+ const profiles = getProfiles();
902
+ if (profiles[key]) {
903
+ process.stderr.write(`Error: Profile "${key}" already exists.
904
+ `);
905
+ process.exit(2);
906
+ return;
907
+ }
908
+ setProfile(key, {});
909
+ process.stdout.write(`Profile "${key}" created.
910
+ `);
911
+ });
912
+ profileCmd.command("delete").description("Delete a profile").argument("<key>", "Profile key").option("--confirm", "Skip confirmation prompt").action(async (key, opts) => {
913
+ const profiles = getProfiles();
914
+ if (!profiles[key]) {
915
+ process.stderr.write(`Error: Profile "${key}" not found.
916
+ `);
917
+ process.exit(2);
918
+ return;
919
+ }
920
+ const isActive = key === getActiveProfileKey();
921
+ if (!opts.confirm) {
922
+ if (!process.stdin.isTTY) {
923
+ process.stderr.write("Error: Add --confirm to delete a profile in non-interactive mode.\n");
924
+ process.exit(2);
925
+ return;
926
+ }
927
+ const { confirm } = await import("@inquirer/prompts");
928
+ const answer = await confirm({
929
+ message: isActive ? `"${key}" is the active profile. Delete it?` : `Delete profile "${key}"?`,
930
+ default: false
931
+ });
932
+ if (!answer) {
933
+ process.stdout.write("Cancelled.\n");
934
+ return;
935
+ }
936
+ }
937
+ deleteProfile(key);
938
+ if (isActive) {
939
+ config.delete("active_profile");
940
+ }
941
+ process.stdout.write(`Profile "${key}" deleted.
942
+ `);
943
+ });
944
+ profileCmd.command("use").description("Switch the active profile").argument("<key>", "Profile key, workspace name, or nickname").action((nameOrKey) => {
945
+ const profiles = getProfiles();
946
+ const resolvedKey = findProfileKey(nameOrKey);
947
+ if (!profiles[resolvedKey]) {
948
+ process.stderr.write(`Error: Profile "${nameOrKey}" not found.
949
+ `);
950
+ process.exit(2);
951
+ return;
952
+ }
953
+ config.set("active_profile", resolvedKey);
954
+ process.stdout.write(`Active profile: ${resolvedKey}
955
+ `);
956
+ });
957
+ profileCmd.command("nickname").description("Set a short nickname for a profile").argument("<key>", "Profile key").argument("<nickname>", "Short nickname").action((key, nickname) => {
958
+ const profiles = getProfiles();
959
+ const profile = profiles[key];
960
+ if (!profile) {
961
+ process.stderr.write(`Error: Profile "${key}" not found.
962
+ `);
963
+ process.exit(2);
964
+ return;
965
+ }
966
+ setProfile(key, { ...profile, nickname });
967
+ process.stdout.write(`Nickname "${nickname}" set for profile "${key}".
968
+ `);
969
+ });
697
970
  }
698
971
 
699
972
  // src/commands/workspace.ts
@@ -869,6 +1142,7 @@ function getFields(resource, action) {
869
1142
  }
870
1143
 
871
1144
  // src/commands/workspace.ts
1145
+ registerSchema("workspace", "setup", "Configure workspace -- fetches workspaces and saves profile", []);
872
1146
  registerSchema("workspace", "list", "List all workspaces accessible to the authenticated user", []);
873
1147
  registerSchema("workspace", "get", "Get workspace details", [
874
1148
  { flag: "--workspace-id", type: "string", required: true, description: "Workspace ID" }
@@ -912,6 +1186,49 @@ function requireWorkspaceId(program) {
912
1186
  }
913
1187
  function registerWorkspaceCommands(program, getClient) {
914
1188
  const workspace = program.command("workspace").description("Manage workspaces");
1189
+ workspace.command("setup").description("Configure workspace for the active profile").action(async () => {
1190
+ const client = getClient();
1191
+ const data = await client.get("/team");
1192
+ const teams = data.teams;
1193
+ if (teams.length === 0) {
1194
+ process.stderr.write("Error: No workspaces found for this token.\n");
1195
+ process.exit(1);
1196
+ return;
1197
+ }
1198
+ let selected;
1199
+ if (teams.length === 1) {
1200
+ selected = teams[0];
1201
+ } else {
1202
+ if (!process.stdin.isTTY) {
1203
+ process.stderr.write("Error: Multiple workspaces found. Use --workspace-id or run in interactive mode.\n");
1204
+ process.exit(2);
1205
+ return;
1206
+ }
1207
+ const { select } = await import("@inquirer/prompts");
1208
+ const selectedId = await select({
1209
+ message: "Select workspace:",
1210
+ choices: teams.map((t) => ({ name: t.name, value: t.id }))
1211
+ });
1212
+ selected = teams.find((t) => t.id === selectedId);
1213
+ }
1214
+ const profileKey = getActiveProfileKey();
1215
+ const profiles = getProfiles();
1216
+ const existing = profiles[profileKey] ?? {};
1217
+ const slugKey = slugifyWorkspaceName(selected.name);
1218
+ setProfile(profileKey, {
1219
+ ...existing,
1220
+ workspace_id: selected.id,
1221
+ workspace_name: selected.name
1222
+ });
1223
+ process.stdout.write(
1224
+ `Workspace "${selected.name}" saved as profile "${profileKey}".
1225
+ `
1226
+ );
1227
+ if (slugKey !== profileKey) {
1228
+ process.stdout.write(`Use --profile "${selected.name}" or --profile ${profileKey}
1229
+ `);
1230
+ }
1231
+ });
915
1232
  workspace.command("list").description("List all workspaces").action(async () => {
916
1233
  const client = getClient();
917
1234
  const data = await client.get("/team");
@@ -1351,6 +1668,17 @@ registerSchema("task", "delete", "Delete a task", [
1351
1668
  registerSchema("task", "time-in-status", "Get time spent in each status for a task", [
1352
1669
  { flag: "<task-id>", type: "string", required: true, description: "Task ID" }
1353
1670
  ]);
1671
+ registerSchema("task", "bulk-update", "Apply the same update to multiple tasks", [
1672
+ { flag: "--task-id", type: "string[]", required: true, description: "Task ID (repeatable)" },
1673
+ { flag: "--name", type: "string", required: false, description: "New task name" },
1674
+ { flag: "--description", type: "string", required: false, description: "New description" },
1675
+ { flag: "--status", type: "string", required: false, description: "New status" },
1676
+ { flag: "--priority", type: "string", required: false, description: "New priority (1-4 or urgent/high/normal/low)" }
1677
+ ]);
1678
+ registerSchema("task", "bulk-delete", "Delete multiple tasks", [
1679
+ { flag: "--task-id", type: "string[]", required: true, description: "Task ID (repeatable)" },
1680
+ { flag: "--confirm", type: "boolean", required: false, description: "Skip confirmation prompt" }
1681
+ ]);
1354
1682
  var TASK_COLUMNS = [
1355
1683
  { key: "id", header: "ID", width: 12 },
1356
1684
  { key: "name", header: "Name", width: 30 },
@@ -1377,6 +1705,35 @@ function requireWorkspaceId3(program) {
1377
1705
  function collect(value, previous) {
1378
1706
  return previous.concat([value]);
1379
1707
  }
1708
+ var PRIORITY_MAP = {
1709
+ urgent: 1,
1710
+ high: 2,
1711
+ normal: 3,
1712
+ low: 4
1713
+ };
1714
+ function parsePriority(value) {
1715
+ const lower = value.toLowerCase();
1716
+ if (PRIORITY_MAP[lower] !== void 0) return PRIORITY_MAP[lower];
1717
+ const num = parseInt(value, 10);
1718
+ if (!isNaN(num) && num >= 1 && num <= 4) return num;
1719
+ throw new Error("--priority must be 1-4 or urgent/high/normal/low");
1720
+ }
1721
+ async function runConcurrent(tasks, limit) {
1722
+ const results = new Array(tasks.length);
1723
+ let idx = 0;
1724
+ async function worker() {
1725
+ while (idx < tasks.length) {
1726
+ const current = idx++;
1727
+ try {
1728
+ results[current] = await tasks[current]();
1729
+ } catch (e) {
1730
+ results[current] = e instanceof Error ? e : new Error(String(e));
1731
+ }
1732
+ }
1733
+ }
1734
+ await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
1735
+ return results;
1736
+ }
1380
1737
  function buildTaskListParams(opts) {
1381
1738
  const params = {};
1382
1739
  if (opts.archived) params["archived"] = "true";
@@ -1455,13 +1812,13 @@ function registerTaskCommands(program, getClient) {
1455
1812
  const data = await client.get(`/task/${taskId}`, params);
1456
1813
  formatOutput(data, TASK_COLUMNS, getOutputOptions(program));
1457
1814
  });
1458
- task.command("create").description("Create a new task").requiredOption("--list-id <id>", "List ID").requiredOption("--name <name>", "Task name").option("--description <desc>", "Plain text description").option("--markdown-description <md>", "Markdown description (overrides --description)").option("--status <s>", "Initial status").option("--priority <n>", "Priority (1=urgent, 2=high, 3=normal, 4=low)", parseInt).option("--due-date <date>", "Due date (Unix ms)").option("--start-date <date>", "Start date (Unix ms)").option("--assignee <id>", "Assignee user ID (repeatable)", collect, []).option("--tag <name>", "Tag name (repeatable)", collect, []).option("--time-estimate <ms>", "Time estimate in milliseconds", parseInt).option("--notify-all", "Notify all assignees and watchers").option("--parent <task-id>", "Parent task ID (creates subtask)").option("--links-to <task-id>", "Link to another task").option("--custom-field <id=value>", "Set custom field (repeatable)", collect, []).option("--check-required-custom-fields", "Reject if required custom fields are missing").action(async (opts) => {
1815
+ task.command("create").description("Create a new task").requiredOption("--list-id <id>", "List ID").requiredOption("--name <name>", "Task name").option("--description <desc>", "Plain text description").option("--markdown-description <md>", "Markdown description (overrides --description)").option("--status <s>", "Initial status").option("--priority <n>", "Priority (1-4 or urgent/high/normal/low)").option("--due-date <date>", "Due date (Unix ms)").option("--start-date <date>", "Start date (Unix ms)").option("--assignee <id>", "Assignee user ID (repeatable)", collect, []).option("--tag <name>", "Tag name (repeatable)", collect, []).option("--time-estimate <ms>", "Time estimate in milliseconds", parseInt).option("--notify-all", "Notify all assignees and watchers").option("--parent <task-id>", "Parent task ID (creates subtask)").option("--links-to <task-id>", "Link to another task").option("--custom-field <id=value>", "Set custom field (repeatable)", collect, []).option("--check-required-custom-fields", "Reject if required custom fields are missing").action(async (opts) => {
1459
1816
  const client = getClient();
1460
1817
  const body = { name: opts.name };
1461
1818
  if (opts.markdownDescription !== void 0) body["markdown_description"] = opts.markdownDescription;
1462
1819
  else if (opts.description !== void 0) body["description"] = opts.description;
1463
1820
  if (opts.status !== void 0) body["status"] = opts.status;
1464
- if (opts.priority !== void 0) body["priority"] = opts.priority;
1821
+ if (opts.priority !== void 0) body["priority"] = parsePriority(opts.priority);
1465
1822
  if (opts.dueDate !== void 0) body["due_date"] = parseInt(opts.dueDate, 10);
1466
1823
  if (opts.startDate !== void 0) body["start_date"] = parseInt(opts.startDate, 10);
1467
1824
  const assignees = opts.assignee;
@@ -1490,13 +1847,13 @@ function registerTaskCommands(program, getClient) {
1490
1847
  const data = await client.post(`/list/${opts.listId}/task`, body);
1491
1848
  formatOutput(data, TASK_COLUMNS, getOutputOptions(program));
1492
1849
  });
1493
- task.command("update").description("Update a task").argument("<task-id>", "Task ID").option("--name <name>", "New task name").option("--description <desc>", "New description").option("--status <s>", "New status").option("--priority <n>", "New priority (1-4)", parseInt).option("--due-date <date>", "New due date (Unix ms)").option("--start-date <date>", "New start date (Unix ms)").option("--time-estimate <ms>", "New time estimate in milliseconds", parseInt).option("--assignee-add <id>", "Add assignee (repeatable)", collect, []).option("--assignee-remove <id>", "Remove assignee (repeatable)", collect, []).option("--archived <bool>", "Archive or unarchive").action(async (taskId, opts) => {
1850
+ task.command("update").description("Update a task").argument("<task-id>", "Task ID").option("--name <name>", "New task name").option("--description <desc>", "New description").option("--status <s>", "New status").option("--priority <n>", "New priority (1-4 or urgent/high/normal/low)").option("--due-date <date>", "New due date (Unix ms)").option("--start-date <date>", "New start date (Unix ms)").option("--time-estimate <ms>", "New time estimate in milliseconds", parseInt).option("--assignee-add <id>", "Add assignee (repeatable)", collect, []).option("--assignee-remove <id>", "Remove assignee (repeatable)", collect, []).option("--archived <bool>", "Archive or unarchive").action(async (taskId, opts) => {
1494
1851
  const client = getClient();
1495
1852
  const body = {};
1496
1853
  if (opts.name !== void 0) body["name"] = opts.name;
1497
1854
  if (opts.description !== void 0) body["description"] = opts.description;
1498
1855
  if (opts.status !== void 0) body["status"] = opts.status;
1499
- if (opts.priority !== void 0) body["priority"] = opts.priority;
1856
+ if (opts.priority !== void 0) body["priority"] = parsePriority(opts.priority);
1500
1857
  if (opts.dueDate !== void 0) body["due_date"] = parseInt(opts.dueDate, 10);
1501
1858
  if (opts.startDate !== void 0) body["start_date"] = parseInt(opts.startDate, 10);
1502
1859
  if (opts.timeEstimate !== void 0) body["time_estimate"] = opts.timeEstimate;
@@ -1553,6 +1910,68 @@ function registerTaskCommands(program, getClient) {
1553
1910
  { key: "statuses", header: "Statuses", width: 50 }
1554
1911
  ], getOutputOptions(program));
1555
1912
  });
1913
+ task.command("bulk-update").description("Apply the same update to multiple tasks").requiredOption("--task-id <id>", "Task ID (repeatable)", collect, []).option("--name <name>", "New task name").option("--description <desc>", "New description").option("--status <s>", "New status").option("--priority <n>", "New priority (1-4 or urgent/high/normal/low)").option("--due-date <date>", "New due date (Unix ms)").option("--start-date <date>", "New start date (Unix ms)").option("--time-estimate <ms>", "New time estimate in milliseconds", parseInt).option("--assignee-add <id>", "Add assignee (repeatable)", collect, []).option("--assignee-remove <id>", "Remove assignee (repeatable)", collect, []).action(async (opts) => {
1914
+ const client = getClient();
1915
+ const taskIds = opts.taskId;
1916
+ const body = {};
1917
+ if (opts.name !== void 0) body["name"] = opts.name;
1918
+ if (opts.description !== void 0) body["description"] = opts.description;
1919
+ if (opts.status !== void 0) body["status"] = opts.status;
1920
+ if (opts.priority !== void 0) body["priority"] = parsePriority(opts.priority);
1921
+ if (opts.dueDate !== void 0) body["due_date"] = parseInt(opts.dueDate, 10);
1922
+ if (opts.startDate !== void 0) body["start_date"] = parseInt(opts.startDate, 10);
1923
+ if (opts.timeEstimate !== void 0) body["time_estimate"] = opts.timeEstimate;
1924
+ const addIds = opts.assigneeAdd;
1925
+ const remIds = opts.assigneeRemove;
1926
+ if (addIds.length || remIds.length) {
1927
+ body["assignees"] = {
1928
+ add: addIds.map((a) => parseInt(a, 10)),
1929
+ rem: remIds.map((a) => parseInt(a, 10))
1930
+ };
1931
+ }
1932
+ const tasks = taskIds.map((id) => async () => {
1933
+ const data = await client.put(`/task/${id}`, body);
1934
+ return { task_id: id, name: data["name"], result: "ok" };
1935
+ });
1936
+ const results = await runConcurrent(tasks, 3);
1937
+ const rows = results.map(
1938
+ (r, i) => r instanceof Error ? { task_id: taskIds[i], name: "", result: r.message } : r
1939
+ );
1940
+ formatOutput(rows, [
1941
+ { key: "task_id", header: "Task ID", width: 14 },
1942
+ { key: "name", header: "Name", width: 30 },
1943
+ { key: "result", header: "Result", width: 20 }
1944
+ ], getOutputOptions(program));
1945
+ });
1946
+ task.command("bulk-delete").description("Delete multiple tasks").requiredOption("--task-id <id>", "Task ID (repeatable)", collect, []).option("--confirm", "Skip confirmation prompt").action(async (opts) => {
1947
+ const client = getClient();
1948
+ const taskIds = opts.taskId;
1949
+ if (!opts.confirm) {
1950
+ if (!process.stdin.isTTY) {
1951
+ process.stderr.write("Error: Use --confirm to bulk delete in non-interactive mode.\n");
1952
+ process.exit(2);
1953
+ return;
1954
+ }
1955
+ const { confirm } = await import("@inquirer/prompts");
1956
+ const yes = await confirm({ message: `Delete ${taskIds.length} task(s)?` });
1957
+ if (!yes) {
1958
+ process.stdout.write("Cancelled.\n");
1959
+ return;
1960
+ }
1961
+ }
1962
+ const tasks = taskIds.map((id) => async () => {
1963
+ await client.delete(`/task/${id}`);
1964
+ return { task_id: id, result: "deleted" };
1965
+ });
1966
+ const results = await runConcurrent(tasks, 3);
1967
+ const rows = results.map(
1968
+ (r, i) => r instanceof Error ? { task_id: taskIds[i], result: r.message } : r
1969
+ );
1970
+ formatOutput(rows, [
1971
+ { key: "task_id", header: "Task ID", width: 14 },
1972
+ { key: "result", header: "Result", width: 20 }
1973
+ ], getOutputOptions(program));
1974
+ });
1556
1975
  }
1557
1976
 
1558
1977
  // src/commands/checklist.ts
@@ -1900,11 +2319,21 @@ function registerRelationCommands(program, getClient) {
1900
2319
  }
1901
2320
 
1902
2321
  // src/commands/attachment.ts
2322
+ import { writeFileSync } from "fs";
2323
+ import { join } from "path";
1903
2324
  registerSchema("attachment", "upload", "Upload a file to a task", [
1904
2325
  { flag: "--task-id", type: "string", required: true, description: "Task ID" },
1905
2326
  { flag: "--file", type: "string", required: true, description: "Local file path" },
1906
2327
  { flag: "--filename", type: "string", required: false, description: "Override display filename" }
1907
2328
  ]);
2329
+ registerSchema("attachment", "list", "List attachments on a task", [
2330
+ { flag: "--task-id", type: "string", required: true, description: "Task ID" }
2331
+ ]);
2332
+ registerSchema("attachment", "download", "Download an attachment from a task", [
2333
+ { flag: "--task-id", type: "string", required: true, description: "Task ID" },
2334
+ { flag: "--attachment-id", type: "string", required: true, description: "Attachment ID" },
2335
+ { flag: "--output", type: "string", required: false, description: "Output file path (default: ./attachment-<id>-<title>)" }
2336
+ ]);
1908
2337
  var ATTACHMENT_COLUMNS = [
1909
2338
  { key: "id", header: "ID", width: 20 },
1910
2339
  { key: "title", header: "Title", width: 30 },
@@ -1919,6 +2348,35 @@ function registerAttachmentCommands(program, getClient) {
1919
2348
  const data = await client.upload(`/task/${opts.taskId}/attachment`, opts.file, opts.filename);
1920
2349
  formatOutput(data, ATTACHMENT_COLUMNS, getOutputOptions(program));
1921
2350
  });
2351
+ attachment.command("list").description("List attachments on a task").requiredOption("--task-id <id>", "Task ID").action(async (opts) => {
2352
+ const client = getClient();
2353
+ const data = await client.get(`/task/${opts.taskId}`);
2354
+ const attachments = data["attachments"] ?? [];
2355
+ formatOutput(attachments, ATTACHMENT_COLUMNS, getOutputOptions(program));
2356
+ });
2357
+ attachment.command("download").description("Download an attachment from a task").requiredOption("--task-id <id>", "Task ID").requiredOption("--attachment-id <id>", "Attachment ID").option("--output <path>", "Output file path").action(async (opts) => {
2358
+ const { default: ora } = await import("ora");
2359
+ const client = getClient();
2360
+ const data = await client.get(`/task/${opts.taskId}`);
2361
+ const attachments = data["attachments"] ?? [];
2362
+ const attachment2 = attachments.find((a) => a.id === opts.attachmentId);
2363
+ if (!attachment2) {
2364
+ process.stderr.write(`Error: Attachment "${opts.attachmentId}" not found on task "${opts.taskId}".
2365
+ `);
2366
+ process.exit(4);
2367
+ return;
2368
+ }
2369
+ const outputPath = opts.output ?? join(process.cwd(), `attachment-${attachment2.id}-${attachment2.title}`);
2370
+ const spinner = ora(`Downloading ${attachment2.title}...`).start();
2371
+ try {
2372
+ const buffer = await client.downloadUrl(attachment2.url);
2373
+ writeFileSync(outputPath, Buffer.from(buffer));
2374
+ spinner.succeed(`Downloaded to ${outputPath}`);
2375
+ } catch (err) {
2376
+ spinner.fail("Download failed");
2377
+ throw err;
2378
+ }
2379
+ });
1922
2380
  }
1923
2381
 
1924
2382
  // src/commands/comment.ts
@@ -3373,6 +3831,22 @@ registerSchema("template", "list", "List task templates in a workspace", [
3373
3831
  { flag: "--workspace-id", type: "string", required: true, description: "Workspace ID" },
3374
3832
  { flag: "--page", type: "integer", required: false, description: "Page number (starts at 0)" }
3375
3833
  ]);
3834
+ registerSchema("template", "apply-task", "Create a task from a task template", [
3835
+ { flag: "--list-id", type: "string", required: true, description: "List to create the task in" },
3836
+ { flag: "--template-id", type: "string", required: true, description: "Template ID to apply" },
3837
+ { flag: "--name", type: "string", required: false, description: "Override the template name" }
3838
+ ]);
3839
+ registerSchema("template", "apply-list", "Create a list from a list template", [
3840
+ { flag: "--template-id", type: "string", required: true, description: "Template ID to apply" },
3841
+ { flag: "--folder-id", type: "string", required: false, description: "Folder to create the list in (use this or --space-id)" },
3842
+ { flag: "--space-id", type: "string", required: false, description: "Space to create the list in (use this or --folder-id)" },
3843
+ { flag: "--name", type: "string", required: false, description: "Override the template name" }
3844
+ ]);
3845
+ registerSchema("template", "apply-folder", "Create a folder from a folder template", [
3846
+ { flag: "--space-id", type: "string", required: true, description: "Space to create the folder in" },
3847
+ { flag: "--template-id", type: "string", required: true, description: "Template ID to apply" },
3848
+ { flag: "--name", type: "string", required: false, description: "Override the template name" }
3849
+ ]);
3376
3850
  function registerTemplateCommands(program, getClient) {
3377
3851
  const template = program.command("template").description("Manage task templates");
3378
3852
  template.command("list").description("List task templates").option("--page <n>", "Page number (starts at 0)", parseInt).action(async (opts) => {
@@ -3384,6 +3858,39 @@ function registerTemplateCommands(program, getClient) {
3384
3858
  const data = await client.get(`/team/${workspaceId}/taskTemplate`, params);
3385
3859
  formatOutput(data.templates, TEMPLATE_COLUMNS, getOutputOptions(program));
3386
3860
  });
3861
+ template.command("apply-task").description("Create a task from a task template").requiredOption("--list-id <id>", "List to create the task in").requiredOption("--template-id <id>", "Template ID to apply").option("--name <name>", "Override the template name").action(async (opts) => {
3862
+ const client = getClient();
3863
+ const body = {};
3864
+ if (opts.name !== void 0) body["name"] = opts.name;
3865
+ const data = await client.post(
3866
+ `/list/${opts.listId}/taskTemplate/${opts.templateId}`,
3867
+ body
3868
+ );
3869
+ formatOutput([data], TEMPLATE_COLUMNS, getOutputOptions(program));
3870
+ });
3871
+ template.command("apply-list").description("Create a list from a list template").requiredOption("--template-id <id>", "Template ID to apply").option("--folder-id <id>", "Folder to create the list in").option("--space-id <id>", "Space to create the list in (folderless)").option("--name <name>", "Override the template name").action(async (opts) => {
3872
+ if (!opts.folderId && !opts.spaceId) {
3873
+ process.stderr.write("Error: Provide either --folder-id or --space-id\n");
3874
+ process.exit(2);
3875
+ return;
3876
+ }
3877
+ const client = getClient();
3878
+ const body = {};
3879
+ if (opts.name !== void 0) body["name"] = opts.name;
3880
+ const path = opts.folderId ? `/folder/${opts.folderId}/listTemplate/${opts.templateId}` : `/space/${opts.spaceId}/listTemplate/${opts.templateId}`;
3881
+ const data = await client.post(path, body);
3882
+ formatOutput([data], TEMPLATE_COLUMNS, getOutputOptions(program));
3883
+ });
3884
+ template.command("apply-folder").description("Create a folder from a folder template").requiredOption("--space-id <id>", "Space to create the folder in").requiredOption("--template-id <id>", "Template ID to apply").option("--name <name>", "Override the template name").action(async (opts) => {
3885
+ const client = getClient();
3886
+ const body = {};
3887
+ if (opts.name !== void 0) body["name"] = opts.name;
3888
+ const data = await client.post(
3889
+ `/space/${opts.spaceId}/folderTemplate/${opts.templateId}`,
3890
+ body
3891
+ );
3892
+ formatOutput([data], TEMPLATE_COLUMNS, getOutputOptions(program));
3893
+ });
3387
3894
  }
3388
3895
 
3389
3896
  // src/commands/task-type.ts
@@ -3729,8 +4236,8 @@ function registerDocCommands(program, getClient) {
3729
4236
  }
3730
4237
 
3731
4238
  // src/commands/skill-cmd.ts
3732
- import { readdirSync, readFileSync as readFileSync2, existsSync } from "fs";
3733
- import { join, dirname } from "path";
4239
+ import { readdirSync, readFileSync as readFileSync4, existsSync } from "fs";
4240
+ import { join as join2, dirname } from "path";
3734
4241
  import { fileURLToPath } from "url";
3735
4242
  var SKILL_COLUMNS = [
3736
4243
  { key: "name", header: "Name", width: 28 },
@@ -3746,9 +4253,9 @@ registerSchema("skill", "path", "Print the file system path to a skill directory
3746
4253
  ]);
3747
4254
  function findSkillsDir() {
3748
4255
  const thisDir = dirname(fileURLToPath(import.meta.url));
3749
- const bundledDir = join(thisDir, "..", "skills");
4256
+ const bundledDir = join2(thisDir, "..", "skills");
3750
4257
  if (existsSync(bundledDir)) return bundledDir;
3751
- const projectDir = join(thisDir, "..", "..", "skills");
4258
+ const projectDir = join2(thisDir, "..", "..", "skills");
3752
4259
  if (existsSync(projectDir)) return projectDir;
3753
4260
  return void 0;
3754
4261
  }
@@ -3789,16 +4296,16 @@ function loadSkills(skillsDir) {
3789
4296
  return skills;
3790
4297
  }
3791
4298
  for (const entry of entries) {
3792
- const skillFile = join(skillsDir, entry, "SKILL.md");
4299
+ const skillFile = join2(skillsDir, entry, "SKILL.md");
3793
4300
  if (!existsSync(skillFile)) continue;
3794
4301
  try {
3795
- const content = readFileSync2(skillFile, "utf-8");
4302
+ const content = readFileSync4(skillFile, "utf-8");
3796
4303
  const { frontmatter } = parseFrontmatter(content);
3797
4304
  skills.push({
3798
4305
  name: frontmatter["name"] || entry,
3799
4306
  description: (frontmatter["description"] || "").slice(0, 100),
3800
4307
  type: classifySkill(frontmatter),
3801
- path: join(skillsDir, entry),
4308
+ path: join2(skillsDir, entry),
3802
4309
  frontmatter
3803
4310
  });
3804
4311
  } catch {
@@ -3813,11 +4320,11 @@ function loadSkills(skillsDir) {
3813
4320
  });
3814
4321
  }
3815
4322
  function findSkill(skillsDir, name) {
3816
- const skillDir = join(skillsDir, name);
3817
- const skillFile = join(skillDir, "SKILL.md");
4323
+ const skillDir = join2(skillsDir, name);
4324
+ const skillFile = join2(skillDir, "SKILL.md");
3818
4325
  if (!existsSync(skillFile)) return void 0;
3819
4326
  try {
3820
- const content = readFileSync2(skillFile, "utf-8");
4327
+ const content = readFileSync4(skillFile, "utf-8");
3821
4328
  return { skillDir, content };
3822
4329
  } catch {
3823
4330
  return void 0;
@@ -3945,15 +4452,18 @@ function registerChatCommands(program, getClient) {
3945
4452
  }
3946
4453
 
3947
4454
  // src/cli.ts
3948
- var VERSION = "0.2.0";
4455
+ var VERSION = "0.3.0";
3949
4456
  function createProgram() {
3950
4457
  const program = new Command();
3951
- program.name("clickup").description("ClickUp CLI - Manage ClickUp workspaces from the terminal").version(VERSION).option("--token <token>", "API token").option("--workspace-id <id>", "Workspace ID").option("--format <format>", "Output format (table|json|csv|tsv|quiet|id|md)").option("--no-color", "Disable colors").option("--no-header", "Omit column headers").option("--fields <fields>", "Show only specified fields (comma-separated)").option("--filter <filter>", "Client-side filter (key=value)").option("--sort <sort>", "Sort by field (field[:asc|:desc])").option("--limit <n>", "Limit results", parseInt).option("--verbose", "Show request details").option("--debug", "Full debug output").option("--dry-run", "Print what would be sent without executing");
4458
+ program.name("clickup").description("ClickUp CLI - Manage ClickUp workspaces from the terminal").version(VERSION).option("--token <token>", "API token").option("--token-file <path>", "Read API token from this file path").option("--profile <name>", "Profile to use (key, workspace name, or nickname)").option("--workspace-id <id>", "Workspace ID").option("--format <format>", "Output format (table|json|csv|tsv|quiet|id|md)").option("--no-color", "Disable colors").option("--no-header", "Omit column headers").option("--fields <fields>", "Show only specified fields (comma-separated)").option("--filter <filter>", "Client-side filter (key=value)").option("--sort <sort>", "Sort by field (field[:asc|:desc])").option("--limit <n>", "Limit results", parseInt).option("--verbose", "Show request details").option("--debug", "Full debug output").option("--dry-run", "Print what would be sent without executing");
3952
4459
  return program;
3953
4460
  }
3954
4461
  function createClient(program) {
3955
4462
  const globalOpts = program.opts();
3956
- const token = resolveToken(globalOpts["token"]);
4463
+ const token = resolveToken(
4464
+ globalOpts["token"],
4465
+ globalOpts["tokenFile"]
4466
+ );
3957
4467
  if (!token) {
3958
4468
  process.stderr.write("Error: No API token found. Run: clickup auth login\n");
3959
4469
  process.exit(EXIT_CODES.AUTH_FAILURE);
@@ -3983,8 +4493,11 @@ function getOutputOptions(program) {
3983
4493
  }
3984
4494
  function run() {
3985
4495
  const program = createProgram();
4496
+ program.hook("preAction", () => {
4497
+ setProfileOverride(program.opts()["profile"]);
4498
+ });
3986
4499
  registerAuthCommands(program, () => createClient(program));
3987
- registerConfigCommands(program);
4500
+ registerConfigCommands(program, () => createClient(program));
3988
4501
  registerWorkspaceCommands(program, () => createClient(program));
3989
4502
  registerSpaceCommands(program, () => createClient(program));
3990
4503
  registerFolderCommands(program, () => createClient(program));
@@ -4014,6 +4527,9 @@ function run() {
4014
4527
  registerSchemaCommands(program);
4015
4528
  registerSkillCommands(program);
4016
4529
  program.parseAsync(process.argv).catch((error) => {
4530
+ if (error instanceof DryRunComplete) {
4531
+ process.exit(EXIT_CODES.SUCCESS);
4532
+ }
4017
4533
  if (error instanceof ClickUpError) {
4018
4534
  process.stderr.write(`Error: ${error.message}
4019
4535
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clickup-agent-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI covering the entire ClickUp API v2 surface",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,8 @@
18
18
  "build": "tsup",
19
19
  "dev": "tsx bin/clickup.ts",
20
20
  "test": "vitest run",
21
- "typecheck": "tsc --noEmit"
21
+ "typecheck": "tsc --noEmit",
22
+ "version": "node scripts/sync-version.js"
22
23
  },
23
24
  "keywords": [
24
25
  "clickup",
@@ -41,6 +41,7 @@ clickup <resource> <action> --help # Full help text
41
41
  | `clickup-chat` | Chat channels, sending messages and notifications |
42
42
  | `clickup-webhooks` | Webhook registration and management |
43
43
  | `clickup-fields` | Custom fields, tags, custom task types |
44
+ | `clickup-templates` | Templates: list available templates, apply task/list/folder templates |
44
45
 
45
46
  ## Recipe Skills (multi-step workflows)
46
47
 
@@ -57,6 +58,7 @@ Recipes accept natural language arguments. Scope them to any team, department, p
57
58
  | `clickup-sprint-closeout` | Close a sprint, carry over incomplete work |
58
59
  | `clickup-time-audit` | Audit time tracking and utilization |
59
60
  | `clickup-project-setup` | Scaffold a new project structure |
61
+ | `clickup-rollout` | Roll out a saved process template (apply + configure) |
60
62
  | `clickup-capacity-check` | Check team workload and availability |
61
63
  | `clickup-blocker-report` | Find blocked tasks and dependency chains |
62
64
  | `clickup-goal-progress` | Report on goal/OKR completion |
@@ -81,6 +83,10 @@ clickup task list --list-id <id> --format quiet | xargs -I{} clickup task get {}
81
83
 
82
84
  All commands support: `--format json|table|csv|tsv|quiet|id|md`, `--dry-run`, `--debug`, `--no-color`
83
85
 
86
+ Additional global flags:
87
+ - `--profile <name>` -- select a named profile (accepts profile key, workspace name, or nickname)
88
+ - `--token-file <path>` -- read API token from a file (useful in CI/CD and secret managers)
89
+
84
90
  Use `--format md` to render output as a markdown table -- ideal for displaying results in chat messages or documents.
85
91
 
86
92
  ## Auth
@@ -91,16 +97,45 @@ Requires a ClickUp API token.
91
97
  # Set up authentication
92
98
  clickup auth login --token pk_XXXXXXXX_YYYYYYYY
93
99
 
100
+ # Read token from file (CI/CD-friendly)
101
+ clickup auth login --token-file /run/secrets/clickup_token
102
+
94
103
  # Or use an environment variable
95
- export CLICKUP_TOKEN=pk_XXXXXXXX_YYYYYYYY
104
+ export CLICKUP_API_TOKEN=pk_XXXXXXXX_YYYYYYYY
96
105
 
97
- # Store default workspace
98
- clickup config set workspace_id <id>
106
+ # Auto-configure workspace after login (single workspace: auto-selects)
107
+ clickup workspace setup
99
108
 
100
109
  # Check current auth status
101
110
  clickup auth status
111
+
112
+ # Validate token and show identity
113
+ clickup config validate
114
+ ```
115
+
116
+ ## Named Profiles (Multi-Account)
117
+
118
+ Profiles store credentials per workspace. A single-workspace setup is transparent -- no `--profile` flag needed.
119
+
120
+ ```bash
121
+ # List profiles
122
+ clickup config profile list
123
+
124
+ # Switch active profile
125
+ clickup config profile use "Acme Corp"
126
+
127
+ # Set a short nickname
128
+ clickup config profile nickname acme-corp acme
129
+
130
+ # Use a specific profile for one command
131
+ clickup task list --list-id 123 --profile acme
102
132
  ```
103
133
 
134
+ Profile resolution for `--profile` accepts:
135
+ 1. The profile key (e.g. `default`)
136
+ 2. Workspace name, case-insensitive (e.g. `"Henry's Workspace"`)
137
+ 3. Nickname if set (e.g. `acme`)
138
+
104
139
  ## Creating Custom Skills
105
140
 
106
141
  Users can create their own skills alongside the built-in ones. Add a SKILL.md to `.claude/skills/<name>/` in any project:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: clickup-fields
3
3
  description: Manages ClickUp custom fields, tags, and custom task types. Use when the user asks about custom fields, wants to set field values on tasks, manage tags, or work with custom task types.
4
- allowed-tools: Bash(clickup custom-field *), Bash(clickup tag *), Bash(clickup custom-task-type *), Bash(clickup schema field*), Bash(clickup schema tag*)
4
+ allowed-tools: Bash(clickup custom-field *), Bash(clickup tag *), Bash(clickup custom-task-type *), Bash(clickup attachment *), Bash(clickup schema field*), Bash(clickup schema tag*)
5
5
  ---
6
6
 
7
7
  # ClickUp Custom Fields, Tags, and Task Types
@@ -69,9 +69,20 @@ clickup field list --list-id 998877 --format json
69
69
  clickup field list --list-id 998877 --format md
70
70
  ```
71
71
 
72
+ ## Attachment Commands
73
+
74
+ ```bash
75
+ clickup attachment upload --task-id <id> --file <path> [--filename <name>]
76
+ clickup attachment list --task-id <id>
77
+ clickup attachment download --task-id <id> --attachment-id <id> [--output <path>]
78
+ ```
79
+
80
+ `attachment download` fetches the file with the auth header and writes to `--output` (default: `./attachment-<id>-<title>`). Shows a spinner during download.
81
+
72
82
  ## Discovery
73
83
 
74
84
  ```bash
75
85
  clickup schema fields.set # Show field set options
76
86
  clickup schema tags.create # Show tag create fields
87
+ clickup schema attachment.list # Show attachment list options
77
88
  ```
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: clickup-rollout
3
+ description: Rolls out a saved process template in ClickUp by applying it to a target location, then configuring assignees, due dates, and custom fields. Use when the user says "start a new [project/process/sprint/onboarding]" or wants to kick off a repeatable workflow from a template.
4
+ disable-model-invocation: true
5
+ context: fork
6
+ agent: general-purpose
7
+ argument-hint: "[template name or ID] [target location] [optional: assignee, due date, custom fields]"
8
+ allowed-tools: Bash(clickup *)
9
+ ---
10
+
11
+ # Template Rollout
12
+
13
+ Apply a saved ClickUp template and configure the resulting task, list, or folder for a new instance of the process.
14
+
15
+ ## Workflow
16
+
17
+ ### Step 1: Find the template
18
+
19
+ ```bash
20
+ # List all templates to find the right one
21
+ clickup template list --format json
22
+ ```
23
+
24
+ If the user gave a template name, find its ID by matching the `name` field. If they gave an ID directly, skip this step.
25
+
26
+ ### Step 2: Determine the template type and target
27
+
28
+ Based on what the user wants to create:
29
+ - **Task template**: needs a `--list-id` target
30
+ - **List template**: needs a `--folder-id` or `--space-id` target
31
+ - **Folder template**: needs a `--space-id` target
32
+
33
+ If the user hasn't specified a target, find it:
34
+ ```bash
35
+ clickup space list --format json
36
+ clickup folder list --space-id <id> --format json
37
+ clickup list list --folder-id <id> --format json
38
+ ```
39
+
40
+ ### Step 3: Apply the template
41
+
42
+ ```bash
43
+ # Task template
44
+ TASK_ID=$(clickup template apply-task \
45
+ --list-id <list-id> \
46
+ --template-id <tmpl-id> \
47
+ --name "<instance-name>" \
48
+ --format id)
49
+
50
+ # List template (into folder)
51
+ LIST_ID=$(clickup template apply-list \
52
+ --folder-id <folder-id> \
53
+ --template-id <tmpl-id> \
54
+ --name "<instance-name>" \
55
+ --format id)
56
+
57
+ # List template (into space, folderless)
58
+ LIST_ID=$(clickup template apply-list \
59
+ --space-id <space-id> \
60
+ --template-id <tmpl-id> \
61
+ --name "<instance-name>" \
62
+ --format id)
63
+
64
+ # Folder template
65
+ FOLDER_ID=$(clickup template apply-folder \
66
+ --space-id <space-id> \
67
+ --template-id <tmpl-id> \
68
+ --name "<instance-name>" \
69
+ --format id)
70
+ ```
71
+
72
+ ### Step 4: Configure the created resource
73
+
74
+ For a task template result, set assignees, dates, and custom fields:
75
+ ```bash
76
+ # Assign team members
77
+ clickup task update "$TASK_ID" --assignee-add <user-id>
78
+
79
+ # Set start and due dates (Unix milliseconds)
80
+ clickup task update "$TASK_ID" --start-date <ms> --due-date <ms>
81
+
82
+ # Fill in custom fields
83
+ clickup field set --task-id "$TASK_ID" --field-id <field-id> --value "<value>"
84
+ ```
85
+
86
+ For a list template result, populate it with initial tasks if needed:
87
+ ```bash
88
+ clickup task list --list-id "$LIST_ID" --format json # review what the template created
89
+ ```
90
+
91
+ For a folder template result, review the created structure:
92
+ ```bash
93
+ clickup list list --folder-id "$FOLDER_ID" --format table
94
+ ```
95
+
96
+ ### Step 5: Confirm and summarize
97
+
98
+ Report what was created:
99
+ - Resource type and ID
100
+ - Name used
101
+ - Assignees set
102
+ - Dates configured
103
+ - Custom fields populated
104
+ - Link or path in the ClickUp hierarchy
105
+
106
+ ## Tips
107
+
108
+ - Use `--name` to give each rollout a unique name (e.g. "Client Onboarding - Acme Corp" or "Sprint 14 - Apr 7")
109
+ - Templates carry over all custom field definitions -- just fill in the values after applying
110
+ - Task IDs from `--format id` can be piped directly into update and field commands
111
+ - If rolling out multiple instances at once, loop over a list of names or targets
@@ -11,12 +11,15 @@ Manage the organizational hierarchy: Workspace > Space > Folder > List. Lists co
11
11
  ## Workspace Commands
12
12
 
13
13
  ```bash
14
+ clickup workspace setup # Auto-configure workspace (run after auth login)
14
15
  clickup workspace list # List all workspaces
15
16
  clickup workspace get [--workspace-id <id>] # Get workspace details
16
17
  clickup workspace seats [--workspace-id <id>] # Show seat usage
17
18
  clickup workspace plan [--workspace-id <id>] # Show billing plan
18
19
  ```
19
20
 
21
+ `workspace setup` calls `GET /team`, auto-selects if only one workspace exists, prompts in TTY if multiple, and saves the workspace to the active profile. After running it, `--workspace-id` is not needed for subsequent commands.
22
+
20
23
  ## Space Commands
21
24
 
22
25
  ```bash
@@ -33,7 +33,7 @@ clickup task get <task-id> [--include-subtasks] [--include-markdown-description]
33
33
  ```bash
34
34
  clickup task create --list-id <id> --name <name>
35
35
  [--description <text>] [--markdown-description <md>]
36
- [--status <s>] [--priority <1-4>] [--due-date <ts>] [--start-date <ts>]
36
+ [--status <s>] [--priority <1-4|urgent|high|normal|low>] [--due-date <ts>] [--start-date <ts>]
37
37
  [--assignee <id>...] [--tag <name>...] [--time-estimate <ms>]
38
38
  [--parent <task-id>] [--custom-field <id=value>...]
39
39
  ```
@@ -41,15 +41,28 @@ clickup task create --list-id <id> --name <name>
41
41
  ### Update a task
42
42
  ```bash
43
43
  clickup task update <task-id> [--name <name>] [--description <text>]
44
- [--status <s>] [--priority <1-4>] [--due-date <ts>] [--start-date <ts>]
44
+ [--status <s>] [--priority <1-4|urgent|high|normal|low>] [--due-date <ts>] [--start-date <ts>]
45
45
  [--assignee-add <id>...] [--assignee-remove <id>...] [--archived <bool>]
46
46
  ```
47
47
 
48
+ Priority accepts both integers (`1`-`4`) and strings (`urgent`, `high`, `normal`, `low`). Mapping: urgent=1, high=2, normal=3, low=4.
49
+
48
50
  ### Delete a task
49
51
  ```bash
50
52
  clickup task delete <task-id> --confirm
51
53
  ```
52
54
 
55
+ ### Bulk operations (concurrency limit 3)
56
+ ```bash
57
+ # Apply the same update to multiple tasks
58
+ clickup task bulk-update --task-id <id> --task-id <id> ... [same update flags as task update]
59
+
60
+ # Delete multiple tasks
61
+ clickup task bulk-delete --task-id <id> --task-id <id> ... --confirm
62
+ ```
63
+
64
+ Both bulk commands output a results table with `task_id | name | result`.
65
+
53
66
  ### Time in status
54
67
  ```bash
55
68
  clickup task time-in-status <task-id>
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: clickup-templates
3
+ description: Lists and applies ClickUp templates to create tasks, lists, and folders from saved process templates. Use when the user wants to roll out a repeatable process, create items from a template, or find what templates are available in the workspace.
4
+ allowed-tools: Bash(clickup template *), Bash(clickup schema template*)
5
+ ---
6
+
7
+ # ClickUp Templates
8
+
9
+ List available templates and apply them to create tasks, lists, and folders. Templates are defined in the ClickUp UI and can be rolled out repeatedly via the CLI.
10
+
11
+ ## List Templates
12
+
13
+ ```bash
14
+ clickup template list [--workspace-id <id>] [--page <n>]
15
+ ```
16
+
17
+ Returns all task templates in the workspace. Use this to find template IDs before applying them.
18
+
19
+ ## Apply a Task Template
20
+
21
+ ```bash
22
+ clickup template apply-task --list-id <id> --template-id <id> [--name <override-name>]
23
+ ```
24
+
25
+ Creates a new task in the specified list using the template structure, including all custom fields defined on the template.
26
+
27
+ ## Apply a List Template
28
+
29
+ ```bash
30
+ # Into a folder
31
+ clickup template apply-list --folder-id <id> --template-id <id> [--name <override-name>]
32
+
33
+ # Into a space (folderless)
34
+ clickup template apply-list --space-id <id> --template-id <id> [--name <override-name>]
35
+ ```
36
+
37
+ Provide exactly one of `--folder-id` or `--space-id`.
38
+
39
+ ## Apply a Folder Template
40
+
41
+ ```bash
42
+ clickup template apply-folder --space-id <id> --template-id <id> [--name <override-name>]
43
+ ```
44
+
45
+ Creates an entire folder structure (with nested lists and tasks) from the template.
46
+
47
+ ## Common Patterns
48
+
49
+ ```bash
50
+ # Step 1: Find available templates
51
+ clickup template list --format json
52
+
53
+ # Step 2: Apply a task template and capture the new task ID
54
+ TASK_ID=$(clickup template apply-task --list-id <id> --template-id <tmpl_id> --format id)
55
+
56
+ # Step 3: Set assignee and due date on the created task
57
+ clickup task update "$TASK_ID" --assignee-add 112233 --due-date 1735689600000
58
+
59
+ # Step 4: Fill in custom fields
60
+ clickup field set --task-id "$TASK_ID" --field-id cf_001 --value "In Review"
61
+
62
+ # Roll out a named instance of a process template
63
+ clickup template apply-list --folder-id <id> --template-id <tmpl_id> --name "Q1 Launch - March 2026"
64
+
65
+ # Apply a folder template to scaffold a whole project
66
+ clickup template apply-folder --space-id <id> --template-id <tmpl_id> --name "Client Onboarding - Acme Corp"
67
+ ```
68
+
69
+ ## Discovery
70
+
71
+ ```bash
72
+ clickup schema template # List all template actions
73
+ clickup schema template.apply-task # Show apply-task fields
74
+ clickup schema template.apply-list # Show apply-list fields
75
+ clickup schema template.apply-folder # Show apply-folder fields
76
+ ```