linear-sdk-cli 0.1.3 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -58
  4. package/COVERAGE.md +5 -5
  5. package/MIGRATING.md +71 -69
  6. package/README.md +115 -90
  7. package/package.json +6 -7
  8. package/skills/README.md +15 -0
  9. package/skills/linear-sdk-cli/SKILL.md +27 -3
  10. package/skills/linear-sdk-cli/references/api.md +9 -9
  11. package/skills/linear-sdk-cli/references/auth.md +18 -0
  12. package/skills/linear-sdk-cli/references/commands.md +2 -2
  13. package/skills/linear-sdk-cli/references/comment.md +9 -6
  14. package/skills/linear-sdk-cli/references/config.md +9 -11
  15. package/skills/linear-sdk-cli/references/issue.md +72 -18
  16. package/skills/linear-sdk-cli/references/open.md +29 -0
  17. package/skills/linear-sdk-cli/references/team.md +16 -0
  18. package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
  19. package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
  20. package/src/bin/linear.ts +16 -6
  21. package/src/cli.ts +3 -1
  22. package/src/commands/agent-session.ts +13 -5
  23. package/src/commands/api.ts +15 -9
  24. package/src/commands/attach.ts +15 -4
  25. package/src/commands/comment.ts +50 -11
  26. package/src/commands/completion.ts +2 -6
  27. package/src/commands/cycle.ts +6 -1
  28. package/src/commands/discover.ts +6 -2
  29. package/src/commands/document.ts +4 -1
  30. package/src/commands/initiative.ts +5 -1
  31. package/src/commands/issue.ts +392 -91
  32. package/src/commands/meta.ts +139 -31
  33. package/src/commands/milestone.ts +4 -1
  34. package/src/commands/notification.ts +15 -6
  35. package/src/commands/open.ts +84 -0
  36. package/src/commands/project.ts +17 -4
  37. package/src/commands/team.ts +13 -0
  38. package/src/commands/user.ts +2 -2
  39. package/src/commands/webhook.ts +12 -2
  40. package/src/config.ts +501 -184
  41. package/src/context.ts +13 -4
  42. package/src/git.ts +28 -3
  43. package/src/lib/body.ts +26 -2
  44. package/src/lib/errors.ts +33 -8
  45. package/src/lib/introspect.ts +76 -7
  46. package/src/lib/keyring.ts +51 -9
  47. package/src/lib/mentions.ts +49 -0
  48. package/src/lib/open.ts +32 -0
  49. package/src/lib/options.ts +108 -12
  50. package/src/lib/output-shapes.ts +81 -9
  51. package/src/lib/pagination.ts +50 -17
  52. package/src/lib/resolve.ts +40 -7
  53. package/src/lib/shape.ts +16 -12
  54. package/src/lib/upload.ts +3 -1
  55. package/src/output/color.ts +52 -0
  56. package/src/output/format.ts +96 -18
  57. package/src/output/markdown.ts +133 -0
  58. package/src/output/pager.ts +131 -0
  59. package/src/output/sanitize.ts +1 -1
  60. package/src/output/table.ts +7 -7
  61. package/src/services/agent-session.ts +11 -3
  62. package/src/services/attachment.ts +14 -5
  63. package/src/services/comment.ts +37 -9
  64. package/src/services/cycle.ts +11 -4
  65. package/src/services/document.ts +29 -10
  66. package/src/services/favorite.ts +15 -9
  67. package/src/services/initiative-update.ts +16 -15
  68. package/src/services/initiative.ts +15 -10
  69. package/src/services/issue.ts +263 -19
  70. package/src/services/label.ts +12 -2
  71. package/src/services/milestone.ts +8 -5
  72. package/src/services/notification.ts +16 -2
  73. package/src/services/organization.ts +23 -18
  74. package/src/services/project.ts +55 -34
  75. package/src/services/roadmap.ts +7 -6
  76. package/src/services/state.ts +9 -3
  77. package/src/services/team.ts +67 -24
  78. package/src/services/user.ts +2 -2
  79. package/src/services/webhook.ts +28 -17
@@ -10,6 +10,8 @@ import { withRetry } from "../client.js";
10
10
  import { resolve } from "node:path";
11
11
  import {
12
12
  writeCredential,
13
+ probeKeyringCredential,
14
+ adoptKeyringCredential,
13
15
  removeCredential,
14
16
  setDefaultWorkspace,
15
17
  listCredentials,
@@ -25,8 +27,8 @@ import {
25
27
  } from "../config.js";
26
28
  import { keyring } from "../lib/keyring.js";
27
29
  import { readStdinSync } from "../lib/body.js";
28
- import { authError, usageError } from "../lib/errors.js";
29
- import { promptSecret, promptSelect } from "../lib/prompt.js";
30
+ import { authError, normalizeError, usageError } from "../lib/errors.js";
31
+ import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
30
32
  import { listTeams } from "../services/team.js";
31
33
  import { ISSUE_SORTS } from "../services/issue.js";
32
34
  import { firstTeam, type Context } from "../context.js";
@@ -58,6 +60,79 @@ const whoamiAction = action(async (ctx: Context) => {
58
60
  );
59
61
  });
60
62
 
63
+ interface AuthIdentity {
64
+ user: { id: string; name: string; email: string };
65
+ organization: { id: string; name: string; urlKey: string };
66
+ }
67
+
68
+ interface AuthValidationClient {
69
+ viewer: Promise<{ id: string; name: string; email: string }>;
70
+ organization: Promise<{ id: string; name: string; urlKey: string }>;
71
+ }
72
+
73
+ export type AuthValidationClientFactory = (apiKey: string) => AuthValidationClient;
74
+
75
+ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (apiKey) =>
76
+ new LinearClient({ apiKey }) as unknown as AuthValidationClient;
77
+ let authValidationClientFactory = defaultAuthValidationClientFactory;
78
+
79
+ /** Test seam for command-level auth tests; production always uses LinearClient. */
80
+ export function setAuthValidationClientFactoryForTests(
81
+ factory: AuthValidationClientFactory | undefined,
82
+ ): void {
83
+ authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
84
+ }
85
+
86
+ /** Merge the local and global login spellings without silently choosing one. */
87
+ export function selectLoginKey(
88
+ localKey: string | undefined,
89
+ globalKey: string | undefined,
90
+ ): string | undefined {
91
+ if (localKey !== undefined && globalKey !== undefined) {
92
+ throw usageError("Pass only one of --key or --api-key to `auth login`.");
93
+ }
94
+ return localKey ?? globalKey;
95
+ }
96
+
97
+ /** Validate a credential and prove that an explicitly requested slug matches it. */
98
+ export async function validateAuthCredential(
99
+ apiKey: string,
100
+ requestedWorkspace?: string,
101
+ ): Promise<AuthIdentity> {
102
+ const client = authValidationClientFactory(apiKey);
103
+ let me;
104
+ let org;
105
+ try {
106
+ me = await withRetry(() => client.viewer);
107
+ org = await withRetry(() => client.organization);
108
+ } catch (err) {
109
+ const normalized = normalizeError(err);
110
+ if (normalized.code === "auth" || normalized.code === "forbidden") {
111
+ throw authError("That API key was rejected by Linear.");
112
+ }
113
+ throw normalized;
114
+ }
115
+ if (requestedWorkspace && requestedWorkspace !== org.urlKey) {
116
+ throw usageError(
117
+ `--workspace '${requestedWorkspace}' does not match the key's workspace '${org.urlKey}'. Store credentials under their Linear workspace slug.`,
118
+ );
119
+ }
120
+ return {
121
+ user: { id: me.id, name: me.name, email: me.email },
122
+ organization: { id: org.id, name: org.name, urlKey: org.urlKey },
123
+ };
124
+ }
125
+
126
+ function explicitWorkspaceSlug(input: string): string {
127
+ const slug = input.trim();
128
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
129
+ throw usageError(
130
+ "Workspace slug must be its lowercase Linear URL key (letters, numbers, and hyphens).",
131
+ );
132
+ }
133
+ return slug;
134
+ }
135
+
61
136
  export function registerMeta(program: Command): void {
62
137
  program.command("whoami").description("Show the authenticated user").action(whoamiAction);
63
138
 
@@ -75,10 +150,7 @@ export function registerMeta(program: Command): void {
75
150
  .command("login")
76
151
  .description("Validate and store a Linear API key for a workspace")
77
152
  .option("--key <key>", "API key (otherwise prompted; '-' reads it from stdin)")
78
- .option(
79
- "--plaintext",
80
- "Store the key in the config file (0600) instead of the system keyring",
81
- )
153
+ .option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
82
154
  .action(
83
155
  // The global `--workspace <slug>` selects the slug to store under
84
156
  // (default: derived from the key's organization urlKey).
@@ -87,14 +159,14 @@ export function registerMeta(program: Command): void {
87
159
  // it is a credential, and it must not reach the screen or scrollback.
88
160
  // Nothing below echoes it either: the receipt reports the user and
89
161
  // where the key went, never the value.
90
- let key: string | undefined = opts.key;
162
+ let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
91
163
  if (key === "-") {
92
164
  key = readStdinSync();
93
165
  } else if (key) {
94
166
  // argv is world-readable (`ps`) and lands in shell history; say so
95
167
  // once, quietly, and carry on — the script author chose it.
96
168
  ctx.output.warn(
97
- "--key on the command line is visible to other processes and shell history; prefer the prompt or `--key -` (stdin).",
169
+ "An API key on the command line is visible to other processes and shell history; prefer the prompt or `--key -` (stdin).",
98
170
  );
99
171
  } else {
100
172
  key = await promptSecret(ctx, "Linear API key:", { required: true });
@@ -102,25 +174,18 @@ export function registerMeta(program: Command): void {
102
174
  key = key.trim();
103
175
  if (!key) throw usageError("No API key provided.");
104
176
  // Validate before persisting and learn the workspace slug.
105
- const client = new LinearClient({ apiKey: key });
106
- let me;
107
- let org;
108
- try {
109
- me = await client.viewer;
110
- org = await client.organization;
111
- } catch {
112
- throw authError("That API key was rejected by Linear.");
113
- }
114
- const slug: string = ctx.options.workspace ?? org.urlKey;
177
+ const identity = await validateAuthCredential(key, ctx.options.workspace);
178
+ const { user: me, organization: org } = identity;
179
+ const slug: string = org.urlKey;
115
180
  const { path, storage, keyringLabel } = writeCredential(slug, key, {
116
181
  plaintext: opts.plaintext === true,
117
182
  });
118
183
  const where =
119
184
  storage === "keychain"
120
- ? `Key saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
185
+ ? `Credential saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
121
186
  : opts.plaintext
122
- ? `Key saved to ${path} (plaintext, 0600).`
123
- : `Key saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
187
+ ? `Credential saved to ${path} (plaintext, 0600).`
188
+ : `Credential saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
124
189
  ctx.output.emit(
125
190
  {
126
191
  success: true,
@@ -137,6 +202,45 @@ export function registerMeta(program: Command): void {
137
202
  }),
138
203
  );
139
204
 
205
+ // adopt ------------------------------------------------------------------
206
+ auth
207
+ .command("adopt <slug>")
208
+ .description("Adopt an existing named credential from the shared OS keyring")
209
+ .action(
210
+ action(async (ctx: Context, _opts, input: string) => {
211
+ const slug = explicitWorkspaceSlug(input);
212
+ if (ctx.options.apiKey !== undefined) {
213
+ throw usageError(
214
+ "`auth adopt` reads only the named OS keyring entry; remove --api-key (or use `auth login --key -` to supply a key on stdin).",
215
+ );
216
+ }
217
+ if (ctx.options.workspace && ctx.options.workspace !== slug) {
218
+ throw usageError(
219
+ `The positional workspace '${slug}' conflicts with --workspace '${ctx.options.workspace}'.`,
220
+ );
221
+ }
222
+
223
+ // Probe one exact account only. No keyring enumeration and no secret
224
+ // on argv, stdout, stderr, or disk.
225
+ const found = probeKeyringCredential(slug);
226
+ const identity = await validateAuthCredential(found.apiKey, slug);
227
+ const adopted = adoptKeyringCredential(slug, found.apiKey);
228
+ ctx.output.emit(
229
+ {
230
+ success: true,
231
+ workspace: slug,
232
+ user: identity.user,
233
+ storage: adopted.storage,
234
+ path: adopted.path,
235
+ },
236
+ () =>
237
+ ctx.output.success(
238
+ `Adopted workspace '${slug}' from the ${adopted.keyringLabel}; ${adopted.path} now records the workspace without storing the secret.`,
239
+ ),
240
+ );
241
+ }),
242
+ );
243
+
140
244
  // list --------------------------------------------------------------------
141
245
  auth
142
246
  .command("list")
@@ -263,6 +367,7 @@ export function registerMeta(program: Command): void {
263
367
  .join(", ")}). Pass --workspace <slug> to choose which to remove.`,
264
368
  );
265
369
  }
370
+ if (!(await confirmDestructive(ctx, `Remove credential for workspace '${slug}'?`))) return;
266
371
  const removed = removeCredential(slug);
267
372
  ctx.output.emit({ success: true, workspace: slug, removed }, () =>
268
373
  removed
@@ -279,7 +384,9 @@ export function registerMeta(program: Command): void {
279
384
  // show (default) ----------------------------------------------------------
280
385
  config
281
386
  .command("show", { isDefault: true })
282
- .description("Show the resolved configuration and where each value came from (secrets redacted)")
387
+ .description(
388
+ "Show the resolved configuration and where each value came from (secrets redacted)",
389
+ )
283
390
  .action(
284
391
  action(async (ctx) => {
285
392
  // Re-resolve to expose sources/paths beyond what Context keeps.
@@ -299,8 +406,10 @@ export function registerMeta(program: Command): void {
299
406
  if (o.source === "none") return "default";
300
407
  return o.path ? `${o.source}: ${o.path}` : o.source;
301
408
  };
302
- const show = (key: keyof typeof c.origins, value: string | undefined): string | undefined =>
303
- value === undefined ? undefined : `${value} (${from(key)})`;
409
+ const show = (
410
+ key: keyof typeof c.origins,
411
+ value: string | undefined,
412
+ ): string | undefined => (value === undefined ? undefined : `${value} (${from(key)})`);
304
413
  ctx.output.detail(
305
414
  {
306
415
  apiKey: redactKey(c.apiKey),
@@ -309,7 +418,6 @@ export function registerMeta(program: Command): void {
309
418
  team: c.team ?? null,
310
419
  workspace: c.workspace ?? null,
311
420
  sort: c.sort,
312
- vcs: c.vcs,
313
421
  origins: c.origins,
314
422
  userConfigPath: c.userConfigPath,
315
423
  projectConfigPath: c.projectConfigPath ?? null,
@@ -321,7 +429,6 @@ export function registerMeta(program: Command): void {
321
429
  ["Team", show("team", c.team)],
322
430
  ["Workspace", show("workspace", c.workspace)],
323
431
  ["Sort", show("sort", c.sort)],
324
- ["VCS", show("vcs", c.vcs)],
325
432
  ["User config", c.userConfigPath],
326
433
  ["Project config", c.projectConfigPath],
327
434
  ["Global config", c.globalConfigPath],
@@ -358,11 +465,15 @@ export function registerMeta(program: Command): void {
358
465
  .map((t) => ({ name: `${t.name} (${t.key})`, value: t.key })),
359
466
  );
360
467
  }
361
- const settings: Partial<Record<SettableKey, string>> = { team: validateSetting("team", team) };
468
+ const settings: Partial<Record<SettableKey, string>> = {
469
+ team: validateSetting("team", team),
470
+ };
362
471
  if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
363
472
  initProjectConfig(path, settings, { force: opts.force === true });
364
473
  ctx.output.emit({ success: true, path, ...settings }, () =>
365
- ctx.output.success(`Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`),
474
+ ctx.output.success(
475
+ `Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
476
+ ),
366
477
  );
367
478
  }),
368
479
  );
@@ -411,9 +522,6 @@ function validateSetting(key: SettableKey, value: string): string {
411
522
  throw usageError(`Invalid sort '${v}'. Valid values: ${ISSUE_SORTS.join(", ")}.`);
412
523
  }
413
524
  return v;
414
- case "vcs":
415
- if (!["git", "jj"].includes(v)) throw usageError(`Invalid vcs '${v}'. Valid values: git, jj.`);
416
- return v;
417
525
  case "team":
418
526
  // A team key: letters and digits, as Linear issues them (TES, ENG2).
419
527
  if (!/^[A-Za-z][A-Za-z0-9]*$/.test(v)) {
@@ -34,7 +34,10 @@ export function formatMilestoneProgress(progress: number | null): string {
34
34
  return `${Math.round(progress)}%`;
35
35
  }
36
36
 
37
- const PROJECT_SCOPE = ["-p, --project <name>", "the milestone's project, when <id> is a name (names are unique per project only)"] as const;
37
+ const PROJECT_SCOPE = [
38
+ "-p, --project <name>",
39
+ "the milestone's project, when <id> is a name (names are unique per project only)",
40
+ ] as const;
38
41
 
39
42
  export function registerMilestone(program: Command): void {
40
43
  const milestone = program
@@ -8,6 +8,7 @@ import { confirmDestructive } from "../lib/prompt.js";
8
8
  import type { Context } from "../context.js";
9
9
  import * as svc from "../services/notification.js";
10
10
  import type { Column } from "../output/table.js";
11
+ import { ExitCode } from "../lib/errors.js";
11
12
 
12
13
  const ROW_COLUMNS: Column<svc.NotificationRow>[] = [
13
14
  { key: "type", header: "Type", value: (r) => r.type, max: 24 },
@@ -67,10 +68,20 @@ export function registerNotification(program: Command): void {
67
68
  action(async (ctx: Context) => {
68
69
  const res = await svc.markAllRead(ctx.client);
69
70
  ctx.output.emit(res, () => {
70
- ctx.output.success(`Marked ${res.count} of ${res.attempted} notification(s) read`);
71
- // A partial batch says so out loud instead of hiding behind the count.
72
- for (const f of res.failed) ctx.output.warn(`${f.id}: ${f.error}`);
71
+ if (res.success) {
72
+ ctx.output.success(`Marked ${res.count} of ${res.attempted} notification(s) read`);
73
+ } else {
74
+ ctx.output.warn(
75
+ `Marked ${res.count} of ${res.attempted} notification(s) read; ${res.failed.length} failed`,
76
+ );
77
+ for (const f of res.failed) ctx.output.warn(`${f.id}: ${f.error}`);
78
+ }
73
79
  });
80
+ // A batch receipt belongs on stdout even when only part of the batch
81
+ // succeeded, but exit zero would let `read-all && ...` treat that as a
82
+ // complete success. Preserve the receipt and make the process outcome
83
+ // unambiguous.
84
+ if (!res.success) process.exitCode = ExitCode.Runtime;
74
85
  }),
75
86
  );
76
87
 
@@ -82,9 +93,7 @@ export function registerNotification(program: Command): void {
82
93
  action(async (ctx: Context, _opts, id: string) => {
83
94
  if (!(await confirmDestructive(ctx, `Archive notification ${id}?`))) return;
84
95
  const success = await svc.archiveNotification(ctx.client, id);
85
- ctx.output.emit({ id, archived: success }, () =>
86
- ctx.output.success(`Archived ${id}`),
87
- );
96
+ ctx.output.emit({ id, archived: success }, () => ctx.output.success(`Archived ${id}`));
88
97
  }),
89
98
  );
90
99
 
@@ -0,0 +1,84 @@
1
+ /** `linear open` — open a workspace, team, project, issue, or explicit URL. */
2
+
3
+ import { Command } from "commander";
4
+ import { action } from "../lib/action.js";
5
+ import { usageError } from "../lib/errors.js";
6
+ import { openUrl } from "../lib/open.js";
7
+ import { normalizeIssueReference, resolveProjectId, resolveTeam } from "../lib/resolve.js";
8
+ import type { Context } from "../context.js";
9
+ import { getIssueDetail } from "../services/issue.js";
10
+ import { getOrganizationDetail } from "../services/organization.js";
11
+ import { getProjectDetail } from "../services/project.js";
12
+
13
+ const ISSUE_REFERENCE = /^[a-zA-Z][a-zA-Z0-9]*-\d+$/;
14
+ const URL = /^https?:\/\//i;
15
+ type OpenTarget = "workspace" | "team" | "project" | "issue" | "url";
16
+
17
+ export function registerOpen(program: Command): void {
18
+ program
19
+ .command("open [target]")
20
+ .description("Open the workspace, an issue, team, project, or URL")
21
+ .option("--app", "open in Linear.app (macOS)")
22
+ .option("-w, --web", "open in the default browser (the default)")
23
+ .addHelpText(
24
+ "after",
25
+ [
26
+ "",
27
+ "Targets: an issue id (TES-123), `team:<key>`, `project:<name>`, or an https URL.",
28
+ "With no target, opens the current workspace. A bare team key opens that team.",
29
+ ].join("\n"),
30
+ )
31
+ .action(
32
+ action(async (ctx: Context, opts, target?: string) => {
33
+ if (opts.app && opts.web) throw usageError("Pass either --web or --app, not both.");
34
+ const resolved = await resolveOpenTarget(ctx, target);
35
+ await openUrl(resolved.url, { app: opts.app === true });
36
+ ctx.output.emit({ ...resolved, opened: true }, () =>
37
+ ctx.output.success(
38
+ `Opened ${resolved.label} in ${opts.app ? "Linear.app" : "the browser"}`,
39
+ ),
40
+ );
41
+ }),
42
+ );
43
+ }
44
+
45
+ async function resolveOpenTarget(
46
+ ctx: Context,
47
+ target: string | undefined,
48
+ ): Promise<{ target: OpenTarget; url: string; label: string }> {
49
+ if (!target) {
50
+ const org = await getOrganizationDetail(ctx.client);
51
+ return { target: "workspace", url: `https://linear.app/${org.urlKey}`, label: org.name };
52
+ }
53
+ if (URL.test(target)) return { target: "url", url: target, label: target };
54
+ if (target.startsWith("team:")) {
55
+ const team = await resolveTeam(ctx.client, target.slice("team:".length), ctx.defaultTeam);
56
+ const org = await getOrganizationDetail(ctx.client);
57
+ return {
58
+ target: "team",
59
+ url: `https://linear.app/${org.urlKey}/team/${team.key}/all`,
60
+ label: `${team.key} (${team.name})`,
61
+ };
62
+ }
63
+ if (target.startsWith("project:")) {
64
+ const project = await getProjectDetail(
65
+ ctx.client,
66
+ await resolveProjectId(ctx.client, target.slice("project:".length)),
67
+ );
68
+ return { target: "project", url: project.url, label: project.name };
69
+ }
70
+ const issueReference = /^\d+$/.test(target)
71
+ ? normalizeIssueReference(target, ctx.defaultTeam)
72
+ : target;
73
+ if (ISSUE_REFERENCE.test(issueReference)) {
74
+ const issue = await getIssueDetail(ctx.client, issueReference, { includeComments: false });
75
+ return { target: "issue", url: issue.url, label: issue.identifier };
76
+ }
77
+ const team = await resolveTeam(ctx.client, target, ctx.defaultTeam);
78
+ const org = await getOrganizationDetail(ctx.client);
79
+ return {
80
+ target: "team",
81
+ url: `https://linear.app/${org.urlKey}/team/${team.key}/all`,
82
+ label: `${team.key} (${team.name})`,
83
+ };
84
+ }
@@ -36,7 +36,10 @@ function formatProgress(p: number | null): string {
36
36
  * stderr, once; `--quiet` silences it, `--json` stdout never carries it.
37
37
  * Shared by `project list` and the issue queries.
38
38
  */
39
- export function noteWorkspaceWide(ctx: Context, opts: { team?: unknown; allTeams?: boolean }): void {
39
+ export function noteWorkspaceWide(
40
+ ctx: Context,
41
+ opts: { team?: unknown; allTeams?: boolean },
42
+ ): void {
40
43
  const teamGiven = Array.isArray(opts.team) ? opts.team.length > 0 : opts.team !== undefined;
41
44
  if (!teamGiven && !opts.allTeams && !ctx.defaultTeam) {
42
45
  ctx.output.info(
@@ -97,8 +100,14 @@ export function registerProject(program: Command): void {
97
100
  ["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],
98
101
  ["Priority", detail.priorityLabel],
99
102
  ["Lead", detail.lead?.displayName ?? null],
100
- ["Teams", detail.teams.length ? detail.teams.map((t) => `${t.key} ${t.name}`).join(", ") : null],
101
- ["Members", detail.members.length ? detail.members.map((m) => m.displayName).join(", ") : null],
103
+ [
104
+ "Teams",
105
+ detail.teams.length ? detail.teams.map((t) => `${t.key} ${t.name}`).join(", ") : null,
106
+ ],
107
+ [
108
+ "Members",
109
+ detail.members.length ? detail.members.map((m) => m.displayName).join(", ") : null,
110
+ ],
102
111
  ["Labels", detail.labels.length ? detail.labels.map((l) => l.name).join(", ") : null],
103
112
  ["Start", detail.startDate],
104
113
  ["Target", detail.targetDate],
@@ -317,7 +326,11 @@ export function registerProject(program: Command): void {
317
326
  { key: "name", header: "Name", value: (m) => m.name, max: 40 },
318
327
  { key: "target", header: "Target", value: (m) => m.targetDate ?? "—" },
319
328
  // A milestone's progress is already a percentage (TES-648), unlike a project's.
320
- { key: "progress", header: "Progress", value: (m) => formatMilestoneProgress(m.progress) },
329
+ {
330
+ key: "progress",
331
+ header: "Progress",
332
+ value: (m) => formatMilestoneProgress(m.progress),
333
+ },
321
334
  ],
322
335
  rows,
323
336
  );
@@ -59,6 +59,19 @@ export function registerTeam(program: Command): void {
59
59
  }),
60
60
  );
61
61
 
62
+ // id ----------------------------------------------------------------------
63
+ team
64
+ .command("id [key]")
65
+ .description("Print a team's UUID (defaults to the configured team)")
66
+ .action(
67
+ action(async (ctx: Context, _opts, keyArg?: string) => {
68
+ const resolved = await svc.getTeamIdentity(ctx.client, keyArg, ctx.defaultTeam);
69
+ // Human mode is deliberately a single UUID for command substitution;
70
+ // JSON retains the resolved key and name as useful context.
71
+ ctx.output.emit(resolved, () => process.stdout.write(resolved.id + "\n"));
72
+ }),
73
+ );
74
+
62
75
  // view --------------------------------------------------------------------
63
76
  team
64
77
  .command("view [key]")
@@ -25,8 +25,8 @@ const ROW_COLUMNS: Column<svc.UserRow>[] = [
25
25
  * list` and `team members`. Ours is the global `--all` — exhaust pagination —
26
26
  * so a transplanted `linear user list --all` succeeds, and the deactivated
27
27
  * users it was asking for are quietly missing (TES-637 item 1). The global
28
- * keeps its one meaning (making it mean "and deactivated" here alone would be a
29
- * second meaning for one flag, the thing ALIGNMENT.md refuses); instead the
28
+ * keeps its one meaning (making it mean "and deactivated" here alone would give
29
+ * one flag two meanings); instead the
30
30
  * combination says what it did and names the flag that does the other thing.
31
31
  * A warning, not `info`: it survives `--quiet`, because a script is exactly
32
32
  * where a wrong result set goes unnoticed. Shared with `team members`.
@@ -91,7 +91,12 @@ export function registerWebhook(program: Command): void {
91
91
  secret: opts.secret,
92
92
  });
93
93
  ctx.output.emit(
94
- { id: created.id, url: created.url, enabled: created.enabled, resourceTypes: created.resourceTypes },
94
+ {
95
+ id: created.id,
96
+ url: created.url,
97
+ enabled: created.enabled,
98
+ resourceTypes: created.resourceTypes,
99
+ },
95
100
  () => ctx.output.success(`Created webhook ${created.id}`),
96
101
  );
97
102
  }),
@@ -121,7 +126,12 @@ export function registerWebhook(program: Command): void {
121
126
  secret: opts.secret,
122
127
  });
123
128
  ctx.output.emit(
124
- { id: updated.id, url: updated.url, enabled: updated.enabled, resourceTypes: updated.resourceTypes },
129
+ {
130
+ id: updated.id,
131
+ url: updated.url,
132
+ enabled: updated.enabled,
133
+ resourceTypes: updated.resourceTypes,
134
+ },
125
135
  () => ctx.output.success(`Updated webhook ${updated.id}`),
126
136
  );
127
137
  }),