paseo-finder-links 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matteo Zampieri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Paseo Finder Links
2
+
3
+ Reveal local file links from Paseo in the operating system's file manager. The plugin selects files
4
+ in macOS Finder or Windows File Explorer, while web links and source references such as
5
+ `src/app.ts:42` keep Paseo's native behavior.
6
+
7
+ ## Installation
8
+
9
+ Enable plugins in Paseo under **Settings > Plugins**, then install the public Git repository:
10
+
11
+ ```bash
12
+ paseo plugin add npm:paseo-finder-links@0.1.0
13
+ paseo plugin ls paseo-finder-links
14
+ ```
15
+
16
+ Paseo plugins are trusted, unsandboxed code. Review this repository before installing it.
17
+
18
+ ## Behavior
19
+
20
+ The client intercepts only unmodified primary clicks on local-file anchors rendered by assistant
21
+ messages. The daemon then:
22
+
23
+ 1. resolves relative paths against the active agent or workspace;
24
+ 2. verifies that the target exists;
25
+ 3. invokes the native file manager with separated arguments and no shell.
26
+
27
+ | Platform | File | Directory |
28
+ | --- | --- | --- |
29
+ | macOS | Selected in Finder with `/usr/bin/open -R` | Revealed in Finder |
30
+ | Windows | Selected in File Explorer with `explorer.exe /select,PATH` | Opened in File Explorer |
31
+
32
+ Windows drive paths, `file:///C:/...` URLs, paths containing spaces, and UNC `file://server/share/...`
33
+ URLs are supported.
34
+
35
+ ## Known limitations
36
+
37
+ - Linux is not supported in version 0.1.0.
38
+ - The click integration is available in Paseo's web-based desktop client, not native mobile clients.
39
+ - Source-position links remain inside Paseo by design.
40
+
41
+ ## Development
42
+
43
+ ```bash
44
+ npm ci
45
+ npm test
46
+ npm run typecheck
47
+ ```
48
+
49
+ CI runs the test and typecheck suite on both macOS and Windows.
50
+
51
+ ## License
52
+
53
+ MIT
package/client/web.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { Platform } from "react-native";
3
+ import {
4
+ getRouteContext,
5
+ isFinderEligibleHref,
6
+ revealInFinderRpc,
7
+ } from "../shared/reveal";
8
+
9
+ interface ClickTarget {
10
+ closest(selector: string): AnchorElement | null;
11
+ }
12
+
13
+ interface AnchorElement {
14
+ getAttribute(name: string): string | null;
15
+ }
16
+
17
+ interface ClickEvent {
18
+ readonly button: number;
19
+ readonly target: unknown;
20
+ readonly altKey: boolean;
21
+ readonly ctrlKey: boolean;
22
+ readonly metaKey: boolean;
23
+ readonly shiftKey: boolean;
24
+ preventDefault(): void;
25
+ stopPropagation(): void;
26
+ stopImmediatePropagation(): void;
27
+ }
28
+
29
+ interface WebDocument {
30
+ addEventListener(type: "click", listener: (event: ClickEvent) => void, capture: boolean): void;
31
+ removeEventListener(type: "click", listener: (event: ClickEvent) => void, capture: boolean): void;
32
+ }
33
+
34
+ declare const document: WebDocument;
35
+ declare const location: { readonly pathname: string };
36
+
37
+ function asClickTarget(value: unknown): ClickTarget | null {
38
+ if (!value || typeof value !== "object" || !("closest" in value)) {
39
+ return null;
40
+ }
41
+ return value as ClickTarget;
42
+ }
43
+
44
+ function isAssistantFileAnchor(anchor: AnchorElement): boolean {
45
+ const style = anchor.getAttribute("style") ?? "";
46
+ return /(?:^|;)\s*display:\s*contents\s*(?:;|$)/i.test(style);
47
+ }
48
+
49
+ export function installFinderFileLinks(client: PluginClientContext): () => void {
50
+ if (Platform.OS !== "web") {
51
+ return () => {};
52
+ }
53
+
54
+ const handleClick = (event: ClickEvent) => {
55
+ if (
56
+ event.button !== 0 ||
57
+ event.altKey ||
58
+ event.ctrlKey ||
59
+ event.metaKey ||
60
+ event.shiftKey
61
+ ) {
62
+ return;
63
+ }
64
+
65
+ const anchor = asClickTarget(event.target)?.closest("a[href]");
66
+ const href = anchor?.getAttribute("href") ?? "";
67
+ if (!anchor || !isAssistantFileAnchor(anchor) || !isFinderEligibleHref(href)) {
68
+ return;
69
+ }
70
+
71
+ event.preventDefault();
72
+ event.stopPropagation();
73
+ event.stopImmediatePropagation();
74
+
75
+ void client
76
+ .rpc(revealInFinderRpc, {
77
+ href,
78
+ ...getRouteContext(location.pathname),
79
+ })
80
+ .then((result) => {
81
+ if (!result.ok) {
82
+ console.error("[paseo-finder-links] " + result.error);
83
+ }
84
+ })
85
+ .catch((error: unknown) => {
86
+ console.error("[paseo-finder-links] Failed to reveal file", error);
87
+ });
88
+ };
89
+
90
+ document.addEventListener("click", handleClick, true);
91
+ return () => document.removeEventListener("click", handleClick, true);
92
+ }
@@ -0,0 +1,6 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { installFinderFileLinks } from "./client/web";
3
+
4
+ export default function contribute(client: PluginClientContext) {
5
+ return installFinderFileLinks(client);
6
+ }
@@ -0,0 +1,8 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { revealInFinder } from "./server/reveal";
3
+ import { revealInFinderRpc } from "./shared/reveal";
4
+
5
+ export default function contribute(server: PluginServerContext) {
6
+ server.handle(revealInFinderRpc, (input, context) => revealInFinder(input, context.paseo));
7
+ return () => {};
8
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "paseo-finder-links",
3
+ "version": "0.1.0",
4
+ "description": "Reveal local file links from Paseo in Finder or File Explorer.",
5
+ "author": "Matteo Zampieri",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/themattvision/paseo-finder-links.git"
10
+ },
11
+ "homepage": "https://github.com/themattvision/paseo-finder-links#readme",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "keywords": [
16
+ "paseo",
17
+ "paseo-plugin",
18
+ "finder",
19
+ "file-explorer"
20
+ ],
21
+ "type": "module",
22
+ "files": [
23
+ "paseo-plugin.json",
24
+ "index.client.ts",
25
+ "index.client.tsx",
26
+ "index.server.ts",
27
+ "index.server.tsx",
28
+ "client/",
29
+ "server/",
30
+ "shared/"
31
+ ],
32
+ "scripts": {
33
+ "test": "node --experimental-strip-types --test shared/reveal.test.ts server/reveal.test.ts",
34
+ "typecheck": "tsc --noEmit"
35
+ },
36
+ "devDependencies": {
37
+ "@getpaseo/client": "^0.9.2",
38
+ "@getpaseo/plugin": "^0.9.2",
39
+ "@getpaseo/protocol": "^0.9.2",
40
+ "@tanstack/react-query": "^5.90.11",
41
+ "@types/node": "^22.20.4",
42
+ "@types/react": "~19.2.0",
43
+ "react": "19.1.0",
44
+ "react-native": "0.81.5",
45
+ "typescript": "^5.9.3",
46
+ "zod": "^4.4.3"
47
+ }
48
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "paseo-finder-links",
3
+ "description": "Reveal local file links in macOS Finder or Windows File Explorer without replacing Paseo's source navigation.",
4
+ "requirements": {
5
+ "paseo": ">=0.9.2"
6
+ }
7
+ }
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import { dirname, resolve } from "node:path";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
6
+ import { buildRevealCommand, hrefToPath, resolveExistingPath } from "./reveal.ts";
7
+
8
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
9
+
10
+ test("resolves a relative file inside the active workspace", async () => {
11
+ const paseo = {
12
+ workspaces: {
13
+ ref: () => ({
14
+ refresh: async () => ({ workspaceDirectory: pluginRoot }),
15
+ }),
16
+ },
17
+ } as unknown as PluginHandlerContext["paseo"];
18
+
19
+ const resolved = await resolveExistingPath(
20
+ { href: "README.md", workspaceId: "test-workspace" },
21
+ paseo,
22
+ );
23
+
24
+ assert.equal(resolved, resolve(pluginRoot, "README.md"));
25
+ });
26
+
27
+ test("converts Windows file URLs and UNC shares to native paths", () => {
28
+ assert.equal(
29
+ hrefToPath("file:///C:/Users/Matteo/My%20File.zip", "win32"),
30
+ String.raw`C:\Users\Matteo\My File.zip`,
31
+ );
32
+ assert.equal(
33
+ hrefToPath("file://server/share/My%20File.zip", "win32"),
34
+ String.raw`\\server\share\My File.zip`,
35
+ );
36
+ });
37
+
38
+ test("builds safe native commands without a shell", () => {
39
+ assert.deepEqual(buildRevealCommand("/tmp/My File.zip", false, "darwin"), {
40
+ executable: "/usr/bin/open",
41
+ args: ["-R", "/tmp/My File.zip"],
42
+ });
43
+ assert.deepEqual(
44
+ buildRevealCommand(String.raw`C:\Users\Matteo\My File.zip`, false, "win32"),
45
+ {
46
+ executable: "explorer.exe",
47
+ args: [String.raw`/select,C:\Users\Matteo\My File.zip`],
48
+ },
49
+ );
50
+ assert.deepEqual(buildRevealCommand(String.raw`C:\Users\Matteo\Downloads`, true, "win32"), {
51
+ executable: "explorer.exe",
52
+ args: [String.raw`C:\Users\Matteo\Downloads`],
53
+ });
54
+ assert.throws(
55
+ () => buildRevealCommand("/tmp/My File.zip", false, "linux"),
56
+ /macOS Finder and Windows File Explorer/,
57
+ );
58
+ });
@@ -0,0 +1,123 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { posix, win32 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
8
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
9
+ import { hasSourcePosition, revealInFinderRpc } from "../shared/reveal.ts";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ type RevealInput = RpcInput<typeof revealInFinderRpc>;
14
+ type RevealOutput = RpcOutput<typeof revealInFinderRpc>;
15
+ type PaseoApi = PluginHandlerContext["paseo"];
16
+
17
+ function decodePath(value: string): string {
18
+ try {
19
+ return decodeURIComponent(value);
20
+ } catch {
21
+ return value;
22
+ }
23
+ }
24
+
25
+ export function hrefToPath(href: string, platform: NodeJS.Platform = process.platform): string | null {
26
+ const trimmed = href.trim();
27
+ if (!trimmed || hasSourcePosition(trimmed)) {
28
+ return null;
29
+ }
30
+
31
+ if (/^file:/i.test(trimmed)) {
32
+ try {
33
+ const url = new URL(trimmed);
34
+ if (url.protocol !== "file:") {
35
+ return null;
36
+ }
37
+ return fileURLToPath(url, { windows: platform === "win32" });
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ const pathOnly = trimmed.split(/[?#]/, 1)[0] ?? "";
44
+ return decodePath(pathOnly);
45
+ }
46
+
47
+ async function getWorkspaceRoot(input: RevealInput, paseo: PaseoApi): Promise<string | null> {
48
+ if (input.agentId) {
49
+ return (await paseo.agents.ref(input.agentId).refresh())?.agent.cwd ?? null;
50
+ }
51
+ if (input.workspaceId) {
52
+ return (await paseo.workspaces.ref(input.workspaceId).refresh())?.workspaceDirectory ?? null;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export async function resolveExistingPath(
58
+ input: RevealInput,
59
+ paseo: PaseoApi,
60
+ platform: NodeJS.Platform = process.platform,
61
+ ): Promise<string> {
62
+ const rawPath = hrefToPath(input.href, platform);
63
+ if (!rawPath) {
64
+ throw new Error("Il link non contiene un percorso locale valido.");
65
+ }
66
+
67
+ const path = platform === "win32" ? win32 : posix;
68
+ let filePath: string;
69
+ if (rawPath === "~" || rawPath.startsWith("~/")) {
70
+ filePath = path.resolve(homedir(), rawPath.slice(2));
71
+ } else if (path.isAbsolute(rawPath)) {
72
+ filePath = path.resolve(rawPath);
73
+ } else {
74
+ const workspaceRoot = await getWorkspaceRoot(input, paseo);
75
+ if (!workspaceRoot) {
76
+ throw new Error("Non riesco a determinare la cartella del workspace corrente.");
77
+ }
78
+ filePath = path.resolve(workspaceRoot, rawPath);
79
+ }
80
+
81
+ await stat(filePath);
82
+ return filePath;
83
+ }
84
+
85
+ export interface RevealCommand {
86
+ executable: string;
87
+ args: string[];
88
+ }
89
+
90
+ export function buildRevealCommand(
91
+ filePath: string,
92
+ isDirectory: boolean,
93
+ platform: NodeJS.Platform = process.platform,
94
+ ): RevealCommand {
95
+ if (platform === "darwin") {
96
+ return { executable: "/usr/bin/open", args: ["-R", filePath] };
97
+ }
98
+ if (platform === "win32") {
99
+ return {
100
+ executable: "explorer.exe",
101
+ args: isDirectory ? [filePath] : [`/select,${filePath}`],
102
+ };
103
+ }
104
+ throw new Error("This plugin supports macOS Finder and Windows File Explorer.");
105
+ }
106
+
107
+ export async function revealInFinder(input: RevealInput, paseo: PaseoApi): Promise<RevealOutput> {
108
+ try {
109
+ const filePath = await resolveExistingPath(input, paseo);
110
+ const fileStats = await stat(filePath);
111
+ const command = buildRevealCommand(filePath, fileStats.isDirectory());
112
+ await execFileAsync(command.executable, command.args, { shell: false });
113
+ return { ok: true, path: filePath };
114
+ } catch (error) {
115
+ return {
116
+ ok: false,
117
+ error:
118
+ error instanceof Error
119
+ ? error.message
120
+ : "Unable to reveal the path in the system file manager.",
121
+ };
122
+ }
123
+ }
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { getRouteContext, hasSourcePosition, isFinderEligibleHref } from "./reveal.ts";
4
+
5
+ test("extracts the active agent or workspace from Paseo routes", () => {
6
+ assert.deepEqual(getRouteContext("/h/local/agent/agent%201"), { agentId: "agent 1" });
7
+ assert.deepEqual(getRouteContext("/h/local/workspace/workspace-1/tab/file"), {
8
+ workspaceId: "workspace-1",
9
+ });
10
+ assert.deepEqual(getRouteContext("/settings"), {});
11
+ });
12
+
13
+ test("keeps source-position links inside Paseo", () => {
14
+ for (const href of ["src/app.ts:42", "src/app.ts:42:8", "src/app.ts#L42", "src/app.ts(42)"]) {
15
+ assert.equal(hasSourcePosition(href), true, href);
16
+ assert.equal(isFinderEligibleHref(href), false, href);
17
+ }
18
+ });
19
+
20
+ test("routes plain local files to the native file manager and leaves web links alone", () => {
21
+ for (const href of [
22
+ "/Users/matteo/Downloads/archive.zip",
23
+ "file:///Users/matteo/Downloads/archive.zip",
24
+ "docs/README.md",
25
+ "~/Downloads/archive.zip",
26
+ String.raw`C:\Users\Matteo\Downloads\archive.zip`,
27
+ "file:///C:/Users/Matteo/Downloads/archive.zip",
28
+ ]) {
29
+ assert.equal(isFinderEligibleHref(href), true, href);
30
+ }
31
+ for (const href of ["https://example.com/file.zip", "mailto:test@example.com", ""]) {
32
+ assert.equal(isFinderEligibleHref(href), false, href);
33
+ }
34
+ });
@@ -0,0 +1,73 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ const LINE_FRAGMENT = /^#L[0-9]+(?:C[0-9]+)?(?:-L?[0-9]+(?:C[0-9]+)?)?$/i;
5
+ const COLON_LINE_SUFFIX = /:[0-9]+(?::[0-9]+)?(?:-[0-9]+(?::[0-9]+)?)?$/;
6
+ const PAREN_LINE_SUFFIX = /\([0-9]+(?:,[0-9]+)?(?:-[0-9]+(?:,[0-9]+)?)?\)$/;
7
+ const WORD_LINE_SUFFIX = /\s+lines?\s+[0-9]+(?:-[0-9]+)?$/i;
8
+
9
+ export const revealInFinderRpc = defineRpc({
10
+ name: "finder-links.reveal",
11
+ input: z.object({
12
+ href: z.string().min(1).max(16_384),
13
+ agentId: z.string().min(1).max(512).optional(),
14
+ workspaceId: z.string().min(1).max(512).optional(),
15
+ }),
16
+ output: z.discriminatedUnion("ok", [
17
+ z.object({ ok: z.literal(true), path: z.string() }),
18
+ z.object({ ok: z.literal(false), error: z.string() }),
19
+ ]),
20
+ });
21
+
22
+ export interface RouteContext {
23
+ agentId?: string;
24
+ workspaceId?: string;
25
+ }
26
+
27
+ function decodeSegment(value: string): string {
28
+ try {
29
+ return decodeURIComponent(value);
30
+ } catch {
31
+ return value;
32
+ }
33
+ }
34
+
35
+ export function getRouteContext(pathname: string): RouteContext {
36
+ const pathOnly = pathname.split(/[?#]/, 1)[0] ?? "";
37
+ const agentMatch = pathOnly.match(/^\/h\/[^/]+\/agent\/([^/]+)(?:\/|$)/);
38
+ if (agentMatch?.[1]) {
39
+ return { agentId: decodeSegment(agentMatch[1]) };
40
+ }
41
+
42
+ const workspaceMatch = pathOnly.match(/^\/h\/[^/]+\/workspace\/([^/]+)(?:\/|$)/);
43
+ if (workspaceMatch?.[1]) {
44
+ return { workspaceId: decodeSegment(workspaceMatch[1]) };
45
+ }
46
+
47
+ return {};
48
+ }
49
+
50
+ export function hasSourcePosition(href: string): boolean {
51
+ const trimmed = href.trim();
52
+ const hashIndex = trimmed.lastIndexOf("#");
53
+ if (hashIndex >= 0 && LINE_FRAGMENT.test(trimmed.slice(hashIndex))) {
54
+ return true;
55
+ }
56
+ const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
57
+ return (
58
+ COLON_LINE_SUFFIX.test(withoutHash) ||
59
+ PAREN_LINE_SUFFIX.test(withoutHash) ||
60
+ WORD_LINE_SUFFIX.test(withoutHash)
61
+ );
62
+ }
63
+
64
+ export function isFinderEligibleHref(href: string): boolean {
65
+ const trimmed = href.trim();
66
+ if (!trimmed || hasSourcePosition(trimmed)) {
67
+ return false;
68
+ }
69
+ if (/^(?:https?|mailto|tel|data|javascript):/i.test(trimmed)) {
70
+ return false;
71
+ }
72
+ return true;
73
+ }