@enrichlayer/el-linear 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +69 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/init/index.js +2 -0
  13. package/dist/commands/init/oauth.d.ts +6 -0
  14. package/dist/commands/init/oauth.js +8 -2
  15. package/dist/commands/init/token.d.ts +0 -8
  16. package/dist/commands/init/token.js +13 -1
  17. package/dist/commands/issues/branch.d.ts +17 -0
  18. package/dist/commands/issues/branch.js +40 -0
  19. package/dist/commands/issues/description.d.ts +89 -0
  20. package/dist/commands/issues/description.js +187 -0
  21. package/dist/commands/issues.js +34 -215
  22. package/dist/commands/labels.js +7 -5
  23. package/dist/commands/profile/migrate-legacy.js +10 -33
  24. package/dist/commands/profile.js +1 -10
  25. package/dist/commands/projects.d.ts +5 -1
  26. package/dist/commands/projects.js +135 -64
  27. package/dist/commands/read-shortcut.d.ts +6 -0
  28. package/dist/commands/read-shortcut.js +6 -1
  29. package/dist/commands/refs.js +2 -1
  30. package/dist/commands/templates.js +127 -1
  31. package/dist/commands/users.js +2 -1
  32. package/dist/config/config.js +31 -9
  33. package/dist/config/issue-validation.js +1 -1
  34. package/dist/config/paths.d.ts +11 -0
  35. package/dist/config/paths.js +44 -6
  36. package/dist/config/term-enforcer.js +1 -1
  37. package/dist/main.js +37 -2
  38. package/dist/queries/templates.d.ts +3 -0
  39. package/dist/queries/templates.js +43 -0
  40. package/dist/utils/auth.js +7 -9
  41. package/dist/utils/auto-link-references.js +15 -1
  42. package/dist/utils/disk-cache.js +2 -1
  43. package/dist/utils/formatters/summary.d.ts +62 -0
  44. package/dist/utils/formatters/summary.js +755 -0
  45. package/dist/utils/graphql-issues-service.d.ts +106 -3
  46. package/dist/utils/graphql-issues-service.js +51 -37
  47. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  48. package/dist/utils/issue-reference-extractor.js +16 -9
  49. package/dist/utils/issue-reference-wrapper.js +1 -54
  50. package/dist/utils/linear-service.d.ts +7 -3
  51. package/dist/utils/linear-service.js +27 -5
  52. package/dist/utils/markdown-prosemirror.js +17 -1
  53. package/dist/utils/mention-resolver.js +17 -5
  54. package/dist/utils/output.d.ts +5 -1
  55. package/dist/utils/output.js +28 -1
  56. package/dist/utils/protected-ranges.d.ts +33 -0
  57. package/dist/utils/protected-ranges.js +73 -0
  58. package/dist/utils/table-formatter.d.ts +36 -0
  59. package/dist/utils/table-formatter.js +46 -24
  60. package/dist/utils/validators.d.ts +9 -1
  61. package/dist/utils/validators.js +10 -0
  62. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -24,16 +24,17 @@ import { setupTeamsCommands } from "./commands/teams.js";
24
24
  import { setupTemplatesCommands } from "./commands/templates.js";
25
25
  import { setupUsersCommands } from "./commands/users.js";
26
26
  import { setActiveProfileForSession } from "./config/paths.js";
27
- import { setFieldsFilter, setJqFilter, setRawMode } from "./utils/output.js";
27
+ import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
28
28
  import { outputUsageInfo } from "./utils/usage.js";
29
29
  import { splitList } from "./utils/validators.js";
30
30
  program
31
31
  .name("el-linear")
32
32
  .description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
33
- .version("1.7.0")
33
+ .version("1.8.1")
34
34
  .option("--api-token <token>", "Linear API token")
35
35
  .option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
36
36
  .option("--json", "output as JSON (default, accepted for compatibility)")
37
+ .option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
37
38
  .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
38
39
  .option("--jq <filter>", "apply a jq filter to the JSON output")
39
40
  .option("--fields <fields>", "filter output to specific fields (comma-separated)")
@@ -49,6 +50,40 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
49
50
  if (rootOpts.fields) {
50
51
  setFieldsFilter(splitList(rootOpts.fields));
51
52
  }
53
+ // Subcommand --format wins over the global flag (commander resolves
54
+ // options closest to the action), so `optsWithGlobals` returns the
55
+ // subcommand value when one is set. We accept any of the per-command
56
+ // formats (table/md/csv) silently — those take a different code path
57
+ // inside the handler and never reach outputSuccess. The global
58
+ // behavior only triggers for `summary`.
59
+ const fmt = typeof rootOpts.format === "string"
60
+ ? rootOpts.format.toLowerCase()
61
+ : "json";
62
+ if (fmt === "summary") {
63
+ setOutputFormat("summary");
64
+ }
65
+ else if (fmt === "json") {
66
+ setOutputFormat("json");
67
+ }
68
+ else if (fmt === "table" ||
69
+ fmt === "md" ||
70
+ fmt === "markdown" ||
71
+ fmt === "csv") {
72
+ // Per-subcommand format: no global state change. The subcommand
73
+ // owns its own output path.
74
+ setOutputFormat("json");
75
+ }
76
+ else {
77
+ // Match outputSuccess's stable JSON-on-stdout shape so machine
78
+ // callers always get a parseable error envelope on stdout. We
79
+ // can't route through handleAsyncCommand here — preAction runs
80
+ // before the action handler.
81
+ const msg = `Unknown --format: "${rootOpts.format}". Use one of: json, summary` +
82
+ " (per-command formats table/md/csv are also accepted on issues" +
83
+ " list/search and projects list).";
84
+ process.stdout.write(`${JSON.stringify({ error: msg }, null, 2)}\n`);
85
+ process.exit(1);
86
+ }
52
87
  // `--profile <name>` is highest-priority. preAction runs BEFORE the
53
88
  // command body, which is BEFORE getApiToken / loadConfig fire — so
54
89
  // setting the override here means the rest of the run picks up the
@@ -1,2 +1,5 @@
1
1
  export declare const TEMPLATES_LIST_QUERY = "\n query {\n templates {\n id\n name\n type\n description\n templateData\n createdAt\n updatedAt\n team { id key name }\n creator { id name }\n }\n }\n";
2
2
  export declare const TEMPLATE_BY_ID_QUERY = "\n query ($id: String!) {\n template(id: $id) {\n id\n name\n type\n description\n templateData\n createdAt\n updatedAt\n team { id key name }\n creator { id name }\n }\n }\n";
3
+ export declare const TEMPLATE_CREATE_MUTATION = "\n mutation ($input: TemplateCreateInput!) {\n templateCreate(input: $input) {\n success\n lastSyncId\n template {\n id\n name\n type\n description\n templateData\n team { id key name }\n createdAt\n updatedAt\n }\n }\n }\n";
4
+ export declare const TEMPLATE_UPDATE_MUTATION = "\n mutation ($id: String!, $input: TemplateUpdateInput!) {\n templateUpdate(id: $id, input: $input) {\n success\n lastSyncId\n template {\n id\n name\n type\n description\n templateData\n team { id key name }\n updatedAt\n }\n }\n }\n";
5
+ export declare const TEMPLATE_DELETE_MUTATION = "\n mutation ($id: String!) {\n templateDelete(id: $id) {\n success\n lastSyncId\n }\n }\n";
@@ -28,3 +28,46 @@ export const TEMPLATE_BY_ID_QUERY = `
28
28
  }
29
29
  }
30
30
  `;
31
+ export const TEMPLATE_CREATE_MUTATION = `
32
+ mutation ($input: TemplateCreateInput!) {
33
+ templateCreate(input: $input) {
34
+ success
35
+ lastSyncId
36
+ template {
37
+ id
38
+ name
39
+ type
40
+ description
41
+ templateData
42
+ team { id key name }
43
+ createdAt
44
+ updatedAt
45
+ }
46
+ }
47
+ }
48
+ `;
49
+ export const TEMPLATE_UPDATE_MUTATION = `
50
+ mutation ($id: String!, $input: TemplateUpdateInput!) {
51
+ templateUpdate(id: $id, input: $input) {
52
+ success
53
+ lastSyncId
54
+ template {
55
+ id
56
+ name
57
+ type
58
+ description
59
+ templateData
60
+ team { id key name }
61
+ updatedAt
62
+ }
63
+ }
64
+ }
65
+ `;
66
+ export const TEMPLATE_DELETE_MUTATION = `
67
+ mutation ($id: String!) {
68
+ templateDelete(id: $id) {
69
+ success
70
+ lastSyncId
71
+ }
72
+ }
73
+ `;
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, TOKEN_PATH, } from "../config/paths.js";
2
+ import { LEGACY_LINCTL_TOKEN_PATH, LEGACY_TOKEN_PATH, resolveActiveProfile, } from "../config/paths.js";
3
3
  import { maybeEmitMigrationHint } from "./migration-hint.js";
4
4
  export function getApiToken(options) {
5
5
  if (options.apiToken) {
@@ -9,18 +9,16 @@ export function getApiToken(options) {
9
9
  return process.env.LINEAR_API_TOKEN;
10
10
  }
11
11
  // Profile-aware: read from <CONFIG_DIR>/profiles/<name>/token when a
12
- // profile is active, falling back to the legacy single-file path
13
- // (TOKEN_PATH) for the no-profile case.
12
+ // profile is active. When the user has explicitly selected a profile
13
+ // (--profile / EL_LINEAR_PROFILE / active-profile marker), do NOT
14
+ // fall back to the legacy single-file token — that path silently
15
+ // posts writes to the wrong workspace when a profile token is missing.
14
16
  const active = resolveActiveProfile();
15
17
  if (fs.existsSync(active.tokenPath)) {
16
18
  return fs.readFileSync(active.tokenPath, "utf8").trim();
17
19
  }
18
- // Even when a profile is selected, fall through to the legacy token
19
- // path so an operator who only has the single-file layout doesn't
20
- // suddenly fail. The active-profile name is informational only when
21
- // no profile-scoped token exists yet.
22
- if (active.tokenPath !== TOKEN_PATH && fs.existsSync(TOKEN_PATH)) {
23
- return fs.readFileSync(TOKEN_PATH, "utf8").trim();
20
+ if (active.name !== null) {
21
+ throw new Error(`No token for active profile \`${active.name}\` (expected at ${active.tokenPath}). Run \`el-linear init token --profile ${active.name}\` (or unset --profile / EL_LINEAR_PROFILE / \`el-linear profile use <name>\`) before retrying. Refusing to fall back to the legacy single-file token to avoid posting writes to the wrong workspace.`);
24
22
  }
25
23
  // Fallback to the linctl-era token (the CLI was briefly published as
26
24
  // `@enrichlayer/linctl` then reverted). Kept for one release.
@@ -155,7 +155,21 @@ export async function autoLinkReferences(input) {
155
155
  const failed = [];
156
156
  const descriptionRefs = extractIssueReferences(description ?? "", identifier);
157
157
  const commentRefs = (comments ?? []).flatMap((body) => extractIssueReferences(body, identifier));
158
- const candidates = mergeCandidates(descriptionRefs, commentRefs);
158
+ const merged = mergeCandidates(descriptionRefs, commentRefs);
159
+ // Cap the number of candidates we'll resolve per call. A malicious
160
+ // (or merely careless) issue body containing hundreds of fake
161
+ // identifiers — `AAA-1, AAA-2, …, AAA-999` — would otherwise trigger
162
+ // 999 GraphQL roundtrips before deciding none resolve. Realistic
163
+ // human-authored bodies stay well under this cap.
164
+ const MAX_CANDIDATES_PER_CALL = 50;
165
+ const candidates = merged.slice(0, MAX_CANDIDATES_PER_CALL);
166
+ if (merged.length > MAX_CANDIDATES_PER_CALL) {
167
+ const overflow = merged.length - MAX_CANDIDATES_PER_CALL;
168
+ failed.push({
169
+ identifier: `+${overflow} more`,
170
+ reason: `Too many candidate references (${merged.length}); only the first ${MAX_CANDIDATES_PER_CALL} are processed.`,
171
+ });
172
+ }
159
173
  if (candidates.length === 0) {
160
174
  return { linked, skipped, failed };
161
175
  }
@@ -25,6 +25,7 @@ import { randomBytes } from "node:crypto";
25
25
  import fs from "node:fs/promises";
26
26
  import path from "node:path";
27
27
  import { resolveActiveProfile } from "../config/paths.js";
28
+ import { logger } from "./logger.js";
28
29
  const CACHE_VERSION = 1;
29
30
  const CACHE_FILE_MODE = 0o644;
30
31
  const CACHE_DIR_MODE = 0o700;
@@ -138,7 +139,7 @@ export async function cached(key, ttlSeconds, fetcher, options) {
138
139
  // Cache writes are best-effort — log to stderr and return the data
139
140
  // anyway so a flaky disk doesn't break the user's command.
140
141
  const msg = err instanceof Error ? err.message : String(err);
141
- process.stderr.write(`[disk-cache] write failed for "${key}": ${msg}\n`);
142
+ logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
142
143
  }
143
144
  return data;
144
145
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Human-readable summary formatters for the `--format summary` output mode.
3
+ *
4
+ * These are pure functions: each takes the same shape that the JSON output
5
+ * path receives (a single resource object or a `{ data, meta }` envelope)
6
+ * and returns a plain string suitable for terminal display.
7
+ *
8
+ * Why this exists: every consumer (humans and LLMs) was piping
9
+ * `el-linear ... | python -c 'json.load(...)'` or jq-based extraction to
10
+ * pull out title/state/assignee. This codifies that recurring shape as a
11
+ * first-class output mode, so callers don't reinvent it in shell.
12
+ *
13
+ * The summary format is intentionally stable — it's a contract, not a
14
+ * pretty-print. Field ordering and labels should not change across
15
+ * patch releases without a CHANGELOG entry.
16
+ */
17
+ export declare function formatIssueSummary(issue: Record<string, unknown>): string;
18
+ export declare function formatIssueList(issues: unknown[]): string;
19
+ export declare function formatProjectSummary(project: Record<string, unknown>): string;
20
+ export declare function formatProjectList(projects: unknown[]): string;
21
+ export declare function formatCommentSummary(comment: Record<string, unknown>): string;
22
+ export declare function formatCommentList(comments: unknown[]): string;
23
+ export declare function formatCycleSummary(cycle: Record<string, unknown>): string;
24
+ export declare function formatCycleList(cycles: unknown[]): string;
25
+ export declare function formatMilestoneSummary(milestone: Record<string, unknown>): string;
26
+ export declare function formatMilestoneList(milestones: unknown[]): string;
27
+ export declare function formatTeamList(teams: unknown[]): string;
28
+ export declare function formatLabelList(labels: unknown[]): string;
29
+ export declare function formatUserSummary(user: Record<string, unknown>): string;
30
+ export declare function formatUserList(users: unknown[]): string;
31
+ export declare function formatDocumentSummary(doc: Record<string, unknown>): string;
32
+ export declare function formatDocumentList(docs: unknown[]): string;
33
+ export declare function formatTemplateSummary(tpl: Record<string, unknown>): string;
34
+ export declare function formatTemplateList(tpls: unknown[]): string;
35
+ export declare function formatAttachmentList(attachments: unknown[]): string;
36
+ export declare function formatReleaseSummary(release: Record<string, unknown>): string;
37
+ export declare function formatReleaseList(releases: unknown[]): string;
38
+ export declare function formatSearchResultList(results: unknown[]): string;
39
+ /**
40
+ * Generic fallback for resources we don't have a dedicated formatter for
41
+ * yet (e.g. profile/config/template/document). Renders any object as a
42
+ * label-padded key/value block, with a "..." footer hinting at JSON for
43
+ * the full payload. Lists fall back to a simple bulleted list.
44
+ */
45
+ export declare function formatGenericSummary(value: unknown): string;
46
+ export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "empty-list" | "generic";
47
+ /**
48
+ * Heuristic — used by the central `outputSuccess` path which doesn't know
49
+ * which command produced the payload. Looks at the shape of the data to
50
+ * pick a formatter.
51
+ *
52
+ * Single-resource detection: uses signature fields (e.g. `identifier` +
53
+ * `state` for issues, `name` + `progress` for projects). When a payload
54
+ * doesn't match any known shape we fall through to `formatGenericSummary`.
55
+ */
56
+ export declare function inferKindFromPayload(value: unknown): ResourceKind;
57
+ /**
58
+ * Central dispatch: given a resource kind and payload, return the
59
+ * formatted string. The payload for list kinds may be either the raw
60
+ * array or a `{ data: [...] }` envelope — both are handled.
61
+ */
62
+ export declare function dispatch(kind: ResourceKind, payload: unknown): string;