@fink-andreas/pi-linear-tools 0.7.1 → 0.7.3

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,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.7.3 (2026-07-28)
4
+
5
+ Patch release that fixes Linear issue-relation updates for OAuth users.
6
+
7
+ ### Bug Fixes
8
+ - **Enable issue relations with default OAuth**: New OAuth grants now request Linear's `write` scope, allowing `linear_issue update` to set `blockedBy`, `blocking`, `relatedTo`, and `duplicateOf` relations.
9
+ - **Explain legacy-token remediation**: Relation updates rejected because `write` is missing now direct OAuth users to run `pi-linear-tools auth login`; API-key users are told to use a key with write access.
10
+
11
+ ### Tests
12
+ - Added regression coverage for the default OAuth authorization URL scope and issue-relation scope guidance.
13
+
14
+ ### Upgrade note
15
+ Existing OAuth tokens cannot gain the newly requested scope automatically. Re-authenticate with `pi-linear-tools auth login` before creating issue relations.
16
+
17
+ ## v0.7.2 (2026-07-21)
18
+
19
+ Patch release for Pi package rename compatibility and terminal-safe fallback rendering.
20
+
21
+ ### Bug Fixes
22
+ - **Support renamed Pi packages**: The extension now supports both legacy `@mariozechner/*` imports and the current `@earendil-works/*` package scope, including globally installed Pi paths.
23
+ - **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.
24
+
25
+ ### Tests
26
+ - Added fallback-renderer regression coverage for CJK text, tabs, unavailable Markdown dependencies, and Markdown rendering failures.
27
+
3
28
  ## v0.7.1 (2026-06-09)
4
29
 
5
30
  Patch release for issue creation milestone assignment and configuration text cleanup.
@@ -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
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fink-andreas/pi-linear-tools",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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-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",
30
+ "test": "node tests/test-package-manifest.js && node tests/test-oauth.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
  },
@@ -5,5 +5,10 @@
5
5
  // Linear OAuth application client ID
6
6
  export const OAUTH_CLIENT_ID = 'a3e177176c6697611367f1a2405d4a34';
7
7
 
8
- // OAuth scopes - minimal required scopes
9
- export const OAUTH_SCOPES = ['read', 'issues:create', 'comments:create'];
8
+ // OAuth scopes - minimal required scopes.
9
+ // `write` is included so issue relations (blockedBy / blocking / relatedTo / duplicateOf) work:
10
+ // those go through the `issueRelationCreate` mutation, which Linear gates behind the `write`
11
+ // scope, whereas `issueUpdate` (title/description/state/assignee/parentId) is permitted under
12
+ // `issues:create`. Without `write`, the `linear_issue` tool accepts the relation params but
13
+ // Linear rejects them with "Invalid scope: `write` required". See upstream issue #27.
14
+ export const OAUTH_SCOPES = ['read', 'issues:create', 'comments:create', 'write'];
@@ -22,3 +22,30 @@ export function withMilestoneScopeHint(error) {
22
22
 
23
23
  return error;
24
24
  }
25
+
26
+ /**
27
+ * Adds remediation guidance when an issue-relation mutation is rejected because
28
+ * an existing credential was issued before the default OAuth scopes included `write`.
29
+ *
30
+ * @param {Error} error - The error to wrap
31
+ * @param {object} patch - Requested issue update fields
32
+ * @returns {Error} The original error or a new error with additional hint
33
+ */
34
+ export function withIssueRelationScopeHint(error, patch = {}) {
35
+ const message = String(error?.message || error || 'Unknown error');
36
+ const relationFields = ['blockedBy', 'blocking', 'relatedTo', 'duplicateOf'];
37
+ const requestsRelation = relationFields.some((field) => {
38
+ const value = patch[field];
39
+ return Array.isArray(value) ? value.length > 0 : String(value || '').trim() !== '';
40
+ });
41
+
42
+ if (requestsRelation && /invalid scope/i.test(message) && /write/i.test(message)) {
43
+ return new Error(
44
+ `${message}\nHint: Issue relations require Linear's write scope. ` +
45
+ `If you authenticated with OAuth before this scope was added, run ` +
46
+ '`pi-linear-tools auth login` to re-authenticate. API-key users need a key with write access.'
47
+ );
48
+ }
49
+
50
+ return error;
51
+ }
package/src/handlers.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  withHandlerErrorHandling,
47
47
  getViewer,
48
48
  } from './linear.js';
49
+ import { withIssueRelationScopeHint } from './error-hints.js';
49
50
  import { debug } from './logger.js';
50
51
 
51
52
  function toTextResult(text, details = {}) {
@@ -804,7 +805,12 @@ export async function executeIssueUpdate(client, params) {
804
805
  projectMilestoneId: updatePatch.projectMilestoneId,
805
806
  });
806
807
 
807
- const result = await updateIssue(client, issue, updatePatch);
808
+ let result;
809
+ try {
810
+ result = await updateIssue(client, issue, updatePatch);
811
+ } catch (error) {
812
+ throw withIssueRelationScopeHint(error, updatePatch);
813
+ }
808
814
 
809
815
  const friendlyChanges = result.changed.map((field) => {
810
816
  if (field === 'stateId') return 'state';
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;