@enrichlayer/el-linear 1.21.0 → 1.22.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/README.md CHANGED
@@ -553,6 +553,38 @@ el-linear read ADM-652 --field "Out of scope"
553
553
  Single-issue only — pair it with `--jq` on full JSON for batch
554
554
  extraction across many issues.
555
555
 
556
+ ### Print the whole description as raw text: `--body`
557
+
558
+ `issues read --body` prints the issue's **entire** description as raw
559
+ markdown — real newlines, no JSON envelope — instead of one named
560
+ section. Single-issue only; exits 1 with a stderr hint when the issue has
561
+ no description. This is the canonical replacement for
562
+ `... --format json | python3 -c "...['description']"`.
563
+
564
+ ```bash
565
+ el-linear issues read DEV-123 --body
566
+ # ## Why we need this
567
+ # ...full description body...
568
+ ```
569
+
570
+ Mutually exclusive with `--field` / `--sections` / `--with`.
571
+
572
+ ### One-line write confirmations: `-q, --quiet`
573
+
574
+ `issues create|update` and `comments create|update` accept `-q, --quiet`,
575
+ printing a single confirmation line instead of the full JSON envelope so
576
+ you don't have to `grep` the result:
577
+
578
+ ```bash
579
+ el-linear issues update DEV-123 --status "In Review" --quiet
580
+ # DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
581
+
582
+ el-linear comments create DEV-123 --body "..." --quiet
583
+ # comment 6f1c…
584
+ ```
585
+
586
+ `--quiet` overrides `--format` and is independent of `--fields` / `--jq`.
587
+
556
588
  ### Render an issue's tree: `issues tree`
557
589
 
558
590
  `issues tree <ID>` walks the parent → children graph for an issue in a
@@ -50,10 +50,20 @@ el-linear projects list --limit 50 2>&1 | head -100
50
50
  # ❌ Don't reach for jq just to print title + state
51
51
  el-linear issues read DEV-123 --jq '.title + " " + .state.name' 2>&1
52
52
 
53
+ # ❌ Don't pipe a read through python just to get the whole description body
54
+ el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'])"
55
+
56
+ # ❌ Don't grep a write's JSON for the new state / url
57
+ el-linear issues update DEV-123 --status Done 2>&1 | grep -iE 'state|url'
58
+
53
59
  # ✅ Just use --format summary
54
60
  el-linear issues search "..." --limit 10 --format summary 2>&1
55
61
  el-linear projects list --limit 50 --format summary 2>&1
56
62
  el-linear issues read DEV-123 --format summary 2>&1
63
+
64
+ # ✅ Whole description as raw text → --body. Terse write confirmation → --quiet
65
+ el-linear issues read DEV-123 --body 2>&1
66
+ el-linear issues update DEV-123 --status Done --quiet 2>&1
57
67
  ```
58
68
 
59
69
  The summary formatter exists exactly because every consumer (humans and LLMs) was reinventing the same `python -c` / `jq` extraction in shell. Pick the canonical path; the per-resource format is a stable contract.
@@ -72,6 +82,34 @@ el-linear issues read DEV-123 2>&1 | python3 -c "import json,sys; print(json.loa
72
82
 
73
83
  `--field` is single-issue only — for batch extraction, fall back to `--jq` on the full JSON.
74
84
 
85
+ ### The whole description as raw text: `--body`
86
+
87
+ When you want the **entire** description (not one section) as plain markdown — to read it, diff it, or pipe it to a file — use `issues read --body`. It prints the raw description with real newlines and no JSON envelope, single-issue only, and exits non-zero when the issue has no description. This is the canonical replacement for `... --format json | python3 -c "...['description']"` and the `sed 's/\\n/\n/g'` newline-unescaping hack.
88
+
89
+ ```bash
90
+ # ✅ Full description, raw markdown, scriptable
91
+ el-linear issues read DEV-123 --body 2>&1
92
+
93
+ # ❌ Don't do this
94
+ el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'])"
95
+ ```
96
+
97
+ `--body` is mutually exclusive with `--field` / `--sections` / `--with` (those extract named parts or extend the JSON envelope; `--body` is the whole thing as text).
98
+
99
+ ### Terse write confirmations: `-q, --quiet`
100
+
101
+ `issues create|update` and `comments create|update` accept `-q, --quiet`, which prints a single machine-stable confirmation line instead of the full JSON envelope — no need to `grep` the result for the identifier / state / url:
102
+
103
+ ```bash
104
+ el-linear issues update DEV-123 --status "In Review" --quiet 2>&1
105
+ # DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
106
+
107
+ el-linear comments create DEV-123 --body "..." --quiet 2>&1
108
+ # comment <id>
109
+ ```
110
+
111
+ `--quiet` overrides `--format` (it's the whole point) and is independent of `--fields`/`--jq`.
112
+
75
113
  ### When you must reach outside el-linear: prefer `jq` over `python3 -c`
76
114
 
77
115
  For tools that aren't `el-linear` (e.g. `gh`, `glab`, `kubectl`), prefer `jq` for JSON extraction in one-shot shell commands. `python3 -c "import json,sys; ..."` produces longer, harder-to-read pipelines, and tends to attract incremental complexity (try/except, fallbacks) that `jq` handles inline. Reach for python only when the transformation genuinely needs control flow that's painful in `jq` (e.g. multi-step assembly with intermediate state).
@@ -201,11 +239,11 @@ el-linear issues link-references ENG-123 --dry-run # preview
201
239
 
202
240
  When starting work on an existing issue (`el-linear issues read ENG-123`):
203
241
 
204
- - [ ] **Assignee set** — if missing, ask user and update: `el-linear issues update ENG-123 --assignee <name>`.
242
+ - [ ] **Branch claim path used** — `el-linear issues create --checkout` and `el-linear issues mark-branch ENG-123` automatically claim the issue by assigning it to the current Linear user and moving it to the team's first started state. Use `--no-claim` only when intentionally creating/marking a branch on someone else's behalf.
205
243
  - [ ] **Project set** — if missing, ask user and update: `el-linear issues update ENG-123 --project "<name>"`.
206
- - [ ] **Status appropriate** — move to "In Progress" or your team's equivalent.
244
+ - [ ] **Status appropriate** — if you did not use one of the branch claim paths, move the issue to "In Progress" or your team's equivalent.
207
245
 
208
- Don't start implementation work on an unassigned issue. The assignee is the person accountable.
246
+ Don't start implementation work on an unassigned issue — the assignee is the person accountable. If you did not use one of the branch claim paths (or used `--no-claim`), assign the issue before proceeding.
209
247
 
210
248
  ---
211
249
 
@@ -291,6 +291,7 @@ export function setupCommentsCommands(program) {
291
291
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
292
292
  .option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
293
293
  .option("--no-footer", "skip the configured messageFooter for this comment")
294
+ .option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
294
295
  .action(handleAsyncCommand(handleCreateComment));
295
296
  comments
296
297
  .command("update <commentId>")
@@ -299,6 +300,7 @@ export function setupCommentsCommands(program) {
299
300
  .option("--body-file <path>", "read new comment body from file")
300
301
  .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
301
302
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
303
+ .option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
302
304
  .action(handleAsyncCommand(handleUpdateComment));
303
305
  comments
304
306
  .command("list <issueId>")
@@ -37,6 +37,24 @@ function isImageFile(filename) {
37
37
  const ext = filename.lastIndexOf(".");
38
38
  return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
39
39
  }
40
+ function errorMessage(error) {
41
+ return error instanceof Error ? error.message : String(error);
42
+ }
43
+ function warnClaimFailure(issueId, error) {
44
+ outputWarning(`Branch operation succeeded for ${issueId}, but auto-claim failed: ${errorMessage(error)}`);
45
+ }
46
+ async function maybeClaimBranchIssue(issueId, options, issuesService) {
47
+ if (options.claim === false) {
48
+ return undefined;
49
+ }
50
+ try {
51
+ return await issuesService.claimIssue(issueId);
52
+ }
53
+ catch (error) {
54
+ warnClaimFailure(issueId, error);
55
+ return undefined;
56
+ }
57
+ }
40
58
  function validateUpdateOptions(options) {
41
59
  if (options.delegate && options.clearDelegate) {
42
60
  throw new Error("Cannot use --delegate and --clear-delegate together");
@@ -497,6 +515,7 @@ async function handleCreateIssue(title, options, command) {
497
515
  }
498
516
  }
499
517
  let branch;
518
+ let claim;
500
519
  if (options.checkout && result.branchName) {
501
520
  const branchName = toBranchName(result.branchName);
502
521
  // Only treat the branch as created (and surface it in the output) when
@@ -516,11 +535,13 @@ async function handleCreateIssue(title, options, command) {
516
535
  outputWarning(`Created branch ${branch} but could not record its Linear issue marker. ` +
517
536
  `Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
518
537
  }
538
+ claim = await maybeClaimBranchIssue(result.identifier, options, issuesService);
519
539
  }
520
540
  }
521
541
  const output = {
522
542
  ...result,
523
543
  ...(branch ? { branch } : {}),
544
+ ...(claim ? { claim } : {}),
524
545
  ...(relations.length > 0 ? { relations } : {}),
525
546
  ...(autoLinked ? { autoLinked } : {}),
526
547
  ...(attachments.length === 1
@@ -852,7 +873,7 @@ async function handleRetrolink(options, command) {
852
873
  ...(newBranch ? { branch: newBranch } : {}),
853
874
  });
854
875
  }
855
- async function handleMarkBranch(issueId, _options, _command) {
876
+ async function handleMarkBranch(issueId, options, command) {
856
877
  const branch = currentGitBranch();
857
878
  if (!branch) {
858
879
  throw new Error("Not on a named git branch (detached HEAD or not in a repository).");
@@ -882,10 +903,22 @@ async function handleMarkBranch(issueId, _options, _command) {
882
903
  }
883
904
  const previous = getBranchLinearIssue(branch);
884
905
  setBranchLinearIssue(branch, identifier);
906
+ let claim;
907
+ if (options.claim !== false) {
908
+ try {
909
+ const rootOpts = getRootOpts(command);
910
+ const { issuesService } = await createIssuesService(rootOpts);
911
+ claim = await issuesService.claimIssue(identifier);
912
+ }
913
+ catch (error) {
914
+ warnClaimFailure(identifier, error);
915
+ }
916
+ }
885
917
  outputSuccess({
886
918
  branch,
887
919
  linearIssue: identifier,
888
920
  ...(previous && previous !== identifier ? { previous } : {}),
921
+ ...(claim ? { claim } : {}),
889
922
  marked: true,
890
923
  });
891
924
  }
@@ -965,10 +998,12 @@ export function setupIssuesCommands(program) {
965
998
  .option("--attachment <path>", "attach a file (image, PDF, etc.) to the created issue (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
966
999
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
967
1000
  .option("--checkout", "create and checkout a git branch named after the issue")
1001
+ .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
968
1002
  .option("--skip-validation", "skip all validation (labels, description, assignee, project)")
969
1003
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
970
1004
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
971
1005
  .option("--no-footer", "skip the configured messageFooter for this issue")
1006
+ .option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
972
1007
  .action(handleAsyncCommand((titleArg, options, command) => {
973
1008
  // Normalize --label alias to --labels
974
1009
  if (options.label && !options.labels) {
@@ -994,6 +1029,10 @@ export function setupIssuesCommands(program) {
994
1029
  .alias("get")
995
1030
  .alias("show")
996
1031
  .description("Get issue details. Accepts multiple IDs for batch retrieval.")
1032
+ .option("--body", "Print the issue's full description as raw markdown text — no JSON " +
1033
+ "envelope, single-issue only. Exits non-zero if the issue has no " +
1034
+ "description. The whole-body sibling of --field; mutually exclusive " +
1035
+ "with --field / --sections / --with.")
997
1036
  .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
998
1037
  "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
999
1038
  "Outputs the section text only — no JSON envelope. Single-issue only.")
@@ -1003,7 +1042,7 @@ export function setupIssuesCommands(program) {
1003
1042
  .option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
1004
1043
  'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
1005
1044
  "(adds an array of cross-issue relations under a top-level `relations` key).")
1006
- .addHelpText("after", '\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789\nExtract a section: el-linear issue read DEV-123 --field "Done when"\nMulti-section: el-linear issue read DEV-123 --sections "Done when,Out of scope"\nWith relations: el-linear issue read DEV-123 --with relations')
1045
+ .addHelpText("after", '\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789\nFull description: el-linear issue read DEV-123 --body\nExtract a section: el-linear issue read DEV-123 --field "Done when"\nMulti-section: el-linear issue read DEV-123 --sections "Done when,Out of scope"\nWith relations: el-linear issue read DEV-123 --with relations')
1007
1046
  .action(handleAsyncCommand(readIssues));
1008
1047
  issues
1009
1048
  .command("update <issueId>")
@@ -1041,6 +1080,7 @@ export function setupIssuesCommands(program) {
1041
1080
  .option("--blocks <issues>", "issues this blocks (comma-separated)")
1042
1081
  .option("--blocked-by <issues>", "issues blocking this (comma-separated)")
1043
1082
  .option("--duplicate-of <issue>", "mark as duplicate of another issue")
1083
+ .option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
1044
1084
  .action(handleAsyncCommand(handleUpdateIssue));
1045
1085
  issues
1046
1086
  .command("archive <issueId>")
@@ -1106,6 +1146,8 @@ export function setupIssuesCommands(program) {
1106
1146
  .description("Record the current branch's Linear issue as git metadata (branch.<branch>.linearIssue).")
1107
1147
  .addHelpText("after", "\nManual recovery for branches not created via 'issues create --checkout' / 'retrolink'.\n" +
1108
1148
  "With no argument, infers the identifier from the branch name (e.g. dev-4293-slug -> DEV-4293).\n" +
1109
- "Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.")
1149
+ "Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.\n" +
1150
+ "By default, also claims the issue (assignee = current Linear user; status = first started state). Pass --no-claim to only write git metadata.")
1151
+ .option("--no-claim", "only record git metadata; skip assigning the issue to the current Linear user and moving it to the first started state")
1110
1152
  .action(handleAsyncCommand(handleMarkBranch));
1111
1153
  }
@@ -23,6 +23,10 @@ export function setupReadShortcut(program) {
23
23
  .alias("view")
24
24
  .alias("show")
25
25
  .description("Shortcut for `issues read`. Get issue details by identifier.")
26
+ .option("--body", "Print the issue's full description as raw markdown text — no JSON " +
27
+ "envelope, single-issue only. Exits non-zero if the issue has no " +
28
+ "description. The whole-body sibling of --field; mutually exclusive " +
29
+ "with --field / --sections / --with.")
26
30
  .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
27
31
  "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
28
32
  "Outputs the section text only — no JSON envelope.")
@@ -32,7 +36,7 @@ export function setupReadShortcut(program) {
32
36
  .option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
33
37
  'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
34
38
  "(adds an array of cross-issue relations under a top-level `relations` key).")
35
- .addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"\n el-linear read DEV-123 --with relations (issue + cross-issue links)')
39
+ .addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --body (full description, raw text)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"\n el-linear read DEV-123 --with relations (issue + cross-issue links)')
36
40
  .action(handleAsyncCommand(readIssues));
37
41
  // Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
38
42
  const originalParse = program.parse.bind(program);
@@ -70,6 +74,7 @@ export async function readIssues(issueIds, options, command) {
70
74
  const { graphQLService, issuesService } = await createIssuesService(rootOpts);
71
75
  const fileService = await createFileService(rootOpts);
72
76
  const fieldName = typeof options.field === "string" ? options.field : null;
77
+ const bodyOnly = options.body === true;
73
78
  const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
74
79
  // DEV-4476: --with opt-in includes (currently `relations`). Throws on
75
80
  // unknown values via parseWithIncludes — fail fast in the CLI per the
@@ -80,12 +85,19 @@ export async function readIssues(issueIds, options, command) {
80
85
  if (fieldName && sectionsRaw) {
81
86
  throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
82
87
  }
83
- // Both --field and --sections are single-issue only — section extraction
84
- // can't sensibly fan out to N different bodies, the caller almost always
85
- // wants the named sections of one issue.
86
- if ((fieldName || sectionsRaw) && issueIds.length > 1) {
87
- throw new Error("--field / --sections are single-issue only; pass exactly one issueId. " +
88
- "For multiple issues, drop the section flags and use --jq or --format summary.");
88
+ // --body prints the WHOLE description as raw text (no envelope) — it's the
89
+ // "all sections" sibling of --field. Pairing it with section extraction or
90
+ // --with would produce two conflicting output shapes, so reject up front.
91
+ if (bodyOnly && (fieldName || sectionsRaw || includes.relations)) {
92
+ throw new Error("--body is mutually exclusive with --field / --sections / --with. " +
93
+ "Use --body for the entire description, --field/--sections for named parts.");
94
+ }
95
+ // --field / --sections / --body are single-issue only — raw text extraction
96
+ // can't sensibly fan out to N different bodies; the caller almost always
97
+ // wants the named sections (or full body) of one issue.
98
+ if ((fieldName || sectionsRaw || bodyOnly) && issueIds.length > 1) {
99
+ throw new Error("--field / --sections / --body are single-issue only; pass exactly one issueId. " +
100
+ "For multiple issues, drop those flags and use --jq or --format summary.");
89
101
  }
90
102
  // Parse the comma-separated --sections list once. Preserve the caller's
91
103
  // order, trim each entry, and drop empties so trailing commas don't
@@ -108,6 +120,17 @@ export async function readIssues(issueIds, options, command) {
108
120
  if (issueIds.length === 1) {
109
121
  const issue = await issuesService.getIssueById(issueIds[0]);
110
122
  const resolved = await downloadLinearUploads(issue, fileService);
123
+ if (bodyOnly) {
124
+ const description = resolved.description ?? "";
125
+ if (description.trim() === "") {
126
+ // Mirror --field's "not found" contract: nothing on stdout,
127
+ // exit non-zero with a stderr hint so scripts can branch.
128
+ process.stderr.write(`el-linear: ${resolved.identifier} has no description\n`);
129
+ process.exit(1);
130
+ }
131
+ process.stdout.write(`${description}\n`);
132
+ return;
133
+ }
111
134
  if (fieldName) {
112
135
  const section = extractField(resolved.description ?? "", fieldName);
113
136
  if (section === null) {
package/dist/main.js CHANGED
@@ -31,7 +31,7 @@ import { setActiveProfileForSession } from "./config/paths.js";
31
31
  import { initCliSentry } from "./sentry.js";
32
32
  import { logger } from "./utils/logger.js";
33
33
  import { applyIpv4Preference } from "./utils/network-preference.js";
34
- import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
34
+ import { setFieldsFilter, setJqFilter, setOutputFormat, setQuietMode, setRawMode, } from "./utils/output.js";
35
35
  import { outputUsageInfo } from "./utils/usage.js";
36
36
  import { splitList } from "./utils/validators.js";
37
37
  // Prefer IPv4 for outbound API calls before any network I/O (Sentry init or a
@@ -79,6 +79,12 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
79
79
  if (rootOpts.fields) {
80
80
  setFieldsFilter(splitList(rootOpts.fields));
81
81
  }
82
+ // --quiet is registered only on the write commands (issues/comments
83
+ // create|update); it surfaces here via optsWithGlobals. One confirmation
84
+ // line instead of the full envelope — see setQuietMode.
85
+ if (rootOpts.quiet) {
86
+ setQuietMode(true);
87
+ }
82
88
  // Subcommand --format wins over the global flag (commander resolves
83
89
  // options closest to the action), so `optsWithGlobals` returns the
84
90
  // subcommand value when one is set. We accept any of the per-command
@@ -214,6 +214,29 @@ export interface IssueStartContextResponse {
214
214
  delegate: AssigneeNode | null;
215
215
  } | null;
216
216
  }
217
+ export interface IssueClaimContextResponse {
218
+ viewer: {
219
+ id: string;
220
+ name: string;
221
+ displayName: string;
222
+ email: string;
223
+ } | null;
224
+ issue: {
225
+ id: string;
226
+ identifier: string;
227
+ state: {
228
+ id: string;
229
+ name: string;
230
+ type: WorkflowStateType;
231
+ } | null;
232
+ assignee: AssigneeNode | null;
233
+ team: {
234
+ id: string;
235
+ key: string;
236
+ name: string;
237
+ } | null;
238
+ } | null;
239
+ }
217
240
  export interface TeamStartedStatusesResponse {
218
241
  team: {
219
242
  states: {
@@ -80,6 +80,12 @@ export declare function buildResolveLabelsByNameQuery(labelNames: string[]): {
80
80
  export declare const GET_ISSUE_STATE_HISTORY_QUERY = "\n query GetIssueStateHistory($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n stateHistory {\n nodes {\n state { id name type }\n startedAt\n endedAt\n }\n }\n }\n }\n";
81
81
  export declare const GET_ISSUE_TEAM_QUERY = "\n query GetIssueTeam($issueId: String!) {\n issue(id: $issueId) {\n team { id }\n }\n }\n";
82
82
  export declare const GET_ISSUE_START_CONTEXT_QUERY = "\n query GetIssueStartContext($id: String!) {\n issue(id: $id) {\n id\n identifier\n state { id name type }\n team { id key name }\n delegate { id name url }\n }\n }\n";
83
+ /**
84
+ * Batch issue lifecycle/assignee context with the authenticated viewer for the
85
+ * branch auto-claim flow. Raw GraphQL is used because this needs `viewer` plus
86
+ * issue fields in one round-trip; the SDK would make separate calls.
87
+ */
88
+ export declare const GET_ISSUE_CLAIM_CONTEXT_QUERY = "\n query GetIssueClaimContext($id: String!) {\n viewer {\n id\n name\n displayName\n email\n }\n issue(id: $id) {\n id\n identifier\n state { id name type }\n assignee { id name url }\n team { id key name }\n }\n }\n";
83
89
  export declare const TEAM_STARTED_STATUSES_QUERY = "\n query TeamStartedStatuses($teamId: String!) {\n team(id: $teamId) {\n states(filter: { type: { eq: \"started\" } }) {\n nodes {\n id\n name\n position\n }\n }\n }\n }\n";
84
90
  export declare const ISSUE_RELATION_CREATE_MUTATION = "\n mutation IssueRelationCreate($input: IssueRelationCreateInput!) {\n issueRelationCreate(input: $input) {\n success\n issueRelation {\n id\n type\n issue {\n id\n identifier\n title\n }\n relatedIssue {\n id\n identifier\n title\n }\n }\n }\n }\n";
85
91
  export declare const GET_ISSUE_RELATIONS_QUERY = "\n query GetIssueRelations($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n relations {\n nodes {\n id\n type\n relatedIssue {\n id\n identifier\n title\n state { id name }\n priority\n assignee { id name }\n team { id key name }\n }\n }\n }\n inverseRelations {\n nodes {\n id\n type\n issue {\n id\n identifier\n title\n state { id name }\n priority\n assignee { id name }\n team { id key name }\n }\n }\n }\n }\n }\n";
@@ -472,6 +472,28 @@ export const GET_ISSUE_START_CONTEXT_QUERY = `
472
472
  }
473
473
  }
474
474
  `;
475
+ /**
476
+ * Batch issue lifecycle/assignee context with the authenticated viewer for the
477
+ * branch auto-claim flow. Raw GraphQL is used because this needs `viewer` plus
478
+ * issue fields in one round-trip; the SDK would make separate calls.
479
+ */
480
+ export const GET_ISSUE_CLAIM_CONTEXT_QUERY = `
481
+ query GetIssueClaimContext($id: String!) {
482
+ viewer {
483
+ id
484
+ name
485
+ displayName
486
+ email
487
+ }
488
+ issue(id: $id) {
489
+ id
490
+ identifier
491
+ state { id name type }
492
+ assignee { id name url }
493
+ team { id key name }
494
+ }
495
+ }
496
+ `;
475
497
  export const TEAM_STARTED_STATUSES_QUERY = `
476
498
  query TeamStartedStatuses($teamId: String!) {
477
499
  team(id: $teamId) {
@@ -60,3 +60,21 @@ export declare function inferKindFromPayload(value: unknown): ResourceKind;
60
60
  * array or a `{ data: [...] }` envelope — both are handled.
61
61
  */
62
62
  export declare function dispatch(kind: ResourceKind, payload: unknown): string;
63
+ /**
64
+ * One-line confirmation render for the `--quiet` write path.
65
+ *
66
+ * Writes (`issues create|update`, `comments create|update`) otherwise emit
67
+ * the full JSON envelope; agents then `grep` it for the identifier / state /
68
+ * url. `--quiet` routes the same payload here so the caller gets exactly one
69
+ * machine-stable line and nothing else.
70
+ *
71
+ * - issue → `IDENTIFIER STATE URL` (two-space separated, matching the
72
+ * summary header style). Empty fields collapse to `-`.
73
+ * - comment → `comment <id>` (the create/update mutation doesn't fetch a url
74
+ * or the parent identifier, so the id — what you need to edit or
75
+ * reference the comment — is the stable handle).
76
+ * - anything else → compact single-line JSON, so the contract ("one line,
77
+ * always parseable") holds even for payloads with no dedicated
78
+ * shape.
79
+ */
80
+ export declare function formatLine(payload: unknown): string;
@@ -758,3 +758,33 @@ export function dispatch(kind, payload) {
758
758
  return formatGenericSummary(payload);
759
759
  }
760
760
  }
761
+ /**
762
+ * One-line confirmation render for the `--quiet` write path.
763
+ *
764
+ * Writes (`issues create|update`, `comments create|update`) otherwise emit
765
+ * the full JSON envelope; agents then `grep` it for the identifier / state /
766
+ * url. `--quiet` routes the same payload here so the caller gets exactly one
767
+ * machine-stable line and nothing else.
768
+ *
769
+ * - issue → `IDENTIFIER STATE URL` (two-space separated, matching the
770
+ * summary header style). Empty fields collapse to `-`.
771
+ * - comment → `comment <id>` (the create/update mutation doesn't fetch a url
772
+ * or the parent identifier, so the id — what you need to edit or
773
+ * reference the comment — is the stable handle).
774
+ * - anything else → compact single-line JSON, so the contract ("one line,
775
+ * always parseable") holds even for payloads with no dedicated
776
+ * shape.
777
+ */
778
+ export function formatLine(payload) {
779
+ const kind = inferKindFromPayload(payload);
780
+ const obj = asObj(payload);
781
+ if (kind === "issue" && obj) {
782
+ // s()/getName() already render missing values as the em-dash placeholder
783
+ // used throughout the summary formatter, keeping --quiet consistent.
784
+ return `${s(obj.identifier)} ${getName(obj.state)} ${s(obj.url)}`;
785
+ }
786
+ if (kind === "comment" && obj) {
787
+ return `comment ${s(obj.id)}`;
788
+ }
789
+ return JSON.stringify(payload);
790
+ }
@@ -151,6 +151,33 @@ export interface StartIssueResult {
151
151
  name: string;
152
152
  };
153
153
  }
154
+ export interface ClaimIssueResult {
155
+ issue: LinearIssue;
156
+ claimed: boolean;
157
+ /** True when the issue was already started and assigned to the viewer, so the claim was a no-op. */
158
+ alreadyClaimed: boolean;
159
+ assigned: boolean;
160
+ started: boolean;
161
+ assignee: {
162
+ id: string;
163
+ name: string;
164
+ displayName: string;
165
+ email: string;
166
+ };
167
+ previousAssignee?: {
168
+ id: string;
169
+ name: string;
170
+ };
171
+ previousState?: {
172
+ id: string;
173
+ name: string;
174
+ type: string;
175
+ };
176
+ targetState?: {
177
+ id: string;
178
+ name: string;
179
+ };
180
+ }
154
181
  export declare class GraphQLIssuesService {
155
182
  private readonly graphQLService;
156
183
  private readonly linearService;
@@ -175,7 +202,9 @@ export declare class GraphQLIssuesService {
175
202
  * for the read handler — the JSON envelope is a positional array.
176
203
  */
177
204
  getIssuesByRefs(refs: string[]): Promise<LinearIssue[]>;
205
+ private getFirstStartedStatus;
178
206
  startIssue(issueId: string): Promise<StartIssueResult>;
207
+ claimIssue(issueId: string): Promise<ClaimIssueResult>;
179
208
  updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
180
209
  archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
181
210
  deleteIssue(issueId: string, options?: {
@@ -1,5 +1,5 @@
1
1
  import { resolveUserDisplayName } from "../config/resolver.js";
2
- import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
2
+ import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
3
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
4
4
  import { toISOStringOrNow } from "./date-format.js";
5
5
  import { extractEmbeds } from "./embed-parser.js";
@@ -150,6 +150,16 @@ export class GraphQLIssuesService {
150
150
  }
151
151
  return ordered.map((issue) => this.transformIssueData(issue));
152
152
  }
153
+ async getFirstStartedStatus(team) {
154
+ const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: team.id });
155
+ const startedStatus = statuses.team?.states.nodes
156
+ .slice()
157
+ .sort((a, b) => a.position - b.position)[0];
158
+ if (!startedStatus) {
159
+ throw new Error(`Team ${team.key} has no workflow status of type "started".`);
160
+ }
161
+ return { id: startedStatus.id, name: startedStatus.name };
162
+ }
153
163
  async startIssue(issueId) {
154
164
  const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
155
165
  const context = await this.graphQLService.rawRequest(GET_ISSUE_START_CONTEXT_QUERY, { id: resolvedIssueId });
@@ -175,13 +185,7 @@ export class GraphQLIssuesService {
175
185
  if (!issue.team?.id) {
176
186
  throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
177
187
  }
178
- const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: issue.team.id });
179
- const startedStatus = statuses.team?.states.nodes
180
- .slice()
181
- .sort((a, b) => a.position - b.position)[0];
182
- if (!startedStatus) {
183
- throw new Error(`Team ${issue.team.key} has no workflow status of type "started".`);
184
- }
188
+ const startedStatus = await this.getFirstStartedStatus(issue.team);
185
189
  const updated = await this.updateIssue({ id: resolvedIssueId, statusId: startedStatus.id }, "adding");
186
190
  return {
187
191
  issue: updated,
@@ -190,6 +194,68 @@ export class GraphQLIssuesService {
190
194
  targetState: { id: startedStatus.id, name: startedStatus.name },
191
195
  };
192
196
  }
197
+ async claimIssue(issueId) {
198
+ const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
199
+ const context = await this.graphQLService.rawRequest(GET_ISSUE_CLAIM_CONTEXT_QUERY, { id: resolvedIssueId });
200
+ const issue = context.issue;
201
+ if (!issue) {
202
+ throw notFoundError("Issue", issueId);
203
+ }
204
+ const viewer = context.viewer;
205
+ if (!viewer?.id) {
206
+ throw new Error("Could not resolve the authenticated Linear viewer.");
207
+ }
208
+ const previousState = issue.state
209
+ ? {
210
+ id: issue.state.id,
211
+ name: issue.state.name,
212
+ type: issue.state.type,
213
+ }
214
+ : undefined;
215
+ const previousAssignee = issue.assignee
216
+ ? { id: issue.assignee.id, name: issue.assignee.name }
217
+ : undefined;
218
+ const terminalState = previousState && ["completed", "canceled"].includes(previousState.type);
219
+ const needsStartedState = !terminalState && previousState?.type !== "started";
220
+ const needsAssignee = issue.assignee?.id !== viewer.id;
221
+ if (!needsStartedState && !needsAssignee) {
222
+ // This path is only reachable when the viewer is already the assignee
223
+ // (`needsAssignee` is false), so `assignee: viewer` is the current assignee.
224
+ return {
225
+ issue: await this.getIssueById(resolvedIssueId),
226
+ previousState,
227
+ previousAssignee,
228
+ claimed: false,
229
+ alreadyClaimed: true,
230
+ assigned: false,
231
+ started: false,
232
+ assignee: viewer,
233
+ };
234
+ }
235
+ let targetState;
236
+ if (needsStartedState) {
237
+ if (!issue.team?.id) {
238
+ throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
239
+ }
240
+ targetState = await this.getFirstStartedStatus(issue.team);
241
+ }
242
+ const updated = await this.updateIssue({
243
+ id: resolvedIssueId,
244
+ ...(targetState ? { statusId: targetState.id } : {}),
245
+ ...(needsAssignee ? { assigneeId: viewer.id } : {}),
246
+ }, "adding");
247
+ return {
248
+ issue: updated,
249
+ previousState,
250
+ previousAssignee,
251
+ ...(targetState ? { targetState } : {}),
252
+ claimed: true,
253
+ alreadyClaimed: false,
254
+ assigned: needsAssignee,
255
+ started: Boolean(targetState),
256
+ assignee: viewer,
257
+ };
258
+ }
193
259
  async updateIssue(args, labelMode = "overwriting") {
194
260
  // Normalize URL/slug-id --project inputs to UUIDs before the batch
195
261
  // resolver runs; see comment on `withNormalizedProjectId`.
@@ -1,5 +1,11 @@
1
1
  type OutputFormat = "json" | "summary";
2
2
  export declare function setRawMode(enabled: boolean): void;
3
+ /**
4
+ * `--quiet` (write commands only): collapse the success payload to a single
5
+ * confirmation line via `formatLine`, bypassing both the JSON envelope and
6
+ * the summary block. Set in main.ts's preAction when the flag is present.
7
+ */
8
+ export declare function setQuietMode(enabled: boolean): void;
3
9
  export declare function setJqFilter(filter: string | null): void;
4
10
  export declare function setFieldsFilter(fields: string[] | null): void;
5
11
  export declare function setOutputFormat(format: OutputFormat): void;
@@ -1,15 +1,24 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
2
+ import { dispatch as dispatchSummary, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
3
3
  import { logger } from "./logger.js";
4
4
  import { sanitizeForLog } from "./sanitize-for-log.js";
5
5
  const warningBuffer = [];
6
6
  let rawMode = false;
7
+ let quietMode = false;
7
8
  let jqFilter = null;
8
9
  let fieldsFilter = null;
9
10
  let outputFormat = "json";
10
11
  export function setRawMode(enabled) {
11
12
  rawMode = enabled;
12
13
  }
14
+ /**
15
+ * `--quiet` (write commands only): collapse the success payload to a single
16
+ * confirmation line via `formatLine`, bypassing both the JSON envelope and
17
+ * the summary block. Set in main.ts's preAction when the flag is present.
18
+ */
19
+ export function setQuietMode(enabled) {
20
+ quietMode = enabled;
21
+ }
13
22
  export function setJqFilter(filter) {
14
23
  jqFilter = filter;
15
24
  }
@@ -102,6 +111,15 @@ export function outputSuccess(data) {
102
111
  // `title` on an issue) breaks shape inference and the summary
103
112
  // formatter falls back to the generic key-value dump.
104
113
  const inferredKind = outputFormat === "summary" ? inferKindFromPayload(output) : "generic";
114
+ // --quiet: one machine-stable confirmation line, nothing else. Highest
115
+ // precedence on the write path and independent of --raw / --fields / --jq
116
+ // (those reshape the payload the flag exists to avoid) — so we emit from
117
+ // the full pre-filter object, otherwise `--fields identifier` would strip
118
+ // the state/url formatLine needs and break shape inference.
119
+ if (quietMode) {
120
+ logger.info(formatLine(output));
121
+ return;
122
+ }
105
123
  // --raw: unwrap { data: [...] } to just the array
106
124
  if (rawMode &&
107
125
  output !== null &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",