@enrichlayer/el-linear 1.44.2 → 1.46.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.
@@ -1,14 +1,89 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { resolveActiveProfile } from "../config/paths.js";
3
3
  import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
4
+ import { readGraphQLErrorDetail } from "./linear-graphql-error.js";
4
5
  import { logger } from "./logger.js";
5
6
  import { sanitizeForLog } from "./sanitize-for-log.js";
6
7
  const warningBuffer = [];
8
+ let rateLimitBuffer = null;
7
9
  let rawMode = false;
8
10
  let quietMode = false;
9
11
  let jqFilter = null;
10
12
  let fieldsFilter = null;
11
13
  let outputFormat = "json";
14
+ function minimumDefined(left, right) {
15
+ if (left === undefined)
16
+ return right;
17
+ if (right === undefined)
18
+ return left;
19
+ return Math.min(left, right);
20
+ }
21
+ function mergeRateLimitInfo(current, next) {
22
+ const endpoints = { ...current.endpoints };
23
+ for (const [name, observation] of Object.entries(next.endpoints ?? {})) {
24
+ const previous = endpoints[name];
25
+ endpoints[name] = {
26
+ ...previous,
27
+ ...observation,
28
+ minimumRemaining: minimumDefined(previous?.minimumRemaining ?? previous?.remaining, observation.minimumRemaining ?? observation.remaining),
29
+ observedRequests: (previous?.observedRequests ?? 0) + (observation.observedRequests ?? 1),
30
+ };
31
+ }
32
+ const currentComplexity = current.complexity;
33
+ const nextComplexity = next.complexity;
34
+ const complexity = currentComplexity || nextComplexity
35
+ ? {
36
+ ...currentComplexity,
37
+ ...nextComplexity,
38
+ totalCost: (currentComplexity?.totalCost ?? currentComplexity?.cost ?? 0) +
39
+ (nextComplexity?.totalCost ?? nextComplexity?.cost ?? 0),
40
+ minimumRemaining: minimumDefined(currentComplexity?.minimumRemaining ?? currentComplexity?.remaining, nextComplexity?.minimumRemaining ?? nextComplexity?.remaining),
41
+ }
42
+ : undefined;
43
+ return {
44
+ ...current,
45
+ ...next,
46
+ observedRequests: (current.observedRequests ?? 1) + (next.observedRequests ?? 1),
47
+ minimumRemaining: minimumDefined(current.minimumRemaining ?? current.remaining, next.minimumRemaining ?? next.remaining),
48
+ ...(complexity ? { complexity } : {}),
49
+ ...(Object.keys(endpoints).length > 0 ? { endpoints } : {}),
50
+ };
51
+ }
52
+ /**
53
+ * Record a quota observation for the command's eventual output.
54
+ *
55
+ * The latest response remains authoritative for current remaining/reset
56
+ * values. Multi-request commands additionally retain their total observed
57
+ * request/complexity cost and the lowest headroom seen in every bucket.
58
+ */
59
+ export function recordRateLimitInfo(info) {
60
+ const endpoints = Object.fromEntries(Object.entries(info.endpoints ?? {}).map(([name, endpoint]) => [
61
+ name,
62
+ {
63
+ ...endpoint,
64
+ minimumRemaining: endpoint.minimumRemaining ?? endpoint.remaining,
65
+ observedRequests: endpoint.observedRequests ?? 1,
66
+ },
67
+ ]));
68
+ const observation = {
69
+ ...info,
70
+ observedRequests: info.observedRequests ?? 1,
71
+ minimumRemaining: info.minimumRemaining ?? info.remaining,
72
+ ...(info.complexity
73
+ ? {
74
+ complexity: {
75
+ ...info.complexity,
76
+ totalCost: info.complexity.totalCost ?? info.complexity.cost,
77
+ minimumRemaining: info.complexity.minimumRemaining ?? info.complexity.remaining,
78
+ },
79
+ }
80
+ : {}),
81
+ ...(Object.keys(endpoints).length > 0 ? { endpoints } : {}),
82
+ };
83
+ rateLimitBuffer = rateLimitBuffer
84
+ ? mergeRateLimitInfo(rateLimitBuffer, observation)
85
+ : observation;
86
+ }
12
87
  export function setRawMode(enabled) {
13
88
  rawMode = enabled;
14
89
  }
@@ -211,6 +286,7 @@ export function outputSuccess(data) {
211
286
  if (quietMode) {
212
287
  logger.info(formatLine(output));
213
288
  drainWarnings();
289
+ drainRateLimitInfo();
214
290
  drainSummaryFieldWarnings();
215
291
  return;
216
292
  }
@@ -273,6 +349,10 @@ export function outputSuccess(data) {
273
349
  // the signal is visible without breaking the text contract.
274
350
  if (outputFormat === "summary") {
275
351
  emitSummary(output, inferredKind, fieldsFilter);
352
+ const rateLimit = drainRateLimitInfo();
353
+ if (rateLimit) {
354
+ logger.info(`_rateLimit: ${formatRateLimitInfo(rateLimit)}`);
355
+ }
276
356
  const summaryWarnings = [
277
357
  ...drainWarnings(),
278
358
  ...drainSummaryFieldWarnings(),
@@ -314,6 +394,22 @@ export function outputSuccess(data) {
314
394
  }
315
395
  }
316
396
  }
397
+ const rateLimit = drainRateLimitInfo();
398
+ if (rateLimit) {
399
+ if (output !== null &&
400
+ typeof output === "object" &&
401
+ !Array.isArray(output)) {
402
+ output = {
403
+ ...output,
404
+ _rateLimit: rateLimit,
405
+ };
406
+ }
407
+ else {
408
+ // A bare array/primitive cannot carry metadata without changing its
409
+ // contract. Keep stdout machine-readable and surface the quota on stderr.
410
+ logger.error(`_rateLimit: ${formatRateLimitInfo(rateLimit)}`);
411
+ }
412
+ }
317
413
  if (jqFilter) {
318
414
  const json = JSON.stringify(output);
319
415
  // Normalize common shell-escape artifacts (zsh history expansion)
@@ -373,8 +469,51 @@ function drainWarnings() {
373
469
  warningBuffer.length = 0;
374
470
  return warnings;
375
471
  }
472
+ function drainRateLimitInfo() {
473
+ const rateLimit = rateLimitBuffer;
474
+ rateLimitBuffer = null;
475
+ return rateLimit;
476
+ }
477
+ function formatRateLimitInfo(info) {
478
+ const parts = [];
479
+ if (info.remaining !== undefined && info.limit !== undefined) {
480
+ parts.push(`${info.remaining}/${info.limit} requests remaining`);
481
+ }
482
+ else if (info.remaining !== undefined) {
483
+ parts.push(`${info.remaining} requests remaining`);
484
+ }
485
+ else if (info.limit !== undefined) {
486
+ parts.push(`${info.limit} request limit`);
487
+ }
488
+ if (info.resetAt) {
489
+ parts.push(`resets at ${info.resetAt}`);
490
+ }
491
+ if ((info.observedRequests ?? 0) > 1) {
492
+ parts.push(`${info.observedRequests} responses observed`);
493
+ }
494
+ if (info.minimumRemaining !== undefined &&
495
+ info.minimumRemaining !== info.remaining) {
496
+ parts.push(`minimum ${info.minimumRemaining} requests remaining`);
497
+ }
498
+ if (info.complexity?.totalCost !== undefined) {
499
+ parts.push(`${info.complexity.totalCost} total complexity`);
500
+ }
501
+ const endpointEntries = Object.entries(info.endpoints ?? {});
502
+ if (endpointEntries.length > 0) {
503
+ parts.push(endpointEntries
504
+ .map(([name, endpoint]) => {
505
+ const remaining = endpoint.minimumRemaining ?? endpoint.remaining;
506
+ return remaining === undefined
507
+ ? `${name} endpoint observed`
508
+ : `${name} endpoint minimum ${remaining} remaining`;
509
+ })
510
+ .join(", "));
511
+ }
512
+ return parts.join("; ");
513
+ }
376
514
  export function resetWarnings() {
377
515
  warningBuffer.length = 0;
516
+ rateLimitBuffer = null;
378
517
  }
379
518
  function outputError(error) {
380
519
  // Run the message and stack through sanitizeForLog so a future SDK
@@ -400,7 +539,19 @@ function outputError(error) {
400
539
  catch {
401
540
  // leave activeProfile as "<unknown>"
402
541
  }
403
- const payload = JSON.stringify({ error: sanitizeForLog(error.message), activeProfile }, null, 2);
542
+ // `errorDetail` (DEV-7987) is the classification block: present only
543
+ // when the failure came from a Linear GraphQL request or its quota admission, absent for
544
+ // every other error, so nothing about the existing two-key envelope
545
+ // changes for callers that never looked at it. See the "Error
546
+ // envelope" section of the README for the published contract.
547
+ const errorDetail = readGraphQLErrorDetail(error);
548
+ const rateLimit = drainRateLimitInfo();
549
+ const payload = JSON.stringify({
550
+ error: sanitizeForLog(error.message),
551
+ activeProfile,
552
+ ...(errorDetail ? { errorDetail } : {}),
553
+ ...(rateLimit ? { _rateLimit: rateLimit } : {}),
554
+ }, null, 2);
404
555
  // Write to stdout (same channel as success) so machine callers always
405
556
  // receive exactly one parseable JSON object regardless of stream capture.
406
557
  logger.info(payload);
@@ -0,0 +1,33 @@
1
+ import type { LinearCredential } from "../auth/linear-credential.js";
2
+ import { LinearGraphQLError } from "./linear-graphql-error.js";
3
+ import type { RateLimitInfo } from "./output.js";
4
+ export interface RateLimitAdmission {
5
+ readonly enabled: boolean;
6
+ admit(): Promise<void>;
7
+ observe(info: RateLimitInfo): Promise<void>;
8
+ }
9
+ /** A local/coordinator quota gate refused before any Linear request was sent. */
10
+ export declare class RateLimitAdmissionRefusal extends LinearGraphQLError {
11
+ constructor(message: string, resetAt?: string);
12
+ }
13
+ export interface RateLimitAdmissionOptions {
14
+ env?: NodeJS.ProcessEnv;
15
+ fetchImpl?: CoordinatorFetchLike;
16
+ now?: () => number;
17
+ stateRoot?: string;
18
+ }
19
+ type CoordinatorFetchLike = (url: string, init: {
20
+ method: string;
21
+ headers: Record<string, string>;
22
+ body: string;
23
+ }) => Promise<{
24
+ ok: boolean;
25
+ status: number;
26
+ json(): Promise<unknown>;
27
+ }>;
28
+ export declare function createRateLimitAdmission(auth: LinearCredential, options?: RateLimitAdmissionOptions): RateLimitAdmission;
29
+ /** Keep local OAuth quota state stable when a profile refreshes its token. */
30
+ export declare function createOAuthProfileRateLimitAdmission(auth: Extract<LinearCredential, {
31
+ oauthToken: string;
32
+ }>, clientId: string, viewerId?: string): RateLimitAdmission;
33
+ export {};
@@ -0,0 +1,240 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { atomicWrite, withFileLock } from "../auth/oauth-fs.js";
6
+ import { LinearGraphQLError } from "./linear-graphql-error.js";
7
+ const STATE_VERSION = 1;
8
+ const PROBE_LEASE_MS = 30_000;
9
+ /** A local/coordinator quota gate refused before any Linear request was sent. */
10
+ export class RateLimitAdmissionRefusal extends LinearGraphQLError {
11
+ constructor(message, resetAt) {
12
+ super(message, {
13
+ httpStatus: null,
14
+ code: "RATELIMITED",
15
+ retryable: true,
16
+ resetAt,
17
+ });
18
+ this.name = "RateLimitAdmissionRefusal";
19
+ }
20
+ }
21
+ function parseHeadroom(env) {
22
+ const raw = env.EL_LINEAR_RATE_LIMIT_HEADROOM?.trim();
23
+ if (!raw)
24
+ return 0;
25
+ const value = Number(raw);
26
+ if (!Number.isInteger(value) || value < 0) {
27
+ throw new Error("EL_LINEAR_RATE_LIMIT_HEADROOM must be a non-negative integer.");
28
+ }
29
+ return value;
30
+ }
31
+ function quotaKey(auth, env) {
32
+ const explicit = env.EL_LINEAR_QUOTA_KEY?.trim();
33
+ const source = explicit || ("oauthToken" in auth ? auth.oauthToken : auth.apiKey);
34
+ return createHash("sha256").update(source).digest("hex");
35
+ }
36
+ function coordinatorUrl(env) {
37
+ const raw = env.EL_LINEAR_RATE_LIMIT_COORDINATOR_URL?.trim();
38
+ if (!raw)
39
+ return null;
40
+ let parsed;
41
+ try {
42
+ parsed = new URL(raw);
43
+ }
44
+ catch {
45
+ throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_URL must be a valid URL.");
46
+ }
47
+ if (!/^https?:$/.test(parsed.protocol)) {
48
+ throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_URL must use HTTPS (HTTP is allowed only for loopback development).");
49
+ }
50
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
51
+ if (parsed.protocol === "http:" && !loopbackHosts.has(parsed.hostname)) {
52
+ throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_URL must use HTTPS; bearer credentials may use HTTP only with a loopback host.");
53
+ }
54
+ return parsed.toString().replace(/\/$/, "");
55
+ }
56
+ function createCoordinatorAdmission(key, headroom, baseUrl, token, fetchImpl) {
57
+ const post = async (action, body) => {
58
+ const response = await fetchImpl(`${baseUrl}/v1/quota/${encodeURIComponent(key)}/${action}`, {
59
+ method: "POST",
60
+ headers: {
61
+ authorization: `Bearer ${token}`,
62
+ "content-type": "application/json",
63
+ },
64
+ body: JSON.stringify(body),
65
+ });
66
+ const payload = await response.json().catch(() => null);
67
+ if (!(response.ok || (action === "admit" && response.status === 429))) {
68
+ throw new Error(`Linear quota coordinator ${action} failed with HTTP ${response.status}.`);
69
+ }
70
+ return { status: response.status, payload };
71
+ };
72
+ return {
73
+ enabled: true,
74
+ async admit() {
75
+ const result = await post("admit", { headroom, cost: 1 });
76
+ if (result.status !== 429)
77
+ return;
78
+ const state = result.payload?.state;
79
+ const remaining = state?.remaining ?? 0;
80
+ const reset = state?.resetAt ? ` until ${state.resetAt}` : "";
81
+ throw new RateLimitAdmissionRefusal(`Linear rate limit exceeded before request; distributed admission refused: ${remaining} requests remain, preserving configured headroom ${headroom}${reset}.`, state?.resetAt);
82
+ },
83
+ async observe(info) {
84
+ if (info.remaining === undefined)
85
+ return;
86
+ await post("observe", {
87
+ limit: info.limit,
88
+ remaining: info.remaining,
89
+ resetAt: info.resetAt,
90
+ });
91
+ },
92
+ };
93
+ }
94
+ function defaultStateRoot(env) {
95
+ const stateHome = env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state");
96
+ return path.join(stateHome, "el-linear", "rate-limit");
97
+ }
98
+ async function readState(statePath) {
99
+ try {
100
+ const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
101
+ if (parsed?.v !== STATE_VERSION ||
102
+ typeof parsed.remaining !== "number" ||
103
+ typeof parsed.observedAt !== "number") {
104
+ return null;
105
+ }
106
+ return parsed;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ async function writeState(statePath, state) {
113
+ await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
114
+ await atomicWrite(statePath, `${JSON.stringify(state)}\n`, 0o600);
115
+ }
116
+ export function createRateLimitAdmission(auth, options = {}) {
117
+ const env = options.env ?? process.env;
118
+ const headroom = parseHeadroom(env);
119
+ if (headroom === 0) {
120
+ return {
121
+ enabled: false,
122
+ admit: async () => { },
123
+ observe: async () => { },
124
+ };
125
+ }
126
+ const now = options.now ?? Date.now;
127
+ const key = quotaKey(auth, env);
128
+ const remote = coordinatorUrl(env);
129
+ if (remote) {
130
+ const token = env.EL_LINEAR_RATE_LIMIT_COORDINATOR_TOKEN?.trim();
131
+ if (!token) {
132
+ throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_TOKEN is required when the coordinator URL is configured.");
133
+ }
134
+ const fetchImpl = options.fetchImpl ??
135
+ (async (url, init) => {
136
+ const response = await globalThis.fetch(url, init);
137
+ return {
138
+ ok: response.ok,
139
+ status: response.status,
140
+ json: () => response.json(),
141
+ };
142
+ });
143
+ return createCoordinatorAdmission(key, headroom, remote, token, fetchImpl);
144
+ }
145
+ const statePath = path.join(options.stateRoot ?? defaultStateRoot(env), `${key}.json`);
146
+ return {
147
+ enabled: true,
148
+ async admit() {
149
+ await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
150
+ await withFileLock(statePath, async () => {
151
+ const state = await readState(statePath);
152
+ const currentTime = now();
153
+ if (!state) {
154
+ await writeState(statePath, {
155
+ v: STATE_VERSION,
156
+ remaining: headroom,
157
+ observedAt: currentTime,
158
+ probeUntil: currentTime + PROBE_LEASE_MS,
159
+ });
160
+ return;
161
+ }
162
+ const resetAtMs = state.resetAt
163
+ ? Date.parse(state.resetAt)
164
+ : Number.NaN;
165
+ const leaseExpired = state.probeUntil !== undefined && currentTime >= state.probeUntil;
166
+ if ((Number.isFinite(resetAtMs) && currentTime >= resetAtMs) ||
167
+ leaseExpired) {
168
+ // Permit exactly one probe into the new window. Closing the local
169
+ // gate before releasing the lock prevents every waiting process
170
+ // from seeing the same expired state and stampeding the reset.
171
+ // Its response headers reopen the gate with authoritative capacity;
172
+ // a headerless failure remains pessimistic until the bounded lease
173
+ // permits exactly one recovery probe.
174
+ await writeState(statePath, {
175
+ ...state,
176
+ remaining: headroom,
177
+ resetAt: undefined,
178
+ observedAt: currentTime,
179
+ probeUntil: currentTime + PROBE_LEASE_MS,
180
+ });
181
+ return;
182
+ }
183
+ if (state.probeUntil !== undefined) {
184
+ throw new RateLimitAdmissionRefusal(`Linear rate limit probe is already in flight; admission refused while preserving configured headroom ${headroom}.`, typeof state.probeUntil === "number"
185
+ ? (new Date(state.probeUntil).toJSON() ?? undefined)
186
+ : undefined);
187
+ }
188
+ if (state.remaining <= headroom) {
189
+ const reset = state.resetAt ? ` until ${state.resetAt}` : "";
190
+ throw new RateLimitAdmissionRefusal(`Linear rate limit exceeded before request; admission refused: ${state.remaining} requests remain, preserving configured headroom ${headroom}${reset}.`, state.resetAt);
191
+ }
192
+ // Reserve one request while holding the process-shared lock. The
193
+ // response observation will replace this pessimistic estimate with
194
+ // Linear's authoritative remaining count.
195
+ await writeState(statePath, {
196
+ ...state,
197
+ remaining: state.remaining - 1,
198
+ observedAt: currentTime,
199
+ });
200
+ });
201
+ },
202
+ async observe(info) {
203
+ if (info.remaining === undefined)
204
+ return;
205
+ await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
206
+ await withFileLock(statePath, async () => {
207
+ const existing = await readState(statePath);
208
+ const sameWindow = existing?.resetAt !== undefined &&
209
+ info.resetAt !== undefined &&
210
+ existing.resetAt === info.resetAt;
211
+ // Concurrent responses can arrive out of order. Within one Linear
212
+ // window the remaining count only moves downward, so retaining the
213
+ // lower observation prevents a stale response from refunding quota.
214
+ const remaining = sameWindow
215
+ ? Math.min(existing.remaining, info.remaining)
216
+ : info.remaining;
217
+ await writeState(statePath, {
218
+ v: STATE_VERSION,
219
+ limit: info.limit,
220
+ remaining,
221
+ resetAt: info.resetAt,
222
+ observedAt: now(),
223
+ probeUntil: undefined,
224
+ });
225
+ });
226
+ },
227
+ };
228
+ }
229
+ /** Keep local OAuth quota state stable when a profile refreshes its token. */
230
+ export function createOAuthProfileRateLimitAdmission(auth, clientId, viewerId) {
231
+ const quotaKey = viewerId
232
+ ? `oauth:${clientId}:${viewerId}`
233
+ : `oauth:${clientId}`;
234
+ return createRateLimitAdmission(auth, {
235
+ env: {
236
+ ...process.env,
237
+ EL_LINEAR_QUOTA_KEY: process.env.EL_LINEAR_QUOTA_KEY?.trim() || quotaKey,
238
+ },
239
+ });
240
+ }
package/package.json CHANGED
@@ -1,80 +1,80 @@
1
1
  {
2
- "name": "@enrichlayer/el-linear",
3
- "version": "1.44.2",
4
- "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
- "main": "dist/main.js",
6
- "types": "dist/main.d.ts",
7
- "type": "module",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/main.d.ts",
11
- "import": "./dist/main.js"
12
- },
13
- "./output": {
14
- "types": "./dist/output.d.ts",
15
- "import": "./dist/output.js",
16
- "default": "./dist/output.js"
17
- }
18
- },
19
- "sideEffects": false,
20
- "bin": {
21
- "el-linear": "dist/main.js"
22
- },
23
- "files": [
24
- "dist/",
25
- "claude-skills/",
26
- "LICENSE",
27
- "README.md"
28
- ],
29
- "engines": {
30
- "node": ">=22.0.0"
31
- },
32
- "license": "MIT",
33
- "repository": {
34
- "type": "git",
35
- "url": "https://github.com/enrichlayer/el-linear.git"
36
- },
37
- "homepage": "https://enrichlayer.com/tools/el-linear",
38
- "bugs": {
39
- "url": "https://github.com/enrichlayer/el-linear/issues"
40
- },
41
- "keywords": [
42
- "linear",
43
- "linear.app",
44
- "cli",
45
- "issue-tracker",
46
- "graphql",
47
- "claude",
48
- "claude-code"
49
- ],
50
- "publishConfig": {
51
- "access": "public"
52
- },
53
- "dependencies": {
54
- "@inquirer/prompts": "^8.4.2",
55
- "@linear/sdk": "^88.0.0",
56
- "commander": "^15.0.0",
57
- "picocolors": "^1.1.1"
58
- },
59
- "devDependencies": {
60
- "@biomejs/biome": "^2.4.14",
61
- "@types/node": "^26.0.0",
62
- "graphql": "^17.0.1",
63
- "tsx": "^4.21.0",
64
- "typescript": "^6.0.3",
65
- "vitest": "^4.0.18"
66
- },
67
- "optionalDependencies": {
68
- "@sentry/node": "^10.50.0"
69
- },
70
- "scripts": {
71
- "build": "tsc -p tsconfig.build.json && chmod +x dist/main.js",
72
- "clean": "rm -rf dist/",
73
- "typecheck": "tsc --noEmit",
74
- "lint": "biome check .",
75
- "lint:fix": "biome check --fix .",
76
- "start": "tsx src/main.ts",
77
- "test": "vitest run",
78
- "test:watch": "vitest"
79
- }
80
- }
2
+ "name": "@enrichlayer/el-linear",
3
+ "version": "1.46.0",
4
+ "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
+ "main": "dist/main.js",
6
+ "types": "dist/main.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/main.d.ts",
11
+ "import": "./dist/main.js"
12
+ },
13
+ "./output": {
14
+ "types": "./dist/output.d.ts",
15
+ "import": "./dist/output.js",
16
+ "default": "./dist/output.js"
17
+ }
18
+ },
19
+ "sideEffects": false,
20
+ "bin": {
21
+ "el-linear": "dist/main.js"
22
+ },
23
+ "files": [
24
+ "dist/",
25
+ "claude-skills/",
26
+ "LICENSE",
27
+ "README.md"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/main.js",
31
+ "clean": "rm -rf dist/",
32
+ "typecheck": "tsc --noEmit",
33
+ "lint": "biome check .",
34
+ "lint:fix": "biome check --fix .",
35
+ "start": "tsx src/main.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.0.0"
41
+ },
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/enrichlayer/el-linear.git"
46
+ },
47
+ "homepage": "https://enrichlayer.com/tools/el-linear",
48
+ "bugs": {
49
+ "url": "https://github.com/enrichlayer/el-linear/issues"
50
+ },
51
+ "keywords": [
52
+ "linear",
53
+ "linear.app",
54
+ "cli",
55
+ "issue-tracker",
56
+ "graphql",
57
+ "claude",
58
+ "claude-code"
59
+ ],
60
+ "publishConfig": {
61
+ "access": "public"
62
+ },
63
+ "dependencies": {
64
+ "@inquirer/prompts": "^8.4.2",
65
+ "@linear/sdk": "^88.0.0",
66
+ "commander": "^15.0.0",
67
+ "graphql": "^17.0.1",
68
+ "picocolors": "^1.1.1"
69
+ },
70
+ "devDependencies": {
71
+ "@biomejs/biome": "^2.4.14",
72
+ "@types/node": "^26.0.0",
73
+ "tsx": "^4.21.0",
74
+ "typescript": "^6.0.3",
75
+ "vitest": "^4.0.18"
76
+ },
77
+ "optionalDependencies": {
78
+ "@sentry/node": "^10.50.0"
79
+ }
80
+ }