@enrichlayer/el-linear 1.7.0 → 1.9.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.
Files changed (62) hide show
  1. package/README.md +69 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/init/index.js +2 -0
  13. package/dist/commands/init/oauth.d.ts +6 -0
  14. package/dist/commands/init/oauth.js +8 -2
  15. package/dist/commands/init/token.d.ts +0 -8
  16. package/dist/commands/init/token.js +13 -1
  17. package/dist/commands/issues/branch.d.ts +17 -0
  18. package/dist/commands/issues/branch.js +40 -0
  19. package/dist/commands/issues/description.d.ts +89 -0
  20. package/dist/commands/issues/description.js +187 -0
  21. package/dist/commands/issues.js +34 -215
  22. package/dist/commands/labels.js +7 -5
  23. package/dist/commands/profile/migrate-legacy.js +10 -33
  24. package/dist/commands/profile.js +1 -10
  25. package/dist/commands/projects.d.ts +5 -1
  26. package/dist/commands/projects.js +135 -64
  27. package/dist/commands/read-shortcut.d.ts +6 -0
  28. package/dist/commands/read-shortcut.js +6 -1
  29. package/dist/commands/refs.js +2 -1
  30. package/dist/commands/templates.js +127 -1
  31. package/dist/commands/users.js +2 -1
  32. package/dist/config/config.js +31 -9
  33. package/dist/config/issue-validation.js +1 -1
  34. package/dist/config/paths.d.ts +11 -0
  35. package/dist/config/paths.js +44 -6
  36. package/dist/config/term-enforcer.js +1 -1
  37. package/dist/main.js +37 -2
  38. package/dist/queries/templates.d.ts +3 -0
  39. package/dist/queries/templates.js +43 -0
  40. package/dist/utils/auth.js +7 -9
  41. package/dist/utils/auto-link-references.js +15 -1
  42. package/dist/utils/disk-cache.js +2 -1
  43. package/dist/utils/formatters/summary.d.ts +62 -0
  44. package/dist/utils/formatters/summary.js +755 -0
  45. package/dist/utils/graphql-issues-service.d.ts +106 -3
  46. package/dist/utils/graphql-issues-service.js +51 -37
  47. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  48. package/dist/utils/issue-reference-extractor.js +16 -9
  49. package/dist/utils/issue-reference-wrapper.js +1 -54
  50. package/dist/utils/linear-service.d.ts +7 -3
  51. package/dist/utils/linear-service.js +27 -5
  52. package/dist/utils/markdown-prosemirror.js +17 -1
  53. package/dist/utils/mention-resolver.js +17 -5
  54. package/dist/utils/output.d.ts +5 -1
  55. package/dist/utils/output.js +28 -1
  56. package/dist/utils/protected-ranges.d.ts +33 -0
  57. package/dist/utils/protected-ranges.js +73 -0
  58. package/dist/utils/table-formatter.d.ts +36 -0
  59. package/dist/utils/table-formatter.js +46 -24
  60. package/dist/utils/validators.d.ts +9 -1
  61. package/dist/utils/validators.js +10 -0
  62. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
2
3
  import { logger } from "./logger.js";
3
4
  const warningBuffer = [];
4
5
  let rawMode = false;
5
6
  let jqFilter = null;
6
7
  let fieldsFilter = null;
8
+ let outputFormat = "json";
7
9
  export function setRawMode(enabled) {
8
10
  rawMode = enabled;
9
11
  }
@@ -13,6 +15,12 @@ export function setJqFilter(filter) {
13
15
  export function setFieldsFilter(fields) {
14
16
  fieldsFilter = fields;
15
17
  }
18
+ export function setOutputFormat(format) {
19
+ outputFormat = format;
20
+ }
21
+ export function getOutputFormat() {
22
+ return outputFormat;
23
+ }
16
24
  function filterFields(obj, fields) {
17
25
  if (Array.isArray(obj)) {
18
26
  return obj.map((item) => filterFields(item, fields));
@@ -29,6 +37,15 @@ function filterFields(obj, fields) {
29
37
  }
30
38
  return obj;
31
39
  }
40
+ /**
41
+ * Emit a summary-format render to stdout for the given payload. The
42
+ * caller (`outputSuccess`) has already applied `--raw` and `--fields`,
43
+ * so the value here is post-filter — no need to unwrap again.
44
+ */
45
+ function emitSummary(payload) {
46
+ const kind = inferKindFromPayload(payload);
47
+ logger.info(dispatchSummary(kind, payload));
48
+ }
32
49
  export function outputSuccess(data) {
33
50
  const warnings = drainWarnings();
34
51
  let output;
@@ -66,6 +83,13 @@ export function outputSuccess(data) {
66
83
  }
67
84
  }
68
85
  }
86
+ // summary format takes the post-raw / post-fields value and renders it
87
+ // as a human-readable block. We bypass the jq path because jq is a
88
+ // JSON-shape filter — it doesn't compose with text output.
89
+ if (outputFormat === "summary") {
90
+ emitSummary(output);
91
+ return;
92
+ }
69
93
  if (jqFilter) {
70
94
  const json = JSON.stringify(output);
71
95
  // Normalize common shell-escape artifacts (zsh history expansion)
@@ -87,7 +111,7 @@ export function outputSuccess(data) {
87
111
  logger.info(JSON.stringify(output, null, 2));
88
112
  }
89
113
  }
90
- export function outputWarning(message, _type) {
114
+ export function outputWarning(message) {
91
115
  const messages = Array.isArray(message) ? message : [message];
92
116
  for (const msg of messages) {
93
117
  warningBuffer.push(msg);
@@ -103,6 +127,9 @@ function drainWarnings() {
103
127
  export function resetWarnings() {
104
128
  warningBuffer.length = 0;
105
129
  }
130
+ export function resetOutputFormat() {
131
+ outputFormat = "json";
132
+ }
106
133
  function outputError(error) {
107
134
  const payload = JSON.stringify({ error: error.message }, null, 2);
108
135
  // Write to stdout (same channel as success) so machine callers always
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared "protected ranges" scanner — used by both
3
+ * `issue-reference-wrapper` and `issue-reference-extractor` so they
4
+ * agree on which spans of input text should be excluded from
5
+ * identifier processing.
6
+ *
7
+ * Why this exists: a transform/consumer pair that disagree about what
8
+ * counts as "protected" silently produces phantom relations or
9
+ * double-wrapped links. Concretely, the wrapper protects fenced code,
10
+ * inline code, markdown links, Slack links, angle-bracket autolinks,
11
+ * AND bare URLs; the extractor used to only strip fenced code, so
12
+ * `[click](https://x/DEV-100)` would wrap correctly but the auto-link
13
+ * extractor would still detect DEV-100 inside the URL and create a
14
+ * phantom relation. Same for `https://github.com/org/repo/DEV-100.md`.
15
+ *
16
+ * Centralising the scanner makes both consumers agree by construction.
17
+ */
18
+ /** Linear identifier shape: ABC-123, EMW-1, DEV-3592. */
19
+ export declare const IDENTIFIER_REGEX: RegExp;
20
+ export interface ProtectedRange {
21
+ end: number;
22
+ start: number;
23
+ }
24
+ /**
25
+ * Find ranges of `text` that should NOT have identifiers processed.
26
+ * Covered: fenced code, inline backticks, existing markdown links,
27
+ * Slack links, angle-bracket autolinks, bare URLs.
28
+ *
29
+ * The returned ranges may overlap; callers only test "is this position
30
+ * inside any protected range" so overlap is harmless.
31
+ */
32
+ export declare function findProtectedRanges(text: string): ProtectedRange[];
33
+ export declare function isProtected(pos: number, ranges: ProtectedRange[]): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared "protected ranges" scanner — used by both
3
+ * `issue-reference-wrapper` and `issue-reference-extractor` so they
4
+ * agree on which spans of input text should be excluded from
5
+ * identifier processing.
6
+ *
7
+ * Why this exists: a transform/consumer pair that disagree about what
8
+ * counts as "protected" silently produces phantom relations or
9
+ * double-wrapped links. Concretely, the wrapper protects fenced code,
10
+ * inline code, markdown links, Slack links, angle-bracket autolinks,
11
+ * AND bare URLs; the extractor used to only strip fenced code, so
12
+ * `[click](https://x/DEV-100)` would wrap correctly but the auto-link
13
+ * extractor would still detect DEV-100 inside the URL and create a
14
+ * phantom relation. Same for `https://github.com/org/repo/DEV-100.md`.
15
+ *
16
+ * Centralising the scanner makes both consumers agree by construction.
17
+ */
18
+ /** Linear identifier shape: ABC-123, EMW-1, DEV-3592. */
19
+ export const IDENTIFIER_REGEX = /\b([A-Z][A-Z0-9]*-\d+)\b/g;
20
+ const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1?(?:```|~~~)(?=\n|$)/g;
21
+ // Inline backtick spans — `EMW-258`. Markdown allows multiple backticks
22
+ // for spans containing backticks; we keep it simple and match
23
+ // single-backtick spans, which covers the common case.
24
+ const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
25
+ // Existing markdown links: [text](url). Both halves protected so
26
+ // identifiers inside the URL or the text aren't reprocessed.
27
+ const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
28
+ // Existing Slack mrkdwn links: <url|text>. Both halves protected for
29
+ // the same reason as markdown links.
30
+ const SLACK_LINK_REGEX = /<https?:\/\/[^|>\s]+\|[^>]*>/g;
31
+ // Angle-bracket autolinks: <https://...>
32
+ const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
33
+ // Bare URLs in prose. We protect these so identifiers inside path
34
+ // components (e.g. "https://github.com/foo/DEV-100") don't get
35
+ // processed.
36
+ const BARE_URL_REGEX = /https?:\/\/\S+/g;
37
+ /**
38
+ * Find ranges of `text` that should NOT have identifiers processed.
39
+ * Covered: fenced code, inline backticks, existing markdown links,
40
+ * Slack links, angle-bracket autolinks, bare URLs.
41
+ *
42
+ * The returned ranges may overlap; callers only test "is this position
43
+ * inside any protected range" so overlap is harmless.
44
+ */
45
+ export function findProtectedRanges(text) {
46
+ const ranges = [];
47
+ for (const re of [
48
+ FENCED_CODE_BLOCK_REGEX,
49
+ INLINE_CODE_REGEX,
50
+ MARKDOWN_LINK_REGEX,
51
+ // Slack links must be considered before generic angle-bracket
52
+ // autolinks — the autolink regex doesn't know about the `|label`
53
+ // boundary, but matchAll resets per regex so order is just for
54
+ // clarity here.
55
+ SLACK_LINK_REGEX,
56
+ ANGLE_AUTOLINK_REGEX,
57
+ BARE_URL_REGEX,
58
+ ]) {
59
+ for (const m of text.matchAll(re)) {
60
+ const start = m.index ?? 0;
61
+ ranges.push({ start, end: start + m[0].length });
62
+ }
63
+ }
64
+ return ranges;
65
+ }
66
+ export function isProtected(pos, ranges) {
67
+ for (const r of ranges) {
68
+ if (pos >= r.start && pos < r.end) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
@@ -1,4 +1,40 @@
1
1
  import type { LinearIssue } from "../types/linear.js";
2
+ /**
3
+ * Column definition for the fixed-width text and CSV renderers.
4
+ * Generic over the row type `T` so the same renderers serve issues,
5
+ * projects, and any future resource that wants table output. The
6
+ * `colorize` hook receives the row so it can branch on entity-
7
+ * specific fields (e.g. the issue's priority or status); pass
8
+ * undefined to skip color.
9
+ */
10
+ export interface ColumnDef<T> {
11
+ colorize?: (value: string, row: T) => string;
12
+ extract: (row: T) => string;
13
+ header: string;
14
+ key: string;
15
+ width: number;
16
+ }
17
+ /**
18
+ * Render `rows` as a fixed-width text table using `columns`. Generic
19
+ * over the row type `T` so issues, projects, and other resources
20
+ * share the same width-padding + colorize machinery.
21
+ */
22
+ export declare function renderFixedWidthTable<T>(rows: T[], columns: ColumnDef<T>[]): string;
23
+ /**
24
+ * Render `rows` as CSV using `columns`. Quotes any value containing
25
+ * `,` or `"` (RFC 4180-style escaping). Generic over the row type.
26
+ */
27
+ export declare function renderCsv<T>(rows: T[], columns: ColumnDef<T>[]): string;
2
28
  export declare function formatTable(issues: LinearIssue[], fieldNames?: string[]): string;
3
29
  export declare function formatCsv(issues: LinearIssue[], fieldNames?: string[]): string;
30
+ export interface MarkdownColumnDef<T> {
31
+ align?: "left" | "right";
32
+ extract: (row: T) => string;
33
+ header: string;
34
+ }
35
+ /**
36
+ * Render `rows` as a markdown table using `columns`. Generic over
37
+ * the row type. Escapes `|` and newlines in cell values.
38
+ */
39
+ export declare function renderMarkdownTable<T>(rows: T[], columns: MarkdownColumnDef<T>[]): string;
4
40
  export declare function formatMarkdown(issues: LinearIssue[], fieldNames?: string[]): string;
@@ -129,39 +129,54 @@ function pad(str, width) {
129
129
  const truncated = truncate(str, width);
130
130
  return truncated + " ".repeat(Math.max(0, width - truncated.length));
131
131
  }
132
- export function formatTable(issues, fieldNames) {
133
- const columnKeys = fieldNames ?? DEFAULT_COLUMNS;
134
- const columns = columnKeys
135
- .map((key) => ALL_COLUMNS[key])
136
- .filter((col) => col !== undefined);
132
+ /**
133
+ * Render `rows` as a fixed-width text table using `columns`. Generic
134
+ * over the row type `T` so issues, projects, and other resources
135
+ * share the same width-padding + colorize machinery.
136
+ */
137
+ export function renderFixedWidthTable(rows, columns) {
137
138
  if (columns.length === 0) {
138
139
  return "No valid columns specified.";
139
140
  }
140
141
  const header = columns.map((col) => pad(col.header, col.width)).join(" ");
141
142
  const separator = columns.map((col) => "─".repeat(col.width)).join("──");
142
- const rows = issues.map((issue) => columns
143
+ const body = rows.map((row) => columns
143
144
  .map((col) => {
144
- const padded = pad(col.extract(issue), col.width);
145
- return col.colorize ? col.colorize(padded, issue) : padded;
145
+ const padded = pad(col.extract(row), col.width);
146
+ return col.colorize ? col.colorize(padded, row) : padded;
146
147
  })
147
148
  .join(" "));
148
- return [header, separator, ...rows].join("\n");
149
+ return [header, separator, ...body].join("\n");
149
150
  }
150
- export function formatCsv(issues, fieldNames) {
151
- const columnKeys = fieldNames ?? DEFAULT_COLUMNS;
152
- const columns = columnKeys
153
- .map((key) => ALL_COLUMNS[key])
154
- .filter((col) => col !== undefined);
151
+ /**
152
+ * Render `rows` as CSV using `columns`. Quotes any value containing
153
+ * `,` or `"` (RFC 4180-style escaping). Generic over the row type.
154
+ */
155
+ export function renderCsv(rows, columns) {
155
156
  const header = columns.map((col) => col.header).join(",");
156
- const rows = issues.map((issue) => columns
157
+ const body = rows.map((row) => columns
157
158
  .map((col) => {
158
- const value = col.extract(issue);
159
+ const value = col.extract(row);
159
160
  return value.includes(",") || value.includes('"')
160
161
  ? `"${value.replace(/"/g, '""')}"`
161
162
  : value;
162
163
  })
163
164
  .join(","));
164
- return [header, ...rows].join("\n");
165
+ return [header, ...body].join("\n");
166
+ }
167
+ export function formatTable(issues, fieldNames) {
168
+ const columnKeys = fieldNames ?? DEFAULT_COLUMNS;
169
+ const columns = columnKeys
170
+ .map((key) => ALL_COLUMNS[key])
171
+ .filter((col) => col !== undefined);
172
+ return renderFixedWidthTable(issues, columns);
173
+ }
174
+ export function formatCsv(issues, fieldNames) {
175
+ const columnKeys = fieldNames ?? DEFAULT_COLUMNS;
176
+ const columns = columnKeys
177
+ .map((key) => ALL_COLUMNS[key])
178
+ .filter((col) => col !== undefined);
179
+ return renderCsv(issues, columns);
165
180
  }
166
181
  // ── Markdown formatting ────────────────────────────────────────
167
182
  const STATUS_MD = {
@@ -234,16 +249,23 @@ const MD_COLUMNS = {
234
249
  function escapeMarkdownCell(str) {
235
250
  return str.replace(/\|/g, "\\|").replace(/\n/g, " ");
236
251
  }
237
- export function formatMarkdown(issues, fieldNames) {
238
- const columnKeys = fieldNames ?? DEFAULT_COLUMNS;
239
- const columns = columnKeys
240
- .map((key) => MD_COLUMNS[key])
241
- .filter((col) => col !== undefined);
252
+ /**
253
+ * Render `rows` as a markdown table using `columns`. Generic over
254
+ * the row type. Escapes `|` and newlines in cell values.
255
+ */
256
+ export function renderMarkdownTable(rows, columns) {
242
257
  if (columns.length === 0) {
243
258
  return "No valid columns specified.";
244
259
  }
245
260
  const header = `| ${columns.map((col) => col.header).join(" | ")} |`;
246
261
  const divider = `| ${columns.map((col) => (col.align === "right" ? "---:" : "---")).join(" | ")} |`;
247
- const rows = issues.map((issue) => `| ${columns.map((col) => escapeMarkdownCell(col.extract(issue))).join(" | ")} |`);
248
- return [header, divider, ...rows].join("\n");
262
+ const body = rows.map((row) => `| ${columns.map((col) => escapeMarkdownCell(col.extract(row))).join(" | ")} |`);
263
+ return [header, divider, ...body].join("\n");
264
+ }
265
+ export function formatMarkdown(issues, fieldNames) {
266
+ const columnKeys = fieldNames ?? DEFAULT_COLUMNS;
267
+ const columns = columnKeys
268
+ .map((key) => MD_COLUMNS[key])
269
+ .filter((col) => col !== undefined);
270
+ return renderMarkdownTable(issues, columns);
249
271
  }
@@ -9,6 +9,14 @@ export declare function parsePositiveInt(value: string, flagName: string): numbe
9
9
  export declare function validatePriority(value: string): number;
10
10
  export declare function validateHexColor(value: string): string;
11
11
  export declare function validateIsoDate(value: string): string;
12
- export declare function splitList(value: string): string[];
12
+ /**
13
+ * Split a comma-separated list into trimmed, non-empty items.
14
+ *
15
+ * Accepts `undefined` so call sites can pipe through commander's
16
+ * optional flag values without an explicit truthy guard. `false`
17
+ * (commander's representation of `--no-foo`) and empty string both
18
+ * resolve to `[]`.
19
+ */
20
+ export declare function splitList(value: string | undefined | null | false): string[];
13
21
  export declare function parsePriorityFilter(value: string): number[];
14
22
  export declare const PRIORITY_LABELS: Record<number, string>;
@@ -42,7 +42,17 @@ export function validateIsoDate(value) {
42
42
  }
43
43
  return value;
44
44
  }
45
+ /**
46
+ * Split a comma-separated list into trimmed, non-empty items.
47
+ *
48
+ * Accepts `undefined` so call sites can pipe through commander's
49
+ * optional flag values without an explicit truthy guard. `false`
50
+ * (commander's representation of `--no-foo`) and empty string both
51
+ * resolve to `[]`.
52
+ */
45
53
  export function splitList(value) {
54
+ if (!value)
55
+ return [];
46
56
  return value
47
57
  .split(",")
48
58
  .map((s) => s.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",