pi-extension-utils 0.3.0 → 0.3.2
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/README.md +90 -27
- package/dist/index.js +9 -140
- package/dist/src/client/index.d.ts +7 -0
- package/dist/src/client/index.js +25 -0
- package/dist/src/client/types.d.ts +17 -0
- package/dist/src/client/types.js +1 -0
- package/dist/src/config/index.d.ts +28 -0
- package/dist/src/config/index.js +222 -0
- package/dist/src/index.d.ts +10 -6
- package/dist/src/index.js +10 -6
- package/dist/src/logger/config.d.ts +11 -0
- package/dist/src/logger/config.js +27 -0
- package/dist/src/logger/index.d.ts +20 -0
- package/dist/src/logger/index.js +76 -0
- package/dist/src/{tui-chrome.js → pane/chrome.js} +22 -13
- package/dist/src/pane/overlay.d.ts +75 -0
- package/dist/src/pane/overlay.js +492 -0
- package/dist/src/reminders/client.d.ts +10 -0
- package/dist/src/reminders/client.js +49 -0
- package/dist/src/reminders/config.d.ts +5 -6
- package/dist/src/reminders/config.js +9 -23
- package/dist/src/reminders/debug.js +2 -2
- package/dist/src/reminders/host.d.ts +5 -0
- package/dist/src/reminders/host.js +298 -0
- package/dist/src/reminders/index.d.ts +3 -5
- package/dist/src/reminders/index.js +3 -308
- package/dist/src/reminders/types.d.ts +3 -0
- package/dist/src/reminders/types.js +3 -0
- package/dist/src/ui/client.d.ts +22 -0
- package/dist/src/ui/client.js +17 -0
- package/dist/src/utils-config.d.ts +24 -0
- package/dist/src/utils-config.js +38 -0
- package/dist/src/widgets/client.d.ts +27 -0
- package/dist/src/widgets/client.js +130 -0
- package/dist/src/widgets/host.d.ts +2 -0
- package/dist/src/widgets/host.js +143 -0
- package/docs/README.md +11 -0
- package/docs/client.md +91 -0
- package/docs/config.md +86 -0
- package/docs/pane-overlay.md +88 -0
- package/docs/reference/reminders-spec.md +351 -0
- package/docs/reminders.md +68 -0
- package/docs/widgets.md +57 -0
- package/examples/README.md +23 -0
- package/examples/config.ts +31 -0
- package/examples/logger.ts +20 -0
- package/examples/pane-overlay.ts +47 -0
- package/examples/reminders.ts +25 -0
- package/examples/widget-coordinator.ts +18 -0
- package/package.json +12 -1
- package/dist/src/client.d.ts +0 -47
- package/dist/src/client.js +0 -194
- package/dist/src/logger.d.ts +0 -12
- package/dist/src/logger.js +0 -45
- /package/dist/src/{tui-chrome.d.ts → pane/chrome.d.ts} +0 -0
- /package/dist/src/{key-dispatch.d.ts → pane/key-dispatch.d.ts} +0 -0
- /package/dist/src/{key-dispatch.js → pane/key-dispatch.js} +0 -0
- /package/dist/src/{pane-state.d.ts → pane/state.d.ts} +0 -0
- /package/dist/src/{pane-state.js → pane/state.js} +0 -0
- /package/dist/src/{protocol.d.ts → widgets/protocol.d.ts} +0 -0
- /package/dist/src/{protocol.js → widgets/protocol.js} +0 -0
package/README.md
CHANGED
|
@@ -1,46 +1,109 @@
|
|
|
1
1
|
# pi-extension-utils
|
|
2
2
|
|
|
3
|
-
Shared utilities for Pi extensions
|
|
3
|
+
Shared utilities for Pi extensions that need coordinated UI, fullscreen overlays, reminders, and logging.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- `src/index.ts` is the client library surface that other extensions can vendor and import.
|
|
5
|
+
Two faces:
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
- **Host extension** — `package.json` loads `./dist/index.js` once per Pi session.
|
|
8
|
+
- **Client library** — other extensions import from `pi-extension-utils`.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## Quick Start
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
```bash
|
|
13
|
+
npm install pi-extension-utils
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Add the package to Pi settings so the host extension loads:
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"packages": ["npm:pi-extension-utils"]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
Use the client from another extension:
|
|
17
25
|
|
|
18
26
|
```ts
|
|
19
|
-
import { connect
|
|
27
|
+
import { connect } from "pi-extension-utils";
|
|
20
28
|
|
|
21
29
|
const client = connect(pi, { ctx, clientId: "my-extension" });
|
|
22
|
-
|
|
23
|
-
client.widgets.remove("belowEditor", "status");
|
|
30
|
+
```
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
// restored afterwards, even if the component throws.
|
|
27
|
-
const result = await client.ui.fullscreen<string | null>(
|
|
28
|
-
(tui, theme, keybindings, done) => new MyOverlayComponent(tui, theme, done),
|
|
29
|
-
);
|
|
32
|
+
## What you get
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
| Feature | Use when |
|
|
35
|
+
|---|---|
|
|
36
|
+
| Widget coordinator | Multiple extensions need ordered widgets above/below the editor |
|
|
37
|
+
| `ui.fullscreen()` | A command opens a custom full-screen UI and should hide widgets while open |
|
|
38
|
+
| `paneOverlay()` | You want a master/detail fullscreen dashboard with built-in keys and legend |
|
|
39
|
+
| Reminders | An extension needs durable model-visible guidance without mutating prompts |
|
|
40
|
+
| Logger | You need namespaced rotating logs |
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
// list() resolves { reminders: [], count: 0 } when no host is present.
|
|
37
|
-
client.reminders.upsert({ id: "my-reminder", text: "...", source: "my-extension" });
|
|
38
|
-
client.reminders.remove("my-reminder");
|
|
39
|
-
client.reminders.clearSource("my-extension");
|
|
40
|
-
const { reminders } = await client.reminders.list();
|
|
42
|
+
## Small Example
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
```ts
|
|
45
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
46
|
+
import { connect, createLogger, paneOverlay } from "pi-extension-utils";
|
|
43
47
|
|
|
44
48
|
const log = createLogger("my-extension");
|
|
45
|
-
|
|
49
|
+
|
|
50
|
+
export default function (pi: ExtensionAPI) {
|
|
51
|
+
pi.on("session_start", (_event, ctx) => {
|
|
52
|
+
const client = connect(pi, { ctx, clientId: "my-extension" });
|
|
53
|
+
|
|
54
|
+
client.widgets.set("belowEditor", "status", () => ({
|
|
55
|
+
render: () => ["my-extension: ready"],
|
|
56
|
+
invalidate: () => {},
|
|
57
|
+
}), { order: 10 });
|
|
58
|
+
|
|
59
|
+
client.reminders.upsert({
|
|
60
|
+
source: "my-extension",
|
|
61
|
+
id: "state",
|
|
62
|
+
text: "Remember the current extension state.",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
pi.registerCommand("my-dashboard", {
|
|
67
|
+
description: "Open my extension dashboard",
|
|
68
|
+
handler: async (_args, ctx) => {
|
|
69
|
+
const client = connect(pi, { ctx, clientId: "my-extension" });
|
|
70
|
+
await client.ui.fullscreen(paneOverlay({
|
|
71
|
+
primary: { title: "Items", mode: "cursor", rows: ["alpha", "beta"] },
|
|
72
|
+
detail: { title: "Details", rows: (o) => [`selected: ${o.selectedRow}`] },
|
|
73
|
+
}));
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
log.info("registered");
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Docs
|
|
82
|
+
|
|
83
|
+
| Topic | Link |
|
|
84
|
+
|---|---|
|
|
85
|
+
| Client APIs | [docs/client.md](docs/client.md) |
|
|
86
|
+
| Config files | [docs/config.md](docs/config.md) |
|
|
87
|
+
| Widget coordinator | [docs/widgets.md](docs/widgets.md) |
|
|
88
|
+
| Pane overlays | [docs/pane-overlay.md](docs/pane-overlay.md) |
|
|
89
|
+
| Reminders | [docs/reminders.md](docs/reminders.md) |
|
|
90
|
+
|
|
91
|
+
## Examples
|
|
92
|
+
|
|
93
|
+
See [examples/](examples/).
|
|
94
|
+
|
|
95
|
+
| Example | Shows |
|
|
96
|
+
|---|---|
|
|
97
|
+
| [`widget-coordinator.ts`](examples/widget-coordinator.ts) | Ordered widgets through the coordinator |
|
|
98
|
+
| [`config.ts`](examples/config.ts) | Extension-owned JSON/JSONC config |
|
|
99
|
+
| [`pane-overlay.ts`](examples/pane-overlay.ts) | Fullscreen master/detail dashboard |
|
|
100
|
+
| [`reminders.ts`](examples/reminders.ts) | Upsert/list/clear reminders |
|
|
101
|
+
| [`logger.ts`](examples/logger.ts) | Namespaced logger |
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm run typecheck
|
|
107
|
+
npm test
|
|
108
|
+
npm run build
|
|
46
109
|
```
|
package/dist/index.js
CHANGED
|
@@ -1,145 +1,14 @@
|
|
|
1
|
-
import { registerReminderHost } from "./src/reminders/
|
|
2
|
-
import {
|
|
3
|
-
const HOST_CLIENT_ID = "pi-extension-utils-host";
|
|
4
|
-
const HOST_WIDGET_PREFIX = "pi-extension-utils";
|
|
5
|
-
const PLACEMENTS = ["aboveEditor", "belowEditor"];
|
|
1
|
+
import { registerReminderHost } from "./src/reminders/host.js";
|
|
2
|
+
import { registerWidgetHost } from "./src/widgets/host.js";
|
|
6
3
|
export default function (pi) {
|
|
7
|
-
registerReminderHost(pi);
|
|
8
|
-
|
|
9
|
-
const fullscreenStack = [];
|
|
10
|
-
let seq = 0;
|
|
11
|
-
let currentCtx;
|
|
12
|
-
for (const placement of PLACEMENTS)
|
|
13
|
-
widgets.set(placement, []);
|
|
14
|
-
function setHostWidget(placement) {
|
|
15
|
-
if (!currentCtx)
|
|
16
|
-
return;
|
|
17
|
-
const records = sortedWidgets(placement);
|
|
18
|
-
const hidden = fullscreenStack.length > 0;
|
|
19
|
-
if (records.length === 0 && !hidden) {
|
|
20
|
-
currentCtx.ui.setWidget(hostKey(placement), undefined, { placement });
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
currentCtx.ui.setWidget(hostKey(placement), (tui, theme) => createHostComponent(tui, theme, records, hidden), { placement });
|
|
24
|
-
}
|
|
25
|
-
function rerenderAll() {
|
|
26
|
-
for (const placement of PLACEMENTS)
|
|
27
|
-
setHostWidget(placement);
|
|
28
|
-
}
|
|
29
|
-
pi.events.on(EVENTS.hello, (data) => {
|
|
30
|
-
const payload = readPayload(data, "hello");
|
|
31
|
-
if (!payload)
|
|
32
|
-
return;
|
|
33
|
-
pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
|
|
34
|
-
});
|
|
35
|
-
pi.events.on(EVENTS.registerWidget, (data) => {
|
|
36
|
-
const payload = readPayload(data, "register-widget");
|
|
37
|
-
if (!payload || !isPlacement(payload.placement) || typeof payload.key !== "string" || typeof payload.order !== "number" || typeof payload.factory !== "function") {
|
|
38
|
-
warnDrop("register-widget", data);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const records = widgets.get(payload.placement);
|
|
42
|
-
const existing = records.find((record) => record.clientId === payload.clientId && record.key === payload.key);
|
|
43
|
-
if (existing) {
|
|
44
|
-
existing.order = payload.order;
|
|
45
|
-
existing.factory = payload.factory;
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
records.push({ clientId: payload.clientId, key: payload.key, order: payload.order, factory: payload.factory, seq: seq++ });
|
|
49
|
-
}
|
|
50
|
-
setHostWidget(payload.placement);
|
|
51
|
-
});
|
|
52
|
-
pi.events.on(EVENTS.unregisterWidget, (data) => {
|
|
53
|
-
const payload = readPayload(data, "unregister-widget");
|
|
54
|
-
if (!payload)
|
|
55
|
-
return;
|
|
56
|
-
if (payload.all) {
|
|
57
|
-
removeClient(payload.clientId);
|
|
58
|
-
rerenderAll();
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
if (!isPlacement(payload.placement) || typeof payload.key !== "string") {
|
|
62
|
-
warnDrop("unregister-widget", data);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const records = widgets.get(payload.placement);
|
|
66
|
-
widgets.set(payload.placement, records.filter((record) => record.clientId !== payload.clientId || record.key !== payload.key));
|
|
67
|
-
setHostWidget(payload.placement);
|
|
68
|
-
});
|
|
69
|
-
pi.events.on(EVENTS.fullscreenAcquire, (data) => {
|
|
70
|
-
const payload = readPayload(data, "fullscreen-acquire");
|
|
71
|
-
if (!payload || typeof payload.token !== "string") {
|
|
72
|
-
warnDrop("fullscreen-acquire", data);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (!fullscreenStack.some((lease) => lease.token === payload.token)) {
|
|
76
|
-
fullscreenStack.push({ clientId: payload.clientId, token: payload.token });
|
|
77
|
-
rerenderAll();
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
pi.events.on(EVENTS.fullscreenRelease, (data) => {
|
|
81
|
-
const payload = readPayload(data, "fullscreen-release");
|
|
82
|
-
if (!payload || typeof payload.token !== "string") {
|
|
83
|
-
warnDrop("fullscreen-release", data);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const before = fullscreenStack.length;
|
|
87
|
-
for (let index = fullscreenStack.length - 1; index >= 0; index--) {
|
|
88
|
-
if (fullscreenStack[index].token === payload.token)
|
|
89
|
-
fullscreenStack.splice(index, 1);
|
|
90
|
-
}
|
|
91
|
-
if (fullscreenStack.length !== before)
|
|
92
|
-
rerenderAll();
|
|
93
|
-
});
|
|
94
|
-
pi.on("session_start", (_event, ctx) => {
|
|
95
|
-
currentCtx = ctx;
|
|
96
|
-
rerenderAll();
|
|
97
|
-
pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
|
|
98
|
-
});
|
|
99
|
-
pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
|
|
100
|
-
function removeClient(clientId) {
|
|
101
|
-
for (const placement of PLACEMENTS) {
|
|
102
|
-
widgets.set(placement, widgets.get(placement).filter((record) => record.clientId !== clientId));
|
|
103
|
-
}
|
|
104
|
-
for (let index = fullscreenStack.length - 1; index >= 0; index--) {
|
|
105
|
-
if (fullscreenStack[index].clientId === clientId)
|
|
106
|
-
fullscreenStack.splice(index, 1);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function sortedWidgets(placement) {
|
|
110
|
-
return [...widgets.get(placement)].sort((a, b) => a.order - b.order || a.seq - b.seq);
|
|
111
|
-
}
|
|
4
|
+
registerHost("reminders", () => registerReminderHost(pi));
|
|
5
|
+
registerHost("widgets", () => registerWidgetHost(pi));
|
|
112
6
|
}
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
render(width) {
|
|
117
|
-
return components.flatMap((component) => component.render(width));
|
|
118
|
-
},
|
|
119
|
-
invalidate() {
|
|
120
|
-
for (const component of components)
|
|
121
|
-
component.invalidate();
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
function hostKey(placement) {
|
|
126
|
-
return `${HOST_WIDGET_PREFIX}-${placement}`;
|
|
127
|
-
}
|
|
128
|
-
function readPayload(data, eventName) {
|
|
129
|
-
if (!data || typeof data !== "object") {
|
|
130
|
-
warnDrop(eventName, data);
|
|
131
|
-
return undefined;
|
|
7
|
+
function registerHost(name, register) {
|
|
8
|
+
try {
|
|
9
|
+
register();
|
|
132
10
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
warnDrop(eventName, data);
|
|
136
|
-
return undefined;
|
|
11
|
+
catch (error) {
|
|
12
|
+
console.warn(`pi-extension-utils: failed to register ${name} host`, error);
|
|
137
13
|
}
|
|
138
|
-
return payload;
|
|
139
|
-
}
|
|
140
|
-
function isPlacement(value) {
|
|
141
|
-
return value === "aboveEditor" || value === "belowEditor";
|
|
142
|
-
}
|
|
143
|
-
function warnDrop(eventName, data) {
|
|
144
|
-
console.warn(`pi-extension-utils: dropping invalid ${eventName} payload`, data);
|
|
145
14
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { UtilsClient, UtilsClientOptions } from "./types.ts";
|
|
3
|
+
export type { UtilsClient, UtilsClientOptions } from "./types.ts";
|
|
4
|
+
export type { RemindersClient } from "../reminders/client.ts";
|
|
5
|
+
export type { FullscreenComponentFactory, UiClient } from "../ui/client.ts";
|
|
6
|
+
export type { FullscreenClient, FullscreenLease, WidgetsClient, WidgetSetOptions } from "../widgets/client.ts";
|
|
7
|
+
export declare function connect(pi: ExtensionAPI, opts: UtilsClientOptions): UtilsClient;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRemindersClient } from "../reminders/client.js";
|
|
2
|
+
import { createUiClient } from "../ui/client.js";
|
|
3
|
+
import { connectWidgetCoordinator } from "../widgets/client.js";
|
|
4
|
+
let nextClientId = 1;
|
|
5
|
+
export function connect(pi, opts) {
|
|
6
|
+
const clientId = opts.clientId ?? `client-${process.pid}-${Date.now()}-${nextClientId++}`;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const coordinator = connectWidgetCoordinator(pi, { ctx: opts.ctx, clientId });
|
|
9
|
+
return {
|
|
10
|
+
clientId,
|
|
11
|
+
get mode() {
|
|
12
|
+
return coordinator.mode;
|
|
13
|
+
},
|
|
14
|
+
widgets: coordinator.widgets,
|
|
15
|
+
fullscreen: coordinator.fullscreen,
|
|
16
|
+
ui: createUiClient(coordinator.fullscreen, opts.ctx),
|
|
17
|
+
reminders: createRemindersClient(pi, () => disposed),
|
|
18
|
+
dispose() {
|
|
19
|
+
if (disposed)
|
|
20
|
+
return;
|
|
21
|
+
disposed = true;
|
|
22
|
+
coordinator.dispose();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { RemindersClient } from "../reminders/client.ts";
|
|
3
|
+
import type { UiClient } from "../ui/client.ts";
|
|
4
|
+
import type { FullscreenClient, WidgetsClient } from "../widgets/client.ts";
|
|
5
|
+
export interface UtilsClientOptions {
|
|
6
|
+
ctx: ExtensionContext;
|
|
7
|
+
clientId?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface UtilsClient {
|
|
10
|
+
readonly clientId: string;
|
|
11
|
+
readonly mode: "fallback" | "coordinated";
|
|
12
|
+
widgets: WidgetsClient;
|
|
13
|
+
fullscreen: FullscreenClient;
|
|
14
|
+
ui: UiClient;
|
|
15
|
+
reminders: RemindersClient;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox";
|
|
2
|
+
export type ConfigFormat = "json" | "jsonc";
|
|
3
|
+
export type DeepPartial<T> = T extends readonly unknown[] ? T : T extends object ? {
|
|
4
|
+
[K in keyof T]?: DeepPartial<T[K]>;
|
|
5
|
+
} : T;
|
|
6
|
+
export interface DefineConfigOptions<Schema extends TSchema> {
|
|
7
|
+
/** File basename, without .json/.jsonc. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Directory containing <name>.json or <name>.jsonc. Defaults to getAgentDir()/config. */
|
|
10
|
+
dir?: string;
|
|
11
|
+
/** TypeBox schema. Field defaults and descriptions are read from this schema. */
|
|
12
|
+
schema: Schema;
|
|
13
|
+
}
|
|
14
|
+
export interface DefinedConfig<Schema extends TSchema> {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly schema: Schema;
|
|
17
|
+
/** Return the resolved config path. If missing, returns the default .jsonc path. */
|
|
18
|
+
path(): string;
|
|
19
|
+
/** Ensure the config file exists, creating .jsonc with schema defaults/comments when missing. */
|
|
20
|
+
ensure(): string;
|
|
21
|
+
/** Ensure, load, apply schema defaults, validate, cache, and return config. */
|
|
22
|
+
get(): Static<Schema>;
|
|
23
|
+
/** Force a disk reread, replacing the cached value. */
|
|
24
|
+
reload(): Static<Schema>;
|
|
25
|
+
/** Patch config or mutate a draft; writes to disk and updates cache. */
|
|
26
|
+
update(patch: DeepPartial<Static<Schema>> | ((draft: Static<Schema>) => void)): Static<Schema>;
|
|
27
|
+
}
|
|
28
|
+
export declare function defineConfig<Schema extends TSchema>(options: DefineConfigOptions<Schema>): DefinedConfig<Schema>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { applyEdits, modify, parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
const FORMAT_OPTIONS = { insertSpaces: false, tabSize: 1, eol: "\n", insertFinalNewline: true };
|
|
7
|
+
export function defineConfig(options) {
|
|
8
|
+
validateName(options.name);
|
|
9
|
+
let cachedPath;
|
|
10
|
+
let cachedValue;
|
|
11
|
+
const config = {
|
|
12
|
+
name: options.name,
|
|
13
|
+
schema: options.schema,
|
|
14
|
+
path() {
|
|
15
|
+
return resolvePath().path;
|
|
16
|
+
},
|
|
17
|
+
ensure() {
|
|
18
|
+
const resolved = resolvePath();
|
|
19
|
+
if (!resolved.exists) {
|
|
20
|
+
mkdirSync(configDir(), { recursive: true });
|
|
21
|
+
writeFileSync(resolved.path, createInitialContent(options.schema, resolved.format));
|
|
22
|
+
cachedPath = { ...resolved, exists: true };
|
|
23
|
+
}
|
|
24
|
+
return resolved.path;
|
|
25
|
+
},
|
|
26
|
+
get() {
|
|
27
|
+
if (cachedValue !== undefined)
|
|
28
|
+
return cloneJson(cachedValue);
|
|
29
|
+
return load(true);
|
|
30
|
+
},
|
|
31
|
+
reload() {
|
|
32
|
+
cachedValue = undefined;
|
|
33
|
+
cachedPath = undefined;
|
|
34
|
+
return load(true);
|
|
35
|
+
},
|
|
36
|
+
update(patch) {
|
|
37
|
+
const filePath = config.ensure();
|
|
38
|
+
const resolved = resolvePath();
|
|
39
|
+
const before = load(false);
|
|
40
|
+
const draft = cloneJson(before);
|
|
41
|
+
if (typeof patch === "function") {
|
|
42
|
+
patch(draft);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
mergeDeep(draft, patch);
|
|
46
|
+
}
|
|
47
|
+
const after = applyDefaultsAndValidate(options.schema, draft, resolved.path);
|
|
48
|
+
writeUpdatedConfig(filePath, resolved.format, before, after);
|
|
49
|
+
cachedValue = cloneJson(after);
|
|
50
|
+
return cloneJson(after);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
function configDir() {
|
|
54
|
+
return options.dir ?? join(getAgentDir(), "config");
|
|
55
|
+
}
|
|
56
|
+
function resolvePath() {
|
|
57
|
+
if (cachedPath?.exists)
|
|
58
|
+
return cachedPath;
|
|
59
|
+
const dir = configDir();
|
|
60
|
+
const jsoncPath = join(dir, `${options.name}.jsonc`);
|
|
61
|
+
const jsonPath = join(dir, `${options.name}.json`);
|
|
62
|
+
const hasJsonc = existsSync(jsoncPath);
|
|
63
|
+
const hasJson = existsSync(jsonPath);
|
|
64
|
+
if (hasJsonc && hasJson) {
|
|
65
|
+
throw new Error(`Config '${options.name}' is ambiguous: both ${jsoncPath} and ${jsonPath} exist`);
|
|
66
|
+
}
|
|
67
|
+
cachedPath = hasJsonc
|
|
68
|
+
? { path: jsoncPath, format: "jsonc", exists: true }
|
|
69
|
+
: hasJson
|
|
70
|
+
? { path: jsonPath, format: "json", exists: true }
|
|
71
|
+
: { path: jsoncPath, format: "jsonc", exists: false };
|
|
72
|
+
return cachedPath;
|
|
73
|
+
}
|
|
74
|
+
function load(ensure) {
|
|
75
|
+
if (ensure)
|
|
76
|
+
config.ensure();
|
|
77
|
+
const resolved = resolvePath();
|
|
78
|
+
let parsed = {};
|
|
79
|
+
if (resolved.exists || existsSync(resolved.path)) {
|
|
80
|
+
parsed = readConfigFile(resolved.path, resolved.format);
|
|
81
|
+
}
|
|
82
|
+
const value = applyDefaultsAndValidate(options.schema, parsed, resolved.path);
|
|
83
|
+
cachedValue = cloneJson(value);
|
|
84
|
+
return cloneJson(value);
|
|
85
|
+
}
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
function validateName(name) {
|
|
89
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
90
|
+
throw new Error(`Config name must be a filename base using letters, numbers, '.', '_' or '-': ${name}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function readConfigFile(path, format) {
|
|
94
|
+
const text = readFileSync(path, "utf8");
|
|
95
|
+
if (!text.trim())
|
|
96
|
+
return {};
|
|
97
|
+
if (format === "json")
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
const errors = [];
|
|
100
|
+
const parsed = parseJsonc(text, errors, { allowTrailingComma: true });
|
|
101
|
+
if (errors.length > 0) {
|
|
102
|
+
const first = errors[0];
|
|
103
|
+
throw new Error(`Invalid JSONC in ${path} at offset ${first.offset}: ${printParseErrorCode(first.error)}`);
|
|
104
|
+
}
|
|
105
|
+
return parsed ?? {};
|
|
106
|
+
}
|
|
107
|
+
function applyDefaultsAndValidate(schema, value, path) {
|
|
108
|
+
const defaults = defaultValue(schema);
|
|
109
|
+
const merged = mergeDefaults(defaults.hasDefault ? defaults.value : {}, isPlainObject(value) ? value : value ?? {});
|
|
110
|
+
Value.Default(schema, merged);
|
|
111
|
+
if (!Value.Check(schema, merged)) {
|
|
112
|
+
const details = [...Value.Errors(schema, merged)]
|
|
113
|
+
.slice(0, 5)
|
|
114
|
+
.map((error) => `${"path" in error && typeof error.path === "string" ? error.path : "/"} ${error.message}`)
|
|
115
|
+
.join("; ");
|
|
116
|
+
throw new Error(`Invalid config ${path}: ${details}`);
|
|
117
|
+
}
|
|
118
|
+
return merged;
|
|
119
|
+
}
|
|
120
|
+
function writeUpdatedConfig(path, format, before, after) {
|
|
121
|
+
if (format === "json") {
|
|
122
|
+
writeFileSync(path, `${JSON.stringify(after, null, "\t")}\n`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
let text = readFileSync(path, "utf8");
|
|
126
|
+
for (const diff of diffLeaves(before, after)) {
|
|
127
|
+
const edits = modify(text, diff.path, diff.value, { formattingOptions: FORMAT_OPTIONS });
|
|
128
|
+
text = applyEdits(text, edits);
|
|
129
|
+
}
|
|
130
|
+
writeFileSync(path, text.endsWith("\n") ? text : `${text}\n`);
|
|
131
|
+
}
|
|
132
|
+
function createInitialContent(schema, format) {
|
|
133
|
+
const typedSchema = schema;
|
|
134
|
+
const defaults = defaultValue(typedSchema);
|
|
135
|
+
const value = defaults.hasDefault ? defaults.value : typedSchema.type === "object" ? {} : null;
|
|
136
|
+
if (format === "json")
|
|
137
|
+
return `${JSON.stringify(value, null, "\t")}\n`;
|
|
138
|
+
return `${stringifyJsoncWithComments(value, typedSchema, 0)}\n`;
|
|
139
|
+
}
|
|
140
|
+
function stringifyJsoncWithComments(value, schema, level) {
|
|
141
|
+
if (isPlainObject(value)) {
|
|
142
|
+
const properties = isPlainObject(schema.properties) ? schema.properties : {};
|
|
143
|
+
const keys = orderedKeys(value, properties);
|
|
144
|
+
if (keys.length === 0)
|
|
145
|
+
return "{}";
|
|
146
|
+
const lines = ["{"];
|
|
147
|
+
keys.forEach((key, index) => {
|
|
148
|
+
const childSchema = properties[key] ?? {};
|
|
149
|
+
const indent = "\t".repeat(level + 1);
|
|
150
|
+
const description = typeof childSchema.description === "string" ? childSchema.description.trim() : "";
|
|
151
|
+
if (description) {
|
|
152
|
+
for (const line of description.split(/\r?\n/)) {
|
|
153
|
+
lines.push(`${indent}// ${line.trim()}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const rendered = stringifyJsoncWithComments(value[key], childSchema, level + 1);
|
|
157
|
+
lines.push(`${indent}${JSON.stringify(key)}: ${rendered}${index === keys.length - 1 ? "" : ","}`);
|
|
158
|
+
});
|
|
159
|
+
lines.push(`${"\t".repeat(level)}}`);
|
|
160
|
+
return lines.join("\n");
|
|
161
|
+
}
|
|
162
|
+
return JSON.stringify(value, null, "\t");
|
|
163
|
+
}
|
|
164
|
+
function orderedKeys(value, properties) {
|
|
165
|
+
const object = value;
|
|
166
|
+
const keys = new Set([...Object.keys(properties), ...Object.keys(object)]);
|
|
167
|
+
return [...keys].filter((key) => Object.prototype.hasOwnProperty.call(object, key));
|
|
168
|
+
}
|
|
169
|
+
function defaultValue(schema) {
|
|
170
|
+
if (Object.prototype.hasOwnProperty.call(schema, "default")) {
|
|
171
|
+
return { hasDefault: true, value: cloneJson(schema.default) };
|
|
172
|
+
}
|
|
173
|
+
if (schema.type === "object" && isPlainObject(schema.properties)) {
|
|
174
|
+
const value = {};
|
|
175
|
+
for (const [key, child] of Object.entries(schema.properties)) {
|
|
176
|
+
const childDefault = defaultValue(child);
|
|
177
|
+
if (childDefault.hasDefault)
|
|
178
|
+
value[key] = childDefault.value;
|
|
179
|
+
}
|
|
180
|
+
return Object.keys(value).length > 0 ? { hasDefault: true, value } : { hasDefault: false, value: undefined };
|
|
181
|
+
}
|
|
182
|
+
return { hasDefault: false, value: undefined };
|
|
183
|
+
}
|
|
184
|
+
function mergeDefaults(defaults, value) {
|
|
185
|
+
if (isPlainObject(defaults) && isPlainObject(value)) {
|
|
186
|
+
const merged = { ...cloneJson(defaults) };
|
|
187
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
188
|
+
merged[key] = Object.prototype.hasOwnProperty.call(merged, key)
|
|
189
|
+
? mergeDefaults(merged[key], childValue)
|
|
190
|
+
: childValue;
|
|
191
|
+
}
|
|
192
|
+
return merged;
|
|
193
|
+
}
|
|
194
|
+
return value === undefined ? cloneJson(defaults) : value;
|
|
195
|
+
}
|
|
196
|
+
function mergeDeep(target, patch) {
|
|
197
|
+
if (!isPlainObject(target) || !isPlainObject(patch))
|
|
198
|
+
return;
|
|
199
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
200
|
+
if (isPlainObject(value) && isPlainObject(target[key])) {
|
|
201
|
+
mergeDeep(target[key], value);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
target[key] = cloneJson(value);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function diffLeaves(before, after, path = []) {
|
|
209
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
210
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
211
|
+
return [...keys].flatMap((key) => diffLeaves(before[key], after[key], [...path, key]));
|
|
212
|
+
}
|
|
213
|
+
if (JSON.stringify(before) === JSON.stringify(after))
|
|
214
|
+
return [];
|
|
215
|
+
return [{ path, value: after }];
|
|
216
|
+
}
|
|
217
|
+
function isPlainObject(value) {
|
|
218
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
219
|
+
}
|
|
220
|
+
function cloneJson(value) {
|
|
221
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
222
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export { connect, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client.ts";
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export * from "./
|
|
1
|
+
export { connect, type FullscreenComponentFactory, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client/index.ts";
|
|
2
|
+
export { defineConfig, type DeepPartial, type DefinedConfig, type DefineConfigOptions } from "./config/index.ts";
|
|
3
|
+
export { createLogger, LOG_LEVELS, loggerLevelSchema, loggingConfigSchema, type Logger, type LoggerLevel, type LoggerOptions, type LoggingConfig, type LogLevel } from "./logger/index.ts";
|
|
4
|
+
export { utilsConfigSchema, type UtilsConfig } from "./utils-config.ts";
|
|
5
|
+
export * from "./pane/key-dispatch.ts";
|
|
6
|
+
export * from "./pane/state.ts";
|
|
7
|
+
export * from "./widgets/protocol.ts";
|
|
8
|
+
export { remindersConfigSchema, type RemindersConfig } from "./reminders/config.ts";
|
|
6
9
|
export * from "./reminders/types.ts";
|
|
7
|
-
export * from "./
|
|
10
|
+
export * from "./pane/chrome.ts";
|
|
11
|
+
export * from "./pane/overlay.ts";
|
package/dist/src/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export { connect } from "./client.js";
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export * from "./
|
|
1
|
+
export { connect } from "./client/index.js";
|
|
2
|
+
export { defineConfig } from "./config/index.js";
|
|
3
|
+
export { createLogger, LOG_LEVELS, loggerLevelSchema, loggingConfigSchema } from "./logger/index.js";
|
|
4
|
+
export { utilsConfigSchema } from "./utils-config.js";
|
|
5
|
+
export * from "./pane/key-dispatch.js";
|
|
6
|
+
export * from "./pane/state.js";
|
|
7
|
+
export * from "./widgets/protocol.js";
|
|
8
|
+
export { remindersConfigSchema } from "./reminders/config.js";
|
|
6
9
|
export * from "./reminders/types.js";
|
|
7
|
-
export * from "./
|
|
10
|
+
export * from "./pane/chrome.js";
|
|
11
|
+
export * from "./pane/overlay.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
export declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error"];
|
|
3
|
+
export type LogLevel = typeof LOG_LEVELS[number];
|
|
4
|
+
export type LoggerLevel = LogLevel | "silent";
|
|
5
|
+
export declare const loggerLevelSchema: Type.TUnion<[Type.TLiteral<"debug">, Type.TLiteral<"info">, Type.TLiteral<"warn">, Type.TLiteral<"error">, Type.TLiteral<"silent">]>;
|
|
6
|
+
export declare const loggingConfigSchema: Type.TObject<{
|
|
7
|
+
level: Type.TUnion<[Type.TLiteral<"debug">, Type.TLiteral<"info">, Type.TLiteral<"warn">, Type.TLiteral<"error">, Type.TLiteral<"silent">]>;
|
|
8
|
+
maxFiles: Type.TNumber;
|
|
9
|
+
maxBytes: Type.TNumber;
|
|
10
|
+
}>;
|
|
11
|
+
export type LoggingConfig = Static<typeof loggingConfigSchema>;
|