@fink-andreas/pi-linear-tools 0.7.0 → 0.7.2

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.
package/CHANGELOG.md CHANGED
@@ -1,25 +1,46 @@
1
1
  # Changelog
2
2
 
3
- ## v0.7.0 (2026-05-05)
3
+ ## v0.7.2 (2026-07-21)
4
+
5
+ Patch release for Pi package rename compatibility and terminal-safe fallback rendering.
6
+
7
+ ### Bug Fixes
8
+ - **Support renamed Pi packages**: The extension now supports both legacy `@mariozechner/*` imports and the current `@earendil-works/*` package scope, including globally installed Pi paths.
9
+ - **Prevent CJK fallback-rendering crashes**: Plain-text fallback output now truncates by terminal columns, preventing over-width lines from CJK/non-ASCII text and tabs from triggering Pi TUI's width guard.
10
+
11
+ ### Tests
12
+ - Added fallback-renderer regression coverage for CJK text, tabs, unavailable Markdown dependencies, and Markdown rendering failures.
13
+
14
+ ## v0.7.1 (2026-06-09)
15
+
16
+ Patch release for issue creation milestone assignment and configuration text cleanup.
17
+
18
+ ### Bug Fixes
19
+ - **Fixed issue create milestone assignment**: `linear_issue create` now assigns project milestones when `milestone` or `projectMilestoneId` is provided.
20
+ - **Fixed configuration text typo**: Corrected config command/help text in the pi extension.
21
+
22
+ ### Release Maintenance
23
+ - Updated package metadata to `0.7.1`.
24
+
25
+ ## v0.7.0 (2026-06-09)
4
26
 
5
27
  Issue image fetching and file download release.
6
28
 
7
29
  ### New Features
8
- - **`linear_issue(action="images")`**: Fetch markdown/HTML embedded images from issue descriptions and comments. Returns images as tool `image` content (base64-encoded). Includes special auth handling for Linear upload URLs (`uploads.linear.app`).
9
- - **`linear_issue(action="download")`**: Download Linear issue attachments to local filesystem. Supports attachment selection by ID, title, URL, or index. Includes relative path validation, filename sanitization, and `allow_overwrite_files` config guard (default: `false`).
30
+ - **Issue image fetching**: Added `linear_issue(action="images")` to fetch embedded markdown/HTML images from issue descriptions and comments as tool image content.
31
+ - **Issue attachment downloads**: Added `linear_issue(action="download")` to download Linear issue attachments to validated local paths with filename sanitization and overwrite controls.
32
+ - **Workspace-wide issue listing**: `linear_issue list` can now list across the workspace without requiring a project context.
33
+ - **Overwrite configuration**: Added `allow_overwrite_files` config support via `/linear-tools-config` and `pi-linear-tools config`.
10
34
 
11
- ### Performance / API Usage
12
- - **N+1 issue resolution optimization**: Merged N+1-per-issue lazy loads in milestone details into a single GraphQL batch query (introduced in v0.6.0).
35
+ ### Bug Fixes
36
+ - Fixed project archive mutation so `archiveProject` archives instead of deleting projects.
37
+ - Fixed comment rendering so submitted comment text appears in `linear_issue comment` results.
38
+ - Fixed rate-limit display to reflect actual complexity-based limits instead of a hardcoded total.
39
+ - Added project and project-update rate-limit pre-checks.
13
40
 
14
41
  ### Documentation
15
- - **Updated smoke tests**: Added coverage for `images` and `download` actions.
16
-
17
- ### Bug Fixes
18
- - **Fixed workspace-wide issue listing (#4)**: `linear_issue list` now falls back to workspace-level listing (using `fetchIssues`) when no project is specified and cwd doesn't match a Linear project. This allows `linear_issue list assignee=me` to work without requiring a project context.
19
- - Fixed project archive mutation to actually archive instead of delete (INN-301).
20
- - Fixed `linear_issue comment` to render submitted comment text in result (INN-305).
21
- - Fixed rate limit display to not show hardcoded total=5000 (INN-304).
22
- - Fixed `linear_project` and `linear_project_update` to have rate-limit pre-check (INN-303).
42
+ - Added smoke test coverage for image and attachment workflows.
43
+ - Added dedicated release notes in `docs/RELEASE_NOTES_v0.7.0.md`.
23
44
 
24
45
  ## v0.6.0 (2026-04-23)
25
46
 
@@ -12,7 +12,11 @@ async function importPiCodingAgent() {
12
12
  try {
13
13
  return await import('@mariozechner/pi-coding-agent');
14
14
  } catch {
15
- return importFromPiRoot('dist/index.js');
15
+ try {
16
+ return await import('@earendil-works/pi-coding-agent');
17
+ } catch {
18
+ return importFromPiRoot('dist/index.js');
19
+ }
16
20
  }
17
21
  }
18
22
 
@@ -20,8 +24,16 @@ async function importPiTui() {
20
24
  try {
21
25
  return await import('@mariozechner/pi-tui');
22
26
  } catch {
23
- // pi-tui is a dependency of pi-coding-agent and may be nested under it
24
- return importFromPiRoot('node_modules/@mariozechner/pi-tui/dist/index.js');
27
+ try {
28
+ return await import('@earendil-works/pi-tui');
29
+ } catch {
30
+ // pi-tui is a dependency of pi-coding-agent and may be nested under it
31
+ try {
32
+ return await importFromPiRoot('node_modules/@mariozechner/pi-tui/dist/index.js');
33
+ } catch {
34
+ return importFromPiRoot('node_modules/@earendil-works/pi-tui/dist/index.js');
35
+ }
36
+ }
25
37
  }
26
38
  }
27
39
 
@@ -358,7 +370,7 @@ async function runInteractiveConfigFlow(ctx, pi) {
358
370
  }
359
371
 
360
372
  if (!client) {
361
- const selectedAuthMethod = await ctx.ui.select('Select authentication method', ['API Key (recommended for full functionlaity)', 'OAuth']);
373
+ const selectedAuthMethod = await ctx.ui.select('Select authentication method', ['API Key (recommended for full functionality)', 'OAuth']);
362
374
 
363
375
  if (!selectedAuthMethod) {
364
376
  ctx.ui.notify('Configuration cancelled', 'warning');
@@ -602,33 +614,56 @@ async function executeToolSafely(operationLabel, operation, options = {}) {
602
614
  }
603
615
  }
604
616
 
605
- function renderMarkdownResult(result, _options, _theme) {
617
+ // Conservative column width used only by the plain-text fallback renderer:
618
+ // printable ASCII counts as 1 column, tabs as 3 (matching pi-tui), and every
619
+ // other code point as 2. This overestimates narrow non-ASCII characters but
620
+ // never underestimates terminal display width, so rendered lines stay within
621
+ // the terminal width and pi-tui's over-width guard never fires.
622
+ function fallbackColumnWidth(codePoint) {
623
+ if (codePoint === 0x09) return 3;
624
+ return codePoint >= 0x20 && codePoint <= 0x7e ? 1 : 2;
625
+ }
626
+
627
+ export function truncateLineToColumns(line, maxColumns) {
628
+ let columns = 0;
629
+ let end = 0;
630
+ for (const char of line) {
631
+ const width = fallbackColumnWidth(char.codePointAt(0));
632
+ if (columns + width > maxColumns) break;
633
+ columns += width;
634
+ end += char.length;
635
+ }
636
+ return line.slice(0, end);
637
+ }
638
+
639
+ export function createPlainTextFallbackRenderer(text) {
640
+ const lines = text.split('\n');
641
+ return {
642
+ render: (width) => lines.map((line) => (width ? truncateLineToColumns(line, width) : line)),
643
+ invalidate: () => {},
644
+ };
645
+ }
646
+
647
+ export function renderMarkdownResult(result, _options, _theme, renderer = { Markdown, Text, getMarkdownTheme }) {
606
648
  const text = result.content?.[0]?.text || '';
649
+ const { Markdown: markdown, Text: textComponent, getMarkdownTheme: getTheme } = renderer;
607
650
 
608
651
  // Fall back to plain text if markdown packages not available
609
- if (!Markdown || !getMarkdownTheme) {
610
- const lines = text.split('\n');
611
- return {
612
- render: (width) => lines.map((line) => (width && line.length > width ? line.slice(0, width) : line)),
613
- invalidate: () => {},
614
- };
652
+ if (!markdown || !getTheme) {
653
+ return createPlainTextFallbackRenderer(text);
615
654
  }
616
655
 
617
656
  // Return Markdown component directly - the TUI will call its render() method
618
657
  try {
619
- const mdTheme = getMarkdownTheme();
620
- return new Markdown(text, 0, 0, mdTheme, _theme ? { color: (t) => _theme.fg('toolOutput', t) } : undefined);
658
+ const mdTheme = getTheme();
659
+ return new markdown(text, 0, 0, mdTheme, _theme ? { color: (t) => _theme.fg('toolOutput', t) } : undefined);
621
660
  } catch (error) {
622
661
  // If markdown rendering fails for any reason, show a visible error so we can diagnose.
623
662
  const msg = `[pi-linear-tools] Markdown render failed: ${String(error?.message || error)}`;
624
- if (Text) {
625
- return new Text((_theme ? _theme.fg('error', msg) : msg) + `\n\n` + text, 0, 0);
663
+ if (textComponent) {
664
+ return new textComponent((_theme ? _theme.fg('error', msg) : msg) + `\n\n` + text, 0, 0);
626
665
  }
627
- const lines = (msg + '\n\n' + text).split('\n');
628
- return {
629
- render: (width) => lines.map((line) => (width && line.length > width ? line.slice(0, width) : line)),
630
- invalidate: () => {},
631
- };
666
+ return createPlainTextFallbackRenderer(msg + '\n\n' + text);
632
667
  }
633
668
  }
634
669
 
@@ -748,11 +783,11 @@ async function registerLinearTools(pi) {
748
783
  },
749
784
  milestone: {
750
785
  type: 'string',
751
- description: 'For update: milestone name/ID, or "none" to clear milestone assignment.',
786
+ description: 'For create/update: milestone name/ID. For create, also provide project so names can be resolved. Use "none" to clear on update.',
752
787
  },
753
788
  projectMilestoneId: {
754
789
  type: 'string',
755
- description: 'Optional explicit milestone ID alias for update.',
790
+ description: 'Optional explicit milestone ID alias for create/update.',
756
791
  },
757
792
  subIssueOf: {
758
793
  type: 'string',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Pi extension with Linear SDK tools and configuration commands",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "scripts": {
29
29
  "start": "node index.js",
30
- "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-issue-comment-result.js && node tests/test-issue-priority.js && node tests/test-settings.js && node tests/test-issue-download.js && node tests/test-api-usage-caching.js && node tests/test-assignee-update.js && node tests/test-rate-limit-fallback-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js && node tests/test-team-filter.js && node tests/test-project-crud.js && node tests/test-project-lifecycle.js && node tests/test-sync-doc.js && node tests/test-issue-activity.js",
30
+ "test": "node tests/test-package-manifest.js && node tests/test-extension-registration.js && node tests/test-render-fallback.js && node tests/test-issue-comment-result.js && node tests/test-issue-priority.js && node tests/test-settings.js && node tests/test-issue-download.js && node tests/test-api-usage-caching.js && node tests/test-issue-create-milestone.js && node tests/test-assignee-update.js && node tests/test-rate-limit-fallback-update.js && node tests/test-full-assignee-flow.js && node tests/test-branch-param.js && node tests/test-team-filter.js && node tests/test-project-crud.js && node tests/test-project-lifecycle.js && node tests/test-sync-doc.js && node tests/test-issue-activity.js",
31
31
  "dev:sync-local-extension": "node scripts/dev-sync-local-extension.mjs",
32
32
  "release:check": "npm test && npm pack --dry-run"
33
33
  },
package/src/handlers.js CHANGED
@@ -25,7 +25,6 @@ import {
25
25
  fetchIssueActivity,
26
26
  formatIssueAsMarkdown,
27
27
  formatIssueActivityAsMarkdown,
28
- fetchIssues,
29
28
  fetchIssuesByProject,
30
29
  fetchProjectMilestones,
31
30
  fetchMilestoneDetails,
@@ -390,24 +389,12 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
390
389
  export async function executeIssueList(client, params) {
391
390
  return withHandlerErrorHandling(async () => {
392
391
  let projectRef = params.project;
393
- let projectMode = false;
394
-
395
- if (projectRef) {
396
- // Explicit project specified - use project-scoped listing
397
- projectMode = true;
398
- } else {
399
- // No explicit project - try cwd fallback
400
- const cwdProject = path.basename(process.cwd());
401
- try {
402
- const resolved = await resolveProjectRef(client, cwdProject);
403
- projectRef = resolved.name; // use resolved name for display
404
- projectMode = true;
405
- } catch {
406
- // cwd doesn't match any project - fall back to workspace-level listing
407
- projectMode = false;
408
- }
392
+ if (!projectRef) {
393
+ projectRef = path.basename(process.cwd());
409
394
  }
410
395
 
396
+ const resolved = await resolveProjectRef(client, projectRef);
397
+
411
398
  let assigneeId = null;
412
399
  if (params.assignee === 'me') {
413
400
  const viewer = await getViewer(client);
@@ -421,50 +408,21 @@ export async function executeIssueList(client, params) {
421
408
  teamId = team.id;
422
409
  }
423
410
 
424
- const limit = params.limit || 20;
425
-
426
- let issues;
427
- let truncated;
428
- let projectId = null;
429
- let projectName = null;
430
-
431
- if (projectMode) {
432
- // Project-scoped listing
433
- const resolved = await resolveProjectRef(client, projectRef);
434
- projectId = resolved.id;
435
- projectName = resolved.name;
436
-
437
- const result = await fetchIssuesByProject(client, resolved.id, params.states || null, {
438
- assigneeId,
439
- teamId,
440
- limit,
441
- });
442
- issues = result.issues;
443
- truncated = result.truncated;
444
- } else {
445
- // Workspace-level listing (no project filter)
446
- // Default to open states if none specified
447
- const workspaceStates = params.states || ['Backlog', 'Triage', 'In Progress', 'In Review'];
448
- const result = await fetchIssues(client, assigneeId, workspaceStates, limit);
449
- issues = result.issues;
450
- truncated = result.truncated;
451
- }
411
+ const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
412
+ assigneeId,
413
+ teamId,
414
+ limit: params.limit || 20,
415
+ });
452
416
 
453
417
  if (issues.length === 0) {
454
- return toTextResult(
455
- projectMode
456
- ? `No issues found in project "${projectName}"`
457
- : 'No issues found in workspace',
458
- {
459
- projectId,
460
- projectName,
461
- issueCount: 0,
462
- }
463
- );
418
+ return toTextResult(`No issues found in project "${resolved.name}"`, {
419
+ projectId: resolved.id,
420
+ projectName: resolved.name,
421
+ issueCount: 0,
422
+ });
464
423
  }
465
424
 
466
- const scopeLabel = projectMode ? `project "${projectName}"` : 'workspace';
467
- const lines = [`## Issues in ${scopeLabel} (${issues.length}${truncated ? '+' : ''})\n`];
425
+ const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
468
426
 
469
427
  for (const issue of issues) {
470
428
  const stateLabel = issue.state?.name || 'Unknown';
@@ -484,8 +442,8 @@ export async function executeIssueList(client, params) {
484
442
  }
485
443
 
486
444
  return toTextResult(lines.join('\n'), {
487
- projectId,
488
- projectName,
445
+ projectId: resolved.id,
446
+ projectName: resolved.name,
489
447
  issueCount: issues.length,
490
448
  truncated,
491
449
  });
@@ -732,6 +690,22 @@ export async function executeIssueCreate(client, params, options = {}) {
732
690
  createInput.projectId = resolvedProject.id;
733
691
  }
734
692
 
693
+ if (params.projectMilestoneId !== undefined && params.projectMilestoneId !== null) {
694
+ createInput.projectMilestoneId = params.projectMilestoneId;
695
+ } else if (params.milestone !== undefined && params.milestone !== null) {
696
+ const milestoneRef = String(params.milestone || '').trim();
697
+ const clearMilestoneValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
698
+
699
+ if (!clearMilestoneValues.has(milestoneRef.toLowerCase())) {
700
+ if (!resolvedProject?.id) {
701
+ throw new Error('Missing required field: project. Provide project when assigning milestone by name during issue create, or use projectMilestoneId.');
702
+ }
703
+
704
+ const milestone = await resolveMilestoneRef(client, milestoneRef, resolvedProject.id);
705
+ createInput.projectMilestoneId = milestone.id;
706
+ }
707
+ }
708
+
735
709
  const issue = await createIssue(client, createInput);
736
710
 
737
711
  const identifier = issue.identifier || issue.id || 'unknown';
@@ -742,8 +716,11 @@ export async function executeIssueCreate(client, params, options = {}) {
742
716
  const stateLabel = issue.state?.name || 'Unknown';
743
717
  const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
744
718
 
719
+ const milestoneLabel = issue.projectMilestone?.name || null;
720
+
745
721
  const metaParts = [`Team: ${team.name}`, `Project: ${projectLabel}`, `State: ${stateLabel}`, `Assignee: ${assigneeLabel}`];
746
722
  if (priorityLabel) metaParts.push(`Priority: ${priorityLabel}`);
723
+ if (milestoneLabel) metaParts.push(`Milestone: ${milestoneLabel}`);
747
724
 
748
725
  return toTextResult(
749
726
  `Created issue **${identifier}**: ${issue.title}\n${metaParts.join(' | ')}`,
@@ -755,6 +732,7 @@ export async function executeIssueCreate(client, params, options = {}) {
755
732
  project: issue.project,
756
733
  state: issue.state,
757
734
  assignee: issue.assignee,
735
+ projectMilestone: issue.projectMilestone,
758
736
  url: issue.url,
759
737
  }
760
738
  );
package/src/linear.js CHANGED
@@ -3150,6 +3150,7 @@ export async function setIssueState(client, issueId, stateId) {
3150
3150
  * @param {string} [input.projectId] - Project ID
3151
3151
  * @param {number|string} [input.priority] - Issue priority: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low; or none/urgent/high/medium/low
3152
3152
  * @param {string} [input.assigneeId] - Assignee ID
3153
+ * @param {string} [input.projectMilestoneId] - Project milestone ID
3153
3154
  * @param {string} [input.parentId] - Parent issue ID for sub-issues
3154
3155
  * @returns {Promise<Object>} Created issue
3155
3156
  */
@@ -3202,6 +3203,10 @@ export async function createIssue(client, input) {
3202
3203
  createInput.stateId = input.stateId;
3203
3204
  }
3204
3205
 
3206
+ if (input.projectMilestoneId !== undefined) {
3207
+ createInput.projectMilestoneId = input.projectMilestoneId;
3208
+ }
3209
+
3205
3210
  if (getRawRequest(client)) {
3206
3211
  const payload = await executeGraphQL(client, ISSUE_CREATE_MUTATION, { input: createInput });
3207
3212
  if (!payload?.issueCreate?.success) {
@@ -3245,6 +3250,9 @@ export async function createIssue(client, input) {
3245
3250
  state: null,
3246
3251
  team: null,
3247
3252
  project: null,
3253
+ projectMilestone: createInput.projectMilestoneId
3254
+ ? { id: createInput.projectMilestoneId, name: 'Unknown' }
3255
+ : null,
3248
3256
  assignee: null,
3249
3257
  };
3250
3258
  }
@@ -3259,6 +3267,9 @@ export async function createIssue(client, input) {
3259
3267
  state: null,
3260
3268
  team: null,
3261
3269
  project: null,
3270
+ projectMilestone: createInput.projectMilestoneId
3271
+ ? { id: createInput.projectMilestoneId, name: 'Unknown' }
3272
+ : null,
3262
3273
  assignee: null,
3263
3274
  };
3264
3275
  }, 'createIssue');
package/src/shared.js CHANGED
@@ -18,7 +18,7 @@ export function isPiCodingAgentRoot(dir) {
18
18
  if (!fs.existsSync(pkgPath)) return false;
19
19
  try {
20
20
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
21
- return pkg?.name === '@mariozechner/pi-coding-agent';
21
+ return pkg?.name === '@mariozechner/pi-coding-agent' || pkg?.name === '@earendil-works/pi-coding-agent';
22
22
  } catch {
23
23
  return false;
24
24
  }
@@ -50,9 +50,11 @@ export function findPiCodingAgentRoot() {
50
50
  {
51
51
  const binDir = path.dirname(entry);
52
52
  const prefix = path.resolve(binDir, '..');
53
- const candidate = path.join(prefix, 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
54
- if (isPiCodingAgentRoot(candidate)) {
55
- return candidate;
53
+ for (const scope of ['@mariozechner', '@earendil-works']) {
54
+ const candidate = path.join(prefix, 'lib', 'node_modules', scope, 'pi-coding-agent');
55
+ if (isPiCodingAgentRoot(candidate)) {
56
+ return candidate;
57
+ }
56
58
  }
57
59
  }
58
60
 
@@ -60,6 +62,9 @@ export function findPiCodingAgentRoot() {
60
62
  for (const candidate of [
61
63
  '/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
62
64
  '/usr/lib/node_modules/@mariozechner/pi-coding-agent',
65
+ '/usr/local/lib/node_modules/@earendil-works/pi-coding-agent',
66
+ '/usr/lib/node_modules/@earendil-works/pi-coding-agent',
67
+ '/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent',
63
68
  ]) {
64
69
  if (isPiCodingAgentRoot(candidate)) {
65
70
  return candidate;