@enrichlayer/el-linear 1.38.2 → 1.40.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.
@@ -371,7 +371,7 @@ Complete ALL items before creating any issue:
371
371
  - [ ] **Assignee** — ask user if unclear (`el-linear users list --active`).
372
372
  - [ ] **Project** — always ask user, never guess (`el-linear projects list`).
373
373
  - [ ] **Labels** — exactly 1 type label + 1–2 domain labels (see Label Taxonomy below).
374
- - [ ] **Title** — action verb matching the type label (see Title Verb Convention), sentence case, specific scope.
374
+ - [ ] **Title** — action verb matching the type label (see Title Verb Convention), sentence case, specific scope, **plain-language and jargon-free** (see Title Readability).
375
375
  - [ ] **Description** — 2–4 sentences with context and intent, formatted with **bold** and `inline code`.
376
376
  - [ ] **"Why we need this"** — genuine motivation, not a restatement of the title.
377
377
 
@@ -447,7 +447,7 @@ Run `el-linear usage` for the full command reference. Non-obvious rules:
447
447
  - **Subcommand aliases** — `read`/`view`/`get`/`show`, `update`/`edit`/`set`.
448
448
  - **`--jq` for GraphQL filtering** — never pipe through `jq` directly (zsh escaping breaks `!=`).
449
449
  - **`--raw` flag** strips the `{ data, meta }` wrapper — emits just the array.
450
- - **Body/description from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path>`. Prefer the file form for any body with backticks, fenced code, or markdown tables — it sidesteps shell-quoting traps (the same reason `el-git mr comment --body-file` exists). `--body` and `--body-file` are **mutually exclusive** (passing both errors); file-sourced bodies get the same auto-link / auto-mention treatment as inline `--body`.
450
+ - **Body/description/content from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path>`; **`projects create`/`update` take `--content-file <path>`** (the project's full markdown **content**, not its short `description` — see the two-field gotcha under *Project Management Gotchas*). Prefer the file form for any body with backticks, fenced code, or markdown tables — it sidesteps shell-quoting traps (the same reason `el-git mr comment --body-file` exists). The inline and file flags are **mutually exclusive** (passing both errors); file-sourced bodies get the same auto-link / auto-mention treatment as inline text.
451
451
 
452
452
  ### Output format
453
453
 
@@ -497,6 +497,27 @@ Title must start with a verb that matches the type label:
497
497
  - `spike` → Research, Investigate, Explore, Evaluate, Audit, Benchmark
498
498
  - `refactor` → Refactor, Restructure, Extract, Decouple, Simplify
499
499
 
500
+ ### Title Readability — plain language, jargon in the body
501
+
502
+ **The title says what problem is being solved, in words a non-author can read.** The title is the shared surface — a teammate scanning the board, a manager triaging priority, a future picker deciding what to pick up. If it only parses for the person who wrote it that week, that whole audience is locked out. This applies to **MR/PR titles too** — they front the same board.
503
+
504
+ **Keep the mechanism detail — function names, env-var constants, symbol soup — in the description, not the title.** Moving it into the body doesn't lose precision; it puts precision where the reader who opens the issue actually wants it. The title carries the *problem*; the body carries the *how*.
505
+
506
+ - **Lead with the problem or outcome**, not the internal symbol at the center of it.
507
+ - **Move code identifiers, env-var constants, and file/function names into the body**, under a `## …` heading where they read as helpful context.
508
+ - **Proper nouns that ARE the clearest name stay.** The name of a tool, service, or protocol a teammate would recognize (a CLI name, `Vault`, `CI`, `OAuth`) is not jargon — don't paraphrase it into vagueness. The test is "would a teammate recognize this?", not "does it contain a lowercase token?".
509
+ - **Don't overcorrect into mush.** "Fix the thing that was broken" is worse than a jargon title — specificity still matters, just express it in problem terms.
510
+
511
+ Before → after:
512
+
513
+ | ❌ Jargon title | ✅ Plain-language title |
514
+ |---|---|
515
+ | `parseTokenBucket drops refill when lastRefillTs is unset` | Fix rate limiter losing its refill allowance after an idle period |
516
+ | `AUTH_SESSION_TTL mismatch logs out users early in refreshSession` | Fix users getting logged out before their session length expires |
517
+ | `Extract validateEntry into shared pkg (3 hand-rolled copies)` | Extract the duplicated entry validator into a shared package |
518
+
519
+ The mechanism (`parseTokenBucket`, `AUTH_SESSION_TTL`, `refreshSession`, the three copies) still gets stated — in the **description**, where it reads as context instead of a barrier.
520
+
500
521
  ### Rules
501
522
 
502
523
  - **Create missing labels liberally** — `el-linear labels create "my-label" --team ENG`.
@@ -23,6 +23,9 @@ import type { LinearService } from "../../utils/linear-service.js";
23
23
  /**
24
24
  * Read description from a file path or stdin ("-").
25
25
  * Avoids shell escaping issues when descriptions contain special characters.
26
+ *
27
+ * Delegates to the shared reader so `projects --content-file` (DEV-6033), which
28
+ * is specified to behave identically, cannot drift from this one.
26
29
  */
27
30
  export declare function readDescriptionFile(filePath: string): string;
28
31
  /**
@@ -16,27 +16,24 @@
16
16
  * Extracted from `commands/issues.ts` (ALL-938) so that file can
17
17
  * focus on commander wiring + handlers.
18
18
  */
19
- import fs from "node:fs";
20
19
  import { loadConfig } from "../../config/config.js";
21
20
  import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
22
21
  import { autoLinkReferences, } from "../../utils/auto-link-references.js";
23
22
  import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
24
23
  import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
25
24
  import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
25
+ import { readTextInputFile } from "../../utils/text-input-file.js";
26
26
  import { validateReferences } from "../../utils/validate-references.js";
27
27
  import { getWorkspaceUrlKey } from "../../utils/workspace-url.js";
28
28
  /**
29
29
  * Read description from a file path or stdin ("-").
30
30
  * Avoids shell escaping issues when descriptions contain special characters.
31
+ *
32
+ * Delegates to the shared reader so `projects --content-file` (DEV-6033), which
33
+ * is specified to behave identically, cannot drift from this one.
31
34
  */
32
35
  export function readDescriptionFile(filePath) {
33
- if (filePath === "-") {
34
- return fs.readFileSync(0, "utf8").trim();
35
- }
36
- if (!fs.existsSync(filePath)) {
37
- throw new Error(`Description file not found: ${filePath}`);
38
- }
39
- return fs.readFileSync(filePath, "utf8").trim();
36
+ return readTextInputFile(filePath, "Description");
40
37
  }
41
38
  /**
42
39
  * Resolve the description from --description, --description-file, or
@@ -5,4 +5,28 @@ export declare function resolveProjectStateFilter(options: OptionValues): {
5
5
  states?: string[];
6
6
  excludeStates?: string[];
7
7
  };
8
+ /**
9
+ * Resolve the project body from `--content <markdown>` or `--content-file <path>`
10
+ * (DEV-6033). The two are sources for the same field, so accepting both would
11
+ * silently drop one — we REJECT up front instead.
12
+ *
13
+ * That matches `comments --body-file` and `project-updates --body-file`, and it
14
+ * deliberately DIVERGES from `issues --description-file`, which documents a
15
+ * precedence (`--description-file` > `--description`) and therefore lets the file
16
+ * silently win when both are passed. Rejecting is the better contract — a caller
17
+ * who passes both has a bug, and telling them beats guessing — but do not describe
18
+ * this as "mirroring" issues: it is not, and a false claim about a sibling's
19
+ * mechanism outlives the person who wrote it.
20
+ *
21
+ * Returns `undefined` only when NEITHER flag was passed, which is what lets
22
+ * `projects update` keep distinguishing "leave content alone" from "clear it"
23
+ * (`--content ""` yields `""`, and `"" !== undefined`, so it still reaches the
24
+ * mutation — the same distinction `hasOption` drew before).
25
+ *
26
+ * Inline `--content` is passed through verbatim, exactly as it was before this
27
+ * flag existed. It is deliberately NOT run through `normalizeInlineTextInput`:
28
+ * that would newly rewrite `\n` inside an existing caller's `--content` string,
29
+ * a behavior change to a shipped flag that this issue does not ask for.
30
+ */
31
+ export declare function resolveProjectContent(options: OptionValues): string | undefined;
8
32
  export declare function setupProjectsCommands(program: Command): void;
@@ -8,6 +8,7 @@ import { logger } from "../utils/logger.js";
8
8
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
9
9
  import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
10
10
  import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
11
+ import { readTextInputFile } from "../utils/text-input-file.js";
11
12
  import { isUuid } from "../utils/uuid.js";
12
13
  import { parsePositiveInt, splitList } from "../utils/validators.js";
13
14
  const VALID_PROJECT_STATES = new Set([
@@ -232,6 +233,43 @@ function formatTeamsOutput(projectUpdate) {
232
233
  function hasOption(options, key) {
233
234
  return options[key] !== undefined;
234
235
  }
236
+ /**
237
+ * Resolve the project body from `--content <markdown>` or `--content-file <path>`
238
+ * (DEV-6033). The two are sources for the same field, so accepting both would
239
+ * silently drop one — we REJECT up front instead.
240
+ *
241
+ * That matches `comments --body-file` and `project-updates --body-file`, and it
242
+ * deliberately DIVERGES from `issues --description-file`, which documents a
243
+ * precedence (`--description-file` > `--description`) and therefore lets the file
244
+ * silently win when both are passed. Rejecting is the better contract — a caller
245
+ * who passes both has a bug, and telling them beats guessing — but do not describe
246
+ * this as "mirroring" issues: it is not, and a false claim about a sibling's
247
+ * mechanism outlives the person who wrote it.
248
+ *
249
+ * Returns `undefined` only when NEITHER flag was passed, which is what lets
250
+ * `projects update` keep distinguishing "leave content alone" from "clear it"
251
+ * (`--content ""` yields `""`, and `"" !== undefined`, so it still reaches the
252
+ * mutation — the same distinction `hasOption` drew before).
253
+ *
254
+ * Inline `--content` is passed through verbatim, exactly as it was before this
255
+ * flag existed. It is deliberately NOT run through `normalizeInlineTextInput`:
256
+ * that would newly rewrite `\n` inside an existing caller's `--content` string,
257
+ * a behavior change to a shipped flag that this issue does not ask for.
258
+ */
259
+ export function resolveProjectContent(options) {
260
+ const hasInline = typeof options.content === "string";
261
+ const hasFile = typeof options.contentFile === "string";
262
+ if (hasInline && hasFile) {
263
+ throw new Error("--content and --content-file are mutually exclusive — pass one or the other");
264
+ }
265
+ if (hasFile) {
266
+ return readTextInputFile(options.contentFile, "Content");
267
+ }
268
+ if (hasInline) {
269
+ return options.content;
270
+ }
271
+ return undefined;
272
+ }
235
273
  function flattenProjectUpdate(projectUpdate) {
236
274
  if (!projectUpdate.project) {
237
275
  throw new Error("Failed to update project");
@@ -345,6 +383,11 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
345
383
  });
346
384
  }
347
385
  async function handleCreateProject(name, options, command) {
386
+ // Resolve the body BEFORE the create mutation. An unreadable --content-file
387
+ // (or --content alongside it) must fail while nothing has been created yet —
388
+ // resolving after the create would leave an orphan project behind and then
389
+ // throw, which is the worst of both outcomes.
390
+ const content = resolveProjectContent(options);
348
391
  const rootOpts = getRootOpts(command);
349
392
  const graphQLService = await createGraphQLService(rootOpts);
350
393
  // Step 1: Check for duplicate projects (case-insensitive)
@@ -376,10 +419,10 @@ async function handleCreateProject(name, options, command) {
376
419
  }
377
420
  const project = createResult.projectCreate.project;
378
421
  // Step 4: Set content if provided (separate mutation — Linear API quirk)
379
- if (options.content) {
422
+ if (content) {
380
423
  await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
381
424
  id: project.id,
382
- input: { content: options.content },
425
+ input: { content },
383
426
  });
384
427
  }
385
428
  const teamList = project.teams.nodes.map((t) => t.key).join(", ");
@@ -465,11 +508,15 @@ async function handleUpdateProject(projectNameOrId, options, command) {
465
508
  if (hasOption(options, "description")) {
466
509
  input.description = options.description;
467
510
  }
468
- if (hasOption(options, "content")) {
469
- input.content = options.content;
511
+ // `undefined` means neither --content nor --content-file was passed. An
512
+ // explicit `--content ""` resolves to "" and still lands here, so clearing a
513
+ // project's body keeps working exactly as it did under `hasOption`.
514
+ const content = resolveProjectContent(options);
515
+ if (content !== undefined) {
516
+ input.content = content;
470
517
  }
471
518
  if (Object.keys(input).length === 0) {
472
- throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
519
+ throw new Error("Nothing to update. Pass at least one of --name, --description, --content, or --content-file.");
473
520
  }
474
521
  const rootOpts = getRootOpts(command);
475
522
  const graphQLService = await createGraphQLService(rootOpts);
@@ -494,6 +541,7 @@ export function setupProjectsCommands(program) {
494
541
  .option("--team <teams>", "comma-separated team keys (e.g., FE,DEV)")
495
542
  .option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
496
543
  .option("--content <markdown>", "full markdown body (shown in project panel)")
544
+ .option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
497
545
  .option("--force", "create even if a project with the same name exists")
498
546
  .action(handleAsyncCommand(handleCreateProject));
499
547
  projects
@@ -514,6 +562,7 @@ export function setupProjectsCommands(program) {
514
562
  .option("--name <name>", "project name")
515
563
  .option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
516
564
  .option("--content <markdown>", "full markdown body (shown in project panel)")
565
+ .option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
517
566
  .action(handleAsyncCommand(handleUpdateProject));
518
567
  projects
519
568
  .command("list")
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Read the body for a `--*-file` flag: a path on disk, or `-` for stdin.
3
+ *
4
+ * These flags exist to keep large markdown bodies away from the shell. The
5
+ * workaround they replace — `--content "$(cat body.md)"` — hands the file's
6
+ * bytes to the shell first, so backticks, `$`, and nested quotes inside the
7
+ * markdown get interpolated before the CLI ever sees them. Reading the path
8
+ * ourselves means the bytes arrive exactly as authored.
9
+ *
10
+ * For the same reason the content is used verbatim: no escape-sequence
11
+ * normalization. That is `normalizeInlineTextInput`'s job for the *inline*
12
+ * flags, where a user typing `\n` at a shell prompt means a newline. In a file,
13
+ * a literal `\n` inside a fenced code block is content, and rewriting it would
14
+ * corrupt the document.
15
+ *
16
+ * `label` names the subject in the not-found error, so each flag reports itself
17
+ * ("Description file not found: …", "Content file not found: …").
18
+ *
19
+ * Single source of truth for `issues --description-file` and `projects
20
+ * --content-file` (DEV-6033) — the two are specified to behave identically, so
21
+ * they share one implementation rather than two that drift.
22
+ */
23
+ export declare function readTextInputFile(filePath: string, label: string): string;
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs";
2
+ /**
3
+ * Read the body for a `--*-file` flag: a path on disk, or `-` for stdin.
4
+ *
5
+ * These flags exist to keep large markdown bodies away from the shell. The
6
+ * workaround they replace — `--content "$(cat body.md)"` — hands the file's
7
+ * bytes to the shell first, so backticks, `$`, and nested quotes inside the
8
+ * markdown get interpolated before the CLI ever sees them. Reading the path
9
+ * ourselves means the bytes arrive exactly as authored.
10
+ *
11
+ * For the same reason the content is used verbatim: no escape-sequence
12
+ * normalization. That is `normalizeInlineTextInput`'s job for the *inline*
13
+ * flags, where a user typing `\n` at a shell prompt means a newline. In a file,
14
+ * a literal `\n` inside a fenced code block is content, and rewriting it would
15
+ * corrupt the document.
16
+ *
17
+ * `label` names the subject in the not-found error, so each flag reports itself
18
+ * ("Description file not found: …", "Content file not found: …").
19
+ *
20
+ * Single source of truth for `issues --description-file` and `projects
21
+ * --content-file` (DEV-6033) — the two are specified to behave identically, so
22
+ * they share one implementation rather than two that drift.
23
+ */
24
+ export function readTextInputFile(filePath, label) {
25
+ if (filePath === "-") {
26
+ return fs.readFileSync(0, "utf8").trim();
27
+ }
28
+ if (!fs.existsSync(filePath)) {
29
+ throw new Error(`${label} file not found: ${filePath}`);
30
+ }
31
+ return fs.readFileSync(filePath, "utf8").trim();
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.38.2",
3
+ "version": "1.40.0",
4
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
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",