@pmndrs/pine 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +180 -2
- package/bin/pine.js +27 -0
- package/docs/SKILL.md +189 -0
- package/docs/references/agents.md +368 -0
- package/docs/references/coding.md +126 -0
- package/docs/references/configuration.md +229 -0
- package/docs/references/events.md +263 -0
- package/docs/references/mcp.md +257 -0
- package/docs/references/operations.md +280 -0
- package/docs/references/overview.md +186 -0
- package/docs/references/security.md +489 -0
- package/docs/references/setup.md +181 -0
- package/docs/references/sources.md +768 -0
- package/docs/references/stack.md +105 -0
- package/docs/references/tui.md +348 -0
- package/examples/home/.env.example +5 -0
- package/examples/home/USER.md +4 -0
- package/examples/home/agents/main/agent.json +4 -0
- package/examples/home/agents/main/workspace/AGENTS.md +14 -0
- package/examples/home/agents/main/workspace/MEMORY.md +4 -0
- package/examples/home/agents/main/workspace/SOUL.md +4 -0
- package/examples/home/models.json +62 -0
- package/examples/home/pine.json +30 -0
- package/examples/home/skills/README.txt +7 -0
- package/examples/launchd/com.pine.daemon.plist +32 -0
- package/mcp/browser.d.ts +34 -0
- package/mcp/browser.js +228 -0
- package/mcp/commands.d.ts +20 -0
- package/mcp/commands.js +88 -0
- package/mcp/lib.d.ts +13 -0
- package/mcp/lib.js +50 -0
- package/mcp/web.d.ts +6 -0
- package/mcp/web.js +140 -0
- package/package.json +32 -4
- package/src/agent-admin.d.ts +24 -0
- package/src/agent-admin.js +55 -0
- package/src/agents.d.ts +343 -0
- package/src/agents.js +1587 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +526 -0
- package/src/config.d.ts +351 -0
- package/src/config.js +514 -0
- package/src/dev/claude-model.d.ts +28 -0
- package/src/dev/claude-model.js +174 -0
- package/src/env-file.d.ts +4 -0
- package/src/env-file.js +40 -0
- package/src/http.d.ts +46 -0
- package/src/http.js +407 -0
- package/src/limiter.d.ts +32 -0
- package/src/limiter.js +84 -0
- package/src/login.d.ts +21 -0
- package/src/login.js +126 -0
- package/src/manual.d.ts +61 -0
- package/src/manual.js +425 -0
- package/src/mcp-config.d.ts +40 -0
- package/src/mcp-config.js +132 -0
- package/src/net.d.ts +36 -0
- package/src/net.js +122 -0
- package/src/pine.d.ts +32 -0
- package/src/pine.js +142 -0
- package/src/router.d.ts +56 -0
- package/src/router.js +164 -0
- package/src/sandbox-fs.d.ts +27 -0
- package/src/sandbox-fs.js +172 -0
- package/src/security.d.ts +121 -0
- package/src/security.js +363 -0
- package/src/skills.d.ts +67 -0
- package/src/skills.js +163 -0
- package/src/source-types.d.ts +61 -0
- package/src/source-types.js +1 -0
- package/src/sources/command.d.ts +4 -0
- package/src/sources/command.js +77 -0
- package/src/sources/folder.d.ts +6 -0
- package/src/sources/folder.js +116 -0
- package/src/sources/ftp.d.ts +4 -0
- package/src/sources/ftp.js +307 -0
- package/src/sources/http.d.ts +4 -0
- package/src/sources/http.js +201 -0
- package/src/sources/mqtt.d.ts +18 -0
- package/src/sources/mqtt.js +97 -0
- package/src/sources/telegram.d.ts +22 -0
- package/src/sources/telegram.js +269 -0
- package/src/sources/webhook.d.ts +4 -0
- package/src/sources/webhook.js +52 -0
- package/src/sources.d.ts +339 -0
- package/src/sources.js +955 -0
- package/src/store.d.ts +89 -0
- package/src/store.js +251 -0
- package/src/timers.d.ts +83 -0
- package/src/timers.js +318 -0
- package/src/tools.d.ts +6 -0
- package/src/tools.js +777 -0
- package/src/tui/app.d.ts +209 -0
- package/src/tui/app.js +2223 -0
- package/src/tui/client.d.ts +178 -0
- package/src/tui/client.js +116 -0
- package/src/tui/code-pane.d.ts +51 -0
- package/src/tui/code-pane.js +398 -0
- package/src/tui/highlight.d.ts +8 -0
- package/src/tui/highlight.js +86 -0
- package/src/tui/picture.d.ts +141 -0
- package/src/tui/picture.js +571 -0
- package/src/tui/run.d.ts +8 -0
- package/src/tui/run.js +97 -0
- package/src/tui/theme.d.ts +32 -0
- package/src/tui/theme.js +128 -0
- package/src/types.d.ts +124 -0
- package/src/types.js +1 -0
- package/src/update.d.ts +25 -0
- package/src/update.js +110 -0
- package/src/util.d.ts +32 -0
- package/src/util.js +116 -0
- package/src/watching.d.ts +20 -0
- package/src/watching.js +68 -0
- package/src/workspace-view.d.ts +38 -0
- package/src/workspace-view.js +160 -0
package/src/cli.d.ts
ADDED
package/src/cli.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ConfigError, defaultHome, effectiveMcp, loadConfig, mainAgentId, PINE_ROOT } from "./config.js";
|
|
5
|
+
import { audit } from "./security.js";
|
|
6
|
+
import { pineManual, showRead, showSearch } from "./manual.js";
|
|
7
|
+
import { allSkills, listSkills } from "./skills.js";
|
|
8
|
+
import { whenText } from "./timers.js";
|
|
9
|
+
import { expandHome } from "./util.js";
|
|
10
|
+
const HELP = `pine - PI neural engine
|
|
11
|
+
|
|
12
|
+
Start here
|
|
13
|
+
pine open the terminal UI (same as "pine tui")
|
|
14
|
+
pine . | pine <folder> the same, with a tab on that folder: a conversation of the main agent that
|
|
15
|
+
works there (the one from last time, if there is one)
|
|
16
|
+
pine tui chat with your agents; starts a daemon inside if none is running
|
|
17
|
+
pine login <provider> sign in to a model provider (OpenCode, Claude, ChatGPT, ...)
|
|
18
|
+
pine logout <provider>
|
|
19
|
+
|
|
20
|
+
Daemon
|
|
21
|
+
pine init [dir] set pine up: a home directory (default ~/.pine) from the example, and the
|
|
22
|
+
browser its web tools use. Safe to run again
|
|
23
|
+
pine check validate the configuration and list what it sets up
|
|
24
|
+
pine start run the daemon in the foreground
|
|
25
|
+
pine dev-model [--port 7464] [--model haiku]
|
|
26
|
+
OpenAI-compatible model server backed by "claude -p" (development only)
|
|
27
|
+
pine update [--check] bring pine up to date (npm for an install, git for a checkout) with what the
|
|
28
|
+
new version needs; --check only says whether there is something newer
|
|
29
|
+
pine version which version this is and how it is installed
|
|
30
|
+
pine browser-install fetch the browser again (pine init and pine update do this)
|
|
31
|
+
|
|
32
|
+
Talking to a running daemon
|
|
33
|
+
pine status running tasks, live sessions, queue
|
|
34
|
+
pine agents configured agents
|
|
35
|
+
pine send <agent> <text...> give an agent a task and print its answer
|
|
36
|
+
[--session <key>] [--no-wait]
|
|
37
|
+
pine emit <topic> [json] publish an event
|
|
38
|
+
pine events [--topic <pattern>] [--limit n] [-f]
|
|
39
|
+
show the event log; -f follows live
|
|
40
|
+
pine tasks [--agent id] [--status s] recent tasks
|
|
41
|
+
pine task <id> one task in full
|
|
42
|
+
pine cancel <id> stop a task
|
|
43
|
+
pine state [key-pattern] shared state
|
|
44
|
+
pine approvals open approval requests
|
|
45
|
+
pine approve <id> [--always] | pine deny <id> --always: do not ask again in that conversation
|
|
46
|
+
pine reset <agent> [session] forget a conversation (also clears its taint)
|
|
47
|
+
pine mode <agent> [manual|auto|bypass]
|
|
48
|
+
show or change how much an agent may do without asking
|
|
49
|
+
|
|
50
|
+
Event sources
|
|
51
|
+
pine sources configured sources, whether they run, who is subscribed, last event
|
|
52
|
+
pine subscribe <agent> <source> [--session name] [--note text] [--throttle 5m] [--when json] [--separate]
|
|
53
|
+
wake that agent whenever the source reports something: in its main
|
|
54
|
+
conversation, or only the conversation named with --session
|
|
55
|
+
pine subscribe --global <source> [--note text] ...
|
|
56
|
+
wake every agent that can use the source
|
|
57
|
+
pine unsubscribe <agent> <source> [--session name] | pine unsubscribe --global <source>
|
|
58
|
+
|
|
59
|
+
Schedules, skills, the manual
|
|
60
|
+
pine schedules what is scheduled: for whom, when, and the task
|
|
61
|
+
pine skills every skill: whom it is for and where it lies
|
|
62
|
+
pine manual <words> search pine's manual, as the agents do; --read <id> shows a part in full
|
|
63
|
+
|
|
64
|
+
MCP servers
|
|
65
|
+
pine mcp list configured servers, who gets them, and whether they connect
|
|
66
|
+
pine mcp add <name> <https-url> [--agent id] [--taints] [--approve always|tainted] [--timeout seconds]
|
|
67
|
+
pine mcp add <name> [--agent id] -- <command> [args...]
|
|
68
|
+
pine mcp remove <name> [--agent id]
|
|
69
|
+
pine mcp login <name> sign in to a remote server through the browser (OAuth)
|
|
70
|
+
pine mcp logout <name>
|
|
71
|
+
|
|
72
|
+
Options
|
|
73
|
+
--home <dir> home directory (default $PINE_HOME or ~/.pine)
|
|
74
|
+
`;
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const flags = {};
|
|
77
|
+
const rest = [];
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const a = argv[i];
|
|
80
|
+
if (a === "-f")
|
|
81
|
+
flags.follow = true;
|
|
82
|
+
else if (a.startsWith("--no-"))
|
|
83
|
+
flags[a.slice(5)] = false;
|
|
84
|
+
else if (a.startsWith("--"))
|
|
85
|
+
flags[a.slice(2)] = argv[i + 1] !== undefined && !argv[i + 1].startsWith("--") && !["follow", "json", "api-key", "oauth", "taints", "offline", "always", "separate", "global"].includes(a.slice(2)) ? argv[++i] : true;
|
|
86
|
+
else
|
|
87
|
+
rest.push(a);
|
|
88
|
+
}
|
|
89
|
+
return { flags, rest };
|
|
90
|
+
}
|
|
91
|
+
function copyDir(from, to, skip = () => false) {
|
|
92
|
+
const created = [];
|
|
93
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
94
|
+
const src = path.join(from, entry.name);
|
|
95
|
+
const dst = path.join(to, entry.name === ".env.example" ? ".env" : entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
98
|
+
created.push(...copyDir(src, dst, skip));
|
|
99
|
+
}
|
|
100
|
+
else if (!fs.existsSync(dst) && !skip(dst)) {
|
|
101
|
+
fs.copyFileSync(src, dst);
|
|
102
|
+
fs.chmodSync(dst, 0o600);
|
|
103
|
+
created.push(dst);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return created;
|
|
107
|
+
}
|
|
108
|
+
function client(cfg) {
|
|
109
|
+
if (!cfg.http)
|
|
110
|
+
throw new Error("http is disabled in pine.json, so there is no control API to talk to");
|
|
111
|
+
const tokenFile = path.join(cfg.dataDir, "http-token");
|
|
112
|
+
const token = cfg.http.token ?? (fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, "utf8").trim() : "");
|
|
113
|
+
const base = `http://${cfg.http.host === "0.0.0.0" ? "127.0.0.1" : cfg.http.host}:${cfg.http.port}`;
|
|
114
|
+
const call = async (method, url, body) => {
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await fetch(base + url, { method, headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new Error(`no daemon answers on ${base}. Start it with "pine start".`);
|
|
121
|
+
}
|
|
122
|
+
const json = await res.json().catch(() => ({}));
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
125
|
+
return json;
|
|
126
|
+
};
|
|
127
|
+
return { base, token, get: (u) => call("GET", u), post: (u, b = {}) => call("POST", u, b) };
|
|
128
|
+
}
|
|
129
|
+
const when = (ts) => new Date(ts).toLocaleString("sv-SE");
|
|
130
|
+
const line = (e) => `${when(e.ts)} #${String(e.id).padEnd(5)} ${e.topic.padEnd(34)} ${e.trust === "owner" ? "owner " : "untrusted"} ${e.source.padEnd(14)} ${JSON.stringify(e.payload).slice(0, 160)}`;
|
|
131
|
+
/** pine mcp list | add | remove | login | logout */
|
|
132
|
+
async function mcpCommand(cfg, argv, out) {
|
|
133
|
+
const { addServer, findServer, listServers, piMcp, removeServer, serverStates } = await import("./mcp-config.js");
|
|
134
|
+
// Everything after "--" is the command line of a local server.
|
|
135
|
+
const dashes = argv.indexOf("--");
|
|
136
|
+
const command = dashes >= 0 ? argv.slice(dashes + 1) : [];
|
|
137
|
+
const { flags, rest } = parseArgs((dashes >= 0 ? argv.slice(0, dashes) : argv).filter((a, i, all) => !(a === "--home" || all[i - 1] === "--home")));
|
|
138
|
+
const sub = rest.shift() ?? "list";
|
|
139
|
+
const agent = typeof flags.agent === "string" ? flags.agent : undefined;
|
|
140
|
+
// A running daemon applies the change at once; without one the files are edited and it applies at the next start.
|
|
141
|
+
const live = async (body) => {
|
|
142
|
+
try {
|
|
143
|
+
const api = client(cfg);
|
|
144
|
+
if (!(await fetch(`${api.base}/health`, { signal: AbortSignal.timeout(800) })).ok)
|
|
145
|
+
return false;
|
|
146
|
+
await api.post("/mcp", body);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (e instanceof Error && !/no daemon|fetch failed|HTTP 404|not found|aborted|timeout/i.test(e.message))
|
|
151
|
+
throw e;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
switch (sub) {
|
|
156
|
+
case "list": {
|
|
157
|
+
const servers = listServers(cfg);
|
|
158
|
+
if (!servers.length)
|
|
159
|
+
return out("no MCP servers configured");
|
|
160
|
+
const states = flags.offline ? {} : await serverStates(cfg);
|
|
161
|
+
for (const s of servers)
|
|
162
|
+
out(`${s.name.padEnd(14)} ${(s.enabled ? (states[s.name] ?? (s.remote ? "?" : "local")) : "disabled").padEnd(16)} ${s.scope === "global" ? `all: ${s.agents.join(", ") || "nobody"}` : `only ${s.scope}`}\n${" ".repeat(15)}${s.target}${s.taints ? " [outside content]" : ""}${s.approve !== "never" ? ` [approval: ${s.approve}]` : ""}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
case "add": {
|
|
166
|
+
const name = rest[0];
|
|
167
|
+
const url = rest[1];
|
|
168
|
+
if (!name || (!url && !command.length))
|
|
169
|
+
throw new Error("usage: pine mcp add <name> <https-url> [--agent id] [--taints] [--approve always|tainted] [--timeout seconds] [--description text]\n pine mcp add <name> [--agent id] -- <command> [args...]");
|
|
170
|
+
const server = url ? { url } : { command: command[0], args: command.slice(1) };
|
|
171
|
+
if (typeof flags.description === "string")
|
|
172
|
+
server.description = flags.description;
|
|
173
|
+
if (flags.taints === true)
|
|
174
|
+
server.taints = true;
|
|
175
|
+
if (flags.timeout !== undefined) {
|
|
176
|
+
if (!(Number(flags.timeout) > 0))
|
|
177
|
+
throw new Error("--timeout takes a number of seconds, for example --timeout 180");
|
|
178
|
+
server.timeout = Number(flags.timeout);
|
|
179
|
+
}
|
|
180
|
+
if (typeof flags.approve === "string")
|
|
181
|
+
server.approve = flags.approve;
|
|
182
|
+
if (typeof flags.exposure === "string")
|
|
183
|
+
server.exposure = flags.exposure;
|
|
184
|
+
if (!(await live({ name, server, agent })))
|
|
185
|
+
addServer(cfg, name, server, agent);
|
|
186
|
+
out(`Added "${name}" ${agent ? `for agent ${agent}` : "for all agents"}.${url ? ` If it requires sign-in: pine mcp login ${name}` : ""}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
case "remove": {
|
|
190
|
+
if (!rest[0])
|
|
191
|
+
throw new Error("usage: pine mcp remove <name> [--agent id]");
|
|
192
|
+
if (!(await live({ name: rest[0], agent, remove: true })) && !removeServer(cfg, rest[0], agent))
|
|
193
|
+
throw new Error(`no server "${rest[0]}"${agent ? ` on agent ${agent}` : " in pine.json"}`);
|
|
194
|
+
return out(`Removed "${rest[0]}".`);
|
|
195
|
+
}
|
|
196
|
+
case "login":
|
|
197
|
+
case "logout": {
|
|
198
|
+
const name = rest[0];
|
|
199
|
+
const server = name ? findServer(cfg, name, agent) : undefined;
|
|
200
|
+
if (!name || !server)
|
|
201
|
+
throw new Error(name ? `no server "${name}". pine mcp list shows what is configured.` : `usage: pine mcp ${sub} <name>`);
|
|
202
|
+
if (!server.url)
|
|
203
|
+
throw new Error(`"${name}" is a local server; only remote servers have a sign-in`);
|
|
204
|
+
const { code } = await piMcp(cfg, { [name]: server }, [sub, name]);
|
|
205
|
+
if (code === 0 && sub === "login") {
|
|
206
|
+
// Sessions that started before the sign-in do not have the server's tools yet.
|
|
207
|
+
try {
|
|
208
|
+
await client(cfg).post("/mcp/refresh");
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// No daemon running: nothing to refresh.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
process.exitCode = code;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
throw new Error(`unknown: pine mcp ${sub}. Use list, add, remove, login or logout.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Download the Chromium build the bundled browser tools drive. It has to match the Playwright
|
|
223
|
+
* version pine ships with, so that copy's own installer is used. Quick when it is there already.
|
|
224
|
+
*/
|
|
225
|
+
async function installBrowser() {
|
|
226
|
+
const { spawnSync } = await import("node:child_process");
|
|
227
|
+
const { createRequire } = await import("node:module");
|
|
228
|
+
const cli = path.join(path.dirname(createRequire(import.meta.url).resolve("playwright-core")), "cli.js");
|
|
229
|
+
return spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" }).status ?? 1;
|
|
230
|
+
}
|
|
231
|
+
const COMMANDS = new Set(["agents", "approvals", "approve", "browser-install", "cancel", "check", "deny", "dev-model", "emit", "events", "help", "init", "login", "logout", "manual", "mcp", "mode", "reset", "schedules", "send", "skills", "sources", "start", "state", "status", "subscribe", "task", "tasks", "tui", "unsubscribe", "update", "version"]);
|
|
232
|
+
/** Whether a first argument means a folder to open: something written as a path, or the name of a folder here that is not a command. */
|
|
233
|
+
function folderArgument(word, cwd = process.cwd()) {
|
|
234
|
+
if (!word || COMMANDS.has(word))
|
|
235
|
+
return undefined;
|
|
236
|
+
const full = path.resolve(cwd, expandHome(word));
|
|
237
|
+
const isDir = (() => {
|
|
238
|
+
try {
|
|
239
|
+
return fs.statSync(full).isDirectory();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
})();
|
|
245
|
+
if (isDir)
|
|
246
|
+
return full;
|
|
247
|
+
// Written as a path but not there: an error about the folder says more than "unknown command".
|
|
248
|
+
if (/^(\.{1,2}|~)($|\/)|\//.test(word))
|
|
249
|
+
throw new Error(`${word} is not a folder`);
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
async function main() {
|
|
253
|
+
const { flags, rest } = parseArgs(process.argv.slice(2));
|
|
254
|
+
let cmd = rest.shift();
|
|
255
|
+
// "pine ." and "pine ~/dev/project" open the terminal UI with a tab on that folder.
|
|
256
|
+
let folder = folderArgument(cmd);
|
|
257
|
+
if (folder)
|
|
258
|
+
cmd = "tui";
|
|
259
|
+
else if (cmd === "tui")
|
|
260
|
+
folder = folderArgument(rest[0]);
|
|
261
|
+
const home = path.resolve(expandHome(typeof flags.home === "string" ? flags.home : defaultHome()));
|
|
262
|
+
const out = (v) => console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
|
|
263
|
+
if (cmd === "help" || flags.help || (!cmd && !process.stdout.isTTY))
|
|
264
|
+
return out(HELP);
|
|
265
|
+
if (cmd === "manual") {
|
|
266
|
+
// The same search the agents use, for you and for trying out what they would find.
|
|
267
|
+
const manual = pineManual(path.join(PINE_ROOT, "docs"));
|
|
268
|
+
if (typeof flags.read === "string")
|
|
269
|
+
return out(showRead(manual, flags.read.split(",")));
|
|
270
|
+
if (!rest.length)
|
|
271
|
+
return out(`The parts of pine's manual. Search it (pine manual telegram pairing), or read a part (pine manual --read sources#telegram).\n${manual.contents()}`);
|
|
272
|
+
return out(showSearch(manual, [rest.join(" ")]));
|
|
273
|
+
}
|
|
274
|
+
if (cmd === "init") {
|
|
275
|
+
const target = path.resolve(expandHome(rest[0] ?? home));
|
|
276
|
+
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
277
|
+
fs.chmodSync(target, 0o700);
|
|
278
|
+
// A home from before instructions moved into the workspace keeps them where they are: with
|
|
279
|
+
// a second AGENTS.md in the workspace the agent would have both.
|
|
280
|
+
const kept = [];
|
|
281
|
+
const created = copyDir(path.join(PINE_ROOT, "examples", "home"), target, (dst) => {
|
|
282
|
+
const old = path.join(path.dirname(path.dirname(dst)), "AGENTS.md");
|
|
283
|
+
const twice = path.basename(dst) === "AGENTS.md" && path.basename(path.dirname(dst)) === "workspace" && fs.existsSync(old);
|
|
284
|
+
if (twice)
|
|
285
|
+
kept.push(old);
|
|
286
|
+
return twice;
|
|
287
|
+
});
|
|
288
|
+
out(created.length ? `Created in ${target}:\n${created.map((f) => ` ${path.relative(target, f)}`).join("\n")}` : `${target} already has every example file; nothing changed.`);
|
|
289
|
+
for (const old of kept)
|
|
290
|
+
out(`\n${path.relative(target, old)} stays where it is: there the agent reads it but cannot change it. To let the agent keep its own instructions, move it to ${path.relative(target, path.join(path.dirname(old), "workspace", "AGENTS.md"))}.`);
|
|
291
|
+
// Everything the built-in tools need comes with it, so that what is set up also works. The
|
|
292
|
+
// browser is the one large piece; a second run finds it there and takes a moment.
|
|
293
|
+
// (PINE_SKIP_DOWNLOADS is for pine's own tests, which must not fetch 600 MB.)
|
|
294
|
+
if (!process.env.PINE_SKIP_DOWNLOADS) {
|
|
295
|
+
out("\nGetting the browser the built-in web tools drive (about 600 MB, once) ...");
|
|
296
|
+
const status = await installBrowser();
|
|
297
|
+
if (status !== 0) {
|
|
298
|
+
out(`\nThe browser could not be downloaded (exit ${status}). Everything else is set up; web search and page reading work without it. Run "pine init" again when you are online to get it.`);
|
|
299
|
+
process.exitCode = 1;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
out(`\nReady. Start with: pine${target === defaultHome() ? "" : ` --home ${target}`}\nThe example uses Claude through "claude -p". For another model: pine login <provider>, then /model in the UI.`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (cmd === "browser-install")
|
|
307
|
+
process.exit(await installBrowser());
|
|
308
|
+
if (cmd === "version" || flags.version === true) {
|
|
309
|
+
const { installed } = await import("./update.js");
|
|
310
|
+
const me = installed();
|
|
311
|
+
return out(`pine ${me.version} (${me.kind === "checkout" ? `checkout in ${me.root}` : me.kind === "global" ? "installed with npm" : `a dependency in ${me.root}`})`);
|
|
312
|
+
}
|
|
313
|
+
if (cmd === "update") {
|
|
314
|
+
const { update } = await import("./update.js");
|
|
315
|
+
process.exit(await update({ check: flags.check === true, out, browser: !process.env.PINE_SKIP_DOWNLOADS }));
|
|
316
|
+
}
|
|
317
|
+
if (cmd === "dev-model") {
|
|
318
|
+
const { startClaudeModel } = await import("./dev/claude-model.js");
|
|
319
|
+
const server = await startClaudeModel({ port: Number(flags.port ?? 7464), model: typeof flags.model === "string" ? flags.model : "haiku", log: (l) => console.error(`${new Date().toISOString()} ${l}`) });
|
|
320
|
+
out(`claude -p model server on ${server.baseUrl} (model ids: claude-haiku, claude-sonnet, claude-opus). Ctrl-C to stop.`);
|
|
321
|
+
process.on("SIGINT", () => void server.close().then(() => process.exit(0)));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if ((!cmd || cmd === "tui") && !fs.existsSync(path.join(home, "pine.json")))
|
|
325
|
+
return out(`No pine home at ${home} yet. Create one with:\n\n pine init\n\n${HELP}`);
|
|
326
|
+
const cfg = loadConfig(home);
|
|
327
|
+
if (!cmd || cmd === "tui") {
|
|
328
|
+
const { runTui } = await import("./tui/run.js");
|
|
329
|
+
return runTui(cfg, { folder });
|
|
330
|
+
}
|
|
331
|
+
if (cmd === "login" || cmd === "logout") {
|
|
332
|
+
const { login, logout } = await import("./login.js");
|
|
333
|
+
if (!rest[0])
|
|
334
|
+
throw new Error(`usage: pine ${cmd} <provider> (for example opencode-go, opencode, anthropic, openai-codex)`);
|
|
335
|
+
if (cmd === "login")
|
|
336
|
+
await login(cfg, rest[0], flags["api-key"] === true ? "api_key" : flags.oauth === true ? "oauth" : undefined);
|
|
337
|
+
else
|
|
338
|
+
await logout(cfg, rest[0]);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (cmd === "mcp")
|
|
342
|
+
return mcpCommand(cfg, process.argv.slice(process.argv.indexOf("mcp") + 1), out);
|
|
343
|
+
if (cmd === "schedules") {
|
|
344
|
+
const list = [...cfg.schedules.map((s) => ({ s, whom: s.task ? "all agents" : `event ${s.topic} only` })), ...Object.values(cfg.agents).flatMap((a) => a.schedules.map((s) => ({ s, whom: s.session ? `conversation ${s.session} of ${a.id}` : `agent ${a.id}` })))];
|
|
345
|
+
if (!list.length)
|
|
346
|
+
return out("nothing is scheduled. An agent schedules something when you ask it to (\"every morning at seven ...\", \"remind me at five\"), or write it under \"schedules\"; see docs/references/events.md");
|
|
347
|
+
for (const { s, whom } of list) {
|
|
348
|
+
out(`${s.name} ${whom} ${whenText(s)}`);
|
|
349
|
+
if (s.task)
|
|
350
|
+
out(` ${s.task}`);
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (cmd === "skills") {
|
|
355
|
+
const list = allSkills(cfg);
|
|
356
|
+
if (!list.length)
|
|
357
|
+
return out("no skills yet. An agent writes one when you ask it to (\"make a skill for this\"), or put a folder with a SKILL.md into the skills folder of pine's home or of an agent; see docs/references/agents.md");
|
|
358
|
+
for (const s of list) {
|
|
359
|
+
out(`${s.name} ${s.scope === "global" ? "all agents" : s.scope === "agent" ? `agent ${s.agent}` : `conversation ${s.session} of ${s.agent}`}${s.managed ? "" : " [yours, from the configuration]"}`);
|
|
360
|
+
out(` ${s.description}`);
|
|
361
|
+
out(` ${s.dir}`);
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (cmd === "check") {
|
|
366
|
+
out(`home ${cfg.home}`);
|
|
367
|
+
out(`http ${cfg.http ? `${cfg.http.host}:${cfg.http.port}, hooks: ${Object.keys(cfg.http.hooks).join(", ") || "none"}` : "off"}`);
|
|
368
|
+
out(`global mcp ${Object.keys(cfg.mcp).join(", ") || "none"}`);
|
|
369
|
+
out(`routes ${cfg.routes.length}, schedules ${cfg.schedules.length}`);
|
|
370
|
+
for (const a of Object.values(cfg.agents)) {
|
|
371
|
+
out(`\nagent ${a.id}${a.id === mainAgentId(cfg) ? " (main)" : ""}: ${a.description || "no description"}`);
|
|
372
|
+
out(` workspace ${a.workspace}`);
|
|
373
|
+
out(` model ${a.model ?? cfg.defaults.model}`);
|
|
374
|
+
out(` tools ${(a.tools ?? ["read", "bash", "edit", "write"]).join(", ")}`);
|
|
375
|
+
out(` mcp ${Object.keys(effectiveMcp(cfg, a)).join(", ") || "none"}`);
|
|
376
|
+
out(` skills ${listSkills(cfg, a, "main").map((s) => s.name).join(", ") || "none"}`);
|
|
377
|
+
out(` delegates ${a.delegates.join(", ") || "none"}; sub-agents ${a.subagents.enabled ? `up to ${a.subagents.maxParallel}` : "off"}`);
|
|
378
|
+
out(` mode ${a.mode}`);
|
|
379
|
+
out(` sandbox ${a.sandbox.enabled ? `on, network ${a.sandbox.network ? "allowed" : "blocked"}` : "OFF"}; untrusted events: ${a.untrusted === false ? "refused" : a.untrusted.tools.join(", ") || "no tools"}`);
|
|
380
|
+
}
|
|
381
|
+
const warnings = audit(cfg);
|
|
382
|
+
out(warnings.length ? `\nSecurity notes:\n${warnings.map((w) => ` ! ${w}`).join("\n")}` : "\nNo security notes.");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (cmd === "start") {
|
|
386
|
+
const { startPine } = await import("./pine.js");
|
|
387
|
+
// One misbehaving extension or MCP client must not take every agent down with it.
|
|
388
|
+
process.on("unhandledRejection", (e) => console.error(`${new Date().toISOString()} ERROR [pine] unhandled rejection: ${e instanceof Error ? (e.stack ?? e.message) : e}`));
|
|
389
|
+
process.on("uncaughtException", (e) => console.error(`${new Date().toISOString()} ERROR [pine] uncaught exception: ${e.stack ?? e.message}`));
|
|
390
|
+
const pine = await startPine({ config: cfg });
|
|
391
|
+
const stop = (sig) => {
|
|
392
|
+
console.error(`\n${sig} received, stopping`);
|
|
393
|
+
void pine.stop().then(() => process.exit(0));
|
|
394
|
+
setTimeout(() => process.exit(1), 10_000).unref();
|
|
395
|
+
};
|
|
396
|
+
process.on("SIGINT", () => stop("SIGINT"));
|
|
397
|
+
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const api = client(cfg);
|
|
401
|
+
const qs = (o) => {
|
|
402
|
+
const p = Object.entries(o).filter(([, v]) => v !== undefined && v !== true && v !== false);
|
|
403
|
+
return p.length ? `?${p.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&")}` : "";
|
|
404
|
+
};
|
|
405
|
+
switch (cmd) {
|
|
406
|
+
case "status":
|
|
407
|
+
return out(await api.get("/status"));
|
|
408
|
+
case "agents":
|
|
409
|
+
return out(await api.get("/agents"));
|
|
410
|
+
case "send": {
|
|
411
|
+
const agent = rest.shift();
|
|
412
|
+
if (!agent || !rest.length)
|
|
413
|
+
throw new Error("usage: pine send <agent> <text...>");
|
|
414
|
+
const r = await api.post(`/agents/${agent}/prompt`, { prompt: rest.join(" "), session: flags.session, wait: flags.wait !== false });
|
|
415
|
+
if (flags.wait === false)
|
|
416
|
+
return out(`task #${r.task} queued`);
|
|
417
|
+
if (r.status !== "done")
|
|
418
|
+
throw new Error(`task #${r.id} ${r.status}: ${r.error ?? ""}`);
|
|
419
|
+
return out(r.text);
|
|
420
|
+
}
|
|
421
|
+
case "emit": {
|
|
422
|
+
if (!rest[0])
|
|
423
|
+
throw new Error("usage: pine emit <topic> [json]");
|
|
424
|
+
const r = await api.post("/events", { topic: rest[0], payload: rest[1] !== undefined ? JSON.parse(rest[1]) : null });
|
|
425
|
+
return out(`event #${r.id}`);
|
|
426
|
+
}
|
|
427
|
+
case "events": {
|
|
428
|
+
const events = await api.get(`/events${qs({ topic: flags.topic, limit: flags.limit ?? 30 })}`);
|
|
429
|
+
for (const e of events)
|
|
430
|
+
out(line(e));
|
|
431
|
+
if (!flags.follow)
|
|
432
|
+
return;
|
|
433
|
+
const res = await fetch(`${api.base}/events/stream${qs({ topic: flags.topic })}`, { headers: { authorization: `Bearer ${api.token}` } });
|
|
434
|
+
let buf = "";
|
|
435
|
+
for await (const chunk of res.body) {
|
|
436
|
+
buf += Buffer.from(chunk).toString("utf8");
|
|
437
|
+
for (let i = buf.indexOf("\n\n"); i >= 0; i = buf.indexOf("\n\n")) {
|
|
438
|
+
const block = buf.slice(0, i);
|
|
439
|
+
buf = buf.slice(i + 2);
|
|
440
|
+
if (block.startsWith("data: "))
|
|
441
|
+
out(line(JSON.parse(block.slice(6))));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
case "tasks": {
|
|
447
|
+
const tasks = await api.get(`/tasks${qs({ agent: flags.agent, status: flags.status, limit: flags.limit ?? 20 })}`);
|
|
448
|
+
for (const t of tasks.reverse())
|
|
449
|
+
out(`#${String(t.id).padEnd(5)} ${when(t.created)} ${t.status.padEnd(9)} ${`${t.agent}/${t.session}`.padEnd(26)} ${t.kind.padEnd(9)} ${String(t.meta?.label ?? t.prompt).replace(/\s+/g, " ").slice(0, 90)}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
case "task":
|
|
453
|
+
return out(await api.get(`/tasks/${rest[0]}`));
|
|
454
|
+
case "cancel":
|
|
455
|
+
return out(await api.post(`/tasks/${rest[0]}/cancel`));
|
|
456
|
+
case "state":
|
|
457
|
+
for (const s of (await api.get(`/state${qs({ key: rest[0] })}`)))
|
|
458
|
+
out(`${s.key} = ${JSON.stringify(s.value)} (${when(s.ts)}, ${s.source})`);
|
|
459
|
+
return;
|
|
460
|
+
case "sources": {
|
|
461
|
+
const list = await api.get("/sources");
|
|
462
|
+
if (!list.length)
|
|
463
|
+
return out(`no event sources configured (add "sources" to pine.json or an agent.json; see docs/references/sources.md)`);
|
|
464
|
+
for (const s of list) {
|
|
465
|
+
out(`${s.name}${s.scope === "global" ? "" : ` (of ${s.scope})`} ${s.type} ${s.error ? `not running: ${s.error}` : s.running ? "running" : "idle, nobody is subscribed"}${s.trust === "owner" ? " [yours]" : ""}`);
|
|
466
|
+
if (s.description)
|
|
467
|
+
out(` ${s.description}`);
|
|
468
|
+
if (s.status)
|
|
469
|
+
out(` ${s.status}`);
|
|
470
|
+
out(` subscribers: ${s.subscribers.join(", ") || "none"} · ${s.last ? `last event ${when(s.last.ts)} (${s.last.topic}), ${s.events} since start` : "no events since start"}`);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
case "subscribe":
|
|
475
|
+
case "unsubscribe": {
|
|
476
|
+
const details = { note: flags.note, throttle: flags.throttle, separate: flags.separate === true, when: typeof flags.when === "string" ? JSON.parse(flags.when) : undefined };
|
|
477
|
+
if (flags.global === true) {
|
|
478
|
+
if (!rest[0])
|
|
479
|
+
throw new Error(`usage: pine ${cmd} --global <source>`);
|
|
480
|
+
if (cmd === "unsubscribe")
|
|
481
|
+
return out((await api.post("/unsubscribe", { source: rest[0] })).unsubscribed ? `all agents: no longer subscribed to ${rest[0]}` : `there was no subscription of all agents to ${rest[0]}`);
|
|
482
|
+
const all = await api.post("/subscribe", { source: rest[0], ...details });
|
|
483
|
+
return out(`all agents are subscribed to ${rest[0]} (${all.running ? "running" : "not running"}); it wakes ${all.agents.join(", ") || "nobody yet"}, each in its main conversation`);
|
|
484
|
+
}
|
|
485
|
+
if (!rest[0] || !rest[1])
|
|
486
|
+
throw new Error(`usage: pine ${cmd} <agent> <source>${cmd === "subscribe" ? " [--session name] [--note text] [--throttle 5m] [--when json]" : " [--session name]"}\n pine ${cmd} --global <source>`);
|
|
487
|
+
if (cmd === "unsubscribe") {
|
|
488
|
+
const r = await api.post(`/agents/${rest[0]}/unsubscribe`, { source: rest[1], session: typeof flags.session === "string" ? flags.session : undefined });
|
|
489
|
+
return out(r.unsubscribed ? `${rest[0]} is no longer subscribed to ${rest[1]}${r.sessions.some((x) => x !== "main") ? ` (conversations: ${r.sessions.join(", ")})` : ""}` : `${rest[0]} was not subscribed to ${rest[1]}`);
|
|
490
|
+
}
|
|
491
|
+
const info = await api.post(`/agents/${rest[0]}/subscribe`, { source: rest[1], session: flags.session, ...details });
|
|
492
|
+
return out(`${rest[0]} is subscribed to ${rest[1]} (${info.running ? "running" : "not running"}); each event wakes it in its conversation "${info.session}"`);
|
|
493
|
+
}
|
|
494
|
+
case "approvals": {
|
|
495
|
+
const list = await api.get("/approvals");
|
|
496
|
+
if (!list.length)
|
|
497
|
+
return out("nothing waits for approval");
|
|
498
|
+
for (const a of list)
|
|
499
|
+
out(`${a.id} ${a.agent} (task #${a.task}) wants ${a.summary.split("\n")[0]}\n why: ${a.why}\n expires ${when(a.expires)}`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
case "approve":
|
|
503
|
+
case "deny":
|
|
504
|
+
return out(await api.post(`/approvals/${rest[0]}`, { allow: cmd === "approve", always: flags.always === true }));
|
|
505
|
+
case "mode": {
|
|
506
|
+
if (!rest[0])
|
|
507
|
+
throw new Error("usage: pine mode <agent> [manual|auto|bypass]");
|
|
508
|
+
if (rest[1])
|
|
509
|
+
return out(await api.post(`/agents/${rest[0]}/mode`, { mode: rest[1] }));
|
|
510
|
+
const agent = (await api.get("/agents")).find((a) => a.id === rest[0]);
|
|
511
|
+
if (!agent)
|
|
512
|
+
throw new Error(`unknown agent "${rest[0]}"`);
|
|
513
|
+
return out(agent.mode);
|
|
514
|
+
}
|
|
515
|
+
case "reset":
|
|
516
|
+
if (!rest[0])
|
|
517
|
+
throw new Error("usage: pine reset <agent> [session]");
|
|
518
|
+
return out(await api.post(`/agents/${rest[0]}/reset`, { session: rest[1] }));
|
|
519
|
+
default:
|
|
520
|
+
throw new Error(`unknown command "${cmd}". Run "pine help".`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
main().catch((e) => {
|
|
524
|
+
console.error(e instanceof ConfigError ? e.message : `pine: ${e instanceof Error ? e.message : e}`);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
});
|