osdy-pi 1.2.0 → 1.4.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/README.md +172 -30
- package/bin/osdy-pi.mjs +29 -6
- package/bin/osdy.mjs +43 -0
- package/extensions/osdy-pi/account-profiles.test.ts +111 -97
- package/extensions/osdy-pi/account-profiles.ts +91 -162
- package/extensions/osdy-pi/agent-coexistence-setup.test.ts +163 -0
- package/extensions/osdy-pi/agent-coexistence-setup.ts +196 -0
- package/extensions/osdy-pi/animation.test.ts +84 -0
- package/extensions/osdy-pi/animation.ts +17 -8
- package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
- package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
- package/extensions/osdy-pi/codex-usage.test.ts +276 -0
- package/extensions/osdy-pi/codex-usage.ts +276 -0
- package/extensions/osdy-pi/constants.ts +134 -34
- package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
- package/extensions/osdy-pi/context-usage-ui.ts +60 -0
- package/extensions/osdy-pi/editor-settings.test.ts +121 -23
- package/extensions/osdy-pi/editor-settings.ts +39 -3
- package/extensions/osdy-pi/metrics.test.ts +95 -0
- package/extensions/osdy-pi/metrics.ts +39 -1
- package/extensions/osdy-pi/profile-label.ts +2 -2
- package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
- package/extensions/osdy-pi/runtime-helpers.ts +42 -19
- package/extensions/osdy-pi/runtime.test.ts +390 -8
- package/extensions/osdy-pi/runtime.ts +406 -66
- package/extensions/osdy-pi/types.ts +26 -1
- package/extensions/osdy-pi/ui.test.ts +128 -0
- package/extensions/osdy-pi/ui.ts +57 -40
- package/package.json +5 -2
- package/scripts/osdy-pi-account-profiles.mjs +521 -76
- package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
- package/scripts/osdy-pi-profile-setup.mjs +175 -0
- package/themes/osdy-pi-catppuccin-frappe.json +6 -2
- package/themes/osdy-pi-catppuccin-latte.json +6 -2
- package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
- package/themes/osdy-pi-catppuccin-mocha.json +6 -2
- package/themes/osdy-pi-dark.json +6 -2
- package/themes/osdy-pi-dracula.json +6 -2
- package/themes/osdy-pi-kanagawa-dragon.json +6 -2
- package/themes/osdy-pi-kanagawa-lotus.json +6 -2
- package/themes/osdy-pi-kanagawa-wave.json +6 -2
- package/themes/osdy-pi-lucent-orange.json +6 -2
- package/themes/osdy-pi-matrix.json +6 -2
- package/themes/osdy-pi-new.json +6 -2
- package/themes/osdy-pi-sexy.json +6 -2
- package/themes/osdy-pi-tokyo-night.json +6 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { registerHooks } from "node:module";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
10
|
+
if (specifier === "./agent-coexistence-setup.js" && context.parentURL?.endsWith(".test.ts"))
|
|
11
|
+
return { shortCircuit: true, url: new URL("./agent-coexistence-setup.ts", context.parentURL).href };
|
|
12
|
+
return nextResolve(specifier, context);
|
|
13
|
+
} });
|
|
14
|
+
const { setupJokerAgents } = await import("./agent-coexistence-setup.js");
|
|
15
|
+
|
|
16
|
+
async function fixture() {
|
|
17
|
+
const root = await mkdtemp(join(tmpdir(), "osdy-joker-"));
|
|
18
|
+
const agentDir = join(root, ".pi", "agent");
|
|
19
|
+
const gentle = join(root, "gentle");
|
|
20
|
+
await mkdir(agentDir, { recursive: true });
|
|
21
|
+
await mkdir(gentle);
|
|
22
|
+
await writeFile(join(gentle, "package.json"), '{"name":"gentle-pi"}');
|
|
23
|
+
const settingsPath = join(agentDir, "settings.json");
|
|
24
|
+
const packages = ["npm:other", { source: gentle, extensions: ["-extensions/gentle-todo.ts"], themes: [], skills: ["skills/*"] }, "npm:gentle-pi@1.2.0", { source: "npm:gentle-pi", extensions: ["-extensions/gentle-agents.ts"] }];
|
|
25
|
+
await writeFile(settingsPath, JSON.stringify({ theme: "dark", packages }));
|
|
26
|
+
return { root, agentDir, gentle, settingsPath, packages };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const base = (f: Awaited<ReturnType<typeof fixture>>) => ({
|
|
30
|
+
agentDir: f.agentDir, home: f.root, cwd: f.root, env: {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
void test("installs Joker then narrowly reconciles every Gentle entry using latest settings", async () => {
|
|
34
|
+
const f = await fixture();
|
|
35
|
+
let calls = 0;
|
|
36
|
+
const install = async () => {
|
|
37
|
+
calls++;
|
|
38
|
+
const latest = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
|
|
39
|
+
latest.packages.push("npm:pi-subagents-j0k3r", "npm:newly-added");
|
|
40
|
+
await writeFile(f.settingsPath, JSON.stringify(latest));
|
|
41
|
+
};
|
|
42
|
+
await setupJokerAgents({ ...base(f), install });
|
|
43
|
+
const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[]; theme: string };
|
|
44
|
+
assert.equal(calls, 1);
|
|
45
|
+
assert.deepEqual(settings.packages, [
|
|
46
|
+
"npm:other",
|
|
47
|
+
{ source: f.gentle, extensions: ["-extensions/gentle-todo.ts", "-extensions/gentle-agents.ts"], themes: [], skills: ["skills/*"] },
|
|
48
|
+
{ source: "npm:gentle-pi@1.2.0", extensions: ["-extensions/gentle-agents.ts"] },
|
|
49
|
+
{ source: "npm:gentle-pi", extensions: ["-extensions/gentle-agents.ts"] },
|
|
50
|
+
"npm:pi-subagents-j0k3r", "npm:newly-added",
|
|
51
|
+
]);
|
|
52
|
+
assert.equal(settings.theme, "dark");
|
|
53
|
+
await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("duplicate install")) });
|
|
54
|
+
assert.equal((await readFile(f.settingsPath, "utf8")).includes("duplicate"), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
void test("relative Gentle sources resolve from settings and project overrides fail closed", async () => {
|
|
58
|
+
const f = await fixture();
|
|
59
|
+
const local = join(f.agentDir, "gentle-local");
|
|
60
|
+
await mkdir(local);
|
|
61
|
+
await writeFile(join(local, "package.json"), '{"name":"gentle-pi"}');
|
|
62
|
+
await writeFile(f.settingsPath, '{"packages":["./gentle-local","npm:pi-subagents-j0k3r"]}');
|
|
63
|
+
await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("should skip")) });
|
|
64
|
+
const result = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
|
|
65
|
+
assert.deepEqual(result.packages[0], { source: "./gentle-local", extensions: ["-extensions/gentle-agents.ts"] });
|
|
66
|
+
const project = join(f.root, "project");
|
|
67
|
+
await mkdir(join(project, ".pi", "gentle"), { recursive: true });
|
|
68
|
+
await writeFile(join(project, ".pi", "gentle", "package.json"), '{"name":"gentle-pi"}');
|
|
69
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":["./gentle"]}');
|
|
70
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project }), /project-local Gentle/i);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
void test("bare, tilde, and file URL local Gentle entries get the narrow personal filter", async () => {
|
|
74
|
+
const f = await fixture();
|
|
75
|
+
const bare = join(f.agentDir, "gentle");
|
|
76
|
+
await mkdir(bare);
|
|
77
|
+
await writeFile(join(bare, "package.json"), '{"name":"gentle-pi"}');
|
|
78
|
+
await writeFile(f.settingsPath, JSON.stringify({ packages: ["gentle", "~/gentle", pathToFileURL(f.gentle).href, "npm:pi-subagents-j0k3r"] }));
|
|
79
|
+
const result = await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("no install")) });
|
|
80
|
+
assert.equal(result.gentleCount, 3);
|
|
81
|
+
const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<{ source: string; extensions: string[] }> };
|
|
82
|
+
assert.deepEqual(settings.packages.slice(0, 3).map((entry) => entry.extensions), Array(3).fill(["-extensions/gentle-agents.ts"]));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
void test("bare project Gentle override and ambiguous Gentle local path fail before install", async () => {
|
|
86
|
+
const f = await fixture();
|
|
87
|
+
const project = join(f.root, "project");
|
|
88
|
+
await mkdir(join(project, ".pi", "gentle"), { recursive: true });
|
|
89
|
+
await writeFile(join(project, ".pi", "gentle", "package.json"), '{"name":"gentle-pi"}');
|
|
90
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":["gentle"]}');
|
|
91
|
+
let calls = 0;
|
|
92
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
93
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /project-local Gentle/i);
|
|
94
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":[]}');
|
|
95
|
+
await writeFile(f.settingsPath, '{"packages":["gentle","npm:pi-subagents-j0k3r"]}');
|
|
96
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /Gentle.*resolve|Gentle.*source/i);
|
|
97
|
+
assert.equal(calls, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
void test("recognizable remote Gentle sources fail closed before install, but unrelated Git packages remain", async () => {
|
|
101
|
+
const f = await fixture();
|
|
102
|
+
let calls = 0;
|
|
103
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
104
|
+
for (const remote of [
|
|
105
|
+
"git:github.com/Gentleman-Programming/gentle-pi",
|
|
106
|
+
"https://github.com/Gentleman-Programming/gentle-pi.git",
|
|
107
|
+
]) {
|
|
108
|
+
await writeFile(f.settingsPath, JSON.stringify({ packages: [remote, "git:github.com/owner/other-package"] }));
|
|
109
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /remote Gentle.*local.*npm/i);
|
|
110
|
+
const project = join(f.root, "project");
|
|
111
|
+
await mkdir(join(project, ".pi"), { recursive: true });
|
|
112
|
+
await writeFile(join(project, ".pi", "settings.json"), JSON.stringify({ packages: [remote] }));
|
|
113
|
+
await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}');
|
|
114
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /remote Gentle.*local.*npm/i);
|
|
115
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":[]}');
|
|
116
|
+
}
|
|
117
|
+
assert.equal(calls, 0);
|
|
118
|
+
await writeFile(f.settingsPath, '{"packages":["git:github.com/owner/other-package","npm:pi-subagents-j0k3r"]}');
|
|
119
|
+
const result = await setupJokerAgents({ ...base(f), install });
|
|
120
|
+
assert.equal(result.gentleCount, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
void test("invalid Gentle filters or duplicate Joker declarations block setup before further writes", async () => {
|
|
124
|
+
const f = await fixture();
|
|
125
|
+
let calls = 0;
|
|
126
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
127
|
+
await writeFile(f.settingsPath, '{"packages":[{"source":"npm:gentle-pi","extensions":false}]}');
|
|
128
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /Gentle extensions/i);
|
|
129
|
+
await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r","npm:pi-subagents-j0k3r"]}');
|
|
130
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /duplicate Joker/i);
|
|
131
|
+
assert.equal(calls, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
void test("without Gentle, only Joker installation changes settings", async () => {
|
|
135
|
+
const f = await fixture();
|
|
136
|
+
await writeFile(f.settingsPath, '{"packages":["npm:other"]}');
|
|
137
|
+
await setupJokerAgents({ ...base(f), install: async () => {
|
|
138
|
+
await writeFile(f.settingsPath, '{"packages":["npm:other","npm:pi-subagents-j0k3r"]}');
|
|
139
|
+
} });
|
|
140
|
+
assert.deepEqual(JSON.parse(await readFile(f.settingsPath, "utf8")) as unknown, { packages: ["npm:other", "npm:pi-subagents-j0k3r"] });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
void test("rejects alternate agent directories and project-local Gentle overrides before install", async () => {
|
|
144
|
+
const f = await fixture();
|
|
145
|
+
let calls = 0;
|
|
146
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
147
|
+
await assert.rejects(setupJokerAgents({ ...base(f), agentDir: join(f.root, "isolated"), install }), /personal|agent/i);
|
|
148
|
+
await assert.rejects(setupJokerAgents({ ...base(f), env: { PI_CODING_AGENT_DIR: f.agentDir }, install }), /override|normal/i);
|
|
149
|
+
const project = join(f.root, "project");
|
|
150
|
+
await mkdir(join(project, ".pi"), { recursive: true });
|
|
151
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":["npm:gentle-pi"]}');
|
|
152
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /project|Gentle/i);
|
|
153
|
+
assert.equal(calls, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
void test("failed installer or malformed latest settings cannot rewrite personal settings", async () => {
|
|
157
|
+
const f = await fixture();
|
|
158
|
+
const original = await readFile(f.settingsPath, "utf8");
|
|
159
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("install failed")) }), /install failed/);
|
|
160
|
+
assert.equal(await readFile(f.settingsPath, "utf8"), original);
|
|
161
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install: async () => { await writeFile(f.settingsPath, "{bad"); } }), /settings/i);
|
|
162
|
+
assert.equal(await readFile(f.settingsPath, "utf8"), "{bad");
|
|
163
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const runFile = promisify(execFile);
|
|
10
|
+
const JOKER = "npm:pi-subagents-j0k3r";
|
|
11
|
+
const GENTLE_EXCLUSION = "-extensions/gentle-agents.ts";
|
|
12
|
+
|
|
13
|
+
type Settings = Record<string, unknown> & { packages?: unknown[] };
|
|
14
|
+
|
|
15
|
+
type SetupOptions = {
|
|
16
|
+
agentDir: string;
|
|
17
|
+
home?: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
install?: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function source(entry: unknown): string | undefined {
|
|
28
|
+
if (typeof entry === "string") return entry;
|
|
29
|
+
return object(entry) && typeof entry.source === "string" ? entry.source : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function npmGentle(entry: unknown): boolean {
|
|
33
|
+
return /^npm:gentle-pi(?:@[^\s]+)?$/i.test(source(entry) ?? "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function joker(entry: unknown): boolean {
|
|
37
|
+
return /^npm:pi-subagents-j0k3r(?:@[^\s]+)?$/i.test(source(entry) ?? "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function gentle(entry: unknown, settingsDir: string, home: string): Promise<boolean> {
|
|
41
|
+
if (npmGentle(entry)) return true;
|
|
42
|
+
const value = source(entry);
|
|
43
|
+
if (!value || /^npm:/i.test(value)) return false;
|
|
44
|
+
if (/^(?:git:|https?:\/\/)/i.test(value)) {
|
|
45
|
+
if (/(?:^|\/)gentle-pi(?:\.git)?(?:[@#?][^\s]*)?$/i.test(value))
|
|
46
|
+
throw new Error("Remote Gentle source unsupported: register a local gentle-pi checkout or npm:gentle-pi in personal Pi, then rerun agents setup.");
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
let path: string;
|
|
50
|
+
try {
|
|
51
|
+
if (value.startsWith("file:")) path = fileURLToPath(value);
|
|
52
|
+
else if (value === "~" || value.startsWith("~/")) path = resolve(home, value.slice(value === "~" ? 1 : 2));
|
|
53
|
+
else if (!value.includes(":")) path = resolve(settingsDir, value);
|
|
54
|
+
else return false;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`Gentle source cannot be resolved: ${value}`);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const manifest: unknown = JSON.parse(await readFile(join(path, "package.json"), "utf8"));
|
|
60
|
+
return object(manifest) && manifest.name === "gentle-pi";
|
|
61
|
+
} catch {
|
|
62
|
+
if (/(?:^|[\\/])gentle(?:-pi)?$/i.test(path))
|
|
63
|
+
throw new Error(`Gentle source cannot be resolved: ${value}`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateGentleFilters(entry: unknown): void {
|
|
69
|
+
if (!object(entry) || entry.extensions === undefined) return;
|
|
70
|
+
if (!Array.isArray(entry.extensions) || !entry.extensions.every((value: unknown) => typeof value === "string"))
|
|
71
|
+
throw new Error("Gentle extensions filter must be an array of strings.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function regularSettings(path: string): Promise<Settings> {
|
|
75
|
+
if (!(await lstat(path)).isFile()) throw new Error("Personal settings.json must be a regular file, not a link.");
|
|
76
|
+
let value: unknown;
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error("Personal settings.json must contain valid JSON.");
|
|
81
|
+
}
|
|
82
|
+
if (!object(value) || (value.packages !== undefined && !Array.isArray(value.packages)))
|
|
83
|
+
throw new Error("Personal settings.json must have an object root and array packages.");
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function assertNoProjectGentle(cwd: string, home: string): Promise<void> {
|
|
88
|
+
let directory = resolve(cwd);
|
|
89
|
+
while (true) {
|
|
90
|
+
const local = join(directory, ".pi", "settings.json");
|
|
91
|
+
let text: string;
|
|
92
|
+
try {
|
|
93
|
+
text = await readFile(local, "utf8");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (object(error) && error.code === "ENOENT") {
|
|
96
|
+
const parent = dirname(directory);
|
|
97
|
+
if (parent === directory) return;
|
|
98
|
+
directory = parent;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
let settings: unknown;
|
|
104
|
+
try { settings = JSON.parse(text); }
|
|
105
|
+
catch { throw new Error(`Project settings invalid at ${local}; cannot guarantee Gentle filtering.`); }
|
|
106
|
+
if (!object(settings) || (settings.packages !== undefined && !Array.isArray(settings.packages)))
|
|
107
|
+
throw new Error(`Project settings invalid at ${local}; cannot guarantee Gentle filtering.`);
|
|
108
|
+
for (const entry of settings.packages ?? []) {
|
|
109
|
+
if (await gentle(entry, dirname(local), home)) throw new Error(`Project-local Gentle package at ${local} can override the personal filter.`);
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(directory);
|
|
112
|
+
if (parent === directory) return;
|
|
113
|
+
directory = parent;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function installJoker(): Promise<void> {
|
|
118
|
+
await runFile("pi", ["install", JOKER], { cwd: homedir(), maxBuffer: 4096 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean }> {
|
|
122
|
+
const target = await validateTarget(options);
|
|
123
|
+
const settings = await regularSettings(join(target, "settings.json"));
|
|
124
|
+
const packages = settings.packages ?? [];
|
|
125
|
+
const flags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
|
|
126
|
+
return {
|
|
127
|
+
jokerInstalled: packages.some(joker),
|
|
128
|
+
gentleCount: flags.filter(Boolean).length,
|
|
129
|
+
filtered: flags.every((isGentle, index) => !isGentle ||
|
|
130
|
+
(object(packages[index]) && Array.isArray(packages[index].extensions) && packages[index].extensions.includes(GENTLE_EXCLUSION))),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function validateTarget(options: Omit<SetupOptions, "install">): Promise<string> {
|
|
135
|
+
const home = resolve(options.home ?? homedir());
|
|
136
|
+
const target = resolve(options.agentDir);
|
|
137
|
+
if (options.env?.PI_CODING_AGENT_DIR !== undefined || !isAbsolute(options.agentDir))
|
|
138
|
+
throw new Error("Agents setup requires normal personal Pi; agent directory override or isolated profile is unsupported.");
|
|
139
|
+
let personal: boolean;
|
|
140
|
+
try {
|
|
141
|
+
personal = (await realpath(target)) === (await realpath(join(home, ".pi", "agent")));
|
|
142
|
+
} catch {
|
|
143
|
+
personal = false;
|
|
144
|
+
}
|
|
145
|
+
if (!personal)
|
|
146
|
+
throw new Error("Agents setup requires the normal personal Pi agent directory, not an isolated profile.");
|
|
147
|
+
await assertNoProjectGentle(options.cwd, home);
|
|
148
|
+
return target;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function setupJokerAgents(options: SetupOptions): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
|
|
152
|
+
const target = await validateTarget(options);
|
|
153
|
+
const path = join(target, "settings.json");
|
|
154
|
+
const before = await regularSettings(path);
|
|
155
|
+
const original = before.packages ?? [];
|
|
156
|
+
if (original.some((entry) => entry !== null && typeof entry !== "string" && !object(entry)))
|
|
157
|
+
throw new Error("Personal settings packages contain an invalid entry.");
|
|
158
|
+
const initialGentle = await Promise.all(original.map((entry) => gentle(entry, target, options.home ?? homedir())));
|
|
159
|
+
if (original.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
|
|
160
|
+
const alreadyJoker = original.some(joker);
|
|
161
|
+
for (const [index, isGentle] of initialGentle.entries()) {
|
|
162
|
+
if (isGentle) validateGentleFilters(original[index]);
|
|
163
|
+
}
|
|
164
|
+
const filtered = initialGentle.every((isGentle, index) => !isGentle ||
|
|
165
|
+
(object(original[index]) && Array.isArray(original[index].extensions) && original[index].extensions.includes(GENTLE_EXCLUSION)));
|
|
166
|
+
if (alreadyJoker && filtered) return { installed: false, changed: false, gentleCount: initialGentle.filter(Boolean).length };
|
|
167
|
+
if (!alreadyJoker) await (options.install ?? installJoker)();
|
|
168
|
+
// Install owns only Joker. Re-read the latest settings instead of replaying a stale snapshot.
|
|
169
|
+
await assertNoProjectGentle(options.cwd, options.home ?? homedir());
|
|
170
|
+
const latest = await regularSettings(path);
|
|
171
|
+
const packages = latest.packages ?? [];
|
|
172
|
+
if (!packages.some(joker)) throw new Error("Pi install did not register Joker in personal settings; no Gentle filters were changed.");
|
|
173
|
+
if (packages.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
|
|
174
|
+
const gentleFlags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
|
|
175
|
+
let changed = false;
|
|
176
|
+
const updated = packages.map((entry, index) => {
|
|
177
|
+
if (!gentleFlags[index]) return entry;
|
|
178
|
+
if (typeof entry !== "string" && !object(entry)) throw new Error("Gentle package entry is invalid.");
|
|
179
|
+
const item = typeof entry === "string" ? { source: entry } : entry;
|
|
180
|
+
validateGentleFilters(item);
|
|
181
|
+
const extensions = item.extensions as string[] | undefined;
|
|
182
|
+
if (extensions?.includes(GENTLE_EXCLUSION)) return entry;
|
|
183
|
+
changed = true;
|
|
184
|
+
return { ...item, extensions: [...(extensions ?? []), GENTLE_EXCLUSION] };
|
|
185
|
+
});
|
|
186
|
+
if (changed) {
|
|
187
|
+
const temporary = join(target, `.settings.${randomUUID()}.tmp`);
|
|
188
|
+
try {
|
|
189
|
+
await writeFile(temporary, `${JSON.stringify({ ...latest, packages: updated }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
190
|
+
await rename(temporary, path);
|
|
191
|
+
} finally {
|
|
192
|
+
await unlink(temporary).catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { installed: !alreadyJoker, changed, gentleCount: gentleFlags.filter(Boolean).length };
|
|
196
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { registerHooks } from "node:module";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
// @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
|
|
5
|
+
import { HEADER_VARIANTS } from "./constants.ts";
|
|
6
|
+
import type { SimpleTheme } from "./types.js";
|
|
7
|
+
|
|
8
|
+
registerHooks({
|
|
9
|
+
resolve(specifier, context, nextResolve) {
|
|
10
|
+
if (
|
|
11
|
+
context.parentURL?.includes("/extensions/osdy-pi/") &&
|
|
12
|
+
specifier.startsWith("./") &&
|
|
13
|
+
specifier.endsWith(".js")
|
|
14
|
+
) {
|
|
15
|
+
return nextResolve(`${specifier.slice(0, -3)}.ts`, context);
|
|
16
|
+
}
|
|
17
|
+
return nextResolve(specifier, context);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
|
|
22
|
+
const { colorAsciiCharacter } = await import("./animation.ts");
|
|
23
|
+
|
|
24
|
+
class RecordingTheme implements SimpleTheme {
|
|
25
|
+
readonly calls: Array<{ name: string; text: string }> = [];
|
|
26
|
+
|
|
27
|
+
fg(name: string, text: string): string {
|
|
28
|
+
this.calls.push({ name, text });
|
|
29
|
+
return `<${name}:${text}>`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function neonPalette() {
|
|
34
|
+
const palette = HEADER_VARIANTS.neon.headerTonePalette;
|
|
35
|
+
assert.ok(palette);
|
|
36
|
+
return palette;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
void test("Neon renders fixed white and near-black as foreground RGB", () => {
|
|
40
|
+
const palette = neonPalette();
|
|
41
|
+
const theme = new RecordingTheme();
|
|
42
|
+
|
|
43
|
+
assert.equal(
|
|
44
|
+
colorAsciiCharacter(theme, palette.c, "W"),
|
|
45
|
+
"\u001B[38;2;255;255;255mW\u001B[39m",
|
|
46
|
+
);
|
|
47
|
+
assert.equal(
|
|
48
|
+
colorAsciiCharacter(theme, palette.d, "B"),
|
|
49
|
+
"\u001B[38;2;18;1;27mB\u001B[39m",
|
|
50
|
+
);
|
|
51
|
+
assert.deepEqual(theme.calls, []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
void test("Neon renders main and dark pink through bounded accent styling", () => {
|
|
55
|
+
const palette = neonPalette();
|
|
56
|
+
const theme = new RecordingTheme();
|
|
57
|
+
|
|
58
|
+
const main = colorAsciiCharacter(theme, palette.h, "H");
|
|
59
|
+
const dark = [palette.l, palette.m, palette.p].map((color, index) =>
|
|
60
|
+
colorAsciiCharacter(theme, color, `D${index}`),
|
|
61
|
+
);
|
|
62
|
+
assert.equal(main, "<accent:H>");
|
|
63
|
+
assert.deepEqual(dark, [
|
|
64
|
+
"\u001B[2m<accent:D0>\u001B[22m",
|
|
65
|
+
"\u001B[2m<accent:D1>\u001B[22m",
|
|
66
|
+
"\u001B[2m<accent:D2>\u001B[22m",
|
|
67
|
+
]);
|
|
68
|
+
assert.deepEqual(theme.calls, [
|
|
69
|
+
{ name: "accent", text: "H" },
|
|
70
|
+
{ name: "accent", text: "D0" },
|
|
71
|
+
{ name: "accent", text: "D1" },
|
|
72
|
+
{ name: "accent", text: "D2" },
|
|
73
|
+
]);
|
|
74
|
+
assert.equal(`${main}${dark.join("")}`.includes("\u001B[48;"), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
void test("Neon never maps a tone to mdHeading or mdLink", () => {
|
|
78
|
+
for (const color of Object.values(neonPalette())) {
|
|
79
|
+
if (typeof color === "string") {
|
|
80
|
+
assert.notEqual(color, "mdHeading");
|
|
81
|
+
assert.notEqual(color, "mdLink");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ANIMATION_ENABLED } from "./constants.js";
|
|
2
2
|
import type { AnimationMode, SimpleTheme } from "./types.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
MascotTonePalette,
|
|
5
|
+
ThemeForegroundColor,
|
|
6
|
+
} from "./constants.js";
|
|
4
7
|
import { positiveModulo } from "./utils.js";
|
|
5
8
|
|
|
6
9
|
export type AsciiAnimationStyle = "static" | "animated";
|
|
@@ -25,12 +28,12 @@ export function asciiAnimationMode(): AnimationMode {
|
|
|
25
28
|
return getAnimationOverrideMode(process.env.OSDY_PI_ANIMATION) ?? "intro";
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
function getAnimatedAsciiColor(
|
|
31
|
+
function getAnimatedAsciiColor<Color>(
|
|
29
32
|
wave: number,
|
|
30
|
-
baseColor:
|
|
31
|
-
highlightColor:
|
|
32
|
-
trailColor:
|
|
33
|
-
):
|
|
33
|
+
baseColor: Color,
|
|
34
|
+
highlightColor: Color,
|
|
35
|
+
trailColor: Color,
|
|
36
|
+
): Color {
|
|
34
37
|
if (wave <= 3) return highlightColor;
|
|
35
38
|
if (wave <= 8) return trailColor;
|
|
36
39
|
return baseColor;
|
|
@@ -50,11 +53,17 @@ const GLOW_TRAIL_TONES = {
|
|
|
50
53
|
v: "c",
|
|
51
54
|
} as const;
|
|
52
55
|
|
|
53
|
-
function colorAsciiCharacter(
|
|
56
|
+
export function colorAsciiCharacter(
|
|
54
57
|
theme: SimpleTheme,
|
|
55
|
-
color: string,
|
|
58
|
+
color: string | ThemeForegroundColor,
|
|
56
59
|
character: string,
|
|
57
60
|
): string {
|
|
61
|
+
if (typeof color !== "string") {
|
|
62
|
+
const themedCharacter = theme.fg(color.token, character);
|
|
63
|
+
return color.dimmed
|
|
64
|
+
? `\u001B[2m${themedCharacter}\u001B[22m`
|
|
65
|
+
: themedCharacter;
|
|
66
|
+
}
|
|
58
67
|
const hexMatch = RAW_HEX_COLOR.exec(color);
|
|
59
68
|
if (!hexMatch) return theme.fg(color, character);
|
|
60
69
|
const red = Number.parseInt(hexMatch[1] ?? "", 16);
|