@enrichlayer/el-linear 1.9.0 → 1.15.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 (135) hide show
  1. package/README.md +139 -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/attachments.js +2 -1
  20. package/dist/commands/batch.js +18 -21
  21. package/dist/commands/comments.js +22 -33
  22. package/dist/commands/config.js +178 -5
  23. package/dist/commands/cycles.js +2 -1
  24. package/dist/commands/documents.js +2 -1
  25. package/dist/commands/graphql.js +4 -6
  26. package/dist/commands/init/aliases.js +1 -1
  27. package/dist/commands/init/defaults.d.ts +2 -1
  28. package/dist/commands/init/index.js +45 -35
  29. package/dist/commands/init/oauth.d.ts +4 -1
  30. package/dist/commands/init/oauth.js +22 -4
  31. package/dist/commands/init/shared.d.ts +24 -2
  32. package/dist/commands/init/shared.js +35 -4
  33. package/dist/commands/init/token.d.ts +3 -3
  34. package/dist/commands/init/token.js +5 -24
  35. package/dist/commands/init/workspace.d.ts +2 -1
  36. package/dist/commands/init/workspace.js +1 -1
  37. package/dist/commands/introspect.d.ts +27 -0
  38. package/dist/commands/introspect.js +178 -0
  39. package/dist/commands/issue-id.js +1 -3
  40. package/dist/commands/issues/branch.js +9 -1
  41. package/dist/commands/issues/description.js +2 -6
  42. package/dist/commands/issues/link-references.d.ts +21 -0
  43. package/dist/commands/issues/link-references.js +171 -0
  44. package/dist/commands/issues/relations.d.ts +44 -0
  45. package/dist/commands/issues/relations.js +132 -0
  46. package/dist/commands/issues.js +269 -309
  47. package/dist/commands/labels.js +15 -24
  48. package/dist/commands/profile.js +1 -0
  49. package/dist/commands/project-milestones.js +13 -20
  50. package/dist/commands/projects.d.ts +2 -0
  51. package/dist/commands/projects.js +157 -44
  52. package/dist/commands/read-shortcut.d.ts +1 -1
  53. package/dist/commands/read-shortcut.js +28 -8
  54. package/dist/commands/refs.js +75 -8
  55. package/dist/commands/releases.js +26 -30
  56. package/dist/commands/search.js +49 -33
  57. package/dist/commands/teams.js +2 -1
  58. package/dist/commands/templates.js +9 -14
  59. package/dist/commands/users.js +5 -2
  60. package/dist/config/config.d.ts +99 -1
  61. package/dist/config/config.js +264 -52
  62. package/dist/config/error-enrichment.d.ts +62 -0
  63. package/dist/config/error-enrichment.js +417 -0
  64. package/dist/config/issue-validation.d.ts +37 -0
  65. package/dist/config/issue-validation.js +63 -1
  66. package/dist/config/paths.d.ts +2 -8
  67. package/dist/config/paths.js +4 -2
  68. package/dist/config/resolver.d.ts +8 -1
  69. package/dist/config/resolver.js +11 -5
  70. package/dist/main.js +13 -1
  71. package/dist/queries/attachments-types.d.ts +30 -0
  72. package/dist/queries/attachments-types.js +5 -0
  73. package/dist/queries/comments-types.d.ts +55 -0
  74. package/dist/queries/comments-types.js +5 -0
  75. package/dist/queries/common.d.ts +2 -2
  76. package/dist/queries/common.js +8 -0
  77. package/dist/queries/documents-types.d.ts +62 -0
  78. package/dist/queries/documents-types.js +9 -0
  79. package/dist/queries/introspect-types.d.ts +58 -0
  80. package/dist/queries/introspect-types.js +10 -0
  81. package/dist/queries/issues-types.d.ts +481 -0
  82. package/dist/queries/issues-types.js +23 -0
  83. package/dist/queries/issues.d.ts +51 -10
  84. package/dist/queries/issues.js +147 -5
  85. package/dist/queries/labels-types.d.ts +65 -0
  86. package/dist/queries/labels-types.js +5 -0
  87. package/dist/queries/project-milestones-types.d.ts +92 -0
  88. package/dist/queries/project-milestones-types.js +10 -0
  89. package/dist/queries/project-milestones.d.ts +1 -1
  90. package/dist/queries/projects-types.d.ts +76 -0
  91. package/dist/queries/projects-types.js +5 -0
  92. package/dist/queries/projects.d.ts +2 -0
  93. package/dist/queries/projects.js +22 -0
  94. package/dist/queries/releases-types.d.ts +85 -0
  95. package/dist/queries/releases-types.js +5 -0
  96. package/dist/queries/search-types.d.ts +102 -0
  97. package/dist/queries/search-types.js +6 -0
  98. package/dist/queries/templates-types.d.ts +62 -0
  99. package/dist/queries/templates-types.js +9 -0
  100. package/dist/types/linear.d.ts +21 -3
  101. package/dist/utils/auto-link-references.d.ts +3 -3
  102. package/dist/utils/auto-link-references.js +30 -34
  103. package/dist/utils/extract-field.d.ts +19 -0
  104. package/dist/utils/extract-field.js +99 -0
  105. package/dist/utils/file-service.d.ts +6 -13
  106. package/dist/utils/file-service.js +0 -2
  107. package/dist/utils/formatters/summary.js +6 -1
  108. package/dist/utils/graphql-attachments-service.js +6 -9
  109. package/dist/utils/graphql-documents-service.js +19 -25
  110. package/dist/utils/graphql-issues-service.d.ts +112 -46
  111. package/dist/utils/graphql-issues-service.js +398 -206
  112. package/dist/utils/graphql-service.d.ts +10 -12
  113. package/dist/utils/graphql-service.js +0 -3
  114. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  115. package/dist/utils/issue-reference-extractor.js +5 -3
  116. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  117. package/dist/utils/issues-service-bootstrap.js +27 -0
  118. package/dist/utils/linear-service.d.ts +21 -14
  119. package/dist/utils/linear-service.js +73 -11
  120. package/dist/utils/markdown-prosemirror.js +12 -12
  121. package/dist/utils/mention-resolver.js +1 -1
  122. package/dist/utils/output.d.ts +82 -2
  123. package/dist/utils/output.js +76 -11
  124. package/dist/utils/project-slug.d.ts +21 -0
  125. package/dist/utils/project-slug.js +45 -0
  126. package/dist/utils/protected-ranges.d.ts +14 -0
  127. package/dist/utils/protected-ranges.js +88 -2
  128. package/dist/utils/sanitize-for-log.d.ts +24 -0
  129. package/dist/utils/sanitize-for-log.js +38 -0
  130. package/dist/utils/table-formatter.js +24 -0
  131. package/dist/utils/validators.d.ts +7 -2
  132. package/dist/utils/validators.js +6 -0
  133. package/dist/utils/workspace-url.d.ts +5 -1
  134. package/dist/utils/workspace-url.js +53 -7
  135. package/package.json +2 -2
@@ -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) {
@@ -68,6 +122,13 @@ async function handleWrap(options, command) {
68
122
  return new Set(map.keys());
69
123
  },
70
124
  async resolveUrlKey() {
125
+ // When --workspace-url-key or EL_LINEAR_WORKSPACE_URL_KEY supplies
126
+ // the key, the live lookup never fires — skip the GraphQL service
127
+ // instantiation entirely so `refs wrap --no-validate` works offline.
128
+ const override = options.workspaceUrlKey;
129
+ if (override || process.env.EL_LINEAR_WORKSPACE_URL_KEY) {
130
+ return getWorkspaceUrlKey(undefined, { override });
131
+ }
71
132
  const graphQLService = await createGraphQLService(rootOpts);
72
133
  return getWorkspaceUrlKey(graphQLService);
73
134
  },
@@ -87,11 +148,17 @@ export function setupRefsCommands(program) {
87
148
  refs
88
149
  .command("wrap")
89
150
  .description("Wrap recognized Linear issue identifiers in input text as links. " +
90
- "Reads from stdin (or --file) and writes to stdout. By default, " +
91
- "each candidate identifier is validated against the workspace; " +
92
- "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.")
93
159
  .option("--file <path>", "read input from a file instead of stdin")
94
160
  .option("--target <target>", "output format: markdown (default) or slack", "markdown")
95
161
  .option("--no-validate", "skip workspace validation; wrap every regex match. Faster, but may produce broken links for IDs that don't exist.")
162
+ .option("--workspace-url-key <key>", "workspace URL key (the segment after linear.app/). Overrides config + env var. With --no-validate, makes the command fully offline.")
96
163
  .action(handleAsyncCommand(handleWrap));
97
164
  }
@@ -2,18 +2,24 @@ import { CREATE_RELEASE_MUTATION, GET_RELEASE_BY_ID_QUERY, GET_RELEASE_PIPELINES
2
2
  import { createGraphQLService } from "../utils/graphql-service.js";
3
3
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
4
4
  import { getRootOpts } from "../utils/root-opts.js";
5
+ import { parsePositiveInt } from "../utils/validators.js";
5
6
  function transformRelease(release) {
7
+ // Lifecycle timestamps + dates only exist on the list/detail node shapes,
8
+ // not on the create-mutation shape; read defensively rather than splitting
9
+ // the function in two.
10
+ const r = release;
11
+ const documents = release.documents?.nodes;
6
12
  return {
7
13
  id: release.id,
8
14
  name: release.name,
9
- description: release.description || undefined,
10
- version: release.version || undefined,
11
- url: release.url || undefined,
12
- startDate: release.startDate || undefined,
13
- targetDate: release.targetDate || undefined,
14
- startedAt: release.startedAt || undefined,
15
- completedAt: release.completedAt || undefined,
16
- canceledAt: release.canceledAt || undefined,
15
+ description: release.description ?? undefined,
16
+ version: release.version ?? undefined,
17
+ url: release.url ?? undefined,
18
+ startDate: r.startDate ?? undefined,
19
+ targetDate: r.targetDate ?? undefined,
20
+ startedAt: r.startedAt ?? undefined,
21
+ completedAt: r.completedAt ?? undefined,
22
+ canceledAt: r.canceledAt ?? undefined,
17
23
  stage: release.stage
18
24
  ? {
19
25
  id: release.stage.id,
@@ -27,7 +33,7 @@ function transformRelease(release) {
27
33
  name: release.pipeline.name,
28
34
  }
29
35
  : undefined,
30
- documents: release.documents?.nodes?.map((d) => ({
36
+ documents: documents?.map((d) => ({
31
37
  id: d.id,
32
38
  title: d.title,
33
39
  slugId: d.slugId,
@@ -40,14 +46,11 @@ async function handleCreateRelease(name, options, command) {
40
46
  const rootOpts = getRootOpts(command);
41
47
  const graphQLService = await createGraphQLService(rootOpts);
42
48
  const pipelines = await graphQLService.rawRequest(GET_RELEASE_PIPELINES_QUERY, { first: 50 });
43
- const pipelineNodes = pipelines.releasePipelines
44
- ?.nodes;
45
- const pipeline = pipelineNodes?.find((p) => p.id === options.pipeline ||
49
+ const pipelineNodes = pipelines.releasePipelines.nodes;
50
+ const pipeline = pipelineNodes.find((p) => p.id === options.pipeline ||
46
51
  p.name.toLowerCase() === options.pipeline.toLowerCase());
47
52
  if (!pipeline) {
48
- const available = (pipelineNodes ?? [])
49
- .map((p) => p.name)
50
- .join(", ");
53
+ const available = pipelineNodes.map((p) => p.name).join(", ");
51
54
  throw new Error(`Pipeline "${options.pipeline}" not found. Available: ${available || "none"}`);
52
55
  }
53
56
  const input = { name, pipelineId: pipeline.id };
@@ -58,21 +61,17 @@ async function handleCreateRelease(name, options, command) {
58
61
  input.version = options.version;
59
62
  }
60
63
  if (options.stage) {
61
- const stageNodes = pipeline.stages?.nodes;
62
- const stage = stageNodes?.find((s) => s.id === options.stage ||
64
+ const stage = pipeline.stages.nodes.find((s) => s.id === options.stage ||
63
65
  s.name.toLowerCase() === options.stage.toLowerCase());
64
66
  if (stage) {
65
67
  input.stageId = stage.id;
66
68
  }
67
69
  }
68
- const result = await graphQLService.rawRequest(CREATE_RELEASE_MUTATION, {
69
- input,
70
- });
71
- const releaseCreate = result.releaseCreate;
72
- if (!releaseCreate?.success) {
70
+ const result = await graphQLService.rawRequest(CREATE_RELEASE_MUTATION, { input });
71
+ if (!result.releaseCreate.success || !result.releaseCreate.release) {
73
72
  throw new Error(`Failed to create release "${name}"`);
74
73
  }
75
- outputSuccess(transformRelease(releaseCreate.release));
74
+ outputSuccess(transformRelease(result.releaseCreate.release));
76
75
  }
77
76
  export function setupReleasesCommands(program) {
78
77
  const releases = program
@@ -92,11 +91,10 @@ export function setupReleasesCommands(program) {
92
91
  filter.pipeline = { name: { eqIgnoreCase: options.pipeline } };
93
92
  }
94
93
  const result = await graphQLService.rawRequest(GET_RELEASES_QUERY, {
95
- first: Number.parseInt(options.limit, 10),
94
+ first: parsePositiveInt(options.limit, "--limit"),
96
95
  filter: Object.keys(filter).length > 0 ? filter : undefined,
97
96
  });
98
- const data = (result.releases
99
- ?.nodes ?? []).map(transformRelease);
97
+ const data = result.releases.nodes.map(transformRelease);
100
98
  outputSuccess({ data, meta: { count: data.length } });
101
99
  }));
102
100
  releases
@@ -126,12 +124,10 @@ export function setupReleasesCommands(program) {
126
124
  const rootOpts = getRootOpts(command);
127
125
  const graphQLService = await createGraphQLService(rootOpts);
128
126
  const result = await graphQLService.rawRequest(GET_RELEASE_PIPELINES_QUERY, { first: 50 });
129
- const data = (result.releasePipelines
130
- ?.nodes ?? []).map((p) => ({
127
+ const data = result.releasePipelines.nodes.map((p) => ({
131
128
  id: p.id,
132
129
  name: p.name,
133
- stages: (p.stages
134
- ?.nodes ?? []).map((s) => ({
130
+ stages: p.stages.nodes.map((s) => ({
135
131
  id: s.id,
136
132
  name: s.name,
137
133
  type: s.type,
@@ -3,6 +3,7 @@ import { SEMANTIC_SEARCH_QUERY } from "../queries/search.js";
3
3
  import { createGraphQLService } from "../utils/graphql-service.js";
4
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
5
  import { getRootOpts } from "../utils/root-opts.js";
6
+ import { parsePositiveInt } from "../utils/validators.js";
6
7
  const TEMPLATES_QUERY = `
7
8
  query {
8
9
  templates {
@@ -22,61 +23,72 @@ const VALID_TYPES = new Set([
22
23
  "document",
23
24
  "template",
24
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
+ }
25
43
  const WHITESPACE_RE = /\s+/;
26
44
  function transformSearchResult(r) {
27
- const rType = r.type;
28
- switch (rType) {
45
+ switch (r.type) {
29
46
  case "issue": {
30
- const issue = r.issue;
31
- const state = issue?.state;
32
- const team = issue?.team;
33
- const assignee = issue?.assignee;
34
- const project = issue?.project;
47
+ const { issue } = r;
35
48
  return {
36
49
  type: "issue",
37
50
  identifier: issue?.identifier,
38
51
  title: issue?.title,
39
- state: state?.name,
40
- team: team?.key,
41
- assignee: assignee
42
- ? resolveUserDisplayName(assignee.id, assignee.name)
52
+ state: issue?.state?.name,
53
+ team: issue?.team?.key,
54
+ assignee: issue?.assignee
55
+ ? resolveUserDisplayName(issue.assignee.id, issue.assignee.name)
43
56
  : undefined,
44
- priority: issue?.priority,
45
- project: project?.name,
57
+ priority: issue?.priority ?? undefined,
58
+ project: issue?.project?.name,
46
59
  id: issue?.id,
47
60
  };
48
61
  }
49
62
  case "project": {
50
- const project = r.project;
51
63
  return {
52
64
  type: "project",
53
- name: project?.name,
54
- state: project?.state,
55
- id: project?.id,
65
+ name: r.project?.name,
66
+ state: r.project?.state,
67
+ id: r.project?.id,
56
68
  };
57
69
  }
58
70
  case "initiative": {
59
- const initiative = r.initiative;
60
71
  return {
61
72
  type: "initiative",
62
- name: initiative?.name,
63
- status: initiative?.status,
64
- id: initiative?.id,
73
+ name: r.initiative?.name,
74
+ status: r.initiative?.status ?? undefined,
75
+ id: r.initiative?.id,
65
76
  };
66
77
  }
67
78
  case "document": {
68
- const doc = r.document;
69
- const docProject = doc?.project;
70
79
  return {
71
80
  type: "document",
72
- title: doc?.title,
73
- project: docProject?.name,
74
- slugId: doc?.slugId,
75
- id: doc?.id,
81
+ title: r.document?.title,
82
+ project: r.document?.project?.name,
83
+ slugId: r.document?.slugId ?? undefined,
84
+ id: r.document?.id,
76
85
  };
77
86
  }
78
87
  default:
79
- return { type: rType, 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;
80
92
  }
81
93
  }
82
94
  function matchesQuery(name, query) {
@@ -122,11 +134,15 @@ function buildSemanticQuery(graphQLService, query, limit, teamOption) {
122
134
  });
123
135
  }
124
136
  function extractSemanticResults(semanticResult, requestedTypes, onlyTemplates) {
125
- const semanticSearch = semanticResult.semanticSearch;
126
- let results = 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);
127
143
  if (requestedTypes && !onlyTemplates) {
128
- const semanticTypes = requestedTypes.filter((t) => t !== "template");
129
- 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));
130
146
  }
131
147
  return results.map(transformSearchResult);
132
148
  }
@@ -140,7 +156,7 @@ export function setupSearchCommands(program) {
140
156
  .action(handleAsyncCommand(async (query, options, command) => {
141
157
  const rootOpts = getRootOpts(command);
142
158
  const graphQLService = await createGraphQLService(rootOpts);
143
- const limit = Number.parseInt(options.limit, 10);
159
+ const limit = parsePositiveInt(options.limit, "--limit");
144
160
  const requestedTypes = options.type
145
161
  ? options.type.split(",").map((t) => t.trim().toLowerCase())
146
162
  : null;
@@ -3,6 +3,7 @@ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
3
3
  import { createLinearService } from "../utils/linear-service.js";
4
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
5
  import { getRootOpts } from "../utils/root-opts.js";
6
+ import { parsePositiveInt } from "../utils/validators.js";
6
7
  export function setupTeamsCommands(program) {
7
8
  const teams = program
8
9
  .command("teams")
@@ -15,7 +16,7 @@ export function setupTeamsCommands(program) {
15
16
  .option("-l, --limit <number>", "limit results", "100")
16
17
  .action(handleAsyncCommand(async (options, command) => {
17
18
  const rootOpts = getRootOpts(command);
18
- const limit = Number.parseInt(options.limit, 10);
19
+ const limit = parsePositiveInt(options.limit, "--limit");
19
20
  const ttl = resolveCacheTTL({
20
21
  configTTL: loadConfig().cacheTTLSeconds,
21
22
  // commander's `--no-cache` produces `cache: false` on the root opts.
@@ -48,14 +48,11 @@ export function setupTemplatesCommands(program) {
48
48
  .action(handleAsyncCommand(async (templateId, _options, command) => {
49
49
  const rootOpts = getRootOpts(command);
50
50
  const graphQLService = await createGraphQLService(rootOpts);
51
- const result = await graphQLService.rawRequest(TEMPLATE_BY_ID_QUERY, {
52
- id: templateId,
53
- });
54
- const template = result.template;
55
- if (!template) {
51
+ const result = await graphQLService.rawRequest(TEMPLATE_BY_ID_QUERY, { id: templateId });
52
+ if (!result.template) {
56
53
  throw new Error(`Template "${templateId}" not found`);
57
54
  }
58
- outputSuccess(template);
55
+ outputSuccess(result.template);
59
56
  }));
60
57
  templates
61
58
  .command("create")
@@ -87,11 +84,10 @@ export function setupTemplatesCommands(program) {
87
84
  if (options.color)
88
85
  input.color = options.color;
89
86
  const result = await graphQLService.rawRequest(TEMPLATE_CREATE_MUTATION, { input });
90
- const payload = result.templateCreate;
91
- if (!payload?.success) {
87
+ if (!result.templateCreate.success || !result.templateCreate.template) {
92
88
  throw new Error("templateCreate returned success=false");
93
89
  }
94
- outputSuccess(payload.template);
90
+ outputSuccess(result.templateCreate.template);
95
91
  }));
96
92
  templates
97
93
  .command("update <templateId>")
@@ -126,11 +122,11 @@ export function setupTemplatesCommands(program) {
126
122
  throw new Error("templates update: at least one of --name, --description, --data, --data-file, --icon, --color, --team-id is required");
127
123
  }
128
124
  const result = await graphQLService.rawRequest(TEMPLATE_UPDATE_MUTATION, { id: templateId, input });
129
- const payload = result.templateUpdate;
130
- if (!payload?.success) {
125
+ if (!result.templateUpdate.success ||
126
+ !result.templateUpdate.template) {
131
127
  throw new Error("templateUpdate returned success=false");
132
128
  }
133
- outputSuccess(payload.template);
129
+ outputSuccess(result.templateUpdate.template);
134
130
  }));
135
131
  templates
136
132
  .command("delete <templateId>")
@@ -139,8 +135,7 @@ export function setupTemplatesCommands(program) {
139
135
  const rootOpts = getRootOpts(command);
140
136
  const graphQLService = await createGraphQLService(rootOpts);
141
137
  const result = await graphQLService.rawRequest(TEMPLATE_DELETE_MUTATION, { id: templateId });
142
- const payload = result.templateDelete;
143
- if (!payload?.success) {
138
+ if (!result.templateDelete.success) {
144
139
  throw new Error("templateDelete returned success=false");
145
140
  }
146
141
  outputSuccess({ id: templateId, deleted: true });
@@ -1,6 +1,7 @@
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
+ import { parsePositiveInt } from "../utils/validators.js";
4
5
  export function setupUsersCommands(program) {
5
6
  const users = program.command("users").description("User operations");
6
7
  users.action(() => users.help());
@@ -13,7 +14,9 @@ export function setupUsersCommands(program) {
13
14
  .action(handleAsyncCommand(async (options, command) => {
14
15
  const rootOpts = getRootOpts(command);
15
16
  const service = await createLinearService(rootOpts);
16
- const result = await service.getUsers(options.active, Number.parseInt(options.limit, 10), 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);
17
20
  outputSuccess({ data: result, meta: { count: result.length } });
18
21
  }));
19
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;