@aliou/pi-ts-aperture 0.0.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.
Files changed (50) hide show
  1. package/.changeset/config.json +10 -0
  2. package/.envrc +21 -0
  3. package/.github/workflows/ci.yml +31 -0
  4. package/.github/workflows/publish.yml +86 -0
  5. package/.osgrep/cache/meta.lmdb +0 -0
  6. package/.osgrep/cache/meta.lmdb-lock +0 -0
  7. package/.osgrep/lancedb/chunks.lance/_indices/b9016ebf-b6e6-4c43-be9f-7413728f6389/metadata.lance +0 -0
  8. package/.osgrep/lancedb/chunks.lance/_indices/b9016ebf-b6e6-4c43-be9f-7413728f6389/part_11_docs.lance +0 -0
  9. package/.osgrep/lancedb/chunks.lance/_indices/b9016ebf-b6e6-4c43-be9f-7413728f6389/part_11_invert.lance +0 -0
  10. package/.osgrep/lancedb/chunks.lance/_indices/b9016ebf-b6e6-4c43-be9f-7413728f6389/part_11_tokens.lance +0 -0
  11. package/.osgrep/lancedb/chunks.lance/_indices/cc7bd185-bc88-4dbd-af7f-6aac2f6b29d3/metadata.lance +0 -0
  12. package/.osgrep/lancedb/chunks.lance/_indices/cc7bd185-bc88-4dbd-af7f-6aac2f6b29d3/part_0_docs.lance +0 -0
  13. package/.osgrep/lancedb/chunks.lance/_indices/cc7bd185-bc88-4dbd-af7f-6aac2f6b29d3/part_0_invert.lance +0 -0
  14. package/.osgrep/lancedb/chunks.lance/_indices/cc7bd185-bc88-4dbd-af7f-6aac2f6b29d3/part_0_tokens.lance +0 -0
  15. package/.osgrep/lancedb/chunks.lance/_indices/cd01c8d9-fae8-4482-b01f-59f06abe1b48/metadata.lance +0 -0
  16. package/.osgrep/lancedb/chunks.lance/_indices/cd01c8d9-fae8-4482-b01f-59f06abe1b48/part_0_docs.lance +0 -0
  17. package/.osgrep/lancedb/chunks.lance/_indices/cd01c8d9-fae8-4482-b01f-59f06abe1b48/part_0_invert.lance +0 -0
  18. package/.osgrep/lancedb/chunks.lance/_indices/cd01c8d9-fae8-4482-b01f-59f06abe1b48/part_0_tokens.lance +0 -0
  19. package/.osgrep/lancedb/chunks.lance/_indices/dedba042-4a7b-44d7-ac2f-258233b62681/metadata.lance +0 -0
  20. package/.osgrep/lancedb/chunks.lance/_indices/dedba042-4a7b-44d7-ac2f-258233b62681/part_0_docs.lance +0 -0
  21. package/.osgrep/lancedb/chunks.lance/_indices/dedba042-4a7b-44d7-ac2f-258233b62681/part_0_invert.lance +0 -0
  22. package/.osgrep/lancedb/chunks.lance/_indices/dedba042-4a7b-44d7-ac2f-258233b62681/part_0_tokens.lance +0 -0
  23. package/.osgrep/lancedb/chunks.lance/_transactions/0-fd59fbcc-e229-4069-b8b0-764b34f2d645.txn +0 -0
  24. package/.osgrep/lancedb/chunks.lance/_transactions/1-f130f13d-123c-4e71-b806-b6b4f6cdd6d8.txn +0 -0
  25. package/.osgrep/lancedb/chunks.lance/_transactions/2-5f641c2a-07a9-45a1-b450-72c7d4d38754.txn +1 -0
  26. package/.osgrep/lancedb/chunks.lance/_transactions/3-83167775-6ec4-4cb6-9388-6549f583087a.txn +0 -0
  27. package/.osgrep/lancedb/chunks.lance/_transactions/4-feb1627d-f860-4c02-b8a6-6b93cf42ec22.txn +0 -0
  28. package/.osgrep/lancedb/chunks.lance/_transactions/5-e00ef82b-90b4-4c91-a7ec-d7b4903e1c61.txn +0 -0
  29. package/.osgrep/lancedb/chunks.lance/_transactions/6-ccc1d487-f511-4e48-afa0-2edf42f8effd.txn +0 -0
  30. package/.osgrep/lancedb/chunks.lance/_transactions/7-b26d7d18-4411-44cd-b559-54bfa2999b70.txn +0 -0
  31. package/.osgrep/lancedb/chunks.lance/_versions/1.manifest +0 -0
  32. package/.osgrep/lancedb/chunks.lance/_versions/2.manifest +0 -0
  33. package/.osgrep/lancedb/chunks.lance/_versions/3.manifest +0 -0
  34. package/.osgrep/lancedb/chunks.lance/_versions/4.manifest +0 -0
  35. package/.osgrep/lancedb/chunks.lance/_versions/5.manifest +0 -0
  36. package/.osgrep/lancedb/chunks.lance/_versions/6.manifest +0 -0
  37. package/.osgrep/lancedb/chunks.lance/_versions/7.manifest +0 -0
  38. package/.osgrep/lancedb/chunks.lance/_versions/8.manifest +0 -0
  39. package/.osgrep/lancedb/chunks.lance/data/0110110100011111000100108ca476475fac277950ba4295de.lance +0 -0
  40. package/.osgrep/lancedb/chunks.lance/data/111110011111110001101001d059cb4ceab8147482b4391458.lance +0 -0
  41. package/AGENTS.md +29 -0
  42. package/README.md +44 -0
  43. package/biome.json +30 -0
  44. package/package.json +54 -0
  45. package/shell.nix +10 -0
  46. package/src/commands/settings.ts +96 -0
  47. package/src/commands/setup.ts +204 -0
  48. package/src/config.ts +29 -0
  49. package/src/index.ts +39 -0
  50. package/tsconfig.json +17 -0
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "public",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch"
10
+ }
package/.envrc ADDED
@@ -0,0 +1,21 @@
1
+ # shellcheck disable=2148,2086
2
+
3
+ # Use `source_up` if part of a monorepo.
4
+
5
+ use nix
6
+
7
+ # Load layouts. This basically just sets up PATH and other env vars.
8
+ # Possible values are "node", "bun", and "uv".
9
+ #
10
+ # Options:
11
+ # --deny bin1,bin2,... Exclude binaries from local PATH, falling back to system.
12
+ # Useful when a local binary shadows a system one you need.
13
+ #
14
+ # Examples:
15
+ # layout node
16
+ # layout node --deny pi,prettier
17
+ # layout uv --deny python
18
+
19
+ if has node; then
20
+ layout node --deny pi
21
+ fi
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ check:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Setup Node
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 22
20
+
21
+ - name: Setup pnpm
22
+ uses: pnpm/action-setup@v4
23
+
24
+ - name: Install dependencies
25
+ run: pnpm install --frozen-lockfile
26
+
27
+ - name: Lint
28
+ run: pnpm run lint
29
+
30
+ - name: Typecheck
31
+ run: pnpm run typecheck
@@ -0,0 +1,86 @@
1
+ name: Publish
2
+
3
+ on:
4
+ workflow_run:
5
+ workflows: ["CI"]
6
+ types: [completed]
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ publish:
14
+ name: Publish
15
+ if: >-
16
+ github.event.workflow_run.conclusion == 'success' &&
17
+ github.event.workflow_run.head_branch == 'main'
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ contents: write
21
+ packages: write
22
+ pull-requests: write
23
+ id-token: write
24
+
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v4
28
+ with:
29
+ ref: ${{ github.event.workflow_run.head_sha }}
30
+ fetch-depth: 0
31
+
32
+ - name: Setup pnpm
33
+ uses: pnpm/action-setup@v4
34
+
35
+ - name: Setup Node.js
36
+ uses: actions/setup-node@v4
37
+ with:
38
+ node-version: "22"
39
+ registry-url: "https://registry.npmjs.org"
40
+ scope: "@aliou"
41
+ cache: "pnpm"
42
+
43
+ - name: Upgrade npm for OIDC support
44
+ run: npm install -g npm@latest
45
+
46
+ - name: Install dependencies
47
+ run: pnpm install --frozen-lockfile
48
+
49
+ - name: Get release info
50
+ id: release-info
51
+ run: |
52
+ pnpm changeset status --output=release.json 2>/dev/null || echo '{"releases":[]}' > release.json
53
+ node <<NODE
54
+ const fs = require('fs');
55
+ const release = JSON.parse(fs.readFileSync('release.json', 'utf8'));
56
+ const releases = release.releases?.filter(r => r.type !== 'none') || [];
57
+
58
+ let title = 'Version Packages';
59
+ let commit = 'Version Packages';
60
+ if (releases.length === 1) {
61
+ const { name, newVersion } = releases[0];
62
+ title = 'Updating ' + name + ' to version ' + newVersion;
63
+ commit = name + '@' + newVersion;
64
+ } else if (releases.length > 1) {
65
+ const summary = releases.map(r => r.name + '@' + r.newVersion).join(', ');
66
+ title = 'Updating ' + summary;
67
+ commit = summary;
68
+ }
69
+
70
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, 'title=' + title + '\n');
71
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, 'commit=' + commit + '\n');
72
+ NODE
73
+ rm -f release.json
74
+ continue-on-error: true
75
+
76
+ - name: Create Release PR or Publish
77
+ id: changesets
78
+ uses: changesets/action@v1
79
+ with:
80
+ version: pnpm changeset version
81
+ publish: pnpm changeset publish
82
+ title: ${{ steps.release-info.outputs.title || 'Version Packages' }}
83
+ commit: ${{ steps.release-info.outputs.commit || 'Version Packages' }}
84
+ env:
85
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86
+ NPM_CONFIG_PROVENANCE: true
Binary file
Binary file
@@ -0,0 +1 @@
1
+ $5f641c2a-07a9-45a1-b450-72c7d4d38754�(&path IN ('tsconfig.json','biome.json')
package/AGENTS.md ADDED
@@ -0,0 +1,29 @@
1
+ # pi-ts-aperture
2
+
3
+ Pi extension that routes LLM providers through Tailscale Aperture.
4
+
5
+ ## Structure
6
+
7
+ - `src/index.ts` - Entry point. Loads config, registers providers, registers commands.
8
+ - `src/config.ts` - Config schema (`ApertureConfig`, `ResolvedConfig`) and `ConfigLoader` instance.
9
+ - `src/commands/setup.ts` - `/aperture:setup` interactive wizard (URL input + provider multi-select).
10
+ - `src/commands/settings.ts` - `/aperture:settings` settings UI via `registerSettingsCommand`.
11
+
12
+ ## Key decisions
13
+
14
+ - Config is global-only (no per-project scope). Aperture is a network-level concern.
15
+ - Provider list comes from `getProviders()` in `@mariozechner/pi-ai`, not hardcoded.
16
+ - `apiKey` is set to `"-"` because Aperture ignores client-provided keys.
17
+ - URLs are normalized on input: `http://` is prepended if missing, trailing `/v1` is stripped (appended at registration time).
18
+
19
+ ## Dependencies
20
+
21
+ - `@aliou/pi-utils-settings` - Config loader and settings command infrastructure.
22
+ - `@mariozechner/pi-ai` - `getProviders()` for the known provider list.
23
+ - `@mariozechner/pi-coding-agent` - Extension API, `getSettingsListTheme`.
24
+ - `@mariozechner/pi-tui` - TUI components (`Input`, `Key`, `matchesKey`, `FuzzySelector`).
25
+
26
+ ## Publishing
27
+
28
+ - Manual publish for 0.0.1, then changesets + GitHub Actions for subsequent versions.
29
+ - CI runs lint + typecheck on push/PR. Publish workflow triggers after CI succeeds on main.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # pi-ts-aperture
2
+
3
+ Route Pi LLM providers through [Tailscale Aperture](https://tailscale.com/docs/features/aperture), a managed AI gateway on your tailnet.
4
+
5
+ Aperture handles API key injection and request routing server-side. This extension overrides the base URL for selected providers so all LLM requests go through your Aperture instance instead of directly to provider APIs.
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ pi install @aliou/pi-ts-aperture
11
+ ```
12
+
13
+ Then run the setup wizard:
14
+
15
+ ```
16
+ /aperture:setup
17
+ ```
18
+
19
+ This will prompt you for:
20
+ 1. Your Aperture base URL (e.g. `ai.your-tailnet.ts.net`)
21
+ 2. Which providers to route through Aperture (fuzzy searchable, multi-select)
22
+
23
+ Configuration is saved globally to `~/.pi/agent/extensions/aperture.json`.
24
+
25
+ ## Commands
26
+
27
+ | Command | Description |
28
+ |---|---|
29
+ | `/aperture:setup` | Interactive wizard to configure Aperture URL and providers |
30
+ | `/aperture:settings` | Settings UI to update base URL and provider list |
31
+
32
+ ## How it works
33
+
34
+ For each configured provider, the extension calls `registerProvider` with:
35
+ - `baseUrl` set to your Aperture URL + `/v1`
36
+ - `apiKey` set to `"-"` (Aperture ignores client keys, it injects its own)
37
+
38
+ This means all requests for those providers are routed through Aperture, which handles authentication, logging, and cost tracking on its end.
39
+
40
+ ## Requirements
41
+
42
+ - A Tailscale tailnet with Aperture configured
43
+ - The device running Pi must be on the tailnet
44
+ - Use HTTP, not HTTPS, for the Aperture URL (WireGuard handles encryption)
package/biome.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.2/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 ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@aliou/pi-ts-aperture",
3
+ "description": "Route Pi LLM providers through Tailscale Aperture",
4
+ "version": "0.0.1",
5
+ "packageManager": "pnpm@10.26.1",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/aliou/pi-ts-aperture"
9
+ },
10
+ "keywords": [
11
+ "pi-package"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "pi": {
17
+ "extensions": [
18
+ "./src/index.ts"
19
+ ],
20
+ "video": "https://assets.aliou.me/pi-extensions/demos/pi-ts-aperture.mp4"
21
+ },
22
+ "dependencies": {
23
+ "@aliou/pi-utils-settings": "^0.3.0"
24
+ },
25
+ "peerDependencies": {
26
+ "@mariozechner/pi-ai": ">=0.52.12",
27
+ "@mariozechner/pi-coding-agent": ">=0.52.12"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "biome check",
32
+ "format": "biome check --write",
33
+ "prepare": "husky",
34
+ "changeset": "changeset",
35
+ "version": "changeset version",
36
+ "release": "pnpm changeset publish"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.3.13",
40
+ "@changesets/cli": "^2.27.11",
41
+ "@mariozechner/pi-coding-agent": "0.52.12",
42
+ "@mariozechner/pi-tui": "0.52.12",
43
+ "@sinclair/typebox": "^0.34.48",
44
+ "@types/node": "^25.0.10",
45
+ "husky": "^9.1.7",
46
+ "typescript": "^5.9.3"
47
+ },
48
+ "pnpm": {
49
+ "overrides": {
50
+ "@mariozechner/pi-ai": "$@mariozechner/pi-coding-agent",
51
+ "@mariozechner/pi-tui": "$@mariozechner/pi-coding-agent"
52
+ }
53
+ }
54
+ }
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,96 @@
1
+ /**
2
+ * aperture:settings -- settings UI for Aperture configuration.
3
+ *
4
+ * Sections:
5
+ * - Connection: base URL
6
+ * - Providers: list of providers routed through Aperture
7
+ */
8
+
9
+ import {
10
+ ArrayEditor,
11
+ registerSettingsCommand,
12
+ type SettingsSection,
13
+ setNestedValue,
14
+ } from "@aliou/pi-utils-settings";
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
17
+ import type { ApertureConfig, ResolvedConfig } from "../config";
18
+ import { configLoader } from "../config";
19
+
20
+ export function registerApertureSettings(
21
+ pi: ExtensionAPI,
22
+ onConfigChange: () => void,
23
+ ): void {
24
+ registerSettingsCommand<ApertureConfig, ResolvedConfig>(pi, {
25
+ commandName: "aperture:settings",
26
+ title: "Aperture Settings",
27
+ configStore: configLoader,
28
+ buildSections: (
29
+ tabConfig: ApertureConfig | null,
30
+ resolved: ResolvedConfig,
31
+ { setDraft },
32
+ ): SettingsSection[] => {
33
+ const settingsTheme = getSettingsListTheme();
34
+
35
+ const providers = tabConfig?.providers ?? resolved.providers;
36
+
37
+ return [
38
+ {
39
+ label: "Connection",
40
+ items: [
41
+ {
42
+ id: "baseUrl",
43
+ label: "Base URL",
44
+ description:
45
+ "Aperture gateway URL on your tailnet (e.g. http://ai.pango-lin.ts.net)",
46
+ currentValue:
47
+ (tabConfig?.baseUrl ?? resolved.baseUrl) || "(not set)",
48
+ values: undefined,
49
+ submenu: undefined,
50
+ },
51
+ ],
52
+ },
53
+ {
54
+ label: "Providers",
55
+ items: [
56
+ {
57
+ id: "providers",
58
+ label: "Routed providers",
59
+ description: "LLM providers routed through Aperture",
60
+ currentValue: `${providers.length} provider(s)`,
61
+ submenu: (_val, submenuDone) => {
62
+ let latest = [...providers];
63
+ return new ArrayEditor({
64
+ label: "Providers",
65
+ items: [...providers],
66
+ theme: settingsTheme,
67
+ onSave: (items) => {
68
+ latest = items;
69
+ const updated = structuredClone(
70
+ tabConfig ?? {},
71
+ ) as ApertureConfig;
72
+ setNestedValue(updated, "providers", items);
73
+ setDraft(updated);
74
+ },
75
+ onDone: () => submenuDone(`${latest.length} provider(s)`),
76
+ });
77
+ },
78
+ },
79
+ ],
80
+ },
81
+ ];
82
+ },
83
+ onSettingChange: (id, newValue, config) => {
84
+ const updated = structuredClone(config);
85
+ if (id === "baseUrl") {
86
+ updated.baseUrl = newValue;
87
+ } else {
88
+ setNestedValue(updated, id, newValue);
89
+ }
90
+ return updated;
91
+ },
92
+ onSave: () => {
93
+ onConfigChange();
94
+ },
95
+ });
96
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * aperture:setup -- interactive wizard for configuring Aperture.
3
+ *
4
+ * Steps:
5
+ * 1. Ask for Aperture base URL (Input)
6
+ * 2. Select providers to route through Aperture (FuzzySelector, multi-select loop)
7
+ * 3. Save config and register providers
8
+ */
9
+
10
+ import { FuzzySelector } from "@aliou/pi-utils-settings";
11
+ import { getProviders } from "@mariozechner/pi-ai";
12
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
14
+ import type { Component } from "@mariozechner/pi-tui";
15
+ import { Input, Key, matchesKey } from "@mariozechner/pi-tui";
16
+ import { configLoader } from "../config";
17
+
18
+ function normalizeUrl(url: string): string {
19
+ let result = url.trim();
20
+ if (!result) return result;
21
+ if (!result.startsWith("http://") && !result.startsWith("https://")) {
22
+ result = `http://${result}`;
23
+ }
24
+ return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
25
+ }
26
+
27
+ /**
28
+ * Simple input prompt component for the base URL step.
29
+ */
30
+ class UrlPrompt implements Component {
31
+ private input: Input;
32
+ private done: (value: string | undefined) => void;
33
+ private theme: ReturnType<typeof getSettingsListTheme>;
34
+ private placeholder = "ai.pango-lin.ts.net";
35
+
36
+ constructor(
37
+ theme: ReturnType<typeof getSettingsListTheme>,
38
+ currentValue: string,
39
+ done: (value: string | undefined) => void,
40
+ ) {
41
+ this.theme = theme;
42
+ this.done = done;
43
+ this.input = new Input();
44
+ if (currentValue) {
45
+ this.input.setValue(currentValue);
46
+ }
47
+
48
+ this.input.onSubmit = () => {
49
+ const value = this.input.getValue().trim();
50
+ if (!value) return;
51
+ this.done(normalizeUrl(value));
52
+ };
53
+ this.input.onEscape = () => {
54
+ this.done(undefined);
55
+ };
56
+ }
57
+
58
+ render(width: number): string[] {
59
+ const lines: string[] = [];
60
+ lines.push(this.theme.label(" Aperture Setup", true));
61
+ lines.push("");
62
+ lines.push(
63
+ this.theme.hint(` Aperture base URL (e.g. ${this.placeholder}):`),
64
+ );
65
+ lines.push(` ${this.input.render(width - 4).join("")}`);
66
+ lines.push("");
67
+ lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
68
+ return lines;
69
+ }
70
+
71
+ invalidate() {}
72
+
73
+ handleInput(data: string) {
74
+ this.input.handleInput(data);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Provider multi-select component. Uses FuzzySelector for search,
80
+ * tracks selected providers, and lets the user confirm with Ctrl+S.
81
+ */
82
+ class ProviderMultiSelect implements Component {
83
+ private allProviders: string[];
84
+ private selected: Set<string>;
85
+ private theme: ReturnType<typeof getSettingsListTheme>;
86
+ private done: (value: string[] | undefined) => void;
87
+ private fuzzy: FuzzySelector;
88
+
89
+ constructor(
90
+ theme: ReturnType<typeof getSettingsListTheme>,
91
+ providers: string[],
92
+ preselected: string[],
93
+ done: (value: string[] | undefined) => void,
94
+ ) {
95
+ this.allProviders = providers;
96
+ this.selected = new Set(preselected);
97
+ this.theme = theme;
98
+ this.done = done;
99
+
100
+ this.fuzzy = new FuzzySelector({
101
+ label: "Select providers (Enter to toggle, Ctrl+S to confirm)",
102
+ items: this.allProviders,
103
+ theme,
104
+ onSelect: (value) => {
105
+ if (this.selected.has(value)) {
106
+ this.selected.delete(value);
107
+ } else {
108
+ this.selected.add(value);
109
+ }
110
+ },
111
+ onDone: () => {
112
+ this.done(undefined);
113
+ },
114
+ });
115
+ }
116
+
117
+ render(width: number): string[] {
118
+ const lines = this.fuzzy.render(width);
119
+
120
+ // Append selected providers summary
121
+ if (this.selected.size > 0) {
122
+ lines.push("");
123
+ lines.push(this.theme.hint(` Selected (${this.selected.size}):`));
124
+ for (const p of this.selected) {
125
+ lines.push(` ${this.theme.value(p, false)}`);
126
+ }
127
+ }
128
+
129
+ // Replace the hint line from FuzzySelector
130
+ const hintIndex = lines.findIndex((l) => l.includes("Type to search"));
131
+ if (hintIndex !== -1) {
132
+ lines[hintIndex] = this.theme.hint(
133
+ " Type to search · Enter: toggle · Ctrl+S: confirm · Esc: cancel",
134
+ );
135
+ }
136
+
137
+ return lines;
138
+ }
139
+
140
+ invalidate() {
141
+ this.fuzzy.invalidate();
142
+ }
143
+
144
+ handleInput(data: string) {
145
+ if (matchesKey(data, Key.ctrl("s"))) {
146
+ this.done([...this.selected]);
147
+ return;
148
+ }
149
+ this.fuzzy.handleInput(data);
150
+ }
151
+ }
152
+
153
+ export function registerSetupCommand(
154
+ pi: ExtensionAPI,
155
+ onConfigChange: () => void,
156
+ ): void {
157
+ pi.registerCommand("aperture:setup", {
158
+ description: "Configure Tailscale Aperture integration",
159
+ handler: async (_args, ctx) => {
160
+ if (!ctx.hasUI) {
161
+ ctx.ui.notify(
162
+ "aperture:setup requires an interactive terminal",
163
+ "error",
164
+ );
165
+ return;
166
+ }
167
+
168
+ const config = configLoader.getConfig();
169
+ const settingsTheme = getSettingsListTheme();
170
+
171
+ // Step 1: base URL
172
+ const baseUrl = await ctx.ui.custom<string | undefined>(
173
+ (_tui, _theme, _kb, done) => {
174
+ return new UrlPrompt(settingsTheme, config.baseUrl, done);
175
+ },
176
+ );
177
+
178
+ if (!baseUrl) return;
179
+
180
+ // Step 2: select providers
181
+ const knownProviders = getProviders();
182
+ const providers = await ctx.ui.custom<string[] | undefined>(
183
+ (_tui, _theme, _kb, done) => {
184
+ return new ProviderMultiSelect(
185
+ settingsTheme,
186
+ knownProviders,
187
+ config.providers,
188
+ done,
189
+ );
190
+ },
191
+ );
192
+
193
+ if (!providers) return;
194
+
195
+ // Step 3: save and register
196
+ await configLoader.save("global", { baseUrl, providers });
197
+ onConfigChange();
198
+ ctx.ui.notify(
199
+ `Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
200
+ "info",
201
+ );
202
+ },
203
+ });
204
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Configuration schema and loader for the Aperture extension.
3
+ *
4
+ * ApertureConfig is the user-facing schema (all fields optional).
5
+ * ResolvedConfig is the internal schema (all fields required, defaults applied).
6
+ */
7
+
8
+ import { ConfigLoader } from "@aliou/pi-utils-settings";
9
+
10
+ export interface ApertureConfig {
11
+ baseUrl?: string;
12
+ providers?: string[];
13
+ }
14
+
15
+ export interface ResolvedConfig {
16
+ baseUrl: string;
17
+ providers: string[];
18
+ }
19
+
20
+ const DEFAULT_CONFIG: ResolvedConfig = {
21
+ baseUrl: "",
22
+ providers: [],
23
+ };
24
+
25
+ export const configLoader = new ConfigLoader<ApertureConfig, ResolvedConfig>(
26
+ "aperture",
27
+ DEFAULT_CONFIG,
28
+ { scopes: ["global"] },
29
+ );
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pi extension for Tailscale Aperture integration.
3
+ *
4
+ * Routes selected LLM providers through an Aperture gateway on your tailnet.
5
+ * Aperture handles API key injection and request routing, so this extension
6
+ * overrides each provider's baseUrl and sets a dummy apiKey.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { registerApertureSettings } from "./commands/settings";
11
+ import { registerSetupCommand } from "./commands/setup";
12
+ import { configLoader } from "./config";
13
+
14
+ function registerProviders(pi: ExtensionAPI): void {
15
+ const config = configLoader.getConfig();
16
+ if (!config.baseUrl || config.providers.length === 0) return;
17
+
18
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
19
+
20
+ for (const provider of config.providers) {
21
+ pi.registerProvider(provider, {
22
+ baseUrl: `${baseUrl}/v1`,
23
+ apiKey: "-",
24
+ });
25
+ }
26
+ }
27
+
28
+ export default async function (pi: ExtensionAPI): Promise<void> {
29
+ await configLoader.load();
30
+
31
+ const onConfigChange = () => {
32
+ // Config is already reloaded by configLoader.save(), just re-register.
33
+ registerProviders(pi);
34
+ };
35
+
36
+ registerProviders(pi);
37
+ registerSetupCommand(pi, onConfigChange);
38
+ registerApertureSettings(pi, onConfigChange);
39
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
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
+ "jsx": "react-jsx",
13
+ "jsxImportSource": "@mariozechner/pi-tui"
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules"]
17
+ }