@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
@@ -3,6 +3,7 @@ import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-se
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 setupAttachmentsCommands(program) {
7
8
  const attachments = program
8
9
  .command("attachments")
@@ -40,7 +41,7 @@ export function setupAttachmentsCommands(program) {
40
41
  const resolvedIssueId = await linearService.resolveIssueId(issueId);
41
42
  const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
42
43
  const allAttachments = await attachmentsService.listAttachments(resolvedIssueId);
43
- const limit = Number.parseInt(options.limit, 10);
44
+ const limit = parsePositiveInt(options.limit, "--limit");
44
45
  const data = allAttachments.slice(0, limit);
45
46
  outputSuccess({ data, meta: { count: data.length } });
46
47
  }));
@@ -1,14 +1,12 @@
1
- import { resolveAssignee, resolveLabels, resolveTeam, } from "../config/resolver.js";
2
- import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
3
- import { createGraphQLService } from "../utils/graphql-service.js";
4
- import { createLinearService } from "../utils/linear-service.js";
1
+ import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
2
+ import { createIssuesService } from "../utils/issues-service-bootstrap.js";
5
3
  import { logger } from "../utils/logger.js";
6
4
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
7
5
  import { getRootOpts } from "../utils/root-opts.js";
8
6
  import { splitList } from "../utils/validators.js";
9
7
  /**
10
8
  * Parse the --filter string into structured search arguments.
11
- * Format: "status:Backlog team:DEV label:Bug assignee:Alice project:Sprint12"
9
+ * Format: "status:Backlog team:DEV label:Bug assignee:Alice delegate:Claude project:Sprint12"
12
10
  */
13
11
  function parseFilterString(filter) {
14
12
  const result = {};
@@ -30,21 +28,28 @@ function parseFilterString(filter) {
30
28
  * Returns LinearIssue[] so we can show a preview.
31
29
  */
32
30
  async function resolveTargetIssues(options, rootOpts) {
33
- const graphQLService = await createGraphQLService(rootOpts);
34
- const linearService = await createLinearService(rootOpts);
35
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
31
+ const { issuesService } = await createIssuesService(rootOpts);
36
32
  if (options.issues) {
37
33
  const ids = splitList(options.issues);
38
34
  return Promise.all(ids.map((id) => issuesService.getIssueById(id)));
39
35
  }
40
36
  if (options.filter) {
41
37
  const filters = parseFilterString(options.filter);
38
+ // Typed as SearchIssueArgs (not Record<string, unknown>) so a future
39
+ // rename of any field — like the DEV-4068 T4 `projectId` → `project`
40
+ // migration — surfaces here at compile time instead of silently
41
+ // dropping the filter.
42
42
  const searchArgs = {
43
43
  teamId: filters.team ? resolveTeam(filters.team) : undefined,
44
44
  assigneeId: filters.assignee
45
45
  ? await resolveAssignee(filters.assignee, rootOpts)
46
46
  : undefined,
47
- projectId: filters.project || undefined,
47
+ delegateId: filters.delegate
48
+ ? resolveMember(filters.delegate)
49
+ : undefined,
50
+ project: filters.project
51
+ ? { kind: "id", id: filters.project }
52
+ : undefined,
48
53
  labelNames: filters.label ? splitList(filters.label) : undefined,
49
54
  status: filters.status ? splitList(filters.status) : undefined,
50
55
  limit: 50,
@@ -117,9 +122,7 @@ async function handleBatchAssign(options, command) {
117
122
  });
118
123
  return;
119
124
  }
120
- const graphQLService = await createGraphQLService(rootOpts);
121
- const linearService = await createLinearService(rootOpts);
122
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
125
+ const { issuesService } = await createIssuesService(rootOpts);
123
126
  const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, assigneeId }, "adding"));
124
127
  outputSuccess({
125
128
  action: "assign",
@@ -164,9 +167,7 @@ async function handleBatchLabel(options, command) {
164
167
  });
165
168
  return;
166
169
  }
167
- const graphQLService = await createGraphQLService(rootOpts);
168
- const linearService = await createLinearService(rootOpts);
169
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
170
+ const { issuesService } = await createIssuesService(rootOpts);
170
171
  const removeLower = removeLabels.map((l) => l.toLowerCase());
171
172
  const { results } = await executeBatch(issues, (issue) => {
172
173
  // Filter out labels to remove (by name, case-insensitive)
@@ -218,9 +219,7 @@ async function handleBatchMove(options, command) {
218
219
  });
219
220
  return;
220
221
  }
221
- const graphQLService = await createGraphQLService(rootOpts);
222
- const linearService = await createLinearService(rootOpts);
223
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
222
+ const { issuesService } = await createIssuesService(rootOpts);
224
223
  const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, projectId: options.project }, "adding"));
225
224
  outputSuccess({
226
225
  action: "move",
@@ -254,9 +253,7 @@ async function handleBatchStatus(options, command) {
254
253
  });
255
254
  return;
256
255
  }
257
- const graphQLService = await createGraphQLService(rootOpts);
258
- const linearService = await createLinearService(rootOpts);
259
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
256
+ const { issuesService } = await createIssuesService(rootOpts);
260
257
  const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, statusId: options.status }, "adding"));
261
258
  outputSuccess({
262
259
  action: "status",
@@ -11,39 +11,38 @@ import { resolveMentions } from "../utils/mention-resolver.js";
11
11
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
12
12
  import { getRootOpts } from "../utils/root-opts.js";
13
13
  import { validateReferences } from "../utils/validate-references.js";
14
+ import { parsePositiveInt } from "../utils/validators.js";
14
15
  import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
15
16
  // Match Linear's bodyData validation error in multiple phrasings so a wording
16
17
  // tweak on their side doesn't silently regress the fallback path.
17
18
  const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
18
19
  const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
19
20
  function readBody(options) {
20
- if (options.file) {
21
- return readFileSync(options.file, "utf-8");
21
+ if (options.bodyFile) {
22
+ return readFileSync(options.bodyFile, "utf-8");
22
23
  }
23
24
  if (options.body) {
24
25
  return options.body;
25
26
  }
26
- throw new Error("Either --body or --file is required");
27
+ throw new Error("Either --body or --body-file is required");
27
28
  }
28
29
  async function fetchSelfUserId(graphQLService) {
29
30
  try {
30
31
  const result = await graphQLService.rawRequest("{ viewer { id } }");
31
- const viewer = result.viewer;
32
- return viewer?.id;
32
+ return result.viewer?.id;
33
33
  }
34
34
  catch {
35
35
  return;
36
36
  }
37
37
  }
38
38
  function transformComment(comment) {
39
- const user = comment.user;
40
39
  return {
41
40
  id: comment.id,
42
41
  body: comment.body,
43
42
  user: {
44
- id: user.id,
45
- name: resolveUserDisplayName(user.id, user.name),
46
- url: user.url || undefined,
43
+ id: comment.user.id,
44
+ name: resolveUserDisplayName(comment.user.id, comment.user.name),
45
+ url: comment.user.url ?? undefined,
47
46
  },
48
47
  createdAt: comment.createdAt,
49
48
  updatedAt: comment.updatedAt,
@@ -133,9 +132,7 @@ async function handleCreateComment(issueId, options, command) {
133
132
  }
134
133
  let result;
135
134
  try {
136
- result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
137
- input,
138
- });
135
+ result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input });
139
136
  }
140
137
  catch (err) {
141
138
  // If the bodyData ProseMirror document is rejected (invalid shape,
@@ -147,16 +144,14 @@ async function handleCreateComment(issueId, options, command) {
147
144
  issueId: resolvedIssueId,
148
145
  body,
149
146
  };
150
- result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
151
- input: fallbackInput,
152
- });
147
+ result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input: fallbackInput });
153
148
  }
154
149
  else {
155
150
  throw err;
156
151
  }
157
152
  }
158
153
  const mutation = result.commentCreate;
159
- if (!mutation.success) {
154
+ if (!mutation.success || !mutation.comment) {
160
155
  throw new Error("Failed to create comment");
161
156
  }
162
157
  // We don't have the parent issue's identifier from the create mutation response,
@@ -201,22 +196,18 @@ async function handleUpdateComment(commentId, options, command) {
201
196
  else {
202
197
  input.body = body;
203
198
  }
204
- const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, {
205
- id: commentId,
206
- input,
207
- });
199
+ const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input });
208
200
  const mutation = result.commentUpdate;
209
- if (!mutation.success) {
201
+ if (!mutation.success || !mutation.comment) {
210
202
  throw new Error("Failed to update comment");
211
203
  }
212
204
  const comment = mutation.comment;
213
- const issue = comment.issue;
214
205
  let autoLinked;
215
- if (issue) {
206
+ if (comment.issue) {
216
207
  // rawBody (pre-wrap) — see note in handleCreateComment about keyword inference
217
208
  autoLinked = await autoLinkCommentReferences({
218
- parentIssueUuid: issue.id,
219
- parentIssueIdentifier: issue.identifier ?? "",
209
+ parentIssueUuid: comment.issue.id,
210
+ parentIssueIdentifier: comment.issue.identifier,
220
211
  body: rawBody,
221
212
  preResolved,
222
213
  options,
@@ -234,19 +225,17 @@ async function handleListComments(issueId, options, command) {
234
225
  const resolvedId = await linearService.resolveIssueId(issueId);
235
226
  const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
236
227
  issueId: resolvedId,
237
- first: Number.parseInt(options.limit, 10),
228
+ first: parsePositiveInt(options.limit, "--limit"),
238
229
  });
239
- const issue = result.issue;
240
- if (!issue) {
230
+ if (!result.issue) {
241
231
  throw new Error(`Issue "${issueId}" not found`);
242
232
  }
243
- const commentsData = issue.comments;
244
- const nodes = commentsData.nodes.map(transformComment);
233
+ const nodes = result.issue.comments.nodes.map(transformComment);
245
234
  outputSuccess({
246
235
  data: nodes,
247
236
  meta: {
248
237
  count: nodes.length,
249
- issue: issue.identifier,
238
+ issue: result.issue.identifier,
250
239
  },
251
240
  });
252
241
  }
@@ -261,7 +250,7 @@ export function setupCommentsCommands(program) {
261
250
  .description("Create new comment on issue.")
262
251
  .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nBare references to known team members (e.g. 'Dima') are auto-converted to @mentions — pass --no-auto-mention to disable.\nIssue identifiers (e.g. DEV-123) are wrapped as markdown links and added as 'related' relations on the parent issue — pass --no-auto-link to disable.")
263
252
  .option("--body <body>", "comment body (inline)")
264
- .option("--file <path>", "read comment body from file")
253
+ .option("--body-file <path>", "read comment body from file")
265
254
  .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
266
255
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
267
256
  .option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
@@ -271,7 +260,7 @@ export function setupCommentsCommands(program) {
271
260
  .command("update <commentId>")
272
261
  .description("Update an existing comment.")
273
262
  .option("--body <body>", "new comment body (inline)")
274
- .option("--file <path>", "read new comment body from file")
263
+ .option("--body-file <path>", "read new comment body from file")
275
264
  .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
276
265
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
277
266
  .action(handleAsyncCommand(handleUpdateComment));
@@ -1,5 +1,47 @@
1
- import { loadConfig } from "../config/config.js";
2
- import { outputSuccess } from "../utils/output.js";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getActiveTeamConfigInfo, getActiveTeamConfigPath, loadConfig, loadLocalConfig, } from "../config/config.js";
5
+ import { resolveActiveProfile } from "../config/paths.js";
6
+ import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
7
+ import { updateConfig } from "./init/shared.js";
8
+ /**
9
+ * Expand a leading `~` to the user's home directory. Shells expand `~` before
10
+ * the command sees it, but a literal `~/...` pasted from documentation reaches
11
+ * Node verbatim. We handle it so `el-linear config team set-path ~/git/...`
12
+ * works regardless of whether the shell did the expansion first.
13
+ */
14
+ function expandHome(p) {
15
+ if (p === "~")
16
+ return os.homedir();
17
+ if (p.startsWith("~/"))
18
+ return path.join(os.homedir(), p.slice(2));
19
+ return p;
20
+ }
21
+ /**
22
+ * Render the team-config source token returned by `getActiveTeamConfigInfo()`
23
+ * as a human-readable string for `config team show`. Kept next to the command
24
+ * (not in `config/config.ts`) so the wire format is owned by the renderer,
25
+ * not the resolver. DEV-4258.
26
+ */
27
+ function sourceLabel(source) {
28
+ switch (source) {
29
+ case "env":
30
+ return "EL_LINEAR_TEAM_CONFIG env var";
31
+ case "personal":
32
+ return "teamConfigPath in personal config";
33
+ case "marker":
34
+ return "auto-discovered via ~/.config/el-tools-root";
35
+ case null:
36
+ return null;
37
+ }
38
+ }
39
+ const LOCAL_SETTABLE_KEYS = [
40
+ "assigneeEmail",
41
+ "defaultAssignee",
42
+ "defaultPriority",
43
+ "cacheTTLSeconds",
44
+ ];
3
45
  export function setupConfigCommands(program) {
4
46
  const config = program
5
47
  .command("config")
@@ -7,9 +49,140 @@ export function setupConfigCommands(program) {
7
49
  config.action(() => config.help());
8
50
  config
9
51
  .command("show")
10
- .description("Show resolved configuration")
11
- .addHelpText("after", "\nDumps the merged config from ~/.config/el-linear/config.json.\n\nExamples:\n el-linear config show")
52
+ .description("Show resolved configuration (team file → personal config → local overrides)")
53
+ .addHelpText("after", "\nDumps the merged config from the active team file + config.json + local.json.\n\nExamples:\n el-linear config show")
54
+ .action(() => {
55
+ const data = loadConfig();
56
+ const teamConfig = getActiveTeamConfigPath();
57
+ const local = loadLocalConfig();
58
+ const out = { data };
59
+ if (teamConfig)
60
+ out.teamConfig = teamConfig;
61
+ if (Object.keys(local).length > 0)
62
+ out.local = local;
63
+ outputSuccess(out);
64
+ });
65
+ const local = config
66
+ .command("local")
67
+ .description("Manage user-local config overrides (local.json)");
68
+ local.action(() => local.help());
69
+ local
70
+ .command("show")
71
+ .description("Show the raw contents of local.json")
72
+ .addHelpText("after", `\nShows ~/.config/el-linear/local.json (or the per-profile equivalent).\n\nExamples:\n el-linear config local show`)
73
+ .action(() => {
74
+ outputSuccess({ data: loadLocalConfig() });
75
+ });
76
+ const team = config
77
+ .command("team")
78
+ .description("Manage the shared team config layer (teamConfigPath in personal config)");
79
+ team.action(() => team.help());
80
+ team
81
+ .command("show")
82
+ .description("Show the active team config layer: path, source (env var, personal config, or auto-discovered marker), file status, top-level keys it contributes")
83
+ .addHelpText("after", "\nResolves the team config in the same order loadConfig() does:\n EL_LINEAR_TEAM_CONFIG (env) > teamConfigPath (personal config) > ~/.config/el-tools-root marker\n\nExamples:\n el-linear config team show")
12
84
  .action(() => {
13
- outputSuccess({ data: loadConfig() });
85
+ const info = getActiveTeamConfigInfo();
86
+ const teamPath = info.path;
87
+ const source = sourceLabel(info.source);
88
+ const out = {
89
+ teamConfigPath: teamPath ?? null,
90
+ source,
91
+ };
92
+ if (teamPath) {
93
+ try {
94
+ const raw = fs.readFileSync(teamPath, "utf8");
95
+ const parsed = JSON.parse(raw);
96
+ out.exists = true;
97
+ out.valid = true;
98
+ out.providedKeys = Object.keys(parsed).sort();
99
+ }
100
+ catch (err) {
101
+ out.exists = fs.existsSync(teamPath);
102
+ out.valid = false;
103
+ out.error = err.message;
104
+ }
105
+ }
106
+ outputSuccess({ data: out });
14
107
  });
108
+ team
109
+ .command("set-path <path>")
110
+ .description("Write teamConfigPath into personal config.json. Path is resolved to an absolute path. The target file must exist and be valid JSON — a pointer to a missing or broken file is footgun-shaped and refused.")
111
+ .addHelpText("after", "\nUse this when adopting a shared team config (e.g. one checked into a tools repo):\n el-linear config team set-path ~/git/enrichlayer/tools/config/el-linear.shared.json\n\nRelative paths resolve against the current working directory; absolute paths are stored as-is. EL_LINEAR_TEAM_CONFIG in the environment still overrides the personal field when both are set — a warning surfaces in that case.")
112
+ .action(handleAsyncCommand(async (rawPath) => {
113
+ const resolved = path.resolve(expandHome(rawPath));
114
+ if (!fs.existsSync(resolved)) {
115
+ throw new Error(`Team config file not found at ${resolved}. Create the file (or fix the path) before pointing personal config at it.`);
116
+ }
117
+ try {
118
+ JSON.parse(fs.readFileSync(resolved, "utf8"));
119
+ }
120
+ catch (err) {
121
+ throw new Error(`Team config at ${resolved} is not valid JSON: ${err.message}`);
122
+ }
123
+ await updateConfig((current) => ({
124
+ ...current,
125
+ teamConfigPath: resolved,
126
+ }));
127
+ const envOverride = process.env.EL_LINEAR_TEAM_CONFIG?.trim() || undefined;
128
+ if (envOverride !== undefined && envOverride !== resolved) {
129
+ outputWarning(`EL_LINEAR_TEAM_CONFIG=${envOverride} is set in this shell and overrides the personal config field. Unset it (or align it) for the persistent setting to take effect.`);
130
+ }
131
+ outputSuccess({
132
+ data: {
133
+ teamConfigPath: resolved,
134
+ written: resolveActiveProfile().configPath,
135
+ },
136
+ });
137
+ }));
138
+ team
139
+ .command("clear")
140
+ .description("Remove teamConfigPath from personal config.json. EL_LINEAR_TEAM_CONFIG (env) is unaffected and continues to override if set.")
141
+ .action(handleAsyncCommand(async () => {
142
+ let removed;
143
+ await updateConfig((current) => {
144
+ removed = current.teamConfigPath;
145
+ const { teamConfigPath: _drop, ...rest } = current;
146
+ return rest;
147
+ });
148
+ const envOverride = process.env.EL_LINEAR_TEAM_CONFIG?.trim() || undefined;
149
+ if (envOverride !== undefined) {
150
+ outputWarning(`EL_LINEAR_TEAM_CONFIG=${envOverride} is still set in this shell; it overrides the personal field and remains active until unset.`);
151
+ }
152
+ outputSuccess({
153
+ data: {
154
+ cleared: true,
155
+ previousTeamConfigPath: removed ?? null,
156
+ written: resolveActiveProfile().configPath,
157
+ },
158
+ });
159
+ }));
160
+ local
161
+ .command("set <key> <value>")
162
+ .description(`Set a key in local.json. Keys: ${LOCAL_SETTABLE_KEYS.join(", ")}`)
163
+ .addHelpText("after", `\nAllowed keys:\n assigneeEmail — your Linear account email (used as default --assignee)\n defaultAssignee — alias / display name / email / UUID\n defaultPriority — none|urgent|high|medium|normal|low\n cacheTTLSeconds — integer seconds (0 = disable cache)\n\nExamples:\n el-linear config local set assigneeEmail you@example.com\n el-linear config local set defaultPriority medium\n el-linear config local set cacheTTLSeconds 0`)
164
+ .action(handleAsyncCommand(async (key, value) => {
165
+ if (!LOCAL_SETTABLE_KEYS.includes(key)) {
166
+ throw new Error(`Unknown local config key "${key}". Allowed: ${LOCAL_SETTABLE_KEYS.join(", ")}`);
167
+ }
168
+ const active = resolveActiveProfile();
169
+ const localPath = active.localConfigPath;
170
+ let existing = {};
171
+ if (fs.existsSync(localPath)) {
172
+ try {
173
+ existing = JSON.parse(fs.readFileSync(localPath, "utf8"));
174
+ }
175
+ catch {
176
+ // overwrite a corrupt file
177
+ }
178
+ }
179
+ const typedValue = key === "cacheTTLSeconds" ? Number(value) : value;
180
+ if (key === "cacheTTLSeconds" && Number.isNaN(typedValue)) {
181
+ throw new Error(`cacheTTLSeconds must be an integer, got "${value}"`);
182
+ }
183
+ const updated = { ...existing, [key]: typedValue };
184
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
185
+ fs.writeFileSync(localPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
186
+ outputSuccess({ data: updated });
187
+ }));
15
188
  }
@@ -3,6 +3,7 @@ import { invalidParameterError, notFoundError, requiresParameterError, } from ".
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 setupCyclesCommands(program) {
7
8
  const cycles = program
8
9
  .command("cycles")
@@ -23,7 +24,7 @@ export function setupCyclesCommands(program) {
23
24
  const rootOpts = getRootOpts(command);
24
25
  const teamFilter = options.team ? resolveTeam(options.team) : undefined;
25
26
  const linearService = await createLinearService(rootOpts);
26
- const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, Number.parseInt(options.limit, 10));
27
+ const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, parsePositiveInt(options.limit, "--limit"));
27
28
  if (options.aroundActive) {
28
29
  const n = Number.parseInt(options.aroundActive, 10);
29
30
  if (Number.isNaN(n) || n < 0) {
@@ -3,6 +3,7 @@ import { createGraphQLDocumentsService } from "../utils/graphql-documents-servic
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
  function extractDocumentIdFromUrl(url) {
7
8
  try {
8
9
  const parsed = new URL(url);
@@ -70,7 +71,7 @@ async function handleListDocuments(options, command) {
70
71
  const rootOpts = getRootOpts(command);
71
72
  const documentsService = await createGraphQLDocumentsService(rootOpts);
72
73
  const linearService = await createLinearService(rootOpts);
73
- const limit = Number.parseInt(options.limit || "50", 10);
74
+ const limit = parsePositiveInt(options.limit || "50", "--limit");
74
75
  if (Number.isNaN(limit) || limit < 1) {
75
76
  throw new Error(`Invalid limit "${options.limit}": must be a positive number`);
76
77
  }
@@ -46,16 +46,14 @@ export function setupGraphQLCommands(program) {
46
46
  const graphQLService = await createGraphQLService(rootOpts);
47
47
  if (typeName) {
48
48
  const result = await graphQLService.rawRequest(INTROSPECT_TYPE_QUERY, { typeName });
49
- const typeInfo = result.__type;
50
- if (!typeInfo) {
49
+ if (!result.__type) {
51
50
  throw new Error(`Type "${typeName}" not found in schema`);
52
51
  }
53
- outputSuccess(typeInfo);
52
+ outputSuccess(result.__type);
54
53
  }
55
54
  else {
56
55
  const result = await graphQLService.rawRequest(INTROSPECT_ROOT_QUERY);
57
- const queryType = result.__type;
58
- let fields = queryType?.fields ?? [];
56
+ let fields = result.__type?.fields ?? [];
59
57
  if (options.filter) {
60
58
  const pattern = options.filter.toLowerCase();
61
59
  fields = fields.filter((f) => f.name.toLowerCase().includes(pattern));
@@ -63,7 +61,7 @@ export function setupGraphQLCommands(program) {
63
61
  const summary = fields.map((f) => ({
64
62
  name: f.name,
65
63
  description: f.description ?? null,
66
- args: (f.args ?? []).map((a) => a.name),
64
+ args: f.args.map((a) => a.name),
67
65
  }));
68
66
  outputSuccess({ fields: summary, count: summary.length });
69
67
  }
@@ -22,7 +22,7 @@ const USERS_QUERY = /* GraphQL */ `
22
22
  }
23
23
  `;
24
24
  export async function fetchAllUsers(token) {
25
- const service = new GraphQLService(token);
25
+ const service = new GraphQLService({ apiKey: token });
26
26
  const out = [];
27
27
  let after;
28
28
  for (;;) {
@@ -6,7 +6,7 @@
6
6
  * with no input is a no-op.
7
7
  */
8
8
  import { type WizardConfig } from "./shared.js";
9
- export interface DefaultsStepResult {
9
+ interface DefaultsStepResult {
10
10
  defaultLabels: string[] | undefined;
11
11
  /**
12
12
  * Default assignee identifier (alias / display name / email / UUID — same
@@ -40,3 +40,4 @@ export interface DefaultsStepResult {
40
40
  cacheTTLSeconds: number | undefined;
41
41
  }
42
42
  export declare function runDefaultsStep(existing: WizardConfig): Promise<DefaultsStepResult>;
43
+ export {};