@faiku/pi-default-model-switcher 0.0.1 → 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 Faiku
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 CHANGED
@@ -1,4 +1,4 @@
1
- # pi-default-model-switcher
1
+ # @faiku/pi-default-model-switcher
2
2
 
3
3
  A [pi](https://github.com/earendil-works/pi) extension that adds commands for changing the **default startup model**, globally or per project, and for switching the model of the running session.
4
4
 
@@ -33,7 +33,19 @@ All model commands accept an optional argument to skip the pickers:
33
33
 
34
34
  ## Install
35
35
 
36
- Personal (all projects):
36
+ From npm (recommended):
37
+
38
+ ```bash
39
+ pi install npm:@faiku/pi-default-model-switcher
40
+ ```
41
+
42
+ Try it for a single run without installing:
43
+
44
+ ```bash
45
+ pi -e npm:@faiku/pi-default-model-switcher
46
+ ```
47
+
48
+ From a local checkout:
37
49
 
38
50
  ```bash
39
51
  ln -s "$PWD" ~/.pi/agent/extensions/pi-default-model-switcher
@@ -47,7 +59,7 @@ pi --extension ./index.ts
47
59
 
48
60
  ```bash
49
61
  # add to <project>/.pi/settings.json
50
- { "extensions": ["/Users/adam/Documents/Coding/pi-default-model-switcher"] }
62
+ { "extensions": ["/absolute/path/to/pi-default-model-switcher"] }
51
63
  ```
52
64
 
53
65
  Startup defaults are read when a session starts, so run `/reload` or start a new pi session for the persisted default to take effect everywhere; the current session already uses the new model.
@@ -61,4 +73,9 @@ npm run typecheck # tsc --noEmit
61
73
  ```
62
74
 
63
75
  Layout: `index.ts` (commands), `lib/settings.ts` (settings paths + merge/write), `lib/models.ts` (model refs, thinking levels), `lib/fuzzy.ts` (search ranking), `lib/picker.ts` (pure item/window math), `lib/model-picker.ts` (overlay component).
64
- # pi-default-model-switcher
76
+
77
+ The extension ships as TypeScript source — pi loads `.ts` entry points directly, so there is no build step.
78
+
79
+ ## License
80
+
81
+ MIT
package/package.json CHANGED
@@ -1,13 +1,47 @@
1
1
  {
2
2
  "name": "@faiku/pi-default-model-switcher",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for switching the default startup model globally or per project",
4
5
  "author": "Faiku",
5
- "private": false,
6
+ "license": "MIT",
6
7
  "type": "module",
7
- "description": "Pi extension for switching the default startup model globally or per project",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "model",
13
+ "settings"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Faikuu/pi-default-model-switcher.git"
18
+ },
19
+ "homepage": "https://github.com/Faikuu/pi-default-model-switcher#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/Faikuu/pi-default-model-switcher/issues"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "files": [
29
+ "index.ts",
30
+ "lib/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*"
40
+ },
8
41
  "scripts": {
9
42
  "test": "node --experimental-strip-types --test test/*.test.mjs",
10
- "typecheck": "tsc -p tsconfig.json"
43
+ "typecheck": "tsc -p tsconfig.json",
44
+ "prepublishOnly": "npm run typecheck && npm test"
11
45
  },
12
46
  "devDependencies": {
13
47
  "@earendil-works/pi-coding-agent": "*",
@@ -1,119 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtemp, readFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
-
7
- import {
8
- agentDir,
9
- describeDefaults,
10
- globalSettingsPath,
11
- projectSettingsPath,
12
- readSettings,
13
- writeSettings,
14
- } from "../lib/settings.ts";
15
- import {
16
- modelOptionLabel,
17
- modelRef,
18
- parseModelRef,
19
- sortModels,
20
- supportedThinkingLevels,
21
- OTHER_OPTION,
22
- } from "../lib/models.ts";
23
-
24
- async function tmp() {
25
- return mkdtemp(join(tmpdir(), "dms-test-"));
26
- }
27
-
28
- test("agentDir honors PI_CODING_AGENT_DIR", () => {
29
- assert.equal(agentDir({ PI_CODING_AGENT_DIR: "/tmp/agent" }), "/tmp/agent");
30
- });
31
-
32
- test("agentDir falls back to ~/.pi/agent", () => {
33
- assert.equal(agentDir({}), join(process.env.HOME ?? "", ".pi", "agent"));
34
- });
35
-
36
- test("settings paths are derived from agent dir and cwd", () => {
37
- assert.equal(projectSettingsPath("/work/repo"), "/work/repo/.pi/settings.json");
38
- assert.equal(globalSettingsPath({ PI_CODING_AGENT_DIR: "/tmp/agent" }), "/tmp/agent/settings.json");
39
- });
40
-
41
- test("writeSettings creates the file, merges, and keeps unrelated keys", async () => {
42
- const dir = await tmp();
43
- const file = join(dir, "nested", "settings.json");
44
- const merged = await writeSettings(file, { defaultModel: "m1", defaultProvider: "p1" });
45
- assert.deepEqual(merged, { defaultModel: "m1", defaultProvider: "p1" });
46
-
47
- await writeSettings(file, { defaultThinkingLevel: "high" });
48
- assert.deepEqual(await readSettings(file), {
49
- defaultModel: "m1",
50
- defaultProvider: "p1",
51
- defaultThinkingLevel: "high",
52
- });
53
- const text = await readFile(file, "utf8");
54
- assert.ok(text.endsWith("\n"));
55
- assert.ok(text.includes(' "defaultModel": "m1"'));
56
- });
57
-
58
- test("readSettings tolerates missing and malformed files", async () => {
59
- const dir = await tmp();
60
- assert.deepEqual(await readSettings(join(dir, "missing.json")), {});
61
- const bad = join(dir, "bad.json");
62
- await writeSettings(bad, {});
63
- const { writeFile } = await import("node:fs/promises");
64
- await writeFile(bad, "{not json", "utf8");
65
- assert.deepEqual(await readSettings(bad), {});
66
- });
67
-
68
- test("describeDefaults reports set and unset defaults", () => {
69
- assert.equal(describeDefaults({}), "(not set)");
70
- assert.equal(
71
- describeDefaults({ defaultProvider: "anthropic", defaultModel: "sonnet", defaultThinkingLevel: "low" }),
72
- "defaultProvider=anthropic defaultModel=sonnet defaultThinkingLevel=low",
73
- );
74
- });
75
-
76
- test("parseModelRef handles provider/id, bare id, and thinking suffix", () => {
77
- assert.deepEqual(parseModelRef(" anthropic/claude-sonnet-4-5 "), { provider: "anthropic", id: "claude-sonnet-4-5", thinkingLevel: undefined });
78
- assert.deepEqual(parseModelRef("gpt-5"), { id: "gpt-5", thinkingLevel: undefined });
79
- assert.deepEqual(parseModelRef("openai/gpt-5:high"), { provider: "openai", id: "gpt-5", thinkingLevel: "high" });
80
- assert.equal(parseModelRef(" "), undefined);
81
- });
82
-
83
- test("parseModelRef keeps colons that are part of the model id", () => {
84
- assert.deepEqual(parseModelRef("openrouter/some-model:exacto"), { provider: "openrouter", id: "some-model:exacto", thinkingLevel: undefined });
85
- });
86
-
87
- test("modelRef joins provider and id", () => {
88
- assert.equal(modelRef({ provider: "anthropic", id: "claude-sonnet-4-5" }), "anthropic/claude-sonnet-4-5");
89
- });
90
-
91
- test("sortModels orders by provider then name", () => {
92
- const sorted = sortModels([
93
- { provider: "openai", id: "b", name: "B" },
94
- { provider: "anthropic", id: "a2", name: "Zeta" },
95
- { provider: "anthropic", id: "a1", name: "Alpha" },
96
- ]);
97
- assert.deepEqual(sorted.map((m) => `${m.provider}/${m.id}`), ["anthropic/a1", "anthropic/a2", "openai/b"]);
98
- });
99
-
100
- test("supportedThinkingLevels respects reasoning and the level map", () => {
101
- assert.deepEqual(supportedThinkingLevels({ id: "m", name: "m", provider: "p" }), ["off"]);
102
- assert.deepEqual(
103
- supportedThinkingLevels({ id: "m", name: "m", provider: "p", reasoning: true }),
104
- ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
105
- );
106
- assert.deepEqual(
107
- supportedThinkingLevels({ id: "m", name: "m", provider: "p", reasoning: true, thinkingLevelMap: { off: 0, minimal: null, low: 1, medium: null, high: null, xhigh: null, max: null } }),
108
- ["off", "low"],
109
- );
110
- });
111
-
112
- test("modelOptionLabel marks the current model", () => {
113
- const current = { provider: "anthropic", id: "sonnet", name: "Sonnet" };
114
- const label = modelOptionLabel(current, current);
115
- assert.match(label, /\(current\)$/);
116
- assert.match(label, /\[sonnet\]/);
117
- assert.doesNotMatch(modelOptionLabel({ provider: "openai", id: "gpt", name: "GPT" }, current), /current/);
118
- assert.notEqual(OTHER_OPTION, "");
119
- });
@@ -1,90 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
-
4
- import { fuzzyFilterItems, fuzzyScore } from "../lib/fuzzy.ts";
5
- import {
6
- buildPickerItems,
7
- clampIndex,
8
- filterPickerItems,
9
- PICKER_CHROME_ROWS,
10
- PICKER_MAX_ROWS,
11
- pickerMaxVisible,
12
- visibleRange,
13
- } from "../lib/picker.ts";
14
-
15
- const models = [
16
- { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true },
17
- { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true },
18
- { provider: "anthropic", id: "claude-haiku-4-5", name: "Claude Haiku 4.5", reasoning: false },
19
- { provider: "openrouter", id: "~anthropic/claude-opus-latest", name: "Claude Opus (latest)", reasoning: true },
20
- ];
21
-
22
- test("fuzzyScore matches subsequences and rejects misses", () => {
23
- assert.equal(fuzzyScore("", "anything"), 0);
24
- assert.equal(fuzzyScore(" ", "anything"), 0);
25
- assert.notEqual(fuzzyScore("sonnet", "claude sonnet 4.5"), undefined);
26
- assert.notEqual(fuzzyScore("claude sonnet", "claude sonnet 4.5"), undefined);
27
- assert.equal(fuzzyScore("zzz", "claude sonnet"), undefined);
28
- // tokens must appear in order
29
- assert.equal(fuzzyScore("sonnet claude", "claude sonnet"), undefined);
30
- });
31
-
32
- test("fuzzyScore prefers earlier and tighter matches", () => {
33
- assert.ok(fuzzyScore("sonnet", "claude sonnet 4.5") < fuzzyScore("sonnet", "claude haiku sonnet preview"));
34
- });
35
-
36
- test("fuzzyFilterItems keeps input order for an empty query and ranks matches otherwise", () => {
37
- const all = fuzzyFilterItems(models, "", (m) => m.id);
38
- assert.deepEqual(all, models);
39
- const ranked = fuzzyFilterItems(models, "opus", (m) => `${m.provider} ${m.id} ${m.name}`);
40
- assert.ok(ranked.length >= 1);
41
- assert.equal(ranked[0].id, "~anthropic/claude-opus-latest");
42
- });
43
-
44
- test("buildPickerItems sorts by provider and marks the current model", () => {
45
- const items = buildPickerItems(models, { provider: "openai", id: "gpt-5", name: "GPT-5" });
46
- assert.deepEqual(items.map((i) => i.value), [
47
- "anthropic/claude-haiku-4-5",
48
- "anthropic/claude-sonnet-4-5",
49
- "openai/gpt-5",
50
- "openrouter/~anthropic/claude-opus-latest",
51
- ]);
52
- const current = items.find((i) => i.value === "openai/gpt-5");
53
- assert.match(current.description, /current/);
54
- assert.ok(!items[0].description.includes("current"));
55
- // search text covers provider, id and name
56
- assert.ok(items[0].searchText.includes("claude haiku"));
57
- });
58
-
59
- test("filterPickerItems matches on provider, id, and name", () => {
60
- const items = buildPickerItems(models);
61
- assert.equal(filterPickerItems(items, "gpt-5")[0].value, "openai/gpt-5");
62
- assert.equal(filterPickerItems(items, "haiku")[0].value, "anthropic/claude-haiku-4-5");
63
- assert.equal(filterPickerItems(items, "openrouter")[0].value, "openrouter/~anthropic/claude-opus-latest");
64
- assert.equal(filterPickerItems(items, "nothing-here").length, 0);
65
- });
66
-
67
- test("visibleRange keeps the selection inside the window", () => {
68
- assert.deepEqual(visibleRange(0, 0, 10), { start: 0, end: 0 });
69
- assert.deepEqual(visibleRange(5, 0, 10), { start: 0, end: 5 });
70
- assert.deepEqual(visibleRange(20, 0, 10), { start: 0, end: 10 });
71
- assert.deepEqual(visibleRange(20, 19, 10), { start: 10, end: 20 });
72
- assert.deepEqual(visibleRange(20, 10, 10), { start: 5, end: 15 });
73
- });
74
-
75
- test("pickerMaxVisible never exceeds the item count, the cap, or the terminal", () => {
76
- assert.equal(pickerMaxVisible(0, 40), 0);
77
- assert.equal(pickerMaxVisible(3, 40), 3);
78
- assert.equal(pickerMaxVisible(100, 200), PICKER_MAX_ROWS);
79
- // A short terminal clamps the window so the overlay still fits.
80
- assert.equal(pickerMaxVisible(100, PICKER_CHROME_ROWS + 4), 4);
81
- assert.equal(pickerMaxVisible(100, 4), 3, "minimum of three rows");
82
- assert.equal(pickerMaxVisible(100, 200, 8), 8);
83
- });
84
-
85
- test("clampIndex keeps the selection in range", () => {
86
- assert.equal(clampIndex(-5, 3), 0);
87
- assert.equal(clampIndex(9, 3), 2);
88
- assert.equal(clampIndex(1, 3), 1);
89
- assert.equal(clampIndex(4, 0), 0);
90
- });
package/tsconfig.json DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "es2022",
4
- "module": "esnext",
5
- "moduleResolution": "bundler",
6
- "allowImportingTsExtensions": true,
7
- "noEmit": true,
8
- "strict": true,
9
- "skipLibCheck": true,
10
- "types": ["node"],
11
- "lib": ["es2023"]
12
- },
13
- "include": ["index.ts", "lib/**/*.ts"]
14
- }