@yofriadi/pi-ast 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,4 +1,22 @@
1
1
  # AST Extension
2
+ ## Install from git URL
3
+
4
+ ```bash
5
+ pi install git:github.com/yofriadi/pi-extensions@ast-v<version>
6
+ ```
7
+
8
+ To load only this extension from the monorepo package source, use package filtering in settings:
9
+
10
+ ```json
11
+ {
12
+ "packages": [
13
+ {
14
+ "source": "git:github.com/yofriadi/pi-extensions@ast-v<version>",
15
+ "extensions": ["packages/ast/src/index.ts"]
16
+ }
17
+ ]
18
+ }
19
+ ```
2
20
 
3
21
  This extension provides integration with `ast-grep` (sg).
4
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yofriadi/pi-ast",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -16,6 +16,12 @@
16
16
  "build": "echo 'nothing to build'"
17
17
  },
18
18
  "peerDependencies": {
19
- "@mariozechner/pi-coding-agent": "*"
20
- }
19
+ "@mariozechner/pi-coding-agent": "^0.52.10"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/yofriadi/pi-extensions.git",
24
+ "directory": "packages/ast"
25
+ },
26
+ "homepage": "https://github.com/yofriadi/pi-extensions/tree/main/packages/ast"
21
27
  }
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { registerAstSearch } from "./tools/ast-search";
4
- import { registerAstRewrite } from "./tools/ast-rewrite";
5
- import { exec } from "./utils/exec";
3
+ import { registerAstRewrite } from "./tools/ast-rewrite.js";
4
+ import { registerAstSearch } from "./tools/ast-search.js";
5
+ import { exec } from "./utils/exec.js";
6
6
 
7
7
  export default function astExtension(pi: ExtensionAPI): void {
8
8
  // Register tools
@@ -17,21 +17,24 @@ export default function astExtension(pi: ExtensionAPI): void {
17
17
  execute: async () => {
18
18
  try {
19
19
  const { exitCode, stdout, stderr } = await exec(["sg", "--version"]);
20
-
20
+
21
21
  if (exitCode !== 0) {
22
- return {
23
- content: [{ type: "text", text: `ast-grep (sg) check failed: ${stderr}` }],
24
- isError: true
25
- }
22
+ return {
23
+ content: [{ type: "text", text: `ast-grep (sg) check failed: ${stderr}` }],
24
+ isError: true,
25
+ details: undefined,
26
+ };
26
27
  }
27
28
 
28
29
  return {
29
30
  content: [{ type: "text", text: `ast-grep (sg) is available: ${stdout.trim()}` }],
31
+ details: undefined,
30
32
  };
31
33
  } catch (error) {
32
34
  return {
33
- content: [{ type: "text", text: `ast-grep (sg) check failed: ${(error as Error).message}` }],
34
- isError: true
35
+ content: [{ type: "text", text: `ast-grep (sg) check failed: ${(error as Error).message}` }],
36
+ isError: true,
37
+ details: undefined,
35
38
  };
36
39
  }
37
40
  },
@@ -1,6 +1,6 @@
1
- import { Type } from "@sinclair/typebox";
2
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import { exec } from "../utils/exec";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { exec } from "../utils/exec.js";
4
4
 
5
5
  const MAX_OUTPUT_LENGTH = 10000;
6
6
 
@@ -8,7 +8,8 @@ export function registerAstRewrite(pi: ExtensionAPI): void {
8
8
  pi.registerTool({
9
9
  name: "ast_rewrite",
10
10
  label: "AST Rewrite",
11
- description: "Search and rewrite code using AST patterns with ast-grep (sg). Defaults to dry-run (preview). Use `apply: true` to execute changes.",
11
+ description:
12
+ "Search and rewrite code using AST patterns with ast-grep (sg). Defaults to dry-run (preview). Use `apply: true` to execute changes.",
12
13
  parameters: Type.Object({
13
14
  pattern: Type.String({ description: "AST pattern to search for" }),
14
15
  rewrite: Type.String({ description: "Replacement pattern" }),
@@ -21,49 +22,52 @@ export function registerAstRewrite(pi: ExtensionAPI): void {
21
22
 
22
23
  try {
23
24
  const args = ["sg", "run", "--pattern", pattern, "--rewrite", rewrite, "--color=never"];
24
-
25
+
25
26
  if (lang) {
26
27
  args.push("--lang", lang);
27
28
  }
28
-
29
+
29
30
  if (apply) {
30
31
  args.push("-U"); // Update all (apply)
31
32
  }
32
-
33
+
33
34
  // Add path at the end
34
35
  if (path) {
35
36
  args.push(path);
36
37
  }
37
38
 
38
39
  const { exitCode, stdout, stderr } = await exec(args);
39
-
40
+
40
41
  if (exitCode !== 0) {
41
- return {
42
- content: [{ type: "text", text: `ast-rewrite failed: ${stderr}` }],
43
- isError: true
44
- }
42
+ return {
43
+ content: [{ type: "text", text: `ast-rewrite failed: ${stderr}` }],
44
+ isError: true,
45
+ details: undefined,
46
+ };
45
47
  }
46
48
 
47
49
  if (!stdout.trim()) {
48
- return {
49
- content: [{ type: "text", text: apply ? "No changes applied (no matches found)." : "No matches found." }],
50
- };
50
+ return {
51
+ content: [{ type: "text", text: apply ? "No changes applied (no matches found)." : "No matches found." }],
52
+ details: undefined,
53
+ };
51
54
  }
52
55
 
53
56
  let output = stdout;
54
57
  if (output.length > MAX_OUTPUT_LENGTH) {
55
- output = output.substring(0, MAX_OUTPUT_LENGTH) + "\n... (truncated)";
58
+ output = `${output.substring(0, MAX_OUTPUT_LENGTH)}\n... (truncated)`;
56
59
  }
57
60
 
58
61
  const mode = apply ? "APPLIED" : "DRY-RUN (preview)";
59
62
  return {
60
63
  content: [{ type: "text", text: `[${mode}]\n\n${output}` }],
64
+ details: undefined,
61
65
  };
62
-
63
66
  } catch (error) {
64
67
  return {
65
- content: [{ type: "text", text: `ast-rewrite execution error: ${(error as Error).message}` }],
66
- isError: true
68
+ content: [{ type: "text", text: `ast-rewrite execution error: ${(error as Error).message}` }],
69
+ isError: true,
70
+ details: undefined,
67
71
  };
68
72
  }
69
73
  },
@@ -1,9 +1,18 @@
1
- import { Type } from "@sinclair/typebox";
2
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import { exec } from "../utils/exec";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { exec } from "../utils/exec.js";
4
4
 
5
5
  const MAX_OUTPUT_LENGTH = 10000;
6
6
 
7
+ interface SgMatch {
8
+ file: string;
9
+ range: {
10
+ start: { line: number };
11
+ end: { line: number };
12
+ };
13
+ text: string;
14
+ }
15
+
7
16
  export function registerAstSearch(pi: ExtensionAPI): void {
8
17
  pi.registerTool({
9
18
  name: "ast_search",
@@ -25,42 +34,49 @@ export function registerAstSearch(pi: ExtensionAPI): void {
25
34
  }
26
35
 
27
36
  const { exitCode, stdout, stderr } = await exec(args);
28
-
37
+
29
38
  if (exitCode !== 0) {
30
- // sg returns non-zero if no matches? No, usually 0 even if no matches.
31
- // But if it fails to parse pattern, it returns non-zero.
32
- return {
33
- content: [{ type: "text", text: `ast-search failed: ${stderr}` }],
34
- isError: true
35
- }
39
+ // sg returns non-zero if no matches? No, usually 0 even if no matches.
40
+ // But if it fails to parse pattern, it returns non-zero.
41
+ return {
42
+ content: [{ type: "text", text: `ast-search failed: ${stderr}` }],
43
+ isError: true,
44
+ details: undefined,
45
+ };
36
46
  }
37
47
 
38
48
  if (!stdout.trim()) {
39
49
  return {
40
50
  content: [{ type: "text", text: "No matches found." }],
51
+ details: undefined,
41
52
  };
42
53
  }
43
54
 
44
- let results;
55
+ let results: SgMatch[];
45
56
  try {
46
57
  results = JSON.parse(stdout);
47
58
  } catch (e) {
48
59
  return {
49
- content: [{ type: "text", text: `Failed to parse ast-search output: ${(e as Error).message}\nOutput: ${stdout}` }],
50
- isError: true
60
+ content: [
61
+ { type: "text", text: `Failed to parse ast-search output: ${(e as Error).message}\nOutput: ${stdout}` },
62
+ ],
63
+ isError: true,
64
+ details: undefined,
51
65
  };
52
66
  }
53
67
 
54
68
  if (!Array.isArray(results)) {
55
- return {
69
+ return {
56
70
  content: [{ type: "text", text: `Unexpected output format from ast-search: ${stdout}` }],
57
- isError: true
71
+ isError: true,
72
+ details: undefined,
58
73
  };
59
74
  }
60
75
 
61
76
  if (results.length === 0) {
62
77
  return {
63
78
  content: [{ type: "text", text: "No matches found." }],
79
+ details: undefined,
64
80
  };
65
81
  }
66
82
 
@@ -70,22 +86,23 @@ export function registerAstSearch(pi: ExtensionAPI): void {
70
86
  const startLine = match.range.start.line + 1;
71
87
  const endLine = match.range.end.line + 1;
72
88
  const text = match.text;
73
-
89
+
74
90
  output += `${file}:${startLine}-${endLine}:\n${text}\n\n`;
75
91
  }
76
-
92
+
77
93
  if (output.length > MAX_OUTPUT_LENGTH) {
78
- output = output.substring(0, MAX_OUTPUT_LENGTH) + "\n... (truncated)";
94
+ output = `${output.substring(0, MAX_OUTPUT_LENGTH)}\n... (truncated)`;
79
95
  }
80
96
 
81
97
  return {
82
98
  content: [{ type: "text", text: output.trim() }],
99
+ details: undefined,
83
100
  };
84
-
85
101
  } catch (error) {
86
102
  return {
87
- content: [{ type: "text", text: `ast-search execution error: ${(error as Error).message}` }],
88
- isError: true
103
+ content: [{ type: "text", text: `ast-search execution error: ${(error as Error).message}` }],
104
+ isError: true,
105
+ details: undefined,
89
106
  };
90
107
  }
91
108
  },
package/src/utils/exec.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { join, resolve } from "node:path";
2
3
 
3
4
  export interface ExecResult {
4
5
  stdout: string;
@@ -6,20 +7,61 @@ export interface ExecResult {
6
7
  exitCode: number;
7
8
  }
8
9
 
10
+ interface BunProcess {
11
+ exited: Promise<number>;
12
+ stdout: ReadableStream<Uint8Array>;
13
+ stderr: ReadableStream<Uint8Array>;
14
+ }
15
+
16
+ interface BunInterface {
17
+ spawn: (
18
+ command: string[],
19
+ options: {
20
+ cwd?: string;
21
+ env?: Record<string, string | undefined>;
22
+ stdout?: "pipe";
23
+ stderr?: "pipe";
24
+ },
25
+ ) => BunProcess;
26
+ }
27
+
9
28
  export async function exec(command: string[], options: { cwd?: string } = {}): Promise<ExecResult> {
29
+ // Add node_modules/.bin to PATH
30
+ const env = { ...process.env };
31
+ const cwd = options.cwd ?? process.cwd();
32
+
33
+ // Try to find the closest node_modules/.bin
34
+ let currentDir = cwd;
35
+ const binPaths: string[] = [];
36
+ while (true) {
37
+ binPaths.push(join(currentDir, "node_modules", ".bin"));
38
+ const parentDir = resolve(currentDir, "..");
39
+ if (parentDir === currentDir) break;
40
+ currentDir = parentDir;
41
+ }
42
+
43
+ const pathSeparator = process.platform === "win32" ? ";" : ":";
44
+ const existingPath = env.PATH ?? "";
45
+ env.PATH = [existingPath, ...binPaths].filter((value) => value && value.length > 0).join(pathSeparator);
46
+
10
47
  // Try Bun first
11
- const bun = (globalThis as any).Bun;
48
+ const bun = (globalThis as Record<string, unknown>).Bun as BunInterface | undefined;
12
49
  if (bun && typeof bun.spawn === "function") {
13
- return execBun(bun, command, options);
50
+ return execBun(bun, command, { ...options, env });
14
51
  }
15
52
 
16
53
  // Fallback to Node
17
- return execNode(command, options);
54
+ return execNode(command, { ...options, env });
18
55
  }
19
56
 
20
- async function execBun(bun: any, command: string[], options: { cwd?: string }): Promise<ExecResult> {
57
+ async function execBun(
58
+ bun: BunInterface,
59
+ command: string[],
60
+ options: { cwd?: string; env?: Record<string, string | undefined> },
61
+ ): Promise<ExecResult> {
21
62
  const proc = bun.spawn(command, {
22
63
  cwd: options.cwd,
64
+ env: options.env,
23
65
  stdout: "pipe",
24
66
  stderr: "pipe",
25
67
  });
@@ -31,11 +73,15 @@ async function execBun(bun: any, command: string[], options: { cwd?: string }):
31
73
  return { stdout, stderr, exitCode };
32
74
  }
33
75
 
34
- function execNode(command: string[], options: { cwd?: string }): Promise<ExecResult> {
76
+ function execNode(
77
+ command: string[],
78
+ options: { cwd?: string; env?: Record<string, string | undefined> },
79
+ ): Promise<ExecResult> {
35
80
  return new Promise((resolve, reject) => {
36
81
  const [cmd, ...args] = command;
37
82
  const child = spawn(cmd, args, {
38
83
  cwd: options.cwd,
84
+ env: options.env,
39
85
  stdio: ["ignore", "pipe", "pipe"],
40
86
  });
41
87
 
@@ -1,3 +1,3 @@
1
- function hello(name: string) {
2
- console.log("Hello, " + name);
1
+ function _hello(name: string) {
2
+ console.log(`Hello, ${name}`);
3
3
  }
@@ -1,11 +1,11 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { registerAstSearch } from '../src/tools/ast-search';
3
- import { registerAstRewrite } from '../src/tools/ast-rewrite';
4
- import type { ExtensionAPI, ToolDefinition } from '@mariozechner/pi-coding-agent';
5
- import { join } from 'path';
1
+ import { join } from "node:path";
2
+ import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { registerAstRewrite } from "../src/tools/ast-rewrite.js";
5
+ import { registerAstSearch } from "../src/tools/ast-search.js";
6
6
 
7
7
  // Using Bun-compatible dirname
8
- const fixturesDir = join(import.meta.dirname, 'fixtures');
8
+ const fixturesDir = join(import.meta.dirname, "fixtures");
9
9
 
10
10
  // Mock ExtensionAPI
11
11
  const createMockPi = () => {
@@ -14,89 +14,95 @@ const createMockPi = () => {
14
14
  registerTool: (tool: ToolDefinition) => {
15
15
  tools[tool.name] = tool;
16
16
  },
17
- tools
17
+ tools,
18
18
  };
19
19
  };
20
20
 
21
- describe('AST Extension Integration', () => {
21
+ describe("AST Extension Integration", () => {
22
22
  let mockPi: ReturnType<typeof createMockPi>;
23
23
 
24
24
  beforeEach(() => {
25
25
  mockPi = createMockPi();
26
26
  });
27
27
 
28
- describe('ast_search', () => {
29
- it('should find patterns in TypeScript', async () => {
28
+ describe("ast_search", () => {
29
+ it("should find patterns in TypeScript", async () => {
30
30
  // Register tool
31
31
  registerAstSearch(mockPi as unknown as ExtensionAPI);
32
- const tool = mockPi.tools['ast_search'];
32
+ const tool = mockPi.tools.ast_search;
33
33
  expect(tool).toBeDefined();
34
34
 
35
35
  // Execute tool
36
36
  const params = {
37
- pattern: 'console.log($A)',
38
- path: join(fixturesDir, 'example.ts'),
39
- lang: 'typescript'
37
+ pattern: "console.log($A)",
38
+ path: join(fixturesDir, "example.ts"),
39
+ lang: "typescript",
40
40
  };
41
-
41
+
42
42
  // Mock context and other args
43
- const result = await tool.execute('test-id', params, undefined, undefined, {} as any);
44
-
45
- expect(result.isError).toBeFalsy();
46
-
47
- const content = result.content[0].text;
43
+ // biome-ignore lint/suspicious/noExplicitAny: Mocking tool result
44
+ const result = (await tool.execute("test-id", params, undefined, undefined, {} as unknown as any)) as any;
45
+
46
+ expect(result.isError, result.content[0]?.type === "text" ? result.content[0].text : undefined).toBeFalsy();
47
+
48
+ const content = result.content[0]?.type === "text" ? result.content[0].text : "";
48
49
  // Should verify output format
49
- expect(content).toContain('example.ts');
50
+ expect(content).toContain("example.ts");
50
51
  // AST grep output usually includes line numbers
51
52
  // e.g. "example.ts:2-2:\n console.log(\"Hello, \" + name);"
52
-
53
+
53
54
  // Just verify matched text is present
54
- expect(content).toContain('console.log("Hello, " + name)');
55
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Testing for literal string in output
56
+ expect(content).toContain("console.log(`Hello, ${name}`)");
55
57
  });
56
58
 
57
- it('should find patterns in Rust', async () => {
59
+ it("should find patterns in Rust", async () => {
58
60
  registerAstSearch(mockPi as unknown as ExtensionAPI);
59
- const tool = mockPi.tools['ast_search'];
60
-
61
+ const tool = mockPi.tools.ast_search;
62
+
61
63
  const params = {
62
- pattern: 'println!($A, $B)',
63
- path: join(fixturesDir, 'example.rs'),
64
- lang: 'rust'
64
+ pattern: "println!($A, $B)",
65
+ path: join(fixturesDir, "example.rs"),
66
+ lang: "rust",
65
67
  };
66
-
67
- const result = await tool.execute('test-id', params, undefined, undefined, {} as any);
68
-
69
- expect(result.isError).toBeFalsy();
70
- const content = result.content[0].text;
68
+
69
+ // biome-ignore lint/suspicious/noExplicitAny: Mocking tool result
70
+ const result = (await tool.execute("test-id", params, undefined, undefined, {} as unknown as any)) as any;
71
+
72
+ expect(result.isError, result.content[0]?.type === "text" ? result.content[0].text : undefined).toBeFalsy();
73
+ const content = result.content[0]?.type === "text" ? result.content[0].text : "";
71
74
  expect(content).toContain('println!("Hello, {}", name)');
72
75
  });
73
76
  });
74
77
 
75
- describe('ast_rewrite', () => {
76
- it('should dry-run rewrite in TypeScript', async () => {
78
+ describe("ast_rewrite", () => {
79
+ it("should dry-run rewrite in TypeScript", async () => {
77
80
  registerAstRewrite(mockPi as unknown as ExtensionAPI);
78
- const tool = mockPi.tools['ast_rewrite'];
81
+ const tool = mockPi.tools.ast_rewrite;
79
82
  expect(tool).toBeDefined();
80
83
 
81
84
  const params = {
82
- pattern: 'console.log($A)',
83
- rewrite: 'logger.info($A)',
84
- path: join(fixturesDir, 'example.ts'),
85
- lang: 'typescript',
86
- apply: false
85
+ pattern: "console.log($A)",
86
+ rewrite: "logger.info($A)",
87
+ path: join(fixturesDir, "example.ts"),
88
+ lang: "typescript",
89
+ apply: false,
87
90
  };
88
-
89
- const result = await tool.execute('test-id', params, undefined, undefined, {} as any);
90
-
91
- expect(result.isError).toBeFalsy();
92
- const content = result.content[0].text;
93
-
94
- expect(content).toContain('[DRY-RUN (preview)]');
91
+
92
+ // biome-ignore lint/suspicious/noExplicitAny: Mocking tool result
93
+ const result = (await tool.execute("test-id", params, undefined, undefined, {} as unknown as any)) as any;
94
+
95
+ expect(result.isError, result.content[0]?.type === "text" ? result.content[0].text : undefined).toBeFalsy();
96
+ const content = result.content[0]?.type === "text" ? result.content[0].text : "";
97
+
98
+ expect(content).toContain("[DRY-RUN (preview)]");
95
99
  // Verify diff output
96
100
  // sg output usually has @@ ... @@
97
101
  // and -old +new lines
98
- expect(content).toContain('- console.log("Hello, " + name);');
99
- expect(content).toContain('+ logger.info("Hello, " + name);');
102
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Testing for literal string in output
103
+ expect(content).toContain("console.log(`Hello, ${name}`);");
104
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Testing for literal string in output
105
+ expect(content).toContain("logger.info(`Hello, ${name}`);");
100
106
  });
101
107
  });
102
108
  });