@aerok/pi-toolkit 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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +277 -0
- package/extensions/bark/client.ts +79 -0
- package/extensions/bark/config.ts +246 -0
- package/extensions/bark/crypto.ts +31 -0
- package/extensions/bark/index.ts +317 -0
- package/extensions/bark/recap.ts +53 -0
- package/extensions/bark/redact.ts +21 -0
- package/extensions/bark/tui/model-selector.ts +102 -0
- package/extensions/bark/tui/settings.ts +213 -0
- package/extensions/bark/tui/setup.ts +375 -0
- package/extensions/bark/tui/theme.ts +49 -0
- package/extensions/bark/types.ts +68 -0
- package/extensions/image-placeholders/README.md +22 -0
- package/extensions/image-placeholders/index.ts +312 -0
- package/package.json +64 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isValidEncryptionValue,
|
|
7
|
+
loadGlobalConfig,
|
|
8
|
+
loadProjectConfig,
|
|
9
|
+
loadResolvedConfig,
|
|
10
|
+
saveGlobalConfig,
|
|
11
|
+
saveProjectConfig,
|
|
12
|
+
} from "../config.js";
|
|
13
|
+
import type { BarkEventKey, BarkGlobalConfig, BarkProjectConfig, ResolvedBarkConfig } from "../types.js";
|
|
14
|
+
import { boxInnerWidth, OverlayTheme } from "./theme.js";
|
|
15
|
+
|
|
16
|
+
type Scope = "global" | "project";
|
|
17
|
+
|
|
18
|
+
const EVENT_ROWS: Array<{ key: BarkEventKey; label: string; detail: string }> = [
|
|
19
|
+
{ key: "agent_settled", label: "Task settled", detail: "Pi is fully done after retries and continuations" },
|
|
20
|
+
{ key: "agent_end", label: "Agent response", detail: "Every low-level agent response (can be noisy)" },
|
|
21
|
+
{ key: "session_shutdown", label: "Session shutdown", detail: "Pi session is closing" },
|
|
22
|
+
{ key: "ask_user_prompt", label: "Question waiting", detail: "rpiv ask-user prompt is shown" },
|
|
23
|
+
{ key: "permission_request", label: "Permission waiting", detail: "permission-system prompt is shown" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export class BarkSettingsOverlay implements Component {
|
|
27
|
+
private scope: Scope;
|
|
28
|
+
private selectedIndex = 0;
|
|
29
|
+
private draft: ResolvedBarkConfig;
|
|
30
|
+
private saved = false;
|
|
31
|
+
private confirmPlaintext = false;
|
|
32
|
+
private notice: string | null = null;
|
|
33
|
+
private readonly cwd: string;
|
|
34
|
+
private readonly overlay = new OverlayTheme();
|
|
35
|
+
onClose?: () => void;
|
|
36
|
+
onOpenModelSelector?: (currentModel: string | undefined) => void;
|
|
37
|
+
requestRender?: () => void;
|
|
38
|
+
|
|
39
|
+
constructor(cwd: string) {
|
|
40
|
+
this.cwd = cwd;
|
|
41
|
+
this.scope = loadProjectConfig(cwd) ? "project" : "global";
|
|
42
|
+
this.draft = this.loadDraft();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setTheme(theme: Theme): void {
|
|
46
|
+
this.overlay.setTheme(theme);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
invalidate(): void {}
|
|
50
|
+
|
|
51
|
+
setRecapModel(model: string): void {
|
|
52
|
+
this.draft.recap.model = model;
|
|
53
|
+
this.saved = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
handleInput(data: string): void {
|
|
57
|
+
if (this.confirmPlaintext) {
|
|
58
|
+
if (data.toLowerCase() === "y") {
|
|
59
|
+
this.draft.encryption.mode = "plaintext";
|
|
60
|
+
this.confirmPlaintext = false;
|
|
61
|
+
this.notice = "Plaintext selected. Press Enter to save globally.";
|
|
62
|
+
this.saved = false;
|
|
63
|
+
} else if (data.toLowerCase() === "n" || matchesKey(data, "escape")) {
|
|
64
|
+
this.confirmPlaintext = false;
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
|
|
69
|
+
this.onClose?.();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
73
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
77
|
+
this.selectedIndex = Math.min(EVENT_ROWS.length + 2, this.selectedIndex + 1);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (data === "s" || data === "S") {
|
|
81
|
+
this.scope = this.scope === "global" ? "project" : "global";
|
|
82
|
+
this.draft = this.loadDraft();
|
|
83
|
+
this.saved = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if ((data === "m" || data === "M") && this.selectedIndex === EVENT_ROWS.length + 2) {
|
|
87
|
+
this.onOpenModelSelector?.(this.draft.recap.model);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (matchesKey(data, "space")) {
|
|
91
|
+
this.toggleSelected();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (matchesKey(data, "enter")) this.save();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private loadDraft(): ResolvedBarkConfig {
|
|
98
|
+
if (this.scope === "project") return loadResolvedConfig(this.cwd);
|
|
99
|
+
return { ...loadGlobalConfig(), projectOverride: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private toggleSelected(): void {
|
|
103
|
+
this.saved = false;
|
|
104
|
+
if (this.selectedIndex === 0) {
|
|
105
|
+
this.draft.enabled = !this.draft.enabled;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.selectedIndex === 1) {
|
|
109
|
+
if (this.scope === "project") {
|
|
110
|
+
this.notice = "Encryption mode is global-only; press S to edit global settings.";
|
|
111
|
+
} else if (this.draft.encryption.mode === "encrypted") {
|
|
112
|
+
this.confirmPlaintext = true;
|
|
113
|
+
} else if (isValidEncryptionValue(this.draft.encryption.key) && isValidEncryptionValue(this.draft.encryption.iv)) {
|
|
114
|
+
this.draft.encryption.mode = "encrypted";
|
|
115
|
+
this.notice = "Encrypted delivery selected. Press Enter to save.";
|
|
116
|
+
} else {
|
|
117
|
+
this.notice = "Run /toolkit:bark-setup to configure encryption Key and IV.";
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (this.selectedIndex <= EVENT_ROWS.length + 1) {
|
|
122
|
+
const event = EVENT_ROWS[this.selectedIndex - 2];
|
|
123
|
+
this.draft.events[event.key] = !this.draft.events[event.key];
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.draft.recap.enabled = !this.draft.recap.enabled;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private save(): void {
|
|
130
|
+
if (this.scope === "global") {
|
|
131
|
+
const config: BarkGlobalConfig = {
|
|
132
|
+
...loadGlobalConfig(),
|
|
133
|
+
enabled: this.draft.enabled,
|
|
134
|
+
encryption: { ...this.draft.encryption },
|
|
135
|
+
events: { ...this.draft.events },
|
|
136
|
+
recap: { ...this.draft.recap },
|
|
137
|
+
};
|
|
138
|
+
saveGlobalConfig(config);
|
|
139
|
+
} else {
|
|
140
|
+
const existing: BarkProjectConfig = loadProjectConfig(this.cwd) ?? { version: 1 };
|
|
141
|
+
saveProjectConfig(this.cwd, {
|
|
142
|
+
...existing,
|
|
143
|
+
enabled: this.draft.enabled,
|
|
144
|
+
events: { ...this.draft.events },
|
|
145
|
+
recap: { ...this.draft.recap },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
this.saved = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const inner = boxInnerWidth(width);
|
|
153
|
+
const lines = [
|
|
154
|
+
this.overlay.borderLine(inner, "top"),
|
|
155
|
+
this.overlay.frameLine(this.overlay.fg("accent", this.overlay.bold("🔔 Pi Toolkit — Notify Settings")), inner),
|
|
156
|
+
this.overlay.frameLine(this.overlay.fg("dim", `Scope: ${this.scope} · S switches scope`), inner),
|
|
157
|
+
this.overlay.ruleLine(inner),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
if (this.confirmPlaintext) {
|
|
161
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("error", "⚠ Disable end-to-end encryption globally?"), inner));
|
|
162
|
+
lines.push(this.overlay.frameLine("Bark Server and Apple APNs will be able to read notification content.", inner));
|
|
163
|
+
lines.push(this.overlay.ruleLine(inner));
|
|
164
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("dim", "Y confirm plaintext · N/Esc keep encryption"), inner));
|
|
165
|
+
lines.push(this.overlay.borderLine(inner, "bottom"));
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.row(lines, inner, 0, this.draft.enabled, "Bark enabled", this.draft.deviceKey ? "Device key configured" : "Run /toolkit:bark-setup first");
|
|
170
|
+
this.row(
|
|
171
|
+
lines,
|
|
172
|
+
inner,
|
|
173
|
+
1,
|
|
174
|
+
this.draft.encryption.mode === "encrypted",
|
|
175
|
+
"Encrypted delivery",
|
|
176
|
+
this.scope === "project"
|
|
177
|
+
? "Global-only setting"
|
|
178
|
+
: this.draft.encryption.mode === "plaintext"
|
|
179
|
+
? "Plaintext — third parties can read content"
|
|
180
|
+
: isValidEncryptionValue(this.draft.encryption.key) && isValidEncryptionValue(this.draft.encryption.iv)
|
|
181
|
+
? "AES-128-CBC · PKCS#7"
|
|
182
|
+
: "Not configured — run /toolkit:bark-setup",
|
|
183
|
+
);
|
|
184
|
+
EVENT_ROWS.forEach((event, index) => {
|
|
185
|
+
this.row(lines, inner, index + 2, this.draft.events[event.key], event.label, event.detail);
|
|
186
|
+
});
|
|
187
|
+
this.row(
|
|
188
|
+
lines,
|
|
189
|
+
inner,
|
|
190
|
+
EVENT_ROWS.length + 2,
|
|
191
|
+
this.draft.recap.enabled,
|
|
192
|
+
"LLM recap",
|
|
193
|
+
this.draft.recap.model ? `Model: ${this.draft.recap.model}` : "Current session model · M selects",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
lines.push(this.overlay.ruleLine(inner));
|
|
197
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("dim", `Defaults: group=${this.draft.group || "—"} · sound=${this.draft.sound || "default"} · level=${this.draft.level}`), inner));
|
|
198
|
+
if (this.notice) lines.push(this.overlay.frameLine(this.overlay.fg("warning", this.notice), inner));
|
|
199
|
+
if (this.saved) lines.push(this.overlay.frameLine(this.overlay.fg("success", "✓ Settings saved"), inner));
|
|
200
|
+
lines.push(this.overlay.ruleLine(inner));
|
|
201
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("dim", "↑↓ navigate · Space toggle · M recap model · S scope · Enter save · Esc close"), inner));
|
|
202
|
+
lines.push(this.overlay.borderLine(inner, "bottom"));
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private row(lines: string[], width: number, index: number, enabled: boolean, label: string, detail: string): void {
|
|
207
|
+
const selected = index === this.selectedIndex;
|
|
208
|
+
const marker = selected ? this.overlay.fg("accent", "▸") : " ";
|
|
209
|
+
const toggle = enabled ? this.overlay.fg("success", "●") : this.overlay.fg("dim", "○");
|
|
210
|
+
const renderedLabel = selected ? this.overlay.bold(label) : this.overlay.fg("dim", label);
|
|
211
|
+
lines.push(this.overlay.frameLine(`${marker} ${toggle} ${renderedLabel} ${this.overlay.fg("dim", detail)}`, width));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { copyToClipboard, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import type { BarkClient } from "../client.js";
|
|
6
|
+
import { createBarkClient } from "../client.js";
|
|
7
|
+
import { isValidEncryptionValue, loadGlobalConfig, normalizeBarkDeviceKey, saveGlobalConfig } from "../config.js";
|
|
8
|
+
import { generateEncryptionCredentials, type BarkEncryptionCredentials } from "../crypto.js";
|
|
9
|
+
import type { BarkEncryptionConfig, BarkLevel } from "../types.js";
|
|
10
|
+
import { boxInnerWidth, OverlayTheme } from "./theme.js";
|
|
11
|
+
|
|
12
|
+
type SetupMode = "setup" | "rotate";
|
|
13
|
+
type Phase =
|
|
14
|
+
| "instructions"
|
|
15
|
+
| "server"
|
|
16
|
+
| "key"
|
|
17
|
+
| "group"
|
|
18
|
+
| "sound"
|
|
19
|
+
| "level"
|
|
20
|
+
| "encryptionMode"
|
|
21
|
+
| "plaintextConfirm"
|
|
22
|
+
| "encryption"
|
|
23
|
+
| "testing"
|
|
24
|
+
| "decryptConfirm"
|
|
25
|
+
| "success"
|
|
26
|
+
| "failed";
|
|
27
|
+
type TextTarget = "server" | "key" | "group" | "sound";
|
|
28
|
+
const LEVELS: BarkLevel[] = ["passive", "active", "timeSensitive", "critical"];
|
|
29
|
+
|
|
30
|
+
export class BarkSetupOverlay implements Component {
|
|
31
|
+
private phase: Phase;
|
|
32
|
+
private serverUrl = "https://api.day.app";
|
|
33
|
+
private deviceKey = "";
|
|
34
|
+
private group = "Pi";
|
|
35
|
+
private sound = "anticipate";
|
|
36
|
+
private levelIndex = 1;
|
|
37
|
+
private encrypted = true;
|
|
38
|
+
private credentials: BarkEncryptionCredentials;
|
|
39
|
+
private pasteBuffer = "";
|
|
40
|
+
private inPaste = false;
|
|
41
|
+
private error: string | null = null;
|
|
42
|
+
private copied: "key" | "iv" | null = null;
|
|
43
|
+
private readonly client: BarkClient;
|
|
44
|
+
private readonly overlay = new OverlayTheme();
|
|
45
|
+
onClose?: () => void;
|
|
46
|
+
requestRender?: () => void;
|
|
47
|
+
|
|
48
|
+
constructor(client: BarkClient = createBarkClient(), private readonly mode: SetupMode = "setup") {
|
|
49
|
+
this.client = client;
|
|
50
|
+
const config = loadGlobalConfig();
|
|
51
|
+
this.serverUrl = config.serverUrl;
|
|
52
|
+
this.deviceKey = config.deviceKey ?? "";
|
|
53
|
+
this.group = config.group ?? "";
|
|
54
|
+
this.sound = config.sound ?? "";
|
|
55
|
+
const levelIndex = LEVELS.indexOf(config.level);
|
|
56
|
+
if (levelIndex >= 0) this.levelIndex = levelIndex;
|
|
57
|
+
this.encrypted = mode === "rotate" || config.encryption.mode === "encrypted";
|
|
58
|
+
this.credentials =
|
|
59
|
+
mode === "setup" && isValidEncryptionValue(config.encryption.key) && isValidEncryptionValue(config.encryption.iv)
|
|
60
|
+
? { key: config.encryption.key!, iv: config.encryption.iv! }
|
|
61
|
+
: generateEncryptionCredentials();
|
|
62
|
+
this.phase = mode === "rotate" ? "encryption" : "instructions";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setTheme(theme: Theme): void {
|
|
66
|
+
this.overlay.setTheme(theme);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
invalidate(): void {}
|
|
70
|
+
|
|
71
|
+
handleInput(data: string): void {
|
|
72
|
+
if (matchesKey(data, "ctrl+c")) return this.onClose?.();
|
|
73
|
+
if (this.phase === "instructions") {
|
|
74
|
+
if (matchesKey(data, "enter") || matchesKey(data, "space")) this.phase = "server";
|
|
75
|
+
else if (matchesKey(data, "escape")) this.onClose?.();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (this.phase === "server" || this.phase === "key" || this.phase === "group" || this.phase === "sound") {
|
|
79
|
+
this.handleText(data, this.phase);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.phase === "level") {
|
|
83
|
+
if (matchesKey(data, "up") || data === "k") this.levelIndex = Math.max(0, this.levelIndex - 1);
|
|
84
|
+
else if (matchesKey(data, "down") || data === "j") this.levelIndex = Math.min(LEVELS.length - 1, this.levelIndex + 1);
|
|
85
|
+
else if (matchesKey(data, "enter")) this.phase = "encryptionMode";
|
|
86
|
+
else if (matchesKey(data, "escape")) this.onClose?.();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (this.phase === "encryptionMode") {
|
|
90
|
+
if (matchesKey(data, "up") || matchesKey(data, "down") || data === "j" || data === "k" || matchesKey(data, "space")) {
|
|
91
|
+
this.encrypted = !this.encrypted;
|
|
92
|
+
} else if (matchesKey(data, "enter")) {
|
|
93
|
+
if (this.encrypted) this.phase = "encryption";
|
|
94
|
+
else if (loadGlobalConfig().encryption.mode === "encrypted") this.phase = "plaintextConfirm";
|
|
95
|
+
else void this.testPlaintext();
|
|
96
|
+
} else if (matchesKey(data, "escape")) this.onClose?.();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (this.phase === "plaintextConfirm") {
|
|
100
|
+
if (data.toLowerCase() === "y") void this.testPlaintext();
|
|
101
|
+
else if (data.toLowerCase() === "n" || matchesKey(data, "escape")) this.phase = "encryptionMode";
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (this.phase === "encryption") {
|
|
105
|
+
if (data.toLowerCase() === "k") void this.copyCredential("key");
|
|
106
|
+
else if (data.toLowerCase() === "i") void this.copyCredential("iv");
|
|
107
|
+
else if (matchesKey(data, "enter")) void this.testEncrypted();
|
|
108
|
+
else if (matchesKey(data, "escape")) this.onClose?.();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (this.phase === "decryptConfirm") {
|
|
112
|
+
if (data.toLowerCase() === "y") this.saveEncrypted();
|
|
113
|
+
else if (data.toLowerCase() === "n") {
|
|
114
|
+
this.error = "Encrypted settings were not activated. Check the App Key and IV, then retry.";
|
|
115
|
+
this.phase = "failed";
|
|
116
|
+
} else if (matchesKey(data, "escape")) this.onClose?.();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (this.phase === "testing") {
|
|
120
|
+
if (matchesKey(data, "escape")) this.onClose?.();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (this.phase === "failed" && matchesKey(data, "enter")) {
|
|
124
|
+
this.error = null;
|
|
125
|
+
this.phase = this.encrypted ? "encryption" : "encryptionMode";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (matchesKey(data, "enter") || matchesKey(data, "space") || matchesKey(data, "escape")) this.onClose?.();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private handleText(data: string, target: TextTarget): void {
|
|
132
|
+
if (this.inPaste) {
|
|
133
|
+
this.pasteBuffer += data;
|
|
134
|
+
const end = this.pasteBuffer.indexOf("\x1b[201~");
|
|
135
|
+
if (end >= 0) {
|
|
136
|
+
this.setText(target, this.pasteBuffer.slice(0, end).trim());
|
|
137
|
+
this.pasteBuffer = "";
|
|
138
|
+
this.inPaste = false;
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (data.includes("\x1b[200~")) {
|
|
143
|
+
this.inPaste = true;
|
|
144
|
+
this.pasteBuffer = data.replace("\x1b[200~", "");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (matchesKey(data, "escape")) return this.onClose?.();
|
|
148
|
+
if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
|
|
149
|
+
this.setText(target, this.getText(target).slice(0, -1));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (matchesKey(data, "enter")) {
|
|
153
|
+
this.error = null;
|
|
154
|
+
if (target === "server") {
|
|
155
|
+
if (!this.serverUrl.trim()) this.serverUrl = "https://api.day.app";
|
|
156
|
+
try {
|
|
157
|
+
const url = new URL(this.serverUrl);
|
|
158
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("protocol");
|
|
159
|
+
this.phase = "key";
|
|
160
|
+
} catch {
|
|
161
|
+
this.error = "Invalid server URL";
|
|
162
|
+
}
|
|
163
|
+
} else if (target === "key") {
|
|
164
|
+
this.deviceKey = normalizeBarkDeviceKey(this.deviceKey);
|
|
165
|
+
if (this.deviceKey && !/^https?:\/\//i.test(this.deviceKey)) this.phase = "group";
|
|
166
|
+
else this.error = "Enter a device key or Bark test URL";
|
|
167
|
+
} else if (target === "group") this.phase = "sound";
|
|
168
|
+
else this.phase = "level";
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!data.startsWith("\x1b[")) this.setText(target, this.getText(target) + data);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private getText(target: TextTarget): string {
|
|
175
|
+
if (target === "server") return this.serverUrl;
|
|
176
|
+
if (target === "key") return this.deviceKey;
|
|
177
|
+
if (target === "group") return this.group;
|
|
178
|
+
return this.sound;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private setText(target: TextTarget, value: string): void {
|
|
182
|
+
if (target === "server") this.serverUrl = value;
|
|
183
|
+
else if (target === "key") this.deviceKey = value;
|
|
184
|
+
else if (target === "group") this.group = value;
|
|
185
|
+
else this.sound = value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async copyCredential(field: "key" | "iv"): Promise<void> {
|
|
189
|
+
try {
|
|
190
|
+
await copyToClipboard(this.credentials[field]);
|
|
191
|
+
this.copied = field;
|
|
192
|
+
this.error = null;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.error = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
195
|
+
}
|
|
196
|
+
this.requestRender?.();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private notification() {
|
|
200
|
+
return {
|
|
201
|
+
title: "Pi Toolkit — Encrypted Test",
|
|
202
|
+
body: "If this sentence is readable, Bark encryption is configured correctly.",
|
|
203
|
+
group: this.group || undefined,
|
|
204
|
+
sound: this.sound || undefined,
|
|
205
|
+
level: LEVELS[this.levelIndex],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async testEncrypted(): Promise<void> {
|
|
210
|
+
this.phase = "testing";
|
|
211
|
+
this.error = null;
|
|
212
|
+
this.requestRender?.();
|
|
213
|
+
try {
|
|
214
|
+
await this.client.send(
|
|
215
|
+
{
|
|
216
|
+
serverUrl: this.serverUrl,
|
|
217
|
+
deviceKey: this.deviceKey,
|
|
218
|
+
encryption: { mode: "encrypted", ...this.credentials },
|
|
219
|
+
},
|
|
220
|
+
this.notification(),
|
|
221
|
+
);
|
|
222
|
+
this.phase = "decryptConfirm";
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
225
|
+
this.phase = "failed";
|
|
226
|
+
}
|
|
227
|
+
this.requestRender?.();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async testPlaintext(): Promise<void> {
|
|
231
|
+
this.phase = "testing";
|
|
232
|
+
this.error = null;
|
|
233
|
+
this.requestRender?.();
|
|
234
|
+
try {
|
|
235
|
+
await this.client.send(
|
|
236
|
+
{ serverUrl: this.serverUrl, deviceKey: this.deviceKey, encryption: { mode: "plaintext" } },
|
|
237
|
+
{ ...this.notification(), title: "Pi Toolkit — Plaintext Test", body: "Plaintext Bark notifications are configured." },
|
|
238
|
+
);
|
|
239
|
+
this.save({ ...loadGlobalConfig().encryption, mode: "plaintext" });
|
|
240
|
+
} catch (error) {
|
|
241
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
242
|
+
this.phase = "failed";
|
|
243
|
+
}
|
|
244
|
+
this.requestRender?.();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private saveEncrypted(): void {
|
|
248
|
+
this.save({ mode: "encrypted", ...this.credentials });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private save(encryption: BarkEncryptionConfig): void {
|
|
252
|
+
const config = loadGlobalConfig();
|
|
253
|
+
saveGlobalConfig({
|
|
254
|
+
...config,
|
|
255
|
+
enabled: true,
|
|
256
|
+
serverUrl: this.serverUrl.replace(/\/+$/, ""),
|
|
257
|
+
deviceKey: this.deviceKey,
|
|
258
|
+
group: this.group || undefined,
|
|
259
|
+
sound: this.sound || undefined,
|
|
260
|
+
level: LEVELS[this.levelIndex],
|
|
261
|
+
encryption,
|
|
262
|
+
});
|
|
263
|
+
this.phase = "success";
|
|
264
|
+
this.requestRender?.();
|
|
265
|
+
setTimeout(() => this.onClose?.(), 1200);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render(width: number): string[] {
|
|
269
|
+
const inner = boxInnerWidth(width);
|
|
270
|
+
const lines = [
|
|
271
|
+
this.overlay.borderLine(inner, "top"),
|
|
272
|
+
this.overlay.frameLine(this.overlay.fg("accent", this.overlay.bold(this.mode === "rotate" ? "🔐 Rotate Bark Encryption" : "🐶 Pi Toolkit — Bark Setup")), inner),
|
|
273
|
+
this.overlay.ruleLine(inner),
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
if (this.phase === "instructions") {
|
|
277
|
+
this.line(lines, inner, "Configure Bark task-completion notifications.", "dim");
|
|
278
|
+
this.line(lines, inner, "");
|
|
279
|
+
this.line(lines, inner, " 1. Copy the test URL from Bark");
|
|
280
|
+
this.line(lines, inner, " 2. Choose encrypted (default) or plaintext delivery");
|
|
281
|
+
this.line(lines, inner, " 3. Verify a real notification before activation");
|
|
282
|
+
this.line(lines, inner, "");
|
|
283
|
+
this.line(lines, inner, "Credentials stay in a global 0600 file and never enter Git.", "dim");
|
|
284
|
+
this.footer(lines, inner, "Enter continue · Esc cancel");
|
|
285
|
+
} else if (this.phase === "server") this.textScreen(lines, inner, "Bark server URL", this.serverUrl, "Default: https://api.day.app");
|
|
286
|
+
else if (this.phase === "key") this.textScreen(lines, inner, "Device key or Bark test URL", this.maskKey(this.deviceKey), "The URL is normalized to its device key");
|
|
287
|
+
else if (this.phase === "group") this.textScreen(lines, inner, "Notification group (optional)", this.group, "Default: Pi · clear for no group");
|
|
288
|
+
else if (this.phase === "sound") this.textScreen(lines, inner, "Bark sound (optional)", this.sound, "Default: anticipate · clear for Bark default");
|
|
289
|
+
else if (this.phase === "level") {
|
|
290
|
+
this.line(lines, inner, "Default interruption level", "dim");
|
|
291
|
+
this.line(lines, inner, "");
|
|
292
|
+
LEVELS.forEach((level, index) => this.line(lines, inner, ` ${index === this.levelIndex ? this.overlay.fg("accent", "▸") : " "} ${index === this.levelIndex ? this.overlay.bold(level) : this.overlay.fg("dim", level)}`));
|
|
293
|
+
this.footer(lines, inner, "↑↓ select · Enter continue · Esc cancel");
|
|
294
|
+
} else if (this.phase === "encryptionMode") {
|
|
295
|
+
this.line(lines, inner, "Delivery privacy", "dim");
|
|
296
|
+
this.line(lines, inner, "");
|
|
297
|
+
this.choice(lines, inner, this.encrypted, "Encrypted", "Bark/APNs cannot read title, body, group, or sound");
|
|
298
|
+
this.choice(lines, inner, !this.encrypted, "Plaintext", "Third-party services can read notification content");
|
|
299
|
+
this.footer(lines, inner, "↑↓ toggle · Enter continue · Esc cancel");
|
|
300
|
+
} else if (this.phase === "plaintextConfirm") {
|
|
301
|
+
this.line(lines, inner, "⚠ Disable end-to-end encryption?", "error");
|
|
302
|
+
this.line(lines, inner, "");
|
|
303
|
+
this.line(lines, inner, "Bark Server and Apple APNs will be able to read notification content.");
|
|
304
|
+
this.line(lines, inner, "This change is global and cannot be enabled per project.", "dim");
|
|
305
|
+
this.footer(lines, inner, "Y confirm plaintext · N go back");
|
|
306
|
+
} else if (this.phase === "encryption") {
|
|
307
|
+
this.line(lines, inner, "Enter these exact values in Bark App → Push Encryption:", "dim");
|
|
308
|
+
this.line(lines, inner, "");
|
|
309
|
+
this.line(lines, inner, " Algorithm AES128");
|
|
310
|
+
this.line(lines, inner, " Mode CBC");
|
|
311
|
+
this.line(lines, inner, " Padding pkcs7");
|
|
312
|
+
this.line(lines, inner, ` Key ${this.overlay.fg("accent", this.overlay.bold(this.credentials.key))}`);
|
|
313
|
+
this.line(lines, inner, ` IV ${this.overlay.fg("accent", this.overlay.bold(this.credentials.iv))}`);
|
|
314
|
+
this.line(lines, inner, "");
|
|
315
|
+
this.line(lines, inner, "Key and IV remain fixed until you explicitly rotate them.", "dim");
|
|
316
|
+
if (this.copied) this.line(lines, inner, `✓ ${this.copied.toUpperCase()} copied; clipboard now contains a secret.`, "success");
|
|
317
|
+
this.footer(lines, inner, "K copy Key · I copy IV · Enter send encrypted test · Esc cancel");
|
|
318
|
+
} else if (this.phase === "testing") {
|
|
319
|
+
this.line(lines, inner, "");
|
|
320
|
+
this.line(lines, inner, " ⠋ Sending a Bark test notification...", "accent");
|
|
321
|
+
this.footer(lines, inner, "Esc close");
|
|
322
|
+
} else if (this.phase === "decryptConfirm") {
|
|
323
|
+
this.line(lines, inner, "Encrypted test accepted by Bark.", "success");
|
|
324
|
+
this.line(lines, inner, "");
|
|
325
|
+
this.line(lines, inner, "Did your device show the readable English test sentence?");
|
|
326
|
+
this.line(lines, inner, "Do not confirm if it showed “Decryption Failed”.", "dim");
|
|
327
|
+
this.footer(lines, inner, "Y readable — activate · N failed — keep old settings");
|
|
328
|
+
} else if (this.phase === "success") {
|
|
329
|
+
this.line(lines, inner, "");
|
|
330
|
+
this.line(lines, inner, ` ✓ Bark ${this.encrypted ? "encrypted" : "plaintext"} notifications activated`, "success");
|
|
331
|
+
this.line(lines, inner, ` Group: ${this.group || "—"} · Sound: ${this.sound || "default"} · Level: ${LEVELS[this.levelIndex]}`, "dim");
|
|
332
|
+
this.footer(lines, inner, "Closing...");
|
|
333
|
+
} else {
|
|
334
|
+
this.line(lines, inner, "");
|
|
335
|
+
this.line(lines, inner, " ✗ Bark setup was not activated", "error");
|
|
336
|
+
this.line(lines, inner, ` ${this.error || "Unknown error"}`, "dim");
|
|
337
|
+
this.footer(lines, inner, "Enter retry · Esc close");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (this.error && this.phase !== "failed") this.line(lines, inner, ` ⚠ ${this.error}`, "error");
|
|
341
|
+
lines.push(this.overlay.borderLine(inner, "bottom"));
|
|
342
|
+
return lines;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private choice(lines: string[], width: number, selected: boolean, label: string, detail: string): void {
|
|
346
|
+
this.line(lines, width, ` ${selected ? this.overlay.fg("accent", "▸") : " "} ${selected ? this.overlay.bold(label) : label} ${this.overlay.fg("dim", detail)}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private textScreen(lines: string[], width: number, label: string, value: string, hint: string): void {
|
|
350
|
+
this.line(lines, width, label, "dim");
|
|
351
|
+
this.line(lines, width, "");
|
|
352
|
+
this.line(lines, width, ` ${this.overlay.fg("accent", this.overlay.bold(value || " "))}${this.overlay.fg("dim", "█")}`);
|
|
353
|
+
this.line(lines, width, "");
|
|
354
|
+
this.line(lines, width, hint, "dim");
|
|
355
|
+
this.footer(lines, width, "Enter continue · Esc cancel");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private line(lines: string[], width: number, text: string, color?: "dim" | "accent" | "success" | "error"): void {
|
|
359
|
+
lines.push(this.overlay.frameLine(color ? this.overlay.fg(color, text) : text, width));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private footer(lines: string[], width: number, hint: string): void {
|
|
363
|
+
lines.push(this.overlay.ruleLine(width));
|
|
364
|
+
this.line(lines, width, hint, "dim");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private maskKey(value: string): string {
|
|
368
|
+
if (!value) return "";
|
|
369
|
+
if (/^https?:\/\//i.test(value)) {
|
|
370
|
+
try { return `${new URL(value).origin}/••••`; } catch { return "••••"; }
|
|
371
|
+
}
|
|
372
|
+
if (value.length <= 8) return "•".repeat(value.length);
|
|
373
|
+
return `${value.slice(0, 4)}${"•".repeat(Math.min(12, value.length - 8))}${value.slice(-4)}`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
const FALLBACK_COLORS: Record<string, string> = {
|
|
5
|
+
accent: "\x1b[36m",
|
|
6
|
+
success: "\x1b[32m",
|
|
7
|
+
warning: "\x1b[33m",
|
|
8
|
+
error: "\x1b[31m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
borderMuted: "\x1b[90m",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function repeat(text: string, count: number): string {
|
|
14
|
+
return count > 0 ? text.repeat(count) : "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function boxInnerWidth(width: number): number {
|
|
18
|
+
return Math.max(1, width - 2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class OverlayTheme {
|
|
22
|
+
private theme: Theme | null = null;
|
|
23
|
+
|
|
24
|
+
setTheme(theme: Theme | null): void {
|
|
25
|
+
this.theme = theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fg(color: string, text: string): string {
|
|
29
|
+
if (this.theme) return this.theme.fg(color as never, text);
|
|
30
|
+
return `${FALLBACK_COLORS[color] ?? ""}${text}\x1b[0m`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
bold(text: string): string {
|
|
34
|
+
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
frameLine(content: string, width: number): string {
|
|
38
|
+
const truncated = truncateToWidth(content, width, "");
|
|
39
|
+
return `${this.fg("borderMuted", "│")}${truncated}${repeat(" ", width - visibleWidth(truncated))}${this.fg("borderMuted", "│")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ruleLine(width: number): string {
|
|
43
|
+
return this.fg("borderMuted", `├${repeat("─", width)}┤`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
borderLine(width: number, edge: "top" | "bottom"): string {
|
|
47
|
+
return this.fg("borderMuted", `${edge === "top" ? "┌" : "└"}${repeat("─", width)}${edge === "top" ? "┐" : "┘"}`);
|
|
48
|
+
}
|
|
49
|
+
}
|