@dcl-regenesislabs/opendcl 0.1.0-22234509684.commit-63dfd19
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 +234 -0
- package/context/components-reference.md +113 -0
- package/context/open-source-3d-assets.md +705 -0
- package/context/sdk7-complete-reference.md +3684 -0
- package/context/sdk7-examples.md +1709 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/scene-context.d.ts +97 -0
- package/dist/scene-context.d.ts.map +1 -0
- package/dist/scene-context.js +203 -0
- package/dist/scene-context.js.map +1 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -0
- package/extensions/dcl-context.ts +123 -0
- package/extensions/dcl-deploy.ts +89 -0
- package/extensions/dcl-header.ts +162 -0
- package/extensions/dcl-init.ts +62 -0
- package/extensions/dcl-preview.ts +144 -0
- package/extensions/dcl-setup-ollama.ts +312 -0
- package/extensions/dcl-setup.ts +96 -0
- package/extensions/dcl-status.ts +88 -0
- package/extensions/dcl-tasks.ts +102 -0
- package/extensions/dcl-update-check.ts +79 -0
- package/extensions/dcl-validate.ts +80 -0
- package/extensions/plan-mode/index.ts +340 -0
- package/extensions/plan-mode/utils.ts +168 -0
- package/extensions/process-registry.ts +25 -0
- package/extensions/scene-utils.ts +31 -0
- package/package.json +65 -0
- package/prompts/explain.md +16 -0
- package/prompts/review.md +19 -0
- package/prompts/system.md +126 -0
- package/skills/add-3d-models/SKILL.md +115 -0
- package/skills/add-interactivity/SKILL.md +176 -0
- package/skills/advanced-input/SKILL.md +238 -0
- package/skills/advanced-rendering/SKILL.md +235 -0
- package/skills/animations-tweens/SKILL.md +173 -0
- package/skills/audio-video/SKILL.md +167 -0
- package/skills/authoritative-server/SKILL.md +329 -0
- package/skills/build-ui/SKILL.md +231 -0
- package/skills/camera-control/SKILL.md +199 -0
- package/skills/create-scene/SKILL.md +67 -0
- package/skills/deploy-scene/SKILL.md +106 -0
- package/skills/deploy-worlds/SKILL.md +107 -0
- package/skills/lighting-environment/SKILL.md +216 -0
- package/skills/multiplayer-sync/SKILL.md +132 -0
- package/skills/nft-blockchain/SKILL.md +246 -0
- package/skills/optimize-scene/SKILL.md +160 -0
- package/skills/player-avatar/SKILL.md +239 -0
- package/skills/smart-items/SKILL.md +181 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom header extension for OpenDCL.
|
|
3
|
+
* Shows a block-character "Decentraland" ASCII art header with version
|
|
4
|
+
* and working directory. Falls back to a compact text header on narrow terminals.
|
|
5
|
+
* Also auto-sets quietStartup in user settings to suppress raw file path listing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
|
|
17
|
+
/** Block-character "Decentraland" art — Small Mono 12 figlet style, 7 lines. */
|
|
18
|
+
const HEADER_ART: string[] = [
|
|
19
|
+
"▗▄▄ ▗▄▖ ▗▖",
|
|
20
|
+
"▐▛▀█ ▐▌ ▝▜▌ ▐▌",
|
|
21
|
+
"▐▌ ▐▌ ▟█▙ ▟██▖ ▟█▙ ▐▙██▖▐███ █▟█▌ ▟██▖ ▐▌ ▟██▖▐▙██▖ ▟█▟▌",
|
|
22
|
+
"▐▌ ▐▌▐▙▄▟▌▐▛ ▘▐▙▄▟▌▐▛ ▐▌ ▐▌ █▘ ▘▄▟▌ ▐▌ ▘▄▟▌▐▛ ▐▌▐▛ ▜▌",
|
|
23
|
+
"▐▌ ▐▌▐▛▀▀▘▐▌ ▐▛▀▀▘▐▌ ▐▌ ▐▌ █ ▗█▀▜▌ ▐▌ ▗█▀▜▌▐▌ ▐▌▐▌ ▐▌",
|
|
24
|
+
"▐▙▄█ ▝█▄▄▌▝█▄▄▌▝█▄▄▌▐▌ ▐▌ ▐▙▄ █ ▐▙▄█▌ ▐▙▄ ▐▙▄█▌▐▌ ▐▌▝█▄█▌",
|
|
25
|
+
"▝▀▀ ▝▀▀ ▝▀▀ ▝▀▀ ▝▘ ▝▘ ▀▀ ▀ ▀▀▝▘ ▀▀ ▀▀▝▘▝▘ ▝▘ ▝▀▝▘",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Minimum terminal width to show the full block-character art. */
|
|
29
|
+
const MIN_ART_WIDTH = 65;
|
|
30
|
+
|
|
31
|
+
export function getVersion(): string {
|
|
32
|
+
try {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
34
|
+
return pkg.version || "0.0.0";
|
|
35
|
+
} catch {
|
|
36
|
+
return "0.0.0";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads skill directories and extracts the `name` field
|
|
42
|
+
* from each SKILL.md YAML frontmatter.
|
|
43
|
+
*/
|
|
44
|
+
export function getSkillNames(skillsDir: string): string[] {
|
|
45
|
+
try {
|
|
46
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
47
|
+
const names: string[] = [];
|
|
48
|
+
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.isDirectory()) continue;
|
|
51
|
+
const skillPath = join(skillsDir, entry.name, "SKILL.md");
|
|
52
|
+
try {
|
|
53
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
54
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
55
|
+
if (match) {
|
|
56
|
+
const nameMatch = match[1].match(/^name:\s*(.+)$/m);
|
|
57
|
+
if (nameMatch) {
|
|
58
|
+
names.push(nameMatch[1].trim());
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Fallback to directory name if frontmatter parsing fails
|
|
63
|
+
names.push(entry.name);
|
|
64
|
+
} catch {
|
|
65
|
+
// Skip skills that can't be read
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return names.sort();
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ensures quietStartup is set in the user's settings file.
|
|
77
|
+
* Creates the settings file if it doesn't exist.
|
|
78
|
+
* If quietStartup is already set (to any value), leaves it alone.
|
|
79
|
+
*/
|
|
80
|
+
export function ensureQuietStartup(settingsPath: string): void {
|
|
81
|
+
try {
|
|
82
|
+
const dir = dirname(settingsPath);
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
if (existsSync(settingsPath)) {
|
|
86
|
+
const raw = readFileSync(settingsPath, "utf-8");
|
|
87
|
+
const settings = JSON.parse(raw);
|
|
88
|
+
if (!("quietStartup" in settings)) {
|
|
89
|
+
settings.quietStartup = true;
|
|
90
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
writeFileSync(settingsPath, JSON.stringify({ quietStartup: true }, null, 2) + "\n", "utf-8");
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Settings write failure should not break startup
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Shorten an absolute path by replacing the user's home directory with `~`. */
|
|
101
|
+
export function shortenPath(path: string): string {
|
|
102
|
+
const home = homedir();
|
|
103
|
+
if (path === home) return "~";
|
|
104
|
+
if (path.startsWith(home + "/")) return "~" + path.slice(home.length);
|
|
105
|
+
return path;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const extension: ExtensionFactory = (pi) => {
|
|
109
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
110
|
+
// Auto-set quietStartup in user settings
|
|
111
|
+
const settingsPath = join(homedir(), ".opendcl", "agent", "settings.json");
|
|
112
|
+
ensureQuietStartup(settingsPath);
|
|
113
|
+
|
|
114
|
+
if (!ctx.hasUI) return;
|
|
115
|
+
|
|
116
|
+
const version = getVersion();
|
|
117
|
+
const cwd = ctx.cwd;
|
|
118
|
+
|
|
119
|
+
ctx.ui.setHeader((_tui, theme) => ({
|
|
120
|
+
render(width: number): string[] {
|
|
121
|
+
if (width >= MIN_ART_WIDTH) {
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
|
|
124
|
+
// Block-character art in accent color
|
|
125
|
+
for (const line of HEADER_ART) {
|
|
126
|
+
lines.push(theme.fg("accent", line));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// "by RegenesisLabs" right-aligned under the art
|
|
130
|
+
const tag = "by RegenesisLabs";
|
|
131
|
+
const artWidth = HEADER_ART[2].length; // widest line
|
|
132
|
+
const pad = Math.max(0, artWidth - tag.length);
|
|
133
|
+
lines.push(" ".repeat(pad) + theme.fg("dim", tag));
|
|
134
|
+
|
|
135
|
+
// Blank line
|
|
136
|
+
lines.push("");
|
|
137
|
+
|
|
138
|
+
// Version line
|
|
139
|
+
lines.push(
|
|
140
|
+
theme.bold(theme.fg("accent", "OpenDCL")) +
|
|
141
|
+
theme.fg("dim", ` v${version} — AI assistant for Decentraland SDK7`),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Working directory
|
|
145
|
+
lines.push(theme.fg("dim", shortenPath(cwd)));
|
|
146
|
+
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Narrow terminal fallback — compact 2-line header
|
|
151
|
+
return [
|
|
152
|
+
theme.bold(theme.fg("accent", "OpenDCL")) +
|
|
153
|
+
theme.fg("dim", ` v${version} — AI assistant for Decentraland SDK7`),
|
|
154
|
+
theme.fg("dim", shortenPath(cwd)),
|
|
155
|
+
];
|
|
156
|
+
},
|
|
157
|
+
invalidate() {},
|
|
158
|
+
}));
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export default extension;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCL Init Extension
|
|
3
|
+
*
|
|
4
|
+
* Registers the `init` tool (LLM-callable) and `/init` command that scaffolds
|
|
5
|
+
* a new Decentraland scene project using `npx @dcl/sdk-commands init`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { fileExists } from "./scene-utils.js";
|
|
12
|
+
|
|
13
|
+
async function initScene(
|
|
14
|
+
cwd: string,
|
|
15
|
+
pi: { exec(cmd: string, args: string[], opts?: unknown): Promise<{ code: number; stdout: string; stderr: string }> }
|
|
16
|
+
): Promise<{ message: string; isError?: boolean }> {
|
|
17
|
+
if (await fileExists(join(cwd, "scene.json"))) {
|
|
18
|
+
return { message: "A scene.json already exists in this directory. Aborting to prevent overwriting.", isError: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const result = await pi.exec("npx", ["@dcl/sdk-commands", "init", "--skip-install"], {
|
|
23
|
+
cwd,
|
|
24
|
+
timeout: 60000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (result.code === 0) {
|
|
28
|
+
return { message: "Scene initialized! Run 'npm install' to install dependencies, then use the preview tool to start." };
|
|
29
|
+
} else {
|
|
30
|
+
return { message: `Init failed (exit code ${result.code}): ${result.stderr || result.stdout}`, isError: true };
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { message: `Failed to initialize scene: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const extension: ExtensionFactory = (pi) => {
|
|
38
|
+
pi.registerTool({
|
|
39
|
+
name: "init",
|
|
40
|
+
label: "Init Scene",
|
|
41
|
+
description:
|
|
42
|
+
"Initialize a new Decentraland SDK7 scene. Scaffolds scene.json, package.json, tsconfig.json, and src/index.ts. Use when user wants to create or start a new scene.",
|
|
43
|
+
parameters: Type.Object({}),
|
|
44
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
45
|
+
const result = await initScene(ctx.cwd, pi);
|
|
46
|
+
if (!result.isError) await ctx.reload();
|
|
47
|
+
return { content: [{ type: "text" as const, text: result.message }], details: undefined };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
pi.registerCommand("init", {
|
|
52
|
+
description: "Initialize a new Decentraland scene project in the current directory",
|
|
53
|
+
handler: async (_args, ctx) => {
|
|
54
|
+
ctx.ui.notify("Initializing new Decentraland scene...", "info");
|
|
55
|
+
const result = await initScene(ctx.cwd, pi);
|
|
56
|
+
ctx.ui.notify(result.message, result.isError ? "error" : "info");
|
|
57
|
+
if (!result.isError) await ctx.reload();
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default extension;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCL Preview Extension
|
|
3
|
+
*
|
|
4
|
+
* Registers the `preview` tool (LLM-callable) and `/preview` command that starts
|
|
5
|
+
* the Decentraland development server using `npx @dcl/sdk-commands start --bevy-web`.
|
|
6
|
+
* Registers with the shared process registry so it can be managed via /tasks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { Type } from "@sinclair/typebox";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
+
import { processes } from "./process-registry.js";
|
|
14
|
+
import { fileExists, findSceneRoot } from "./scene-utils.js";
|
|
15
|
+
import { updateStatus } from "./dcl-tasks.js";
|
|
16
|
+
|
|
17
|
+
export function selectPreviewUrl(
|
|
18
|
+
output: string,
|
|
19
|
+
bevyUrlAlreadyFound: boolean
|
|
20
|
+
): { url: string; shouldNotify: boolean } | null {
|
|
21
|
+
const urls = [...output.matchAll(/https?:\/\/[^\s]+/g)].map((m) => m[0]);
|
|
22
|
+
if (urls.length === 0) return null;
|
|
23
|
+
|
|
24
|
+
const bevyUrl = urls.find((u) => u.includes("decentraland.zone/bevy-web"));
|
|
25
|
+
if (bevyUrl) return { url: bevyUrl, shouldNotify: true };
|
|
26
|
+
|
|
27
|
+
if (!bevyUrlAlreadyFound) return { url: urls[0], shouldNotify: false };
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PreviewContext {
|
|
33
|
+
ui: {
|
|
34
|
+
notify(message: string, type?: string): void;
|
|
35
|
+
setStatus(key: string, text: string | undefined): void;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const extension: ExtensionFactory = (pi) => {
|
|
40
|
+
let previewProcess: ChildProcess | null = null;
|
|
41
|
+
|
|
42
|
+
function cleanupPreview(): void {
|
|
43
|
+
if (previewProcess && !previewProcess.killed) {
|
|
44
|
+
previewProcess.kill();
|
|
45
|
+
}
|
|
46
|
+
previewProcess = null;
|
|
47
|
+
processes.delete("preview");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function startPreviewServer(
|
|
51
|
+
cwd: string,
|
|
52
|
+
ctx: PreviewContext
|
|
53
|
+
): Promise<{ message: string; isError?: boolean }> {
|
|
54
|
+
const sceneRoot = await findSceneRoot(cwd);
|
|
55
|
+
|
|
56
|
+
if (!sceneRoot) {
|
|
57
|
+
return { message: "No scene.json found. Create a scene first with /init.", isError: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!(await fileExists(join(sceneRoot, "node_modules")))) {
|
|
61
|
+
return { message: "node_modules not found. Run 'npm install' first.", isError: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
cleanupPreview();
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
previewProcess = spawn("npx", ["@dcl/sdk-commands", "start", "--bevy-web"], {
|
|
68
|
+
cwd: sceneRoot,
|
|
69
|
+
stdio: "pipe",
|
|
70
|
+
shell: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
processes.set("preview", {
|
|
74
|
+
name: "Preview server",
|
|
75
|
+
kill: cleanupPreview,
|
|
76
|
+
});
|
|
77
|
+
updateStatus(ctx);
|
|
78
|
+
|
|
79
|
+
let bevyUrlFound = false;
|
|
80
|
+
|
|
81
|
+
function handleOutput(data: Buffer): void {
|
|
82
|
+
const output = data.toString().trim();
|
|
83
|
+
if (output.includes("EADDRINUSE") || output.includes("address already in use")) {
|
|
84
|
+
ctx.ui.notify("Port already in use. Try /tasks to stop existing servers.", "error");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const result = selectPreviewUrl(output, bevyUrlFound);
|
|
88
|
+
if (result) {
|
|
89
|
+
if (result.shouldNotify) bevyUrlFound = true;
|
|
90
|
+
processes.set("preview", {
|
|
91
|
+
name: "Preview server",
|
|
92
|
+
info: result.url,
|
|
93
|
+
kill: cleanupPreview,
|
|
94
|
+
});
|
|
95
|
+
if (result.shouldNotify) {
|
|
96
|
+
ctx.ui.notify(`Preview server running at ${result.url}`, "info");
|
|
97
|
+
}
|
|
98
|
+
updateStatus(ctx);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
previewProcess.stdout?.on("data", handleOutput);
|
|
103
|
+
previewProcess.stderr?.on("data", handleOutput);
|
|
104
|
+
|
|
105
|
+
previewProcess.on("error", (err) => {
|
|
106
|
+
ctx.ui.notify(`Failed to start preview: ${err.message}`, "error");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
previewProcess.on("exit", (code) => {
|
|
110
|
+
if (code !== 0 && code !== null) {
|
|
111
|
+
ctx.ui.notify(`Preview server exited with code ${code}`, "warning");
|
|
112
|
+
}
|
|
113
|
+
cleanupPreview();
|
|
114
|
+
updateStatus(ctx);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return { message: `Preview server starting in ${sceneRoot}` };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { message: `Failed to start preview: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pi.registerTool({
|
|
124
|
+
name: "preview",
|
|
125
|
+
label: "Preview",
|
|
126
|
+
description:
|
|
127
|
+
"Start the Decentraland preview server. Use when the user wants to preview, run, test, or see their scene.",
|
|
128
|
+
parameters: Type.Object({}),
|
|
129
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
130
|
+
const result = await startPreviewServer(ctx.cwd, ctx);
|
|
131
|
+
return { content: [{ type: "text" as const, text: result.message }], details: undefined };
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pi.registerCommand("preview", {
|
|
136
|
+
description: "Start the Decentraland preview server",
|
|
137
|
+
handler: async (_args, ctx) => {
|
|
138
|
+
const result = await startPreviewServer(ctx.cwd, ctx);
|
|
139
|
+
ctx.ui.notify(result.message, result.isError ? "error" : "info");
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default extension;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCL Setup Ollama Extension
|
|
3
|
+
*
|
|
4
|
+
* Registers the /setup-ollama command that walks users through
|
|
5
|
+
* model selection and configuration for a free local LLM setup.
|
|
6
|
+
* On session start, nudges users who have no provider configured.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
interface OllamaProvider {
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
api?: string;
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
models?: { id: string }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const API_KEY_ENV_VARS = [
|
|
23
|
+
"ANTHROPIC_API_KEY",
|
|
24
|
+
"OPENAI_API_KEY",
|
|
25
|
+
"GOOGLE_API_KEY",
|
|
26
|
+
"OPENROUTER_API_KEY",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const OLLAMA_MODELS = [
|
|
30
|
+
{ id: "qwen2.5-coder:32b", label: "qwen2.5-coder:32b (Recommended — best coding benchmarks, ~18GB)" },
|
|
31
|
+
{ id: "qwen3-coder:30b", label: "qwen3-coder:30b (Latest Alibaba coder, 256K context, ~19GB)" },
|
|
32
|
+
{ id: "devstral:24b", label: "devstral:24b (Mistral coding agent model, ~14GB)" },
|
|
33
|
+
{ id: "glm-4.7-flash", label: "glm-4.7-flash (Reasoning + code generation, ~25GB)" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
async function readJsonFile<T>(path: string, fallback: T): Promise<T> {
|
|
37
|
+
try {
|
|
38
|
+
const content = await readFile(path, "utf-8");
|
|
39
|
+
return JSON.parse(content);
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function writeJsonFile(path: string, data: unknown): Promise<void> {
|
|
46
|
+
await mkdir(dirname(path), { recursive: true });
|
|
47
|
+
await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function writeModelsConfig(modelsPath: string, modelId: string): Promise<void> {
|
|
51
|
+
const config = await readJsonFile<{ providers?: Record<string, unknown> }>(modelsPath, {});
|
|
52
|
+
config.providers ??= {};
|
|
53
|
+
|
|
54
|
+
const ollama = config.providers.ollama as OllamaProvider | undefined;
|
|
55
|
+
if (ollama) {
|
|
56
|
+
ollama.models ??= [];
|
|
57
|
+
if (!ollama.models.some((m) => m.id === modelId)) {
|
|
58
|
+
ollama.models.push({ id: modelId });
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
config.providers.ollama = {
|
|
62
|
+
baseUrl: "http://localhost:11434/v1",
|
|
63
|
+
api: "openai-completions",
|
|
64
|
+
apiKey: "ollama",
|
|
65
|
+
models: [{ id: modelId }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await writeJsonFile(modelsPath, config);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function setDefaultModel(settingsPath: string, provider: string, modelId: string): Promise<void> {
|
|
73
|
+
const settings = await readJsonFile<Record<string, unknown>>(settingsPath, {});
|
|
74
|
+
|
|
75
|
+
settings.defaultProvider = provider;
|
|
76
|
+
settings.defaultModel = modelId;
|
|
77
|
+
|
|
78
|
+
await writeJsonFile(settingsPath, settings);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function isProviderConfigured(): Promise<boolean> {
|
|
82
|
+
if (API_KEY_ENV_VARS.some((v) => process.env[v])) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const configDir = join(homedir(), ".opendcl", "agent");
|
|
87
|
+
|
|
88
|
+
const modelsConfig = await readJsonFile<{ providers?: Record<string, unknown> }>(join(configDir, "models.json"), {});
|
|
89
|
+
if (modelsConfig.providers != null && Object.keys(modelsConfig.providers).length > 0) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const authConfig = await readJsonFile<Record<string, unknown>>(join(configDir, "auth.json"), {});
|
|
94
|
+
return Object.keys(authConfig).length > 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseOllamaList(output: string): string[] {
|
|
98
|
+
const lines = output.split(/\r?\n/).filter((l) => l.trim());
|
|
99
|
+
// Skip header line (starts with "NAME")
|
|
100
|
+
const dataLines = lines.filter((l) => !l.startsWith("NAME"));
|
|
101
|
+
return dataLines
|
|
102
|
+
.map((l) => l.split(/\s+/)[0])
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((name) => name.replace(/:latest$/, ""));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function removeOllamaModel(
|
|
108
|
+
modelsPath: string,
|
|
109
|
+
settingsPath: string,
|
|
110
|
+
modelId: string,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const config = await readJsonFile<{ providers?: Record<string, unknown> }>(modelsPath, {});
|
|
113
|
+
const ollama = config.providers?.ollama as OllamaProvider | undefined;
|
|
114
|
+
if (ollama?.models) {
|
|
115
|
+
ollama.models = ollama.models.filter((m) => m.id !== modelId);
|
|
116
|
+
if (ollama.models.length === 0) {
|
|
117
|
+
delete config.providers!.ollama;
|
|
118
|
+
}
|
|
119
|
+
await writeJsonFile(modelsPath, config);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const settings = await readJsonFile<Record<string, unknown>>(settingsPath, {});
|
|
123
|
+
if (settings.defaultProvider === "ollama" && settings.defaultModel === modelId) {
|
|
124
|
+
delete settings.defaultProvider;
|
|
125
|
+
delete settings.defaultModel;
|
|
126
|
+
await writeJsonFile(settingsPath, settings);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run ollama commands through the user's login shell so the full PATH is used.
|
|
132
|
+
* (pi.exec without shell won't find binaries added by installers to profile files.)
|
|
133
|
+
*/
|
|
134
|
+
function ollamaExec(pi: Parameters<ExtensionFactory>[0], args: string, timeout = 10000) {
|
|
135
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
136
|
+
return pi.exec(shell, ["-lc", `ollama ${args}`], { timeout }).catch(() => null);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stripAnsi(text: string): string {
|
|
140
|
+
return text.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract a clean, human-readable progress line from raw ollama pull output.
|
|
145
|
+
* Ollama outputs ANSI-heavy terminal UI — this strips escape codes and
|
|
146
|
+
* picks the most informative line (percentage progress > phase labels).
|
|
147
|
+
*/
|
|
148
|
+
export function extractPullProgress(raw: string): string | null {
|
|
149
|
+
const clean = stripAnsi(raw);
|
|
150
|
+
const lines = clean.split(/\r\n|\r|\n/).map((l) => l.trim()).filter(Boolean);
|
|
151
|
+
// Prefer the line with download percentage (e.g. "pulling abc123: 45% 8 GB/18 GB 12 MB/s")
|
|
152
|
+
const progress = lines.findLast((l) => /\d+%/.test(l));
|
|
153
|
+
if (progress) return progress;
|
|
154
|
+
// Fall back to phase lines like "pulling manifest", "verifying sha256 digest"
|
|
155
|
+
const phase = lines.findLast((l) => /^(pulling|verifying|writing|success)/.test(l));
|
|
156
|
+
return phase ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Pull a model with streaming progress via spawn.
|
|
161
|
+
* Throttles notifications to avoid UI spam (one update every 2 seconds max).
|
|
162
|
+
*/
|
|
163
|
+
export function ollamaPull(
|
|
164
|
+
modelId: string,
|
|
165
|
+
onProgress: (line: string) => void,
|
|
166
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
169
|
+
const child = spawn(shell, ["-lc", `ollama pull ${modelId}`], {
|
|
170
|
+
stdio: "pipe",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let lastNotify = 0;
|
|
174
|
+
let lastLine = "";
|
|
175
|
+
let errorOutput = "";
|
|
176
|
+
|
|
177
|
+
function handleData(data: Buffer): void {
|
|
178
|
+
const text = data.toString();
|
|
179
|
+
errorOutput += text;
|
|
180
|
+
const line = extractPullProgress(text);
|
|
181
|
+
if (!line) return;
|
|
182
|
+
lastLine = line;
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (now - lastNotify >= 2000) {
|
|
185
|
+
lastNotify = now;
|
|
186
|
+
onProgress(line);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
child.stdout?.on("data", handleData);
|
|
191
|
+
child.stderr?.on("data", handleData);
|
|
192
|
+
|
|
193
|
+
child.on("error", (err) => {
|
|
194
|
+
resolve({ success: false, error: err.message });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
child.on("exit", (code) => {
|
|
198
|
+
if (lastLine) onProgress(lastLine);
|
|
199
|
+
if (code === 0) {
|
|
200
|
+
resolve({ success: true });
|
|
201
|
+
} else {
|
|
202
|
+
resolve({ success: false, error: stripAnsi(errorOutput).slice(-500) });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const extension: ExtensionFactory = (pi) => {
|
|
209
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
210
|
+
if (!(await isProviderConfigured())) {
|
|
211
|
+
ctx.ui.notify(
|
|
212
|
+
"Get started by running /setup (cloud providers) or /setup-ollama (free local models)",
|
|
213
|
+
"warning",
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const configDir = join(homedir(), ".opendcl", "agent");
|
|
219
|
+
const settingsPath = join(configDir, "settings.json");
|
|
220
|
+
const settings = await readJsonFile<Record<string, unknown>>(settingsPath, {});
|
|
221
|
+
if (settings.defaultProvider !== "ollama") return;
|
|
222
|
+
|
|
223
|
+
const defaultModel = settings.defaultModel as string | undefined;
|
|
224
|
+
if (!defaultModel) return;
|
|
225
|
+
|
|
226
|
+
const listResult = await ollamaExec(pi, "list");
|
|
227
|
+
if (!listResult || listResult.code !== 0) return;
|
|
228
|
+
|
|
229
|
+
const installed = parseOllamaList(listResult.stdout || "");
|
|
230
|
+
if (installed.includes(defaultModel)) return;
|
|
231
|
+
|
|
232
|
+
const modelsPath = join(configDir, "models.json");
|
|
233
|
+
await removeOllamaModel(modelsPath, settingsPath, defaultModel);
|
|
234
|
+
ctx.ui.notify(
|
|
235
|
+
`Model '${defaultModel}' is no longer installed in Ollama. Run /setup-ollama to configure a new model.`,
|
|
236
|
+
"warning",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
pi.registerCommand("setup-ollama", {
|
|
241
|
+
description: "Configure Ollama as your free local LLM provider",
|
|
242
|
+
handler: async (_args, ctx) => {
|
|
243
|
+
const configDir = join(homedir(), ".opendcl", "agent");
|
|
244
|
+
|
|
245
|
+
const versionResult = await ollamaExec(pi, "--version");
|
|
246
|
+
if (!versionResult || versionResult.code !== 0) {
|
|
247
|
+
ctx.ui.notify("Ollama is not installed. Download it from https://ollama.com then run /setup-ollama again.", "warning");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const listResult = await ollamaExec(pi, "list");
|
|
252
|
+
if (!listResult || listResult.code !== 0) {
|
|
253
|
+
ctx.ui.notify("Ollama is installed but not running. Start it with 'ollama serve', then run /setup-ollama again.", "warning");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const installed = parseOllamaList(listResult.stdout || "");
|
|
258
|
+
const theme = ctx.ui.theme;
|
|
259
|
+
const sorted = [...OLLAMA_MODELS].sort((a, b) => {
|
|
260
|
+
const aInstalled = installed.includes(a.id) ? 0 : 1;
|
|
261
|
+
const bInstalled = installed.includes(b.id) ? 0 : 1;
|
|
262
|
+
return aInstalled - bInstalled;
|
|
263
|
+
});
|
|
264
|
+
const labels = sorted.map((m) =>
|
|
265
|
+
installed.includes(m.id)
|
|
266
|
+
? `${m.label} ${theme.fg("success", "● ready")}`
|
|
267
|
+
: `${m.label} ${theme.fg("dim", "○ needs download")}`,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const selected = await ctx.ui.select("Which model do you want to use?", labels);
|
|
271
|
+
if (!selected) {
|
|
272
|
+
ctx.ui.notify("Setup cancelled.", "info");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const model = sorted.find((m) => selected.startsWith(m.label));
|
|
277
|
+
if (!model) {
|
|
278
|
+
ctx.ui.notify("Invalid selection.", "error");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const alreadyInstalled = installed.includes(model.id);
|
|
283
|
+
if (!alreadyInstalled) {
|
|
284
|
+
ctx.ui.setStatus("pull", `Pulling ${model.id}...`);
|
|
285
|
+
const pullResult = await ollamaPull(model.id, (line) => {
|
|
286
|
+
ctx.ui.setStatus("pull", line);
|
|
287
|
+
});
|
|
288
|
+
ctx.ui.setStatus("pull", undefined);
|
|
289
|
+
if (!pullResult.success) {
|
|
290
|
+
ctx.ui.notify(`Failed to pull model: ${pullResult.error || "unknown error"}`, "error");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
ctx.ui.notify("Model ready.", "info");
|
|
295
|
+
|
|
296
|
+
const modelsPath = join(configDir, "models.json");
|
|
297
|
+
await writeModelsConfig(modelsPath, model.id);
|
|
298
|
+
|
|
299
|
+
const settingsPath = join(configDir, "settings.json");
|
|
300
|
+
await setDefaultModel(settingsPath, "ollama", model.id);
|
|
301
|
+
|
|
302
|
+
ctx.ui.notify(
|
|
303
|
+
`Ollama configured: ${model.id}. Reloading...`,
|
|
304
|
+
"info",
|
|
305
|
+
);
|
|
306
|
+
await ctx.reload();
|
|
307
|
+
return;
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export default extension;
|