@adeildo/pi-kit 4.0.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 +21 -0
- package/README.md +84 -0
- package/package.json +32 -0
- package/src/app/attribution.ts +31 -0
- package/src/app/builder.ts +161 -0
- package/src/app/claim.ts +29 -0
- package/src/app/feature.ts +44 -0
- package/src/app/scope.ts +63 -0
- package/src/decode.ts +187 -0
- package/src/events.ts +40 -0
- package/src/index.ts +7 -0
- package/src/settings/files.ts +70 -0
- package/src/settings/setting.ts +39 -0
- package/src/settings/store.ts +129 -0
- package/src/testing.ts +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Felipe Adeildo
|
|
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,84 @@
|
|
|
1
|
+
# @adeildo/pi-kit
|
|
2
|
+
|
|
3
|
+
What every pi-harness package is built on. It's a library, not a Pi package, so installing it adds nothing to Pi.
|
|
4
|
+
|
|
5
|
+
## An app, and features in it
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createApp, defineFeature, matching, setting } from "@adeildo/pi-kit";
|
|
9
|
+
|
|
10
|
+
export const version = setting({
|
|
11
|
+
id: "subscription.claudeCodeVersion",
|
|
12
|
+
default: "2.1.280",
|
|
13
|
+
decoder: matching(/^\d+\.\d+\.\d+$/, "a version like 2.1.280"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const subscription = defineFeature({
|
|
17
|
+
id: "subscription",
|
|
18
|
+
description: "Bill Anthropic OAuth requests to the Claude plan",
|
|
19
|
+
settings: [version],
|
|
20
|
+
setup(scope) {
|
|
21
|
+
scope.on("before_provider_headers", (event) => {
|
|
22
|
+
event.headers["user-agent"] = `claude-cli/${version.get(scope)}`;
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default function (pi: ExtensionAPI): void {
|
|
28
|
+
createApp(pi, { name: "pi-providers" }).use(subscription).build();
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A package is one app. The harness will be one app with every feature in it, and Pi loads it as a single extension.
|
|
33
|
+
|
|
34
|
+
## What a feature gets
|
|
35
|
+
|
|
36
|
+
`setup` receives the feature's scope. It extends `ExtensionAPI`, so it has every pi method, with two of them wired to the feature:
|
|
37
|
+
|
|
38
|
+
| | |
|
|
39
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `scope.on` | `pi.on`, but an error comes out as `pi-harness: permission: tool_call: boom` instead of just the extension's name. It's still thrown, because a `tool_call` handler that throws is how pi blocks a tool. |
|
|
41
|
+
| `scope.registerCommand` | `pi.registerCommand`. If another feature in the same app already took the name, this one is skipped with a warning. |
|
|
42
|
+
| `scope.onSessionStart` | Runs once the session's settings are loaded. Anything that lasts, like a watcher, a server or a child process, starts here and never in `setup`. |
|
|
43
|
+
| `scope.onShutdown` | Runs once per session, newest first, even when pi fires `session_shutdown` twice. |
|
|
44
|
+
| `scope.warn` | Shows a notification when there's a UI and writes to stderr when there isn't. Messages sent before a session starts are held until it does. |
|
|
45
|
+
| everything else | The scope **is** the extension API, so `scope.registerTool`, `scope.appendEntry`, `scope.events` and the rest work as they always did. |
|
|
46
|
+
|
|
47
|
+
A feature whose `setup` throws turns into a warning, and the other features still mount. If the same feature runs in two apps of one Pi process, say the harness and a standalone package, the first app runs it and the second one logs who has it.
|
|
48
|
+
|
|
49
|
+
## Settings
|
|
50
|
+
|
|
51
|
+
Every app reads `~/.pi/agent/extensions/pi-harness/settings.json`. A setting's `id` is its path in that file. A store only reads the settings its own features declared, so keys that belong to other apps never produce a warning.
|
|
52
|
+
|
|
53
|
+
`store.set` writes one value, and `store.setAll` writes several in one pass, which is what a feature does when it saves a whole config object. A value that already reads the same is not written, so a file holds what was decided and not a copy of every default, which would freeze them.
|
|
54
|
+
|
|
55
|
+
A setting declared with `project: true` also reads `<project>/.pi/extensions/pi-harness/settings.json`, but only once pi trusts the project, and there the project value wins. Anything without that flag can only be set globally. A project file that tries to set it gets a warning, so a cloned repository can't loosen what runs without asking.
|
|
56
|
+
|
|
57
|
+
A value that fails to decode is ignored, and the warning names the file and the key.
|
|
58
|
+
|
|
59
|
+
Every feature also gets `features.<id>.enabled`, which defaults to on. A feature that's off never runs `setup`, so it registers nothing and costs nothing. The change takes effect on the next `/reload`.
|
|
60
|
+
|
|
61
|
+
## Events and contracts
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const accountChanged = defineEvent("providers:account-changed", object({ account: string }));
|
|
65
|
+
|
|
66
|
+
accountChanged.on(scope, ({ account }) => …);
|
|
67
|
+
accountChanged.emit(scope, { account: "work" });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Events travel over `pi.events` on the channel `harness:<name>`. Two features behave the same whether they share an app or come from different packages. The payload might come from an older or newer version of the other package, so it's decoded when it arrives, and one that doesn't decode is dropped with a warning.
|
|
71
|
+
|
|
72
|
+
These rules hold for every event that crosses packages:
|
|
73
|
+
|
|
74
|
+
- It's declared in `contracts/` in this package, never in the package that emits it. That way the listener doesn't depend on the emitter, and either one works without the other.
|
|
75
|
+
- The payload is plain JSON, with no functions, classes or `Date`. A remote control or a web UI can then forward any contract without knowing what's inside.
|
|
76
|
+
- A payload can gain fields but never lose them.
|
|
77
|
+
|
|
78
|
+
## Testing
|
|
79
|
+
|
|
80
|
+
`@adeildo/pi-kit/testing` has `fakePi()` and `fakeContext()`. Two fakes built on one `createEventBus()` behave like two extensions in the same Pi process. `fire` runs the handlers in order and returns what each one returned.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adeildo/pi-kit",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "The app builder, settings and events that pi-harness packages are built on.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Felipe Adeildo",
|
|
8
|
+
"email": "contato@felipeadeildo.com",
|
|
9
|
+
"url": "https://github.com/felipeadeildo"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/felipeadeildo/pi-harness.git",
|
|
14
|
+
"directory": "packages/kit"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.ts",
|
|
24
|
+
"./testing": "./src/testing.ts"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// In the harness every feature runs inside one extension, so pi reports every failure under the
|
|
2
|
+
// same name. These put the app and the feature in front of it instead.
|
|
3
|
+
|
|
4
|
+
export function describe(error: unknown): string {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A sync function stays sync, because pi treats some handler results differently when they are
|
|
9
|
+
// promises.
|
|
10
|
+
export function attributed<A extends unknown[], R>(
|
|
11
|
+
label: string,
|
|
12
|
+
run: (...args: A) => R,
|
|
13
|
+
): (...args: A) => R {
|
|
14
|
+
return (...args: A): R => {
|
|
15
|
+
let result: R;
|
|
16
|
+
try {
|
|
17
|
+
result = run(...args);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw relabel(label, error);
|
|
20
|
+
}
|
|
21
|
+
if (!(result instanceof Promise)) return result;
|
|
22
|
+
// Still the same kind of promise, only rejected with the relabeled error.
|
|
23
|
+
return result.catch((error: unknown) => {
|
|
24
|
+
throw relabel(label, error);
|
|
25
|
+
}) as R;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function relabel(label: string, error: unknown): Error {
|
|
30
|
+
return new Error(`${label}: ${describe(error)}`, { cause: error });
|
|
31
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The app builder. A package's extension is `createApp(pi, …).use(feature).build()`, and the
|
|
2
|
+
// harness is the same call with every feature. The builder is where the rules from pi's extension
|
|
3
|
+
// docs live: registration only in the factory, session work from `session_start`, cleanup once per
|
|
4
|
+
// session, and a feature that fails to set up becomes a warning.
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { boolean } from "../decode.ts";
|
|
8
|
+
import { globalSettingsPath, projectSettingsPath } from "../settings/files.ts";
|
|
9
|
+
import { type Setting, type SettingsScope, setting } from "../settings/setting.ts";
|
|
10
|
+
import { SettingsStore } from "../settings/store.ts";
|
|
11
|
+
import { describe } from "./attribution.ts";
|
|
12
|
+
import { answerClaims, ownerOf } from "./claim.ts";
|
|
13
|
+
import type { Feature } from "./feature.ts";
|
|
14
|
+
import { type AppState, createScope, type Hook } from "./scope.ts";
|
|
15
|
+
|
|
16
|
+
export interface App extends SettingsScope {
|
|
17
|
+
/** Shown in front of every warning, like `pi-harness` or `pi-providers`. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly settings: SettingsStore;
|
|
20
|
+
/** True when this app runs a feature with that id. */
|
|
21
|
+
has(featureId: string): boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AppOptions {
|
|
25
|
+
name: string;
|
|
26
|
+
settingsPath?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AppBuilder {
|
|
30
|
+
use(feature: Feature): AppBuilder;
|
|
31
|
+
build(): App;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createApp(pi: ExtensionAPI, options: AppOptions): AppBuilder {
|
|
35
|
+
const features: Feature[] = [];
|
|
36
|
+
const builder: AppBuilder = {
|
|
37
|
+
use(feature) {
|
|
38
|
+
features.push(feature);
|
|
39
|
+
return builder;
|
|
40
|
+
},
|
|
41
|
+
build: () => mount(pi, options, features),
|
|
42
|
+
};
|
|
43
|
+
return builder;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function enabledSetting(feature: Feature): Setting<boolean> {
|
|
47
|
+
return setting({
|
|
48
|
+
id: `features.${feature.id}.enabled`,
|
|
49
|
+
default: true,
|
|
50
|
+
decoder: boolean,
|
|
51
|
+
ui: { group: "Features", label: feature.id, description: feature.description },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mount(pi: ExtensionAPI, options: AppOptions, features: readonly Feature[]): App {
|
|
56
|
+
const settings = new SettingsStore(options.settingsPath ?? globalSettingsPath());
|
|
57
|
+
const mounted = new Set<string>();
|
|
58
|
+
const queued: string[] = [];
|
|
59
|
+
let session: ExtensionContext | undefined;
|
|
60
|
+
let live = true;
|
|
61
|
+
|
|
62
|
+
function deliver(line: string): void {
|
|
63
|
+
if (session === undefined) queued.push(line);
|
|
64
|
+
else if (session.hasUI) session.ui.notify(line, "warning");
|
|
65
|
+
else console.error(line);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const state: AppState = {
|
|
69
|
+
name: options.name,
|
|
70
|
+
pi,
|
|
71
|
+
settings,
|
|
72
|
+
mounted,
|
|
73
|
+
commands: new Map(),
|
|
74
|
+
starts: [],
|
|
75
|
+
shutdowns: [],
|
|
76
|
+
report: (source, message) => deliver(`${options.name}: ${source}: ${message}`),
|
|
77
|
+
};
|
|
78
|
+
const app: App = { name: options.name, settings, has: (id) => mounted.has(id) };
|
|
79
|
+
|
|
80
|
+
answerClaims(pi, options.name, mounted, () => live);
|
|
81
|
+
|
|
82
|
+
// Registered first, so these run ahead of the features' own handlers.
|
|
83
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
84
|
+
live = true;
|
|
85
|
+
session = ctx;
|
|
86
|
+
for (const line of queued.splice(0)) deliver(line);
|
|
87
|
+
const project = ctx.isProjectTrusted() ? projectSettingsPath(ctx.cwd) : undefined;
|
|
88
|
+
for (const line of settings.load(project)) state.report("settings", line);
|
|
89
|
+
await runInOrder(
|
|
90
|
+
state.starts,
|
|
91
|
+
(run) => run(ctx),
|
|
92
|
+
(source, error) => {
|
|
93
|
+
state.report(source, `failed to start the session: ${describe(error)}`);
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("session_shutdown", async () => {
|
|
99
|
+
if (!live) return;
|
|
100
|
+
live = false;
|
|
101
|
+
await runInOrder(
|
|
102
|
+
state.shutdowns.toReversed(),
|
|
103
|
+
(run) => run(),
|
|
104
|
+
(source, error) => {
|
|
105
|
+
state.report(source, `failed to shut down: ${describe(error)}`);
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
session = undefined;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// All the switches first: one read of the file decides what is on, and a feature that is off
|
|
112
|
+
// still shows up on the settings screen.
|
|
113
|
+
const candidates: { feature: Feature; enabled: Setting<boolean> }[] = [];
|
|
114
|
+
for (const feature of features) {
|
|
115
|
+
if (candidates.some((candidate) => candidate.feature.id === feature.id)) {
|
|
116
|
+
state.report(feature.id, "was added to this app twice; keeping the first");
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const enabled = enabledSetting(feature);
|
|
120
|
+
try {
|
|
121
|
+
settings.register([enabled, ...(feature.settings ?? [])]);
|
|
122
|
+
candidates.push({ feature, enabled });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
state.report(feature.id, `failed to set up: ${describe(error)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const { feature, enabled } of candidates) {
|
|
129
|
+
if (!enabled.get(app)) continue;
|
|
130
|
+
|
|
131
|
+
const owner = ownerOf(pi, feature.id);
|
|
132
|
+
if (owner !== undefined) {
|
|
133
|
+
state.report(feature.id, `already loaded by ${owner}, so this copy stays off`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
feature.setup(createScope(state, feature.id));
|
|
139
|
+
mounted.add(feature.id);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
state.report(feature.id, `failed to set up: ${describe(error)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return app;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function runInOrder<T>(
|
|
149
|
+
hooks: readonly Hook<T>[],
|
|
150
|
+
call: (run: T) => void | Promise<void>,
|
|
151
|
+
failed: (source: string, error: unknown) => void,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
for (const hook of hooks) {
|
|
154
|
+
try {
|
|
155
|
+
// oxlint-disable-next-line no-await-in-loop -- the order is the point
|
|
156
|
+
await call(hook.run);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
failed(hook.source, error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/app/claim.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Before mounting a feature, an app asks the other apps on pi.events whether one already runs it,
|
|
2
|
+
// which is how the harness and a standalone copy of the same package avoid running it twice. The
|
|
3
|
+
// answer is a field written on the payload, which works because pi's bus is synchronous. An app
|
|
4
|
+
// answers only while it is live, and pi fires `session_shutdown` before a reload builds the next.
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { isObject, object, string } from "../decode.ts";
|
|
8
|
+
|
|
9
|
+
const CHANNEL = "harness:kit:claim";
|
|
10
|
+
const claimShape = object({ feature: string });
|
|
11
|
+
|
|
12
|
+
export function answerClaims(
|
|
13
|
+
pi: ExtensionAPI,
|
|
14
|
+
appName: string,
|
|
15
|
+
mounted: ReadonlySet<string>,
|
|
16
|
+
isLive: () => boolean,
|
|
17
|
+
): void {
|
|
18
|
+
pi.events.on(CHANNEL, (data) => {
|
|
19
|
+
const claim = claimShape.decode(data, "");
|
|
20
|
+
if (!isLive() || !claim.ok || !isObject(data) || data.owner !== undefined) return;
|
|
21
|
+
if (mounted.has(claim.value.feature)) data.owner = appName;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ownerOf(pi: ExtensionAPI, featureId: string): string | undefined {
|
|
26
|
+
const claim: { feature: string; owner?: unknown } = { feature: featureId };
|
|
27
|
+
pi.events.emit(CHANNEL, claim);
|
|
28
|
+
return typeof claim.owner === "string" ? claim.owner : undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
RegisteredCommand,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import type { Setting, SettingsScope } from "../settings/setting.ts";
|
|
8
|
+
import type { SettingsStore } from "../settings/store.ts";
|
|
9
|
+
|
|
10
|
+
export type SessionHook = (ctx: ExtensionContext) => void | Promise<void>;
|
|
11
|
+
export type ShutdownHook = () => void | Promise<void>;
|
|
12
|
+
export type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
|
|
13
|
+
|
|
14
|
+
export interface Feature {
|
|
15
|
+
/** Unique across every package, like `subscription`. It names the feature's settings too. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** One line for the settings screen. */
|
|
18
|
+
description: string;
|
|
19
|
+
settings?: readonly Setting<unknown>[];
|
|
20
|
+
/** Registers handlers, commands and providers. Starts nothing: that goes in `onSessionStart`. */
|
|
21
|
+
setup(scope: FeatureScope): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a feature gets in `setup`: the extension API, with `on` and `registerCommand` wired to this
|
|
26
|
+
* feature, plus the app's services. It extends `ExtensionAPI` on purpose. Reading a method through
|
|
27
|
+
* another type collapses pi's per-event overloads, and extending an interface keeps them.
|
|
28
|
+
*/
|
|
29
|
+
export interface FeatureScope extends ExtensionAPI, SettingsScope {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly settings: SettingsStore;
|
|
32
|
+
/** Runs when a session starts, after the settings are loaded for it. */
|
|
33
|
+
onSessionStart(hook: SessionHook): void;
|
|
34
|
+
/** Runs once per session, newest first. */
|
|
35
|
+
onShutdown(hook: ShutdownHook): void;
|
|
36
|
+
/** Shown as `<app>: <feature>: <message>`. */
|
|
37
|
+
warn(message: string): void;
|
|
38
|
+
/** True when this app runs a feature with that id. */
|
|
39
|
+
has(featureId: string): boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function defineFeature(feature: Feature): Feature {
|
|
43
|
+
return feature;
|
|
44
|
+
}
|
package/src/app/scope.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { SettingsStore } from "../settings/store.ts";
|
|
4
|
+
import { attributed } from "./attribution.ts";
|
|
5
|
+
import type { FeatureScope, SessionHook, ShutdownHook } from "./feature.ts";
|
|
6
|
+
|
|
7
|
+
export interface Hook<T> {
|
|
8
|
+
/** The feature that registered it, named when it fails. */
|
|
9
|
+
source: string;
|
|
10
|
+
run: T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The parts of an app a feature scope reads and writes. */
|
|
14
|
+
export interface AppState {
|
|
15
|
+
name: string;
|
|
16
|
+
pi: ExtensionAPI;
|
|
17
|
+
settings: SettingsStore;
|
|
18
|
+
mounted: ReadonlySet<string>;
|
|
19
|
+
/** Command name to the feature that registered it. */
|
|
20
|
+
commands: Map<string, string>;
|
|
21
|
+
starts: Hook<SessionHook>[];
|
|
22
|
+
shutdowns: Hook<ShutdownHook>[];
|
|
23
|
+
report(source: string, message: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createScope(state: AppState, featureId: string): FeatureScope {
|
|
27
|
+
const label = `${state.name}: ${featureId}`;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...state.pi,
|
|
31
|
+
id: featureId,
|
|
32
|
+
settings: state.settings,
|
|
33
|
+
on: attributedOn(state.pi, label),
|
|
34
|
+
registerCommand(name, options) {
|
|
35
|
+
const owner = state.commands.get(name);
|
|
36
|
+
if (owner !== undefined) {
|
|
37
|
+
state.report(featureId, `/${name} is already registered by ${owner}; skipped`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
state.commands.set(name, featureId);
|
|
41
|
+
state.pi.registerCommand(name, {
|
|
42
|
+
...options,
|
|
43
|
+
handler: attributed(`${label}: /${name}`, options.handler),
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
onSessionStart: (run) => state.starts.push({ source: featureId, run }),
|
|
47
|
+
onShutdown: (run) => state.shutdowns.push({ source: featureId, run }),
|
|
48
|
+
warn: (message) => state.report(featureId, message),
|
|
49
|
+
has: (id) => state.mounted.has(id),
|
|
50
|
+
} as FeatureScope;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type UntypedHandler = (...args: unknown[]) => unknown;
|
|
54
|
+
type UntypedOn = (event: string, handler: UntypedHandler) => () => void;
|
|
55
|
+
|
|
56
|
+
// pi.on has one overload per event and TypeScript cannot implement an overloaded signature
|
|
57
|
+
// generically, so this is the one place that casts. Callers keep the exact overload types.
|
|
58
|
+
function attributedOn(pi: ExtensionAPI, label: string): ExtensionAPI["on"] {
|
|
59
|
+
const on = pi.on.bind(pi) as unknown as UntypedOn;
|
|
60
|
+
const wrapped: UntypedOn = (event, handler) =>
|
|
61
|
+
on(event, attributed(`${label}: ${event}`, handler));
|
|
62
|
+
return wrapped as unknown as ExtensionAPI["on"];
|
|
63
|
+
}
|
package/src/decode.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Decoders for everything that comes from outside: settings files and event payloads from other
|
|
2
|
+
// packages. The only module that inspects `typeof`, and no decoder throws.
|
|
3
|
+
export interface Problem {
|
|
4
|
+
path: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type Decoded<T> =
|
|
9
|
+
| { ok: true; value: T; problems: Problem[] }
|
|
10
|
+
| { ok: false; problems: Problem[] };
|
|
11
|
+
|
|
12
|
+
export interface Decoder<T> {
|
|
13
|
+
decode(input: unknown, path: string): Decoded<T>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function pass<T>(value: T, problems: Problem[] = []): Decoded<T> {
|
|
17
|
+
return { ok: true, value, problems };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fail<T>(entry: Problem): Decoded<T> {
|
|
21
|
+
return { ok: false, problems: [entry] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function problem(path: string, message: string): Problem {
|
|
25
|
+
return { path, message };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function fieldPath(path: string, key: string): string {
|
|
29
|
+
return path === "" ? key : `${path}.${key}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatProblems(problems: Problem[]): string[] {
|
|
33
|
+
return problems.map((entry) => `${entry.path}: ${entry.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isObject(input: unknown): input is Record<string, unknown> {
|
|
37
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const string: Decoder<string> = {
|
|
41
|
+
decode(input, path) {
|
|
42
|
+
return typeof input === "string" ? pass(input) : fail(problem(path, "expected a string"));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const trimmedString: Decoder<string> = {
|
|
47
|
+
decode(input, path) {
|
|
48
|
+
if (typeof input !== "string" || input.trim() === "")
|
|
49
|
+
return fail(problem(path, "expected a non-empty string"));
|
|
50
|
+
return pass(input.trim());
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const boolean: Decoder<boolean> = {
|
|
55
|
+
decode(input, path) {
|
|
56
|
+
return typeof input === "boolean" ? pass(input) : fail(problem(path, "expected a boolean"));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const unit: Decoder<number> = {
|
|
61
|
+
decode(input, path) {
|
|
62
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input < 0 || input > 1)
|
|
63
|
+
return fail(problem(path, "expected a number from 0 to 1"));
|
|
64
|
+
return pass(input);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const duration: Decoder<number> = {
|
|
69
|
+
decode(input, path) {
|
|
70
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
|
|
71
|
+
return fail(problem(path, "expected a non-negative number of milliseconds"));
|
|
72
|
+
return pass(input);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A whole number from `min` to `max`, inclusive. */
|
|
77
|
+
export function integer(min: number, max: number): Decoder<number> {
|
|
78
|
+
return {
|
|
79
|
+
decode(input, path) {
|
|
80
|
+
if (typeof input !== "number" || !Number.isInteger(input) || input < min || input > max) {
|
|
81
|
+
return fail(problem(path, `expected a whole number from ${min} to ${max}`));
|
|
82
|
+
}
|
|
83
|
+
return pass(input);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A string that matches `pattern`, described to the user as `expected`. */
|
|
89
|
+
export function matching(pattern: RegExp, expected: string): Decoder<string> {
|
|
90
|
+
return {
|
|
91
|
+
decode(input, path) {
|
|
92
|
+
if (typeof input === "string" && pattern.test(input)) return pass(input);
|
|
93
|
+
return fail(problem(path, `expected ${expected}`));
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function literal<const T extends readonly string[]>(...values: T): Decoder<T[number]> {
|
|
99
|
+
const expected = values.map((value) => `"${value}"`).join(" or ");
|
|
100
|
+
return {
|
|
101
|
+
decode(input, path) {
|
|
102
|
+
if (typeof input === "string" && (values as readonly string[]).includes(input))
|
|
103
|
+
return pass(input as T[number]);
|
|
104
|
+
return fail(problem(path, `expected ${expected}`));
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function nullable<T>(inner: Decoder<T>): Decoder<T | null> {
|
|
110
|
+
return {
|
|
111
|
+
decode(input, path) {
|
|
112
|
+
return input === null ? pass(null) : inner.decode(input, path);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function withDefaultOf<T>(inner: Decoder<T>, fallback: () => T): Decoder<T> {
|
|
118
|
+
return {
|
|
119
|
+
decode(input, path) {
|
|
120
|
+
if (input === undefined) return pass(fallback());
|
|
121
|
+
const result = inner.decode(input, path);
|
|
122
|
+
return result.ok ? result : pass(fallback(), result.problems);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function withDefault<T>(inner: Decoder<T>, fallback: T): Decoder<T> {
|
|
128
|
+
return withDefaultOf(inner, () => fallback);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function stringList(expected: string): Decoder<string[]> {
|
|
132
|
+
const notAList = `expected an array of ${expected}`;
|
|
133
|
+
const dropped = "ignored entries that are not non-empty strings";
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
decode(input, path) {
|
|
137
|
+
if (!Array.isArray(input)) return fail(problem(path, notAList));
|
|
138
|
+
|
|
139
|
+
const value: string[] = [];
|
|
140
|
+
let missing = 0;
|
|
141
|
+
for (const entry of input) {
|
|
142
|
+
if (typeof entry === "string" && entry !== "") value.push(entry);
|
|
143
|
+
else missing++;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const problems = missing > 0 ? [problem(path, dropped)] : [];
|
|
147
|
+
return pass(value, problems);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function stringListOrEmpty(
|
|
153
|
+
fallback: readonly string[],
|
|
154
|
+
expected: string,
|
|
155
|
+
): Decoder<string[]> {
|
|
156
|
+
const parse = stringList(expected);
|
|
157
|
+
return {
|
|
158
|
+
decode(input, path) {
|
|
159
|
+
if (input === undefined) return pass([...fallback]);
|
|
160
|
+
const result = parse.decode(input, path);
|
|
161
|
+
return result.ok ? result : pass([], result.problems);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function object<T extends Record<string, unknown>>(shape: {
|
|
167
|
+
[K in keyof T]: Decoder<T[K]>;
|
|
168
|
+
}): Decoder<T> {
|
|
169
|
+
return {
|
|
170
|
+
decode(input, path) {
|
|
171
|
+
if (!isObject(input)) return fail(problem(path, "expected an object"));
|
|
172
|
+
|
|
173
|
+
const problems: Problem[] = [];
|
|
174
|
+
const value = {} as T;
|
|
175
|
+
let complete = true;
|
|
176
|
+
|
|
177
|
+
for (const key of Object.keys(shape) as (keyof T & string)[]) {
|
|
178
|
+
const result = shape[key].decode(input[key], fieldPath(path, key));
|
|
179
|
+
problems.push(...result.problems);
|
|
180
|
+
if (result.ok) value[key] = result.value;
|
|
181
|
+
else complete = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return complete ? pass(value, problems) : { ok: false, problems };
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// How features talk to each other, over pi.events, which every extension in a process shares. A
|
|
2
|
+
// payload is decoded on arrival, because it may come from another version of the other package.
|
|
3
|
+
//
|
|
4
|
+
// An event that crosses packages is declared in `contracts/`, never in the package that emits it,
|
|
5
|
+
// so the listener does not depend on the emitter.
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { type Decoder, formatProblems } from "./decode.ts";
|
|
9
|
+
|
|
10
|
+
/** What an event needs to travel. A feature scope is one. */
|
|
11
|
+
export interface EventScope {
|
|
12
|
+
readonly events: ExtensionAPI["events"];
|
|
13
|
+
warn(message: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HarnessEvent<T> {
|
|
17
|
+
/** The pi.events channel: `harness:` and the name. */
|
|
18
|
+
readonly channel: string;
|
|
19
|
+
emit(scope: EventScope, payload: T): void;
|
|
20
|
+
/** A payload that does not decode is dropped with a warning. */
|
|
21
|
+
on(scope: EventScope, listener: (payload: T) => void): () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Name it `<area>:<what happened>`, like `providers:account-changed`. */
|
|
25
|
+
export function defineEvent<T>(name: string, decoder: Decoder<T>): HarnessEvent<T> {
|
|
26
|
+
const channel = `harness:${name}`;
|
|
27
|
+
return {
|
|
28
|
+
channel,
|
|
29
|
+
emit(scope, payload) {
|
|
30
|
+
scope.events.emit(channel, payload);
|
|
31
|
+
},
|
|
32
|
+
on(scope, listener) {
|
|
33
|
+
return scope.events.on(channel, (data) => {
|
|
34
|
+
const result = decoder.decode(data, name);
|
|
35
|
+
if (result.ok) listener(result.value);
|
|
36
|
+
else scope.warn(`ignored a ${name} event: ${formatProblems(result.problems).join("; ")}`);
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./app/builder.ts";
|
|
2
|
+
export * from "./app/feature.ts";
|
|
3
|
+
export * from "./decode.ts";
|
|
4
|
+
export * from "./events.ts";
|
|
5
|
+
export { globalSettingsPath, projectSettingsPath } from "./settings/files.ts";
|
|
6
|
+
export * from "./settings/setting.ts";
|
|
7
|
+
export * from "./settings/store.ts";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Where the settings files live and how they are read and written. Every app shares them, so a
|
|
2
|
+
// write keeps every key it does not know.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { isObject } from "../decode.ts";
|
|
9
|
+
|
|
10
|
+
export type SettingsData = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
export interface SettingsFile {
|
|
13
|
+
data: SettingsData;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function globalSettingsPath(): string {
|
|
18
|
+
return join(getAgentDir(), "extensions", "pi-harness", "settings.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function projectSettingsPath(cwd: string): string {
|
|
22
|
+
return join(cwd, CONFIG_DIR_NAME, "extensions", "pi-harness", "settings.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readSettingsFile(path: string): SettingsFile {
|
|
26
|
+
if (!existsSync(path)) return { data: {}, warnings: [] };
|
|
27
|
+
try {
|
|
28
|
+
const data: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
29
|
+
if (isObject(data)) return { data, warnings: [] };
|
|
30
|
+
return { data: {}, warnings: [`${path}: must contain a JSON object; using the defaults`] };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return { data: {}, warnings: [`${path}: could not be read (${reason}); using the defaults`] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeSettingsFile(path: string, data: SettingsData): void {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, "utf8");
|
|
41
|
+
renameSync(temporary, path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function lookup(data: SettingsData, id: string): unknown {
|
|
45
|
+
let node: unknown = data;
|
|
46
|
+
for (const key of id.split(".")) {
|
|
47
|
+
if (!isObject(node)) return undefined;
|
|
48
|
+
node = node[key];
|
|
49
|
+
}
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function assign(data: SettingsData, id: string, value: unknown): void {
|
|
54
|
+
const keys = id.split(".");
|
|
55
|
+
const last = keys.pop();
|
|
56
|
+
if (last === undefined) return;
|
|
57
|
+
|
|
58
|
+
let node = data;
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
const next = node[key];
|
|
61
|
+
if (isObject(next)) {
|
|
62
|
+
node = next;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const created: SettingsData = {};
|
|
66
|
+
node[key] = created;
|
|
67
|
+
node = created;
|
|
68
|
+
}
|
|
69
|
+
node[last] = value;
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A setting is declared next to the feature that reads it. The declaration is plain data plus two
|
|
2
|
+
// helpers, so one handle works against any app.
|
|
3
|
+
import type { Decoder } from "../decode.ts";
|
|
4
|
+
import type { SettingsStore } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
/** How a setting shows up on a settings screen. */
|
|
7
|
+
export interface SettingUi {
|
|
8
|
+
group: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SettingDefinition<T> {
|
|
14
|
+
/** Dotted path in the settings file, like `subscription.claudeCodeVersion`. */
|
|
15
|
+
id: string;
|
|
16
|
+
default: T;
|
|
17
|
+
decoder: Decoder<T>;
|
|
18
|
+
/** Accept a value from the project's settings file, once pi trusts the project. */
|
|
19
|
+
project?: boolean;
|
|
20
|
+
ui?: SettingUi;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SettingsScope {
|
|
24
|
+
readonly settings: SettingsStore;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Setting<T> extends SettingDefinition<T> {
|
|
28
|
+
get(scope: SettingsScope): T;
|
|
29
|
+
listen(scope: SettingsScope, listener: (value: T) => void): () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setting<T>(definition: SettingDefinition<T>): Setting<T> {
|
|
33
|
+
const handle: Setting<T> = {
|
|
34
|
+
...definition,
|
|
35
|
+
get: (scope) => scope.settings.get(handle),
|
|
36
|
+
listen: (scope, listener) => scope.settings.listen(handle, listener),
|
|
37
|
+
};
|
|
38
|
+
return handle;
|
|
39
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// One store per app, reading only the settings its own features declared. A value comes from the
|
|
2
|
+
// first layer with a valid one: project (for settings that accept it, in a trusted project),
|
|
3
|
+
// global, default.
|
|
4
|
+
import { formatProblems } from "../decode.ts";
|
|
5
|
+
import { assign, lookup, readSettingsFile, type SettingsData, writeSettingsFile } from "./files.ts";
|
|
6
|
+
import type { Setting } from "./setting.ts";
|
|
7
|
+
|
|
8
|
+
type Found<T> = { found: true; value: T } | { found: false };
|
|
9
|
+
|
|
10
|
+
export class SettingsStore {
|
|
11
|
+
readonly globalPath: string;
|
|
12
|
+
#projectPath: string | undefined;
|
|
13
|
+
#known = new Map<string, Setting<unknown>>();
|
|
14
|
+
#values = new Map<string, unknown>();
|
|
15
|
+
#listeners = new Map<string, Set<(value: unknown) => void>>();
|
|
16
|
+
#loaded = false;
|
|
17
|
+
|
|
18
|
+
constructor(globalPath: string) {
|
|
19
|
+
this.globalPath = globalPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
register(settings: readonly Setting<unknown>[]): void {
|
|
23
|
+
for (const entry of settings) {
|
|
24
|
+
const known = this.#known.get(entry.id);
|
|
25
|
+
if (known !== undefined && known !== entry)
|
|
26
|
+
throw new Error(`two settings are named "${entry.id}"`);
|
|
27
|
+
this.#known.set(entry.id, entry);
|
|
28
|
+
}
|
|
29
|
+
this.#loaded = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get<T>(entry: Setting<T>): T {
|
|
33
|
+
if (!this.#loaded) this.load(this.#projectPath);
|
|
34
|
+
// Only `load` writes to #values, and always with a value this setting's decoder produced.
|
|
35
|
+
return this.#values.has(entry.id) ? (this.#values.get(entry.id) as T) : entry.default;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
listen<T>(entry: Setting<T>, listener: (value: T) => void): () => void {
|
|
39
|
+
const listeners = this.#listeners.get(entry.id) ?? new Set();
|
|
40
|
+
this.#listeners.set(entry.id, listeners);
|
|
41
|
+
// Same reasoning as `get`: every value handed to a listener came from this setting's decoder.
|
|
42
|
+
const untyped = listener as (value: unknown) => void;
|
|
43
|
+
listeners.add(untyped);
|
|
44
|
+
return () => listeners.delete(untyped);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads the files again. Pass the project file only when pi trusts the project. */
|
|
48
|
+
load(projectPath?: string): string[] {
|
|
49
|
+
this.#loaded = true;
|
|
50
|
+
this.#projectPath = projectPath;
|
|
51
|
+
const global = readSettingsFile(this.globalPath);
|
|
52
|
+
const project = projectPath === undefined ? undefined : readSettingsFile(projectPath);
|
|
53
|
+
const warnings = [...global.warnings, ...(project?.warnings ?? [])];
|
|
54
|
+
|
|
55
|
+
for (const entry of this.#known.values()) {
|
|
56
|
+
let value = entry.default;
|
|
57
|
+
|
|
58
|
+
const fromGlobal = decodeAt(entry, global.data, this.globalPath, warnings);
|
|
59
|
+
if (fromGlobal.found) value = fromGlobal.value;
|
|
60
|
+
|
|
61
|
+
if (project !== undefined && projectPath !== undefined) {
|
|
62
|
+
if (entry.project) {
|
|
63
|
+
const fromProject = decodeAt(entry, project.data, projectPath, warnings);
|
|
64
|
+
if (fromProject.found) value = fromProject.value;
|
|
65
|
+
} else if (lookup(project.data, entry.id) !== undefined) {
|
|
66
|
+
warnings.push(
|
|
67
|
+
`${projectPath}: ${entry.id}: only the global settings file can set this; ignored`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.#update(entry, value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return warnings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Writes one value to the global file, keeping the rest. Returns an error message on failure. */
|
|
79
|
+
set<T>(entry: Setting<T>, value: T): string | undefined {
|
|
80
|
+
return this.setAll([[entry, value]]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Writes several values in one pass, keeping the rest of the file. A value that already reads the
|
|
85
|
+
* same is not written, so the file ends up holding what was decided and not a copy of every
|
|
86
|
+
* default, which would freeze them.
|
|
87
|
+
*/
|
|
88
|
+
setAll(entries: readonly (readonly [Setting<unknown>, unknown])[]): string | undefined {
|
|
89
|
+
const changed = entries.filter(([entry, value]) => !same(this.get(entry), value));
|
|
90
|
+
if (changed.length === 0) return undefined;
|
|
91
|
+
|
|
92
|
+
const { data } = readSettingsFile(this.globalPath);
|
|
93
|
+
for (const [entry, value] of changed) assign(data, entry.id, value);
|
|
94
|
+
try {
|
|
95
|
+
writeSettingsFile(this.globalPath, data);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return error instanceof Error ? error.message : String(error);
|
|
98
|
+
}
|
|
99
|
+
this.load(this.#projectPath);
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#update(entry: Setting<unknown>, value: unknown): void {
|
|
104
|
+
const previous = this.#values.has(entry.id) ? this.#values.get(entry.id) : entry.default;
|
|
105
|
+
this.#values.set(entry.id, value);
|
|
106
|
+
if (same(previous, value)) return;
|
|
107
|
+
for (const listener of this.#listeners.get(entry.id) ?? []) listener(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function decodeAt<T>(
|
|
112
|
+
entry: Setting<T>,
|
|
113
|
+
data: SettingsData,
|
|
114
|
+
path: string,
|
|
115
|
+
warnings: string[],
|
|
116
|
+
): Found<T> {
|
|
117
|
+
const input = lookup(data, entry.id);
|
|
118
|
+
if (input === undefined) return { found: false };
|
|
119
|
+
|
|
120
|
+
const result = entry.decoder.decode(input, entry.id);
|
|
121
|
+
const suffix = result.ok ? "" : "; ignored";
|
|
122
|
+
for (const line of formatProblems(result.problems)) warnings.push(`${path}: ${line}${suffix}`);
|
|
123
|
+
return result.ok ? { found: true, value: result.value } : { found: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Decoders build new arrays and objects on every read, so two values are compared by content. */
|
|
127
|
+
function same(left: unknown, right: unknown): boolean {
|
|
128
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
129
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Fakes for testing features without a running pi. Two fakes on one bus behave like two extensions
|
|
2
|
+
// in the same pi process.
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createEventBus,
|
|
8
|
+
type EntryRenderer,
|
|
9
|
+
type EventBus,
|
|
10
|
+
type ExtensionAPI,
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
type RegisteredCommand,
|
|
13
|
+
type ToolDefinition,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
import type { FeatureScope } from "./app/feature.ts";
|
|
17
|
+
import { SettingsStore } from "./settings/store.ts";
|
|
18
|
+
|
|
19
|
+
export type FakeHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
20
|
+
type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
|
|
21
|
+
|
|
22
|
+
export interface FakePi {
|
|
23
|
+
pi: ExtensionAPI;
|
|
24
|
+
handlers: Map<string, FakeHandler[]>;
|
|
25
|
+
commands: Map<string, CommandOptions>;
|
|
26
|
+
shortcuts: string[];
|
|
27
|
+
tools: ToolDefinition[];
|
|
28
|
+
providers: { name: string; config: unknown }[];
|
|
29
|
+
renderers: Map<string, EntryRenderer>;
|
|
30
|
+
entries: { customType: string; data: unknown }[];
|
|
31
|
+
messages: unknown[];
|
|
32
|
+
/** Runs every handler registered for `name`, in order, and returns what each returned. */
|
|
33
|
+
fire(name: string, event: unknown, ctx: ExtensionContext): Promise<unknown[]>;
|
|
34
|
+
count(name: string): number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function fakePi(bus: EventBus = createEventBus()): FakePi {
|
|
38
|
+
const handlers = new Map<string, FakeHandler[]>();
|
|
39
|
+
const commands = new Map<string, CommandOptions>();
|
|
40
|
+
const fake: FakePi = {
|
|
41
|
+
pi: undefined as unknown as ExtensionAPI,
|
|
42
|
+
handlers,
|
|
43
|
+
commands,
|
|
44
|
+
shortcuts: [],
|
|
45
|
+
tools: [],
|
|
46
|
+
providers: [],
|
|
47
|
+
renderers: new Map(),
|
|
48
|
+
entries: [],
|
|
49
|
+
messages: [],
|
|
50
|
+
async fire(name, event, ctx) {
|
|
51
|
+
const results: unknown[] = [];
|
|
52
|
+
for (const handler of handlers.get(name) ?? []) {
|
|
53
|
+
// oxlint-disable-next-line no-await-in-loop -- pi runs handlers one at a time, in order
|
|
54
|
+
results.push(await handler(event, ctx));
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
},
|
|
58
|
+
count: (name) => handlers.get(name)?.length ?? 0,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
fake.pi = {
|
|
62
|
+
on(name: string, handler: FakeHandler) {
|
|
63
|
+
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
|
64
|
+
return () => {};
|
|
65
|
+
},
|
|
66
|
+
events: bus,
|
|
67
|
+
registerCommand: (name: string, options: CommandOptions) => void commands.set(name, options),
|
|
68
|
+
registerShortcut: (shortcut: unknown) => void fake.shortcuts.push(String(shortcut)),
|
|
69
|
+
registerTool: (tool: ToolDefinition) => void fake.tools.push(tool),
|
|
70
|
+
registerProvider: (name: unknown, config?: unknown) =>
|
|
71
|
+
void fake.providers.push({ name: String(name), config }),
|
|
72
|
+
registerEntryRenderer: (customType: string, renderer: EntryRenderer) =>
|
|
73
|
+
void fake.renderers.set(customType, renderer),
|
|
74
|
+
appendEntry: (customType: string, data?: unknown) =>
|
|
75
|
+
void fake.entries.push({ customType, data }),
|
|
76
|
+
sendMessage: (message: unknown) => void fake.messages.push(message),
|
|
77
|
+
} as unknown as ExtensionAPI;
|
|
78
|
+
|
|
79
|
+
return fake;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FakeScopeOptions {
|
|
83
|
+
id?: string;
|
|
84
|
+
pi?: FakePi;
|
|
85
|
+
/** Collects what the feature warned about. */
|
|
86
|
+
notes?: string[];
|
|
87
|
+
settingsPath?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A feature scope over a fake pi, for calling a feature's own modules directly. Session hooks
|
|
92
|
+
* registered through it run when `fire("session_start")` or `fire("session_shutdown")` is called.
|
|
93
|
+
* It does not add the app's error attribution, which the kit tests on its own.
|
|
94
|
+
*/
|
|
95
|
+
export function fakeScope(options: FakeScopeOptions = {}): FeatureScope {
|
|
96
|
+
const fake = options.pi ?? fakePi();
|
|
97
|
+
const notes = options.notes ?? [];
|
|
98
|
+
const settings = new SettingsStore(
|
|
99
|
+
options.settingsPath ?? join(tmpdir(), `pi-kit-scope-${process.pid}-${Math.random()}.json`),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
...fake.pi,
|
|
104
|
+
id: options.id ?? "test",
|
|
105
|
+
settings,
|
|
106
|
+
on: fake.pi.on,
|
|
107
|
+
registerCommand: (name, command) => fake.pi.registerCommand(name, command),
|
|
108
|
+
onSessionStart: (run) => void fake.pi.on("session_start", (_event, ctx) => run(ctx)),
|
|
109
|
+
onShutdown: (run) => void fake.pi.on("session_shutdown", () => run()),
|
|
110
|
+
warn: (message) => void notes.push(message),
|
|
111
|
+
has: () => false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function fakeContext(
|
|
116
|
+
notes: string[] = [],
|
|
117
|
+
hasUI = true,
|
|
118
|
+
extra: Record<string, unknown> = {},
|
|
119
|
+
): ExtensionContext {
|
|
120
|
+
return {
|
|
121
|
+
hasUI,
|
|
122
|
+
mode: hasUI ? "tui" : "print",
|
|
123
|
+
cwd: "/nonexistent",
|
|
124
|
+
isProjectTrusted: () => false,
|
|
125
|
+
ui: { notify: (message: string) => notes.push(message) },
|
|
126
|
+
...extra,
|
|
127
|
+
} as unknown as ExtensionContext;
|
|
128
|
+
}
|