@enrichlayer/el-linear 1.10.0 → 1.16.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 (108) hide show
  1. package/README.md +126 -10
  2. package/claude-skills/linear-operations/SKILL.md +41 -1
  3. package/dist/auth/linear-credential.d.ts +27 -0
  4. package/dist/auth/linear-credential.js +1 -0
  5. package/dist/auth/oauth-app-config.d.ts +4 -3
  6. package/dist/auth/oauth-app-config.js +13 -2
  7. package/dist/auth/oauth-callback.d.ts +2 -3
  8. package/dist/auth/oauth-callback.js +2 -2
  9. package/dist/auth/oauth-client.d.ts +8 -2
  10. package/dist/auth/oauth-client.js +26 -0
  11. package/dist/auth/oauth-fs.d.ts +2 -1
  12. package/dist/auth/oauth-headless.d.ts +2 -1
  13. package/dist/auth/oauth-storage.d.ts +5 -1
  14. package/dist/auth/oauth-storage.js +1 -1
  15. package/dist/auth/oauth-token.d.ts +4 -3
  16. package/dist/auth/oauth-token.js +16 -4
  17. package/dist/auth/token-resolver.d.ts +14 -5
  18. package/dist/auth/token-resolver.js +6 -1
  19. package/dist/commands/batch.js +18 -21
  20. package/dist/commands/comments.js +27 -6
  21. package/dist/commands/config.js +178 -5
  22. package/dist/commands/init/aliases.js +1 -1
  23. package/dist/commands/init/defaults.d.ts +2 -1
  24. package/dist/commands/init/index.js +45 -35
  25. package/dist/commands/init/oauth.d.ts +4 -1
  26. package/dist/commands/init/oauth.js +22 -4
  27. package/dist/commands/init/shared.d.ts +24 -2
  28. package/dist/commands/init/shared.js +35 -4
  29. package/dist/commands/init/token.d.ts +3 -3
  30. package/dist/commands/init/token.js +5 -24
  31. package/dist/commands/init/workspace.d.ts +2 -1
  32. package/dist/commands/init/workspace.js +1 -1
  33. package/dist/commands/introspect.d.ts +27 -0
  34. package/dist/commands/introspect.js +178 -0
  35. package/dist/commands/issues/branch.js +9 -1
  36. package/dist/commands/issues/relations.d.ts +3 -14
  37. package/dist/commands/issues/relations.js +3 -3
  38. package/dist/commands/issues.js +222 -43
  39. package/dist/commands/labels.js +2 -1
  40. package/dist/commands/profile.js +1 -0
  41. package/dist/commands/projects.d.ts +2 -0
  42. package/dist/commands/projects.js +91 -7
  43. package/dist/commands/read-shortcut.d.ts +1 -1
  44. package/dist/commands/read-shortcut.js +28 -8
  45. package/dist/commands/refs.js +67 -8
  46. package/dist/commands/search.js +30 -5
  47. package/dist/commands/users.js +4 -2
  48. package/dist/config/config.d.ts +99 -1
  49. package/dist/config/config.js +264 -52
  50. package/dist/config/error-enrichment.d.ts +62 -0
  51. package/dist/config/error-enrichment.js +417 -0
  52. package/dist/config/issue-validation.d.ts +37 -0
  53. package/dist/config/issue-validation.js +63 -1
  54. package/dist/config/paths.d.ts +2 -8
  55. package/dist/config/paths.js +4 -2
  56. package/dist/config/resolver.d.ts +8 -1
  57. package/dist/config/resolver.js +9 -2
  58. package/dist/main.js +13 -1
  59. package/dist/output.d.ts +82 -0
  60. package/dist/output.js +82 -0
  61. package/dist/queries/comments-types.d.ts +15 -9
  62. package/dist/queries/common.d.ts +2 -2
  63. package/dist/queries/common.js +8 -0
  64. package/dist/queries/documents-types.d.ts +4 -3
  65. package/dist/queries/introspect-types.d.ts +8 -7
  66. package/dist/queries/issues-types.d.ts +92 -27
  67. package/dist/queries/issues.d.ts +49 -10
  68. package/dist/queries/issues.js +125 -5
  69. package/dist/queries/labels-types.d.ts +7 -6
  70. package/dist/queries/project-milestones-types.d.ts +5 -4
  71. package/dist/queries/project-milestones.d.ts +1 -1
  72. package/dist/queries/projects-types.d.ts +8 -7
  73. package/dist/queries/releases-types.d.ts +5 -4
  74. package/dist/queries/search-types.d.ts +28 -12
  75. package/dist/queries/templates-types.d.ts +3 -2
  76. package/dist/types/linear.d.ts +13 -1
  77. package/dist/utils/auto-link-references.d.ts +3 -3
  78. package/dist/utils/auto-link-references.js +1 -10
  79. package/dist/utils/extract-field.d.ts +19 -0
  80. package/dist/utils/extract-field.js +99 -0
  81. package/dist/utils/file-service.d.ts +6 -13
  82. package/dist/utils/file-service.js +0 -2
  83. package/dist/utils/formatters/summary.js +6 -1
  84. package/dist/utils/graphql-issues-service.d.ts +101 -45
  85. package/dist/utils/graphql-issues-service.js +252 -39
  86. package/dist/utils/graphql-service.d.ts +10 -12
  87. package/dist/utils/graphql-service.js +0 -3
  88. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  89. package/dist/utils/issue-reference-extractor.js +5 -3
  90. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  91. package/dist/utils/issues-service-bootstrap.js +27 -0
  92. package/dist/utils/linear-service.d.ts +21 -14
  93. package/dist/utils/linear-service.js +73 -11
  94. package/dist/utils/markdown-prosemirror.js +12 -12
  95. package/dist/utils/mention-resolver.js +1 -1
  96. package/dist/utils/output.d.ts +81 -3
  97. package/dist/utils/output.js +61 -6
  98. package/dist/utils/project-slug.d.ts +21 -0
  99. package/dist/utils/project-slug.js +45 -0
  100. package/dist/utils/protected-ranges.d.ts +14 -0
  101. package/dist/utils/protected-ranges.js +88 -2
  102. package/dist/utils/sanitize-for-log.d.ts +24 -0
  103. package/dist/utils/sanitize-for-log.js +38 -0
  104. package/dist/utils/table-formatter.js +24 -0
  105. package/dist/utils/validators.d.ts +7 -2
  106. package/dist/utils/validators.js +6 -0
  107. package/dist/utils/workspace-url.js +20 -4
  108. package/package.json +7 -2
@@ -5,7 +5,7 @@ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
5
5
  import { createGraphQLService } from "../utils/graphql-service.js";
6
6
  import { createLinearService } from "../utils/linear-service.js";
7
7
  import { logger } from "../utils/logger.js";
8
- import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
8
+ import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
9
9
  import { getRootOpts } from "../utils/root-opts.js";
10
10
  import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
11
11
  import { isUuid } from "../utils/uuid.js";
@@ -18,6 +18,58 @@ const VALID_PROJECT_STATES = new Set([
18
18
  "completed",
19
19
  "canceled",
20
20
  ]);
21
+ /**
22
+ * Display ordering for `projects list`: active work first, terminal states
23
+ * last. Without this, the SDK's `updatedAt` order surfaces recently-touched
24
+ * completed/canceled projects ahead of in-progress ones — so an active project
25
+ * can fall past the `--limit` window and an agent concludes it does not exist
26
+ * (the DEV-4175 silent-truncation failure). Stable sort preserves `updatedAt`
27
+ * within each rank.
28
+ *
29
+ * `Linear` exposes additional internal states (e.g. archived) that we don't
30
+ * enumerate here; anything not in this table sorts to the end via
31
+ * `UNRANKED_STATE`.
32
+ */
33
+ const PROJECT_STATE_RANK = {
34
+ started: 0,
35
+ planned: 1,
36
+ paused: 2,
37
+ backlog: 3,
38
+ completed: 4,
39
+ canceled: 5,
40
+ };
41
+ // Sentinel for any state Linear surfaces that isn't in the rank table above
42
+ // (e.g. `archived`, future additions). Number.MAX_SAFE_INTEGER reads more
43
+ // deliberately than a magic `9` and stays correct if PROJECT_STATE_RANK grows.
44
+ const UNRANKED_STATE = Number.MAX_SAFE_INTEGER;
45
+ export function sortActiveFirst(projects) {
46
+ return [...projects].sort((a, b) => (PROJECT_STATE_RANK[a.state ?? ""] ?? UNRANKED_STATE) -
47
+ (PROJECT_STATE_RANK[b.state ?? ""] ?? UNRANKED_STATE));
48
+ }
49
+ /**
50
+ * Emit a truncation warning when `projects list` returned exactly `--limit`
51
+ * rows (the same `count === limit` heuristic as `warnIfTruncated` in
52
+ * `output.ts`). JSON buffers it into `_warnings` so the payload stays a single
53
+ * parseable object; every human-facing format prints to stderr so it can't be
54
+ * silently scrolled past. DEV-4175.
55
+ *
56
+ * The exact total is deliberately omitted from the message. Returning it would
57
+ * require a second `client.projects({ first: 0 })` round-trip (the SDK has no
58
+ * `count`-only helper) or a full pagination walk — both negate the cheap-page
59
+ * default. `--all` in the hint gives the caller a deterministic way to learn
60
+ * the real total without us guessing, so the absence is a feature, not a gap.
61
+ */
62
+ function warnProjectsTruncated(count, limit, format) {
63
+ const msg = `results_truncated: returned ${count} projects matching --limit ${limit}; ` +
64
+ "more may exist. Re-run with --all to fetch every project " +
65
+ `(or --limit ${limit * 2}, or narrow with --name / --state / --team).`;
66
+ if (format === "json") {
67
+ outputWarning(msg);
68
+ }
69
+ else {
70
+ logger.error(msg);
71
+ }
72
+ }
21
73
  function parseStateList(value, flagName) {
22
74
  const states = splitList(value).map((s) => s.toLowerCase());
23
75
  for (const s of states) {
@@ -390,13 +442,22 @@ export function setupProjectsCommands(program) {
390
442
  .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
391
443
  .option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
392
444
  .option("--name <substring>", "filter by case-insensitive substring on project name")
445
+ .option("--team <key|name>", "filter to projects belonging to a specific team (e.g. DEV, FE)")
393
446
  .option("--state <names>", "include only projects in these states (comma-separated: backlog, planned, started, paused, completed, canceled)")
394
447
  .option("--exclude-state <names>", "exclude projects in these states (comma-separated). Mutually exclusive with --state.")
395
448
  .option("--active", "shorthand for --exclude-state completed,canceled (mutually exclusive with --state / --exclude-state)")
449
+ .option("--all", "fetch every project (paginates fully). Equivalent to --limit 0; overrides --limit when both are given.")
396
450
  .action(handleAsyncCommand(async (options, command) => {
397
451
  const rootOpts = getRootOpts(command);
398
- const limit = parsePositiveInt(options.limit, "--limit");
452
+ // `--all` / `--limit 0` mean unlimited. Stored as `limit = 0`,
453
+ // which `LinearService.getProjects` interprets as "paginate
454
+ // fully". DEV-4175.
455
+ const unlimited = options.all === true || options.limit === "0";
456
+ const limit = unlimited
457
+ ? 0
458
+ : parsePositiveInt(options.limit, "--limit");
399
459
  const nameFilter = options.name;
460
+ const teamFilter = options.team;
400
461
  const stateFilter = resolveProjectStateFilter(options);
401
462
  const ttl = resolveCacheTTL({
402
463
  configTTL: loadConfig().cacheTTLSeconds,
@@ -406,31 +467,54 @@ export function setupProjectsCommands(program) {
406
467
  // don't collide.
407
468
  const cacheKey = `projects-list-limit:${limit}` +
408
469
  `-name:${nameFilter ?? "_all"}` +
470
+ `-team:${teamFilter ?? "_all"}` +
409
471
  `-states:${stateFilter.states?.join(",") ?? "_any"}` +
410
472
  `-excl:${stateFilter.excludeStates?.join(",") ?? "_none"}`;
411
473
  const result = await cached(cacheKey, ttl, async () => {
412
474
  const service = await createLinearService(rootOpts);
475
+ let teamId;
476
+ if (teamFilter) {
477
+ const resolved = resolveTeam(teamFilter);
478
+ teamId = await service.resolveTeamId(resolved);
479
+ }
413
480
  return service.getProjects(limit, {
414
481
  nameFilter,
482
+ teamId,
415
483
  states: stateFilter.states,
416
484
  excludeStates: stateFilter.excludeStates,
417
485
  });
418
486
  });
487
+ // Active-first sort: keeps started/planned/paused/backlog
488
+ // ahead of completed/canceled in every format, so the active
489
+ // set always fits within `--limit`. DEV-4175.
490
+ const sorted = sortActiveFirst(result);
419
491
  const format = options.format;
420
- if (format === "table" ||
492
+ const isTabular = format === "table" ||
421
493
  format === "md" ||
422
494
  format === "markdown" ||
423
- format === "csv") {
495
+ format === "csv";
496
+ // Emit the truncation warning BEFORE outputting: the JSON
497
+ // branch buffers into `_warnings`, which the next
498
+ // `outputSuccess()` call drains. All other formats route to
499
+ // stderr via `logger.error` so the visible payload (table,
500
+ // markdown, summary) stays clean.
501
+ if (!unlimited && sorted.length === limit) {
502
+ warnProjectsTruncated(sorted.length, limit, format);
503
+ }
504
+ if (isTabular) {
424
505
  const fieldList = options.fields
425
506
  ? splitList(options.fields)
426
507
  : undefined;
427
- formatProjectsOutput(result, format, fieldList);
508
+ formatProjectsOutput(sorted, format, fieldList);
428
509
  if (format === "table") {
429
- logger.info(`\n${result.length} projects`);
510
+ logger.info(`\n${sorted.length} projects`);
430
511
  }
431
512
  }
432
513
  else {
433
- outputSuccess({ data: result, meta: { count: result.length } });
514
+ outputSuccess({
515
+ data: sorted,
516
+ meta: { count: sorted.length },
517
+ });
434
518
  }
435
519
  }));
436
520
  projects
@@ -9,4 +9,4 @@ export declare function setupReadShortcut(program: Command): void;
9
9
  * `issues read` (in `commands/issues.ts`) both call one implementation
10
10
  * instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
11
11
  */
12
- export declare function readIssues(issueIds: string[], _options: Record<string, unknown>, command: Command): Promise<void>;
12
+ export declare function readIssues(issueIds: string[], options: Record<string, unknown>, command: Command): Promise<void>;
@@ -1,8 +1,7 @@
1
1
  import { downloadLinearUploads } from "../utils/download-uploads.js";
2
+ import { extractField } from "../utils/extract-field.js";
2
3
  import { createFileService } from "../utils/file-service.js";
3
- import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
4
- import { createGraphQLService } from "../utils/graphql-service.js";
5
- import { createLinearService } from "../utils/linear-service.js";
4
+ import { createIssuesService } from "../utils/issues-service-bootstrap.js";
6
5
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
7
6
  import { getRootOpts } from "../utils/root-opts.js";
8
7
  /**
@@ -21,7 +20,10 @@ export function setupReadShortcut(program) {
21
20
  .alias("view")
22
21
  .alias("show")
23
22
  .description("Shortcut for `issues read`. Get issue details by identifier.")
24
- .addHelpText("after", "\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)")
23
+ .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
24
+ "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
25
+ "Outputs the section text only — no JSON envelope.")
26
+ .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)')
25
27
  .action(handleAsyncCommand(readIssues));
26
28
  // Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
27
29
  const originalParse = program.parse.bind(program);
@@ -54,15 +56,33 @@ export function setupReadShortcut(program) {
54
56
  * `issues read` (in `commands/issues.ts`) both call one implementation
55
57
  * instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
56
58
  */
57
- export async function readIssues(issueIds, _options, command) {
59
+ export async function readIssues(issueIds, options, command) {
58
60
  const rootOpts = getRootOpts(command);
59
- const graphQLService = await createGraphQLService(rootOpts);
60
- const linearService = await createLinearService(rootOpts);
61
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
61
+ const { issuesService } = await createIssuesService(rootOpts);
62
62
  const fileService = await createFileService(rootOpts);
63
+ const fieldName = typeof options.field === "string" ? options.field : null;
64
+ // --field is single-issue only. With multiple issues, a section
65
+ // extraction can't sensibly fan out to N different bodies — the
66
+ // caller almost always wants one section from one issue.
67
+ if (fieldName && issueIds.length > 1) {
68
+ throw new Error("--field is single-issue only; pass exactly one issueId. " +
69
+ "For multiple issues, drop --field and use --jq or --format summary.");
70
+ }
63
71
  if (issueIds.length === 1) {
64
72
  const issue = await issuesService.getIssueById(issueIds[0]);
65
73
  const resolved = await downloadLinearUploads(issue, fileService);
74
+ if (fieldName) {
75
+ const section = extractField(resolved.description ?? "", fieldName);
76
+ if (section === null) {
77
+ // Print nothing to stdout, exit non-zero with a stderr hint.
78
+ // Mirrors `grep` semantics for "not found" — scripts can
79
+ // branch on the exit code.
80
+ process.stderr.write(`el-linear: section "${fieldName}" not found in ${resolved.identifier}'s description\n`);
81
+ process.exit(1);
82
+ }
83
+ process.stdout.write(`${section}\n`);
84
+ return;
85
+ }
66
86
  outputSuccess(resolved);
67
87
  }
68
88
  else {
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createGraphQLService } from "../utils/graphql-service.js";
3
3
  import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
4
4
  import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
@@ -50,16 +50,70 @@ export async function wrapRefsCore(input, deps) {
50
50
  const urlKey = await deps.resolveUrlKey();
51
51
  return wrapIssueReferencesAsLinks(input.text, validIds, urlKey, input.target);
52
52
  }
53
- async function handleWrap(options, command) {
53
+ /**
54
+ * Return `path` if it resolves to an existing regular file on disk, else
55
+ * `null`. Used by `handleWrap` to disambiguate the `el-linear refs wrap <arg>`
56
+ * shape between "wrap this literal text" and "wrap the contents of this file".
57
+ *
58
+ * Concerns intentionally NOT addressed:
59
+ * - Symlinks → resolved transparently by `statSync` (followSymlinks=true).
60
+ * - Directories → returned as null (statSync().isFile() is false).
61
+ * - Permission-denied / EACCES → returned as null; the caller falls back to
62
+ * treating the arg as text. The eventual `readFileSync` would surface the
63
+ * real error if the user did mean a file.
64
+ *
65
+ * Reference: DEV-4077 (vertical-int/tools MR !516 / !517 incident reports).
66
+ */
67
+ function pathIfExistingFile(candidate) {
68
+ if (!candidate || candidate.includes("\n"))
69
+ return null;
70
+ try {
71
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
72
+ return candidate;
73
+ }
74
+ }
75
+ catch {
76
+ // EACCES / EPERM / ELOOP → treat as not-a-file; downstream text path
77
+ // either passes through harmlessly or raises a more useful error.
78
+ }
79
+ return null;
80
+ }
81
+ async function handleWrap(textArgs, options, command) {
54
82
  const target = options.target ?? "markdown";
55
83
  if (!isWrapTarget(target)) {
56
84
  throw new Error(`Invalid --target "${target}". Expected one of: ${[...VALID_TARGETS].join(", ")}`);
57
85
  }
58
86
  // `validate` is `true` by default and `false` when `--no-validate` is passed.
59
87
  const validate = options.validate !== false;
60
- const text = typeof options.file === "string" && options.file.length > 0
61
- ? readFileSync(options.file, "utf8")
62
- : await readAllStdin();
88
+ // Input precedence: positional args > --file > stdin. A single positional
89
+ // arg that resolves to an existing file is treated as a file path (the
90
+ // natural `el-linear refs wrap body.md` shape that users hit blind);
91
+ // otherwise positional args are joined as text. This auto-detect closes
92
+ // DEV-4077: the published v1.8.1 rejected `wrap <file>` outright, and the
93
+ // v1.10.0 source treated it as literal text — producing silently wrong
94
+ // output instead of an error. With this change, either form works.
95
+ let text;
96
+ if (textArgs.length === 1) {
97
+ const positionalFile = pathIfExistingFile(textArgs[0]);
98
+ if (positionalFile !== null) {
99
+ if (typeof options.file === "string" && options.file.length > 0) {
100
+ throw new Error(`Both a positional file path (${positionalFile}) and --file ${options.file} were provided. Use one or the other.`);
101
+ }
102
+ text = readFileSync(positionalFile, "utf8");
103
+ }
104
+ else {
105
+ text = textArgs[0];
106
+ }
107
+ }
108
+ else if (textArgs.length > 1) {
109
+ text = textArgs.join(" ");
110
+ }
111
+ else if (typeof options.file === "string" && options.file.length > 0) {
112
+ text = readFileSync(options.file, "utf8");
113
+ }
114
+ else {
115
+ text = await readAllStdin();
116
+ }
63
117
  const rootOpts = getRootOpts(command);
64
118
  const deps = {
65
119
  async resolveValidIdentifiers(ids) {
@@ -94,9 +148,14 @@ export function setupRefsCommands(program) {
94
148
  refs
95
149
  .command("wrap")
96
150
  .description("Wrap recognized Linear issue identifiers in input text as links. " +
97
- "Reads from stdin (or --file) and writes to stdout. By default, " +
98
- "each candidate identifier is validated against the workspace; " +
99
- "unresolvable ones are left as plain text.")
151
+ "Input precedence: positional args > --file > stdin. A single positional " +
152
+ "arg that resolves to an existing file is read as a file (so " +
153
+ "`el-linear refs wrap body.md` works the same as `--file body.md`); " +
154
+ "otherwise positional args are joined as literal text. " +
155
+ "Writes the result to stdout. By default, each candidate identifier " +
156
+ "is validated against the workspace; unresolvable ones are left as " +
157
+ "plain text.")
158
+ .argument("[text...]", "input text OR a path to a file. A single positional arg that exists as a file is read; otherwise positional args are joined as literal text with a single space.")
100
159
  .option("--file <path>", "read input from a file instead of stdin")
101
160
  .option("--target <target>", "output format: markdown (default) or slack", "markdown")
102
161
  .option("--no-validate", "skip workspace validation; wrap every regex match. Faster, but may produce broken links for IDs that don't exist.")
@@ -23,11 +23,28 @@ const VALID_TYPES = new Set([
23
23
  "document",
24
24
  "template",
25
25
  ]);
26
+ /**
27
+ * `SemanticSearchResult`-valid types only (drops "template" — templates
28
+ * come from a separate query, not `semanticSearch`). Used to filter
29
+ * unknown types out of the response BEFORE they reach
30
+ * `transformSearchResult` — the transformer's switch has a
31
+ * `satisfies never` exhaustiveness guard that would otherwise return
32
+ * the raw row on a never-typed default branch.
33
+ */
34
+ const SEMANTIC_TYPES = new Set([
35
+ "issue",
36
+ "project",
37
+ "initiative",
38
+ "document",
39
+ ]);
40
+ function isKnownSemanticResult(r) {
41
+ return SEMANTIC_TYPES.has(r.type);
42
+ }
26
43
  const WHITESPACE_RE = /\s+/;
27
44
  function transformSearchResult(r) {
28
45
  switch (r.type) {
29
46
  case "issue": {
30
- const issue = r.issue;
47
+ const { issue } = r;
31
48
  return {
32
49
  type: "issue",
33
50
  identifier: issue?.identifier,
@@ -68,7 +85,10 @@ function transformSearchResult(r) {
68
85
  };
69
86
  }
70
87
  default:
71
- return { type: r.type, id: null };
88
+ // Exhaustiveness check: SemanticSearchResult is a closed union, so
89
+ // `r` is `never` here — adding a new arm to the union forces a new
90
+ // case branch (the compiler error makes it impossible to forget).
91
+ return r;
72
92
  }
73
93
  }
74
94
  function matchesQuery(name, query) {
@@ -114,10 +134,15 @@ function buildSemanticQuery(graphQLService, query, limit, teamOption) {
114
134
  });
115
135
  }
116
136
  function extractSemanticResults(semanticResult, requestedTypes, onlyTemplates) {
117
- let results = semanticResult.semanticSearch?.results ?? [];
137
+ // Always drop rows with unknown `type` first — defends the transformer
138
+ // against future Linear API expansions adding a new variant before
139
+ // SemanticSearchResult's union is widened. Without this, an unknown
140
+ // type would reach the transformer's `satisfies never` default and
141
+ // pass through as a raw row.
142
+ let results = (semanticResult.semanticSearch?.results ?? []).filter(isKnownSemanticResult);
118
143
  if (requestedTypes && !onlyTemplates) {
119
- const semanticTypes = requestedTypes.filter((t) => t !== "template");
120
- results = results.filter((r) => semanticTypes.includes(r.type));
144
+ const semanticTypes = new Set(requestedTypes.filter((t) => t !== "template"));
145
+ results = results.filter((r) => semanticTypes.has(r.type));
121
146
  }
122
147
  return results.map(transformSearchResult);
123
148
  }
@@ -1,5 +1,5 @@
1
1
  import { createLinearService } from "../utils/linear-service.js";
2
- import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
2
+ import { handleAsyncCommand, outputSuccess, warnIfTruncated, } from "../utils/output.js";
3
3
  import { getRootOpts } from "../utils/root-opts.js";
4
4
  import { parsePositiveInt } from "../utils/validators.js";
5
5
  export function setupUsersCommands(program) {
@@ -14,7 +14,9 @@ export function setupUsersCommands(program) {
14
14
  .action(handleAsyncCommand(async (options, command) => {
15
15
  const rootOpts = getRootOpts(command);
16
16
  const service = await createLinearService(rootOpts);
17
- const result = await service.getUsers(options.active, parsePositiveInt(options.limit, "--limit"), options.name);
17
+ const limit = parsePositiveInt(options.limit, "--limit");
18
+ const result = await service.getUsers(options.active, limit, options.name);
19
+ warnIfTruncated(result.length, limit);
18
20
  outputSuccess({ data: result, meta: { count: result.length } });
19
21
  }));
20
22
  }
@@ -1,4 +1,15 @@
1
1
  import type { TermRule } from "./term-enforcer.js";
2
+ /**
3
+ * Source attribution for the active team-config layer. Surfaced via
4
+ * `getActiveTeamConfigInfo()` and rendered by `el-linear config team show` so
5
+ * the operator can debug "which file is el-linear actually loading?".
6
+ *
7
+ * - `env`: `EL_LINEAR_TEAM_CONFIG` is set in the environment (highest).
8
+ * - `personal`: `teamConfigPath` field in personal `config.json`.
9
+ * - `marker`: auto-discovered via `~/.config/el-tools-root` (DEV-4258).
10
+ * - `null`: no team layer active.
11
+ */
12
+ export type TeamConfigSource = "env" | "personal" | "marker" | null;
2
13
  /**
3
14
  * Canonical shape of `~/.config/el-linear/config.json`. This is the single source
4
15
  * of truth for the on-disk config — the wizard reads/writes the same shape
@@ -27,6 +38,10 @@ export interface ElLinearConfig {
27
38
  * Term-enforcement rules. Each rule has a canonical form and a list of
28
39
  * rejected forms; rejected forms in issue titles/descriptions are flagged
29
40
  * (or thrown on, in strict mode) with a hint to use the canonical form.
41
+ *
42
+ * When a team config layer is active, personal `terms` are appended to the
43
+ * team's rules (not replaced). To start fresh, omit `terms` from personal
44
+ * config entirely.
30
45
  */
31
46
  terms: TermRule[];
32
47
  validation?: {
@@ -61,12 +76,17 @@ export interface ElLinearConfig {
61
76
  * Default assignee identifier (alias / display name / email / UUID — same
62
77
  * shapes resolveAssignee accepts) for `issues create`. Applied when
63
78
  * `--assignee` is not passed. Pass `--no-assignee` to override at one site.
79
+ *
80
+ * Prefer setting this in `local.json` instead of `config.json` so it stays
81
+ * out of shared team config.
64
82
  */
65
83
  defaultAssignee?: string;
66
84
  /**
67
85
  * Default priority for `issues create` / `issues update`. Accepts the same
68
86
  * keywords as the --priority flag: `none|urgent|high|medium|normal|low`
69
87
  * or `0`–`4`. Applied when `--priority` is not passed.
88
+ *
89
+ * Prefer setting this in `local.json` instead of `config.json`.
70
90
  */
71
91
  defaultPriority?: string;
72
92
  /**
@@ -74,9 +94,87 @@ export interface ElLinearConfig {
74
94
  * and `projects list`. Defaults to 3600 (1 hour) when omitted. A value of
75
95
  * `0` disables the cache entirely. Override per-invocation with
76
96
  * `--no-cache`.
97
+ *
98
+ * Prefer setting this in `local.json` instead of `config.json`.
77
99
  */
78
100
  cacheTTLSeconds?: number;
101
+ /**
102
+ * Path to a shared team config file. Fields in the team config are merged
103
+ * under the personal config (personal config wins on conflicts). Arrays such
104
+ * as `terms` and `defaultLabels` are concatenated — personal entries are
105
+ * appended to team entries. The team config accepts any ElLinearConfig fields
106
+ * except `teamConfigPath` itself. Useful for sharing member aliases, label
107
+ * maps, and term rules across a team by checking the file into a shared
108
+ * repository. Override at runtime with the `EL_LINEAR_TEAM_CONFIG` env var
109
+ * (env var takes precedence over this field).
110
+ */
111
+ teamConfigPath?: string;
79
112
  }
80
- /** Test seam — resets the cache between test cases. */
113
+ /**
114
+ * Fields valid in a shared team config file (the file pointed to by
115
+ * `teamConfigPath` or `EL_LINEAR_TEAM_CONFIG`). All fields are optional —
116
+ * omitted fields fall back to defaults or the personal config layer. The type
117
+ * excludes `teamConfigPath` itself to prevent circular references.
118
+ */
119
+ export type TeamConfig = Omit<ElLinearConfig, "teamConfigPath">;
120
+ /**
121
+ * User-local overrides that live in `~/.config/el-linear/local.json` (or the
122
+ * per-profile equivalent). These are applied on top of the merged team+personal
123
+ * config, so every field here takes the highest precedence.
124
+ *
125
+ * Use `local.json` for anything that is personal rather than team-wide:
126
+ * your own email, personal default priority, cache preferences. Never commit
127
+ * `local.json` to a shared repo — it is `.gitignore`-worthy by nature.
128
+ *
129
+ * Merge order (lowest → highest priority):
130
+ * defaults → team config file → personal config.json → local.json
131
+ */
132
+ export interface ElLinearLocalConfig {
133
+ /**
134
+ * Your Linear account email. Used as the default `--assignee` when creating
135
+ * issues. Takes precedence over `config.json`'s `defaultAssignee`.
136
+ *
137
+ * Example: "ytspar@gmail.com"
138
+ */
139
+ assigneeEmail?: string;
140
+ /** Same semantics as `ElLinearConfig.defaultAssignee`, but local-wins. */
141
+ defaultAssignee?: string;
142
+ /** Same semantics as `ElLinearConfig.defaultPriority`, but local-wins. */
143
+ defaultPriority?: string;
144
+ /** Same semantics as `ElLinearConfig.cacheTTLSeconds`, but local-wins. */
145
+ cacheTTLSeconds?: number;
146
+ }
147
+ /** Test seam — resets all caches between test cases. */
81
148
  export declare function _resetConfigCacheForTests(): void;
82
149
  export declare function loadConfig(): ElLinearConfig;
150
+ /**
151
+ * Returns the team config file path that is active for the current process
152
+ * (resolved via `EL_LINEAR_TEAM_CONFIG` env var, `teamConfigPath` in personal
153
+ * config, or `~/.config/el-tools-root` marker — in that order). Returns
154
+ * `undefined` when no team config is configured.
155
+ *
156
+ * Calling this before `loadConfig()` will trigger a `loadConfig()` internally
157
+ * so the path cache is populated.
158
+ */
159
+ export declare function getActiveTeamConfigPath(): string | undefined;
160
+ /**
161
+ * Returns the active team-config path AND the source it was resolved from
162
+ * (env var / personal config field / onboarding-marker auto-discovery).
163
+ * `el-linear config team show` uses this to render which resolver fired —
164
+ * so an operator debugging "why isn't my team config loading?" sees the
165
+ * answer in one command.
166
+ *
167
+ * Re-derives source on each call rather than caching it: the lookup is cheap
168
+ * (one file read at most for personal config, one for the marker) and only
169
+ * `config team show` calls it.
170
+ */
171
+ export declare function getActiveTeamConfigInfo(): {
172
+ path: string | undefined;
173
+ source: TeamConfigSource;
174
+ };
175
+ /**
176
+ * Load user-local overrides from `local.json`. Returns an empty object when
177
+ * no file exists — callers treat it as "no overrides". Errors are silent so
178
+ * a missing or malformed `local.json` never breaks a command.
179
+ */
180
+ export declare function loadLocalConfig(): ElLinearLocalConfig;