@aliou/pi-linkup 0.0.1 → 0.2.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.
@@ -0,0 +1,11 @@
1
+ # Changesets
2
+
3
+ This folder is used by [Changesets](https://github.com/changesets/changesets) to manage versioning and changelogs.
4
+
5
+ ## Adding a changeset
6
+
7
+ Run `pnpm changeset` to create a new changeset when you make changes that should be released.
8
+
9
+ ## Releasing
10
+
11
+ When changesets are merged to main, a PR will be created to version and release the package.
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "public",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": []
11
+ }
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ check:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: pnpm/action-setup@v4
19
+
20
+ - uses: actions/setup-node@v4
21
+ with:
22
+ node-version: "22"
23
+ cache: "pnpm"
24
+
25
+ - name: Install dependencies
26
+ run: pnpm install --frozen-lockfile
27
+
28
+ - name: Lint
29
+ run: pnpm lint
30
+
31
+ - name: Typecheck
32
+ run: pnpm typecheck
@@ -0,0 +1,151 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+ inputs:
9
+ skip-checks:
10
+ description: "Skip lint and typecheck"
11
+ type: boolean
12
+ default: false
13
+
14
+ concurrency:
15
+ group: ${{ github.workflow }}-${{ github.ref }}
16
+ cancel-in-progress: true
17
+
18
+ jobs:
19
+ check:
20
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip-checks) }}
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: pnpm/action-setup@v4
26
+
27
+ - uses: actions/setup-node@v4
28
+ with:
29
+ node-version: "22"
30
+ cache: "pnpm"
31
+
32
+ - name: Install dependencies
33
+ run: pnpm install --frozen-lockfile
34
+
35
+ - name: Lint
36
+ run: pnpm lint
37
+
38
+ - name: Typecheck
39
+ run: pnpm typecheck
40
+
41
+ publish:
42
+ name: Publish
43
+ needs: check
44
+ if: ${{ always() && (needs.check.result == 'success' || needs.check.result == 'skipped') }}
45
+ runs-on: ubuntu-latest
46
+ permissions:
47
+ contents: write
48
+ packages: write
49
+ pull-requests: write
50
+ id-token: write
51
+
52
+ steps:
53
+ - name: Checkout
54
+ uses: actions/checkout@v4
55
+ with:
56
+ fetch-depth: 0
57
+
58
+ - name: Setup pnpm
59
+ uses: pnpm/action-setup@v4
60
+
61
+ - name: Setup Node.js
62
+ uses: actions/setup-node@v4
63
+ with:
64
+ node-version: "22"
65
+ registry-url: "https://registry.npmjs.org"
66
+ scope: "@aliou"
67
+ cache: "pnpm"
68
+
69
+ - name: Upgrade npm for OIDC support
70
+ run: npm install -g npm@latest
71
+
72
+ - name: Install dependencies
73
+ run: pnpm install --frozen-lockfile
74
+
75
+ - name: Get release info
76
+ id: release-info
77
+ run: |
78
+ pnpm changeset status --output=release.json 2>/dev/null || echo '{"releases":[]}' > release.json
79
+ node <<NODE
80
+ const fs = require('fs');
81
+ const release = JSON.parse(fs.readFileSync('release.json', 'utf8'));
82
+ const releases = release.releases?.filter(r => r.type !== 'none') || [];
83
+
84
+ let title = 'Version Packages';
85
+ let commit = 'Version Packages';
86
+ if (releases.length === 1) {
87
+ const { name, newVersion } = releases[0];
88
+ title = 'Updating ' + name + ' to version ' + newVersion;
89
+ commit = name + '@' + newVersion;
90
+ } else if (releases.length > 1) {
91
+ const summary = releases.map(r => r.name + '@' + r.newVersion).join(', ');
92
+ title = 'Updating ' + summary;
93
+ commit = summary;
94
+ }
95
+
96
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, 'title=' + title + '\n');
97
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, 'commit=' + commit + '\n');
98
+ NODE
99
+ rm -f release.json
100
+ continue-on-error: true
101
+
102
+ - name: Create Release PR or Publish
103
+ id: changesets
104
+ uses: changesets/action@v1
105
+ with:
106
+ version: pnpm changeset version
107
+ publish: pnpm changeset publish
108
+ title: ${{ steps.release-info.outputs.title || 'Version Packages' }}
109
+ commit: ${{ steps.release-info.outputs.commit || 'Version Packages' }}
110
+ env:
111
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
112
+ NPM_CONFIG_PROVENANCE: true
113
+
114
+ - name: Create GitHub releases
115
+ if: steps.changesets.outputs.published == 'true'
116
+ env:
117
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
118
+ PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
119
+ run: |
120
+ node <<'NODE'
121
+ const { execSync } = require("node:child_process");
122
+
123
+ const published = JSON.parse(process.env.PUBLISHED_PACKAGES || "[]");
124
+
125
+ for (const pkg of published) {
126
+ const shortName = pkg.name.replace(/^@[^/]+\//, "");
127
+ const tag = `${shortName}@${pkg.version}`;
128
+
129
+ const existing = execSync(`git tag --list ${tag}`, { encoding: "utf8" }).trim();
130
+ if (!existing) {
131
+ execSync(`git tag ${tag}`);
132
+ execSync(`git push origin ${tag}`);
133
+ }
134
+
135
+ let hasRelease = false;
136
+ try {
137
+ const output = execSync(`gh release view ${tag} --json tagName --jq .tagName`, {
138
+ stdio: ["ignore", "pipe", "ignore"],
139
+ }).toString().trim();
140
+ hasRelease = output.length > 0;
141
+ } catch {
142
+ hasRelease = false;
143
+ }
144
+
145
+ if (!hasRelease) {
146
+ execSync(`gh release create ${tag} --title ${tag} --notes "Release ${tag}"`, {
147
+ stdio: "inherit",
148
+ });
149
+ }
150
+ }
151
+ NODE
@@ -0,0 +1,3 @@
1
+ pnpm run typecheck
2
+ pnpm run lint
3
+ pnpm run format
package/AGENTS.md ADDED
@@ -0,0 +1,48 @@
1
+ # pi-linkup
2
+
3
+ Public Pi extension providing web search, answer, and fetch tools via the Linkup API. People could be using this, so consider backwards compatibility when making changes.
4
+
5
+ Pi is pre-1.0.0, so breaking changes can happen between Pi versions. This extension must stay up to date with Pi or things will break.
6
+
7
+ ## Stack
8
+
9
+ - TypeScript (strict mode)
10
+ - pnpm 10.26.1
11
+ - Biome for linting/formatting
12
+ - Changesets for versioning
13
+
14
+ ## Scripts
15
+
16
+ ```bash
17
+ pnpm typecheck # Type check
18
+ pnpm lint # Lint (runs on pre-commit)
19
+ pnpm format # Format
20
+ pnpm changeset # Create changeset for versioning
21
+ ```
22
+
23
+ ## Structure
24
+
25
+ ```
26
+ src/
27
+ index.ts # Extension entry, registers tools and commands
28
+ client.ts # Linkup API client
29
+ types.ts # Shared types
30
+ tools/ # Tool implementations
31
+ commands/ # Command implementations
32
+ skills/
33
+ linkup/SKILL.md # Skill docs for agents using this extension
34
+ ```
35
+
36
+ ## Conventions
37
+
38
+ - New tools: follow patterns in `src/tools/`
39
+ - API keys come from environment (`LINKUP_API_KEY`)
40
+ - Update `skills/linkup/SKILL.md` when tool behavior changes
41
+
42
+ ## Versioning
43
+
44
+ Uses changesets. Run `pnpm changeset` before committing user-facing changes.
45
+
46
+ - `patch`: bug fixes
47
+ - `minor`: new features/tools
48
+ - `major`: breaking changes
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @aliou/pi-linkup
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2307674: Update pi packages to 0.51.0. Adapt tool execute signatures to new parameter order.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # pi-linkup
2
+
3
+ Web search and content fetching extension for [Pi](https://buildwithpi.ai/) using the [Linkup API](https://linkup.so).
4
+
5
+ ## Features
6
+
7
+ - `linkup_web_search` - Search the web, get relevant sources with content
8
+ - `linkup_web_answer` - Get synthesized answers with citations
9
+ - `linkup_web_fetch` - Extract clean markdown from URLs
10
+ - `/linkup:balance` - Check API credit balance
11
+
12
+ ## Installation
13
+
14
+ ### Get API Key
15
+
16
+ Sign up at [app.linkup.so](https://app.linkup.so) to get an API key.
17
+
18
+ ### Set Environment Variable
19
+
20
+ ```bash
21
+ export LINKUP_API_KEY="your-api-key-here"
22
+ ```
23
+
24
+ Add to shell profile for persistence:
25
+
26
+ ```bash
27
+ echo 'export LINKUP_API_KEY="your-api-key-here"' >> ~/.zshrc
28
+ ```
29
+
30
+ ### Install Extension
31
+
32
+ ```bash
33
+ # From npm
34
+ pi install npm:@aliou/pi-linkup
35
+
36
+ # From git
37
+ pi install git:github.com/aliou/pi-linkup
38
+
39
+ # Local development
40
+ pi -e ./src/index.ts
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### linkup_web_search
46
+
47
+ Search the web and get a list of sources with content snippets.
48
+
49
+ **Parameters:**
50
+ - `query` (string, required) - The search query
51
+ - `deep` (boolean, optional) - Use deep search mode for comprehensive results. Default: false
52
+
53
+ **Example prompts:**
54
+ ```
55
+ Search for "TypeScript 5.0 new features"
56
+ ```
57
+
58
+ ```
59
+ Use linkup_web_search with deep mode to research WebAssembly WASI
60
+ ```
61
+
62
+ The agent will use `linkup_web_search` to find relevant sources. Results are shown in compact view by default. Press `Ctrl+O` to expand and see all sources with full content.
63
+
64
+ ### linkup_web_answer
65
+
66
+ Get a synthesized answer with sources.
67
+
68
+ **Parameters:**
69
+ - `query` (string, required) - The question to answer
70
+ - `deep` (boolean, optional) - Use deep search mode. Default: false
71
+
72
+ **Example prompts:**
73
+ ```
74
+ What is the latest stable Node.js version?
75
+ ```
76
+
77
+ ```
78
+ Use linkup_web_answer to find Microsoft's 2024 revenue
79
+ ```
80
+
81
+ The agent will use `linkup_web_answer` for concise answers. Press `Ctrl+O` to expand and see the full answer with all sources.
82
+
83
+ ### linkup_web_fetch
84
+
85
+ Fetch content from a specific URL as markdown.
86
+
87
+ **Parameters:**
88
+ - `url` (string, required) - The URL to fetch
89
+ - `renderJs` (boolean, optional) - Render JavaScript. Default: true
90
+
91
+ **Example prompts:**
92
+ ```
93
+ Fetch the content from https://docs.linkup.so
94
+ ```
95
+
96
+ ```
97
+ Use linkup_web_fetch without JavaScript rendering for https://example.com/docs
98
+ ```
99
+
100
+ The agent will use `linkup_web_fetch` to extract clean markdown. Press `Ctrl+O` to expand and see more content.
101
+
102
+ ### Check Balance
103
+
104
+ ```
105
+ /linkup:balance
106
+ ```
107
+
108
+ Shows remaining API credits.
109
+
110
+ ## Skill
111
+
112
+ Includes agentskills.io compliant skill with detailed usage guide:
113
+
114
+ ```
115
+ /skill:linkup
116
+ ```
117
+
118
+ Provides:
119
+ - Tool selection guidance
120
+ - Query formulation tips
121
+ - When to use deep vs standard search
122
+ - Prompting best practices
123
+ - Example workflows
124
+
125
+ ## Tool Selection Guide
126
+
127
+ **Use linkup_web_search when:**
128
+ - Finding information across multiple sources
129
+ - Research and discovery
130
+ - Need to see different perspectives
131
+
132
+ **Use linkup_web_answer when:**
133
+ - Need a direct answer to a specific question
134
+ - Want a quick summary from multiple sources
135
+ - Time-sensitive queries
136
+
137
+ **Use linkup_web_fetch when:**
138
+ - Reading documentation from a known URL
139
+ - Following up on search results
140
+ - Extracting content from specific articles
141
+
142
+ ## Best Practices
143
+
144
+ 1. Be specific with queries: "Microsoft 2024 Q4 revenue" beats "Microsoft revenue"
145
+ 2. Use deep mode strategically: Deep searches are thorough but slower
146
+ 3. Choose the right tool: search for discovery, answer for facts, fetch for known URLs
147
+ 4. Monitor usage: Check `/linkup:balance` to track credit consumption
148
+
149
+ ## Development
150
+
151
+ ### Setup
152
+
153
+ ```bash
154
+ git clone https://github.com/aliou/pi-linkup.git
155
+ cd pi-linkup
156
+
157
+ # Install dependencies (sets up pre-commit hooks)
158
+ pnpm install
159
+ ```
160
+
161
+ Pre-commit hooks run on every commit:
162
+ - TypeScript type checking
163
+ - Biome linting
164
+ - Biome formatting with auto-fix
165
+
166
+ ### Commands
167
+
168
+ ```bash
169
+ # Type check
170
+ pnpm run typecheck
171
+
172
+ # Lint
173
+ pnpm run lint
174
+
175
+ # Format
176
+ pnpm run format
177
+ ```
178
+
179
+ ### Test Locally
180
+
181
+ ```bash
182
+ pi -e ./src/index.ts
183
+
184
+ # Then in Pi
185
+ /skill:linkup
186
+ ```
187
+
188
+ ## Requirements
189
+
190
+ - Pi coding agent v0.50.0+
191
+ - LINKUP_API_KEY environment variable
192
+
193
+ ## Links
194
+
195
+ - [Linkup Documentation](https://docs.linkup.so)
196
+ - [Linkup API Reference](https://docs.linkup.so/pages/documentation/api-reference)
197
+ - [Get API Key](https://app.linkup.so)
198
+ - [Pi Documentation](https://buildwithpi.ai/)
199
+ - [Agent Skills Spec](https://agentskills.io/specification)
200
+
201
+ ## License
202
+
203
+ MIT
package/biome.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "includes": ["**/*.ts", "**/*.json"],
10
+ "ignoreUnknown": true
11
+ },
12
+ "assist": {
13
+ "actions": {
14
+ "source": {
15
+ "organizeImports": "on"
16
+ }
17
+ }
18
+ },
19
+ "linter": {
20
+ "enabled": true,
21
+ "rules": {
22
+ "recommended": true
23
+ }
24
+ },
25
+ "formatter": {
26
+ "enabled": true,
27
+ "indentStyle": "space",
28
+ "indentWidth": 2
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@aliou/pi-linkup",
3
- "version": "0.0.1",
4
- "private": false,
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/aliou/pi-linkup"
7
+ },
5
8
  "keywords": [
6
9
  "pi-package"
7
10
  ],
@@ -12,5 +15,23 @@
12
15
  "skills": [
13
16
  "./skills"
14
17
  ]
18
+ },
19
+ "devDependencies": {
20
+ "@biomejs/biome": "^2.3.13",
21
+ "@changesets/cli": "^2.27.11",
22
+ "@mariozechner/pi-coding-agent": "0.51.0",
23
+ "@mariozechner/pi-tui": "0.51.0",
24
+ "@sinclair/typebox": "^0.34.48",
25
+ "@types/node": "^25.0.10",
26
+ "husky": "^9.1.7",
27
+ "typescript": "^5.9.3"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "biome check",
32
+ "format": "biome check --write",
33
+ "changeset": "changeset",
34
+ "version": "changeset version",
35
+ "release": "pnpm changeset publish"
15
36
  }
16
- }
37
+ }
package/shell.nix ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ pkgs ? import <nixpkgs> { },
3
+ }:
4
+
5
+ pkgs.mkShell {
6
+ buildInputs = with pkgs; [
7
+ nodejs
8
+ pnpm_10
9
+ ];
10
+ }
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: linkup
3
+ description: "Web search and content fetching using Linkup extension. Use when needing to search the web, get answers to questions with sources, or fetch content from specific URLs. Provides three tools: linkup_web_search (discovery), linkup_web_answer (direct answers), linkup_web_fetch (URL content extraction)."
4
+ ---
5
+
6
+ # Linkup Extension
7
+
8
+ Web search and content fetching tools powered by Linkup API.
9
+
10
+ ## Tools
11
+
12
+ ### linkup_web_search
13
+
14
+ Search the web and get sources with content snippets.
15
+
16
+ ```
17
+ linkup_web_search(query: string, deep?: boolean)
18
+ ```
19
+
20
+ - `query`: Be specific and detailed. Include context like dates, locations, company names.
21
+ - `deep`: Use for complex research requiring multiple searches. Default: false (faster).
22
+
23
+ **Use when:** Discovering information across multiple sources, researching topics, comparing perspectives.
24
+
25
+ ### linkup_web_answer
26
+
27
+ Get a synthesized answer with source citations.
28
+
29
+ ```
30
+ linkup_web_answer(query: string, deep?: boolean)
31
+ ```
32
+
33
+ **Use when:** Need a direct answer to a specific question, quick facts with citations.
34
+
35
+ ### linkup_web_fetch
36
+
37
+ Fetch content from a URL as clean markdown.
38
+
39
+ ```
40
+ linkup_web_fetch(url: string, renderJs?: boolean)
41
+ ```
42
+
43
+ - `url`: The URL to fetch.
44
+ - `renderJs`: Set false for static pages (faster). Default: true.
45
+
46
+ **Use when:** Reading documentation, following up on search results, extracting content from known URLs.
47
+
48
+ ## Tool Selection
49
+
50
+ | Need | Tool |
51
+ |------|------|
52
+ | Find information across sources | `linkup_web_search` |
53
+ | Get a direct answer with sources | `linkup_web_answer` |
54
+ | Read content from a known URL | `linkup_web_fetch` |
55
+
56
+ ## Query Formulation
57
+
58
+ **Good queries are specific:**
59
+
60
+ | Bad | Good |
61
+ |-----|------|
62
+ | "Microsoft revenue" | "Microsoft fiscal year 2024 total revenue" |
63
+ | "React hooks" | "React useEffect cleanup function best practices" |
64
+ | "AI news" | "OpenAI announcements January 2026" |
65
+
66
+ **Add context:**
67
+ - Time: "2025", "last quarter", "since version 5.0"
68
+ - Location: "French company Total", "US market"
69
+ - Specifics: company names, version numbers, exact terms
70
+
71
+ ## When to Use Deep Mode
72
+
73
+ **Standard (default):** Simple questions, quick lookups, known topics.
74
+
75
+ **Deep:** Complex research, multi-step queries, comprehensive coverage needed.
76
+
77
+ ```
78
+ // Standard - one search is enough
79
+ linkup_web_search("Node.js 22 release date")
80
+
81
+ // Deep - needs multiple searches
82
+ linkup_web_search("comparison of Rust web frameworks performance benchmarks 2025", deep: true)
83
+ ```
84
+
85
+ ## Common Patterns
86
+
87
+ ### Research workflow
88
+ 1. `linkup_web_search` to discover sources
89
+ 2. `linkup_web_fetch` on promising URLs for full content
90
+
91
+ ### Quick facts
92
+ 1. `linkup_web_answer` for direct answer with citations
93
+
94
+ ### Documentation reading
95
+ 1. `linkup_web_fetch` on known documentation URL
96
+
97
+ ## Commands
98
+
99
+ - `/linkup:balance` - Check remaining API credits
package/src/client.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type {
2
+ LinkupBalanceResponse,
3
+ LinkupErrorResponse,
4
+ LinkupFetchResponse,
5
+ LinkupSearchResponse,
6
+ LinkupSourcedAnswerResponse,
7
+ } from "./types";
8
+
9
+ const BASE_URL = "https://api.linkup.so/v1";
10
+
11
+ export class LinkupClient {
12
+ private apiKey: string;
13
+
14
+ constructor(apiKey: string) {
15
+ this.apiKey = apiKey;
16
+ }
17
+
18
+ private async request<T>(
19
+ endpoint: string,
20
+ options: RequestInit = {},
21
+ ): Promise<T> {
22
+ const response = await fetch(`${BASE_URL}${endpoint}`, {
23
+ ...options,
24
+ headers: {
25
+ Authorization: `Bearer ${this.apiKey}`,
26
+ "Content-Type": "application/json",
27
+ ...options.headers,
28
+ },
29
+ });
30
+
31
+ if (!response.ok) {
32
+ const error = (await response.json()) as LinkupErrorResponse;
33
+ throw new Error(
34
+ error.error?.message ||
35
+ `HTTP ${response.status}: ${response.statusText}`,
36
+ );
37
+ }
38
+
39
+ return response.json();
40
+ }
41
+
42
+ async search(params: {
43
+ query: string;
44
+ depth: "standard" | "deep";
45
+ outputType: "searchResults" | "sourcedAnswer";
46
+ }): Promise<LinkupSearchResponse | LinkupSourcedAnswerResponse> {
47
+ return this.request("/search", {
48
+ method: "POST",
49
+ body: JSON.stringify({
50
+ q: params.query,
51
+ depth: params.depth,
52
+ outputType: params.outputType,
53
+ }),
54
+ });
55
+ }
56
+
57
+ async fetch(params: {
58
+ url: string;
59
+ renderJs?: boolean;
60
+ }): Promise<LinkupFetchResponse> {
61
+ return this.request("/fetch", {
62
+ method: "POST",
63
+ body: JSON.stringify({
64
+ url: params.url,
65
+ renderJs: params.renderJs ?? true,
66
+ }),
67
+ });
68
+ }
69
+
70
+ async getBalance(): Promise<LinkupBalanceResponse> {
71
+ return this.request("/credits/balance", {
72
+ method: "GET",
73
+ });
74
+ }
75
+ }
76
+
77
+ export function getClient(): LinkupClient {
78
+ const apiKey = process.env.LINKUP_API_KEY;
79
+ if (!apiKey) {
80
+ throw new Error("LINKUP_API_KEY environment variable is not set");
81
+ }
82
+ return new LinkupClient(apiKey);
83
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { getClient } from "../client";
3
+
4
+ export function registerBalanceCommand(pi: ExtensionAPI) {
5
+ pi.registerCommand("linkup:balance", {
6
+ description: "Display remaining Linkup API credits",
7
+ async handler(_args, ctx) {
8
+ const client = getClient();
9
+
10
+ try {
11
+ const response = await client.getBalance();
12
+ ctx.ui.notify(
13
+ `Linkup Balance: ${response.balance.toFixed(2)} credits`,
14
+ "info",
15
+ );
16
+ } catch (error) {
17
+ const message =
18
+ error instanceof Error ? error.message : "Unknown error";
19
+ ctx.ui.notify(`Error: ${message}`, "error");
20
+ }
21
+ },
22
+ });
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerBalanceCommand } from "./commands/balance";
3
+ import { registerWebAnswerTool } from "./tools/web-answer";
4
+ import { registerWebFetchTool } from "./tools/web-fetch";
5
+ import { registerWebSearchTool } from "./tools/web-search";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ const hasApiKey = !!process.env.LINKUP_API_KEY;
9
+
10
+ if (!hasApiKey) {
11
+ console.warn(
12
+ "[linkup] Warning: LINKUP_API_KEY not set. Linkup extension will not load.",
13
+ );
14
+
15
+ pi.on("session_start", (_event, ctx) => {
16
+ if (ctx.hasUI) {
17
+ ctx.ui.notify(
18
+ "LINKUP_API_KEY not set. Linkup extension disabled.",
19
+ "warning",
20
+ );
21
+ }
22
+ });
23
+ return;
24
+ }
25
+
26
+ // Register tools
27
+ registerWebSearchTool(pi);
28
+ registerWebAnswerTool(pi);
29
+ registerWebFetchTool(pi);
30
+
31
+ // Register commands
32
+ registerBalanceCommand(pi);
33
+ }
@@ -0,0 +1,143 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { getClient } from "../client";
5
+ import type { LinkupSource, LinkupSourcedAnswerResponse } from "../types";
6
+
7
+ interface WebAnswerDetails {
8
+ answer?: string;
9
+ sources?: LinkupSource[];
10
+ query?: string;
11
+ error?: string;
12
+ isError?: boolean;
13
+ }
14
+
15
+ export function registerWebAnswerTool(pi: ExtensionAPI) {
16
+ pi.registerTool({
17
+ name: "linkup_web_answer",
18
+ label: "Linkup Web Answer",
19
+ description:
20
+ "Get a synthesized answer to a question using Linkup API. Returns a direct answer with sources. Use when you need a concise answer to a specific question.",
21
+ parameters: Type.Object({
22
+ query: Type.String({
23
+ description:
24
+ "The question to answer. Be specific and detailed for best results.",
25
+ }),
26
+ deep: Type.Optional(
27
+ Type.Boolean({
28
+ description:
29
+ "Use deep search for more comprehensive answer (slower). Default: false (standard search).",
30
+ }),
31
+ ),
32
+ }),
33
+
34
+ async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
35
+ const client = getClient();
36
+
37
+ try {
38
+ onUpdate?.({
39
+ content: [
40
+ {
41
+ type: "text",
42
+ text: `Searching for answer${params.deep ? " (deep mode)" : ""}...`,
43
+ },
44
+ ],
45
+ details: {},
46
+ });
47
+
48
+ const response = (await client.search({
49
+ query: params.query,
50
+ depth: params.deep ? "deep" : "standard",
51
+ outputType: "sourcedAnswer",
52
+ })) as LinkupSourcedAnswerResponse;
53
+
54
+ let content = `${response.answer}\n\n`;
55
+ content += `Sources:\n`;
56
+ for (const source of response.sources) {
57
+ content += `- ${source.name}: ${source.url}\n`;
58
+ if (source.snippet) {
59
+ content += ` ${source.snippet}\n`;
60
+ }
61
+ }
62
+
63
+ return {
64
+ content: [{ type: "text", text: content }],
65
+ details: {
66
+ answer: response.answer,
67
+ sources: response.sources,
68
+ query: params.query,
69
+ },
70
+ };
71
+ } catch (error) {
72
+ const message =
73
+ error instanceof Error ? error.message : "Unknown error";
74
+ return {
75
+ content: [{ type: "text", text: `Error: ${message}` }],
76
+ details: { error: message, isError: true },
77
+ };
78
+ }
79
+ },
80
+
81
+ renderCall(args, theme) {
82
+ let text = theme.fg("toolTitle", theme.bold("Linkup: WebAnswer "));
83
+ text += theme.fg("accent", `"${args.query}"`);
84
+ if (args.deep) {
85
+ text += theme.fg("dim", " (deep)");
86
+ }
87
+ return new Text(text, 0, 0);
88
+ },
89
+
90
+ renderResult(result, { expanded, isPartial }, theme) {
91
+ if (isPartial) {
92
+ const text =
93
+ result.content?.[0]?.type === "text"
94
+ ? result.content[0].text
95
+ : "Searching...";
96
+ return new Text(theme.fg("dim", text), 0, 0);
97
+ }
98
+
99
+ const details = result.details as WebAnswerDetails;
100
+
101
+ if (details?.isError) {
102
+ const errorMsg =
103
+ result.content?.[0]?.type === "text"
104
+ ? result.content[0].text
105
+ : "Error occurred";
106
+ return new Text(theme.fg("error", errorMsg), 0, 0);
107
+ }
108
+
109
+ const answer = details?.answer || "";
110
+ const sources = details?.sources || [];
111
+
112
+ let text = theme.fg("success", "✓ Answer received");
113
+
114
+ if (!expanded) {
115
+ const preview = answer.slice(0, 100);
116
+ text += `\n ${theme.fg("muted", preview)}`;
117
+ if (answer.length > 100) {
118
+ text += theme.fg("dim", "...");
119
+ }
120
+ text += `\n ${theme.fg("dim", `${sources.length} source(s)`)}`;
121
+ text += theme.fg("muted", ` [Ctrl+O to expand]`);
122
+ }
123
+
124
+ if (expanded) {
125
+ text += `\n\n${theme.fg("accent", "Answer:")}`;
126
+ text += `\n${answer}`;
127
+
128
+ if (sources.length > 0) {
129
+ text += `\n\n${theme.fg("accent", "Sources:")}`;
130
+ for (const source of sources) {
131
+ text += `\n• ${theme.bold(source.name)}`;
132
+ text += `\n ${theme.fg("dim", source.url)}`;
133
+ if (source.snippet) {
134
+ text += `\n ${theme.fg("muted", source.snippet)}`;
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ return new Text(text, 0, 0);
141
+ },
142
+ });
143
+ }
@@ -0,0 +1,119 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { getClient } from "../client";
5
+
6
+ interface WebFetchDetails {
7
+ url?: string;
8
+ markdown?: string;
9
+ error?: string;
10
+ isError?: boolean;
11
+ }
12
+
13
+ export function registerWebFetchTool(pi: ExtensionAPI) {
14
+ pi.registerTool({
15
+ name: "linkup_web_fetch",
16
+ label: "Linkup Web Fetch",
17
+ description:
18
+ "Fetch and extract content from a specific URL using Linkup API. Returns clean markdown content. Use for reading documentation, articles, or any specific webpage.",
19
+ parameters: Type.Object({
20
+ url: Type.String({
21
+ description: "The URL to fetch content from.",
22
+ }),
23
+ renderJs: Type.Optional(
24
+ Type.Boolean({
25
+ description:
26
+ "Whether to render JavaScript on the page. Default: true. Set to false for faster fetching of static pages.",
27
+ }),
28
+ ),
29
+ }),
30
+
31
+ async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
32
+ const client = getClient();
33
+
34
+ try {
35
+ onUpdate?.({
36
+ content: [
37
+ {
38
+ type: "text",
39
+ text: `Fetching ${params.url}...`,
40
+ },
41
+ ],
42
+ details: {},
43
+ });
44
+
45
+ const response = await client.fetch({
46
+ url: params.url,
47
+ renderJs: params.renderJs,
48
+ });
49
+
50
+ return {
51
+ content: [{ type: "text", text: response.markdown }],
52
+ details: { url: params.url, markdown: response.markdown },
53
+ };
54
+ } catch (error) {
55
+ const message =
56
+ error instanceof Error ? error.message : "Unknown error";
57
+ return {
58
+ content: [{ type: "text", text: `Error: ${message}` }],
59
+ details: { error: message, url: params.url, isError: true },
60
+ };
61
+ }
62
+ },
63
+
64
+ renderCall(args, theme) {
65
+ let text = theme.fg("toolTitle", theme.bold("Linkup: WebFetch "));
66
+ text += theme.fg("accent", args.url);
67
+ if (args.renderJs === false) {
68
+ text += theme.fg("dim", " (no JS)");
69
+ }
70
+ return new Text(text, 0, 0);
71
+ },
72
+
73
+ renderResult(result, { expanded, isPartial }, theme) {
74
+ if (isPartial) {
75
+ const text =
76
+ result.content?.[0]?.type === "text"
77
+ ? result.content[0].text
78
+ : "Fetching...";
79
+ return new Text(theme.fg("dim", text), 0, 0);
80
+ }
81
+
82
+ const details = result.details as WebFetchDetails;
83
+
84
+ if (details?.isError) {
85
+ const errorMsg =
86
+ result.content?.[0]?.type === "text"
87
+ ? result.content[0].text
88
+ : "Error occurred";
89
+ return new Text(theme.fg("error", errorMsg), 0, 0);
90
+ }
91
+
92
+ const markdown = details?.markdown || "";
93
+ const url = details?.url || "";
94
+
95
+ let text = theme.fg("success", "✓ Fetched");
96
+ text += ` ${theme.fg("dim", url)}`;
97
+
98
+ if (!expanded) {
99
+ const preview = markdown.slice(0, 100).replace(/\n/g, " ");
100
+ text += `\n ${theme.fg("muted", preview)}`;
101
+ if (markdown.length > 100) {
102
+ text += theme.fg("dim", "...");
103
+ }
104
+ text += theme.fg("muted", ` [Ctrl+O to expand]`);
105
+ }
106
+
107
+ if (expanded) {
108
+ const lines = markdown.split("\n");
109
+ const previewLines = lines.slice(0, 50);
110
+ text += `\n\n${previewLines.join("\n")}`;
111
+ if (lines.length > 50) {
112
+ text += `\n${theme.fg("dim", `\n[${lines.length - 50} more lines...]`)}`;
113
+ }
114
+ }
115
+
116
+ return new Text(text, 0, 0);
117
+ },
118
+ });
119
+ }
@@ -0,0 +1,134 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { getClient } from "../client";
5
+ import type { LinkupSearchResponse, LinkupSearchResult } from "../types";
6
+
7
+ interface WebSearchDetails {
8
+ results?: LinkupSearchResult[];
9
+ query?: string;
10
+ error?: string;
11
+ isError?: boolean;
12
+ }
13
+
14
+ export function registerWebSearchTool(pi: ExtensionAPI) {
15
+ pi.registerTool({
16
+ name: "linkup_web_search",
17
+ label: "Linkup Web Search",
18
+ description:
19
+ "Search the web using Linkup API. Returns a list of relevant sources with content snippets. Use for finding information, documentation, articles, or any web content.",
20
+ parameters: Type.Object({
21
+ query: Type.String({
22
+ description:
23
+ "The search query. Be specific and detailed for best results.",
24
+ }),
25
+ deep: Type.Optional(
26
+ Type.Boolean({
27
+ description:
28
+ "Use deep search for comprehensive results (slower). Default: false (standard search).",
29
+ }),
30
+ ),
31
+ }),
32
+
33
+ async execute(_toolCallId, params, _signal, onUpdate, _ctx) {
34
+ const client = getClient();
35
+
36
+ try {
37
+ onUpdate?.({
38
+ content: [
39
+ {
40
+ type: "text",
41
+ text: `Searching${params.deep ? " (deep mode)" : ""}...`,
42
+ },
43
+ ],
44
+ details: {},
45
+ });
46
+
47
+ const response = (await client.search({
48
+ query: params.query,
49
+ depth: params.deep ? "deep" : "standard",
50
+ outputType: "searchResults",
51
+ })) as LinkupSearchResponse;
52
+
53
+ let content = `Found ${response.results.length} result(s):\n\n`;
54
+ for (const result of response.results) {
55
+ content += `## ${result.name}\n`;
56
+ content += `URL: ${result.url}\n`;
57
+ if (result.content) {
58
+ content += `\n${result.content}\n`;
59
+ }
60
+ content += "\n---\n\n";
61
+ }
62
+
63
+ return {
64
+ content: [{ type: "text", text: content }],
65
+ details: { results: response.results, query: params.query },
66
+ };
67
+ } catch (error) {
68
+ const message =
69
+ error instanceof Error ? error.message : "Unknown error";
70
+ return {
71
+ content: [{ type: "text", text: `Error: ${message}` }],
72
+ details: { error: message, isError: true },
73
+ };
74
+ }
75
+ },
76
+
77
+ renderCall(args, theme) {
78
+ let text = theme.fg("toolTitle", theme.bold("Linkup: WebSearch "));
79
+ text += theme.fg("accent", `"${args.query}"`);
80
+ if (args.deep) {
81
+ text += theme.fg("dim", " (deep)");
82
+ }
83
+ return new Text(text, 0, 0);
84
+ },
85
+
86
+ renderResult(result, { expanded, isPartial }, theme) {
87
+ if (isPartial) {
88
+ const text =
89
+ result.content?.[0]?.type === "text"
90
+ ? result.content[0].text
91
+ : "Searching...";
92
+ return new Text(theme.fg("dim", text), 0, 0);
93
+ }
94
+
95
+ const details = result.details as WebSearchDetails;
96
+
97
+ if (details?.isError) {
98
+ const errorMsg =
99
+ result.content?.[0]?.type === "text"
100
+ ? result.content[0].text
101
+ : "Error occurred";
102
+ return new Text(theme.fg("error", errorMsg), 0, 0);
103
+ }
104
+
105
+ const results = details?.results || [];
106
+ let text = theme.fg("success", `✓ Found ${results.length} result(s)`);
107
+
108
+ if (!expanded && results.length > 0) {
109
+ const first = results[0];
110
+ text += `\n ${theme.fg("dim", `${first.name}`)}`;
111
+ if (results.length > 1) {
112
+ text += theme.fg("dim", ` (${results.length - 1} more)`);
113
+ }
114
+ text += theme.fg("muted", ` [Ctrl+O to expand]`);
115
+ }
116
+
117
+ if (expanded) {
118
+ for (const r of results) {
119
+ text += `\n\n${theme.fg("accent", theme.bold(r.name))}`;
120
+ text += `\n${theme.fg("dim", r.url)}`;
121
+ if (r.content) {
122
+ const preview = r.content.slice(0, 200);
123
+ text += `\n${theme.fg("muted", preview)}`;
124
+ if (r.content.length > 200) {
125
+ text += theme.fg("dim", "...");
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ return new Text(text, 0, 0);
132
+ },
133
+ });
134
+ }
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ export interface LinkupSearchResult {
2
+ name: string;
3
+ url: string;
4
+ content?: string;
5
+ }
6
+
7
+ export interface LinkupSearchResponse {
8
+ results: LinkupSearchResult[];
9
+ }
10
+
11
+ export interface LinkupSource {
12
+ name: string;
13
+ url: string;
14
+ snippet?: string;
15
+ }
16
+
17
+ export interface LinkupSourcedAnswerResponse {
18
+ answer: string;
19
+ sources: LinkupSource[];
20
+ }
21
+
22
+ export interface LinkupFetchResponse {
23
+ markdown: string;
24
+ }
25
+
26
+ export interface LinkupBalanceResponse {
27
+ balance: number;
28
+ }
29
+
30
+ export interface LinkupErrorResponse {
31
+ error?: {
32
+ message?: string;
33
+ };
34
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules"]
15
+ }