@qearlyao/familiar 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HEARTBEAT.md +1 -1
- package/README.md +63 -4
- package/dist/agent.js +88 -36
- package/dist/browser-tools.js +0 -12
- package/dist/cli.js +68 -24
- package/dist/control.js +1 -0
- package/dist/discord.js +14 -2
- package/dist/hot-reload.js +132 -0
- package/dist/index.js +1 -0
- package/dist/runtime.js +1 -1
- package/dist/scheduler.js +3 -1
- package/dist/service.js +284 -0
- package/dist/web-auth.js +5 -2
- package/dist/web.js +6 -1
- package/package.json +9 -5
- package/scripts/install.ps1 +185 -0
- package/scripts/install.sh +226 -0
- package/skills/image-gen/SKILL.md +36 -0
package/dist/runtime.js
CHANGED
|
@@ -223,7 +223,7 @@ export class ConversationRuntime {
|
|
|
223
223
|
return undefined;
|
|
224
224
|
const [rawCommand = "", ...argParts] = normalized.split(" ");
|
|
225
225
|
const command = rawCommand.replace(/^\//, "").toLowerCase();
|
|
226
|
-
if (!["stop", "status", "new", "reload", "compact", "model", "thinking", "channel-trigger"].includes(command)) {
|
|
226
|
+
if (!["stop", "status", "new", "reload", "restart", "compact", "model", "thinking", "channel-trigger"].includes(command)) {
|
|
227
227
|
return undefined;
|
|
228
228
|
}
|
|
229
229
|
return {
|
package/dist/scheduler.js
CHANGED
|
@@ -193,7 +193,9 @@ export function isHeartbeatDue(options) {
|
|
|
193
193
|
if (idleDurationMs < options.idleThresholdMs)
|
|
194
194
|
return false;
|
|
195
195
|
const lastHeartbeatAt = options.lastHeartbeatAt ? Date.parse(options.lastHeartbeatAt) : undefined;
|
|
196
|
-
if (lastHeartbeatAt == null ||
|
|
196
|
+
if (lastHeartbeatAt == null ||
|
|
197
|
+
!Number.isFinite(lastHeartbeatAt) ||
|
|
198
|
+
lastHeartbeatAt <= options.lastUserInteractionAt) {
|
|
197
199
|
return true;
|
|
198
200
|
}
|
|
199
201
|
return options.now - lastHeartbeatAt >= Math.max(0, options.intervalMs);
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const SERVICE_LABEL = "com.qearlyao.familiar";
|
|
9
|
+
const SYSTEMD_SERVICE = "familiar.service";
|
|
10
|
+
function servicePaths(workspacePath, input) {
|
|
11
|
+
const logDir = resolve(workspacePath, "logs");
|
|
12
|
+
return {
|
|
13
|
+
servicePath: input.platform === "darwin"
|
|
14
|
+
? resolve(input.homeDir, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`)
|
|
15
|
+
: resolve(input.homeDir, ".config", "systemd", "user", SYSTEMD_SERVICE),
|
|
16
|
+
logDir,
|
|
17
|
+
stdoutPath: resolve(logDir, "familiar.out.log"),
|
|
18
|
+
stderrPath: resolve(logDir, "familiar.err.log"),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function buildSpec(workspacePath, options = {}) {
|
|
22
|
+
const currentPlatform = options.platform ?? platform();
|
|
23
|
+
const cliPath = options.cliPath ?? currentCliPath();
|
|
24
|
+
const resolvedWorkspacePath = resolve(workspacePath);
|
|
25
|
+
return {
|
|
26
|
+
platform: currentPlatform,
|
|
27
|
+
workspacePath: resolvedWorkspacePath,
|
|
28
|
+
nodePath: options.nodePath ?? process.execPath,
|
|
29
|
+
cliPath,
|
|
30
|
+
paths: servicePaths(resolvedWorkspacePath, {
|
|
31
|
+
platform: currentPlatform,
|
|
32
|
+
homeDir: options.homeDir ?? homedir(),
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function currentCliPath() {
|
|
37
|
+
if (!process.argv[1])
|
|
38
|
+
throw new Error("Cannot determine familiar CLI path for service installation.");
|
|
39
|
+
return resolve(process.argv[1]);
|
|
40
|
+
}
|
|
41
|
+
function versionManagedPathWarning(spec) {
|
|
42
|
+
const joined = `${spec.nodePath}\n${spec.cliPath}`;
|
|
43
|
+
const marker = ["/.nvm/", "/.asdf/", "/.fnm/", "/.volta/"].find((candidate) => joined.includes(candidate));
|
|
44
|
+
if (!marker)
|
|
45
|
+
return undefined;
|
|
46
|
+
return `warning: service uses a version-manager path (${marker}); reinstall the service after changing Node versions.`;
|
|
47
|
+
}
|
|
48
|
+
function xmlEscape(value) {
|
|
49
|
+
return value
|
|
50
|
+
.replaceAll("&", "&")
|
|
51
|
+
.replaceAll("<", "<")
|
|
52
|
+
.replaceAll(">", ">")
|
|
53
|
+
.replaceAll('"', """)
|
|
54
|
+
.replaceAll("'", "'");
|
|
55
|
+
}
|
|
56
|
+
function systemdQuote(value) {
|
|
57
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value))
|
|
58
|
+
return value;
|
|
59
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
|
|
60
|
+
}
|
|
61
|
+
function launchdPlist(spec) {
|
|
62
|
+
const args = [spec.nodePath, spec.cliPath, "run", spec.workspacePath]
|
|
63
|
+
.map((value) => `\t\t<string>${xmlEscape(value)}</string>`)
|
|
64
|
+
.join("\n");
|
|
65
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
66
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
67
|
+
<plist version="1.0">
|
|
68
|
+
<dict>
|
|
69
|
+
\t<key>Label</key>
|
|
70
|
+
\t<string>${SERVICE_LABEL}</string>
|
|
71
|
+
\t<key>ProgramArguments</key>
|
|
72
|
+
\t<array>
|
|
73
|
+
${args}
|
|
74
|
+
\t</array>
|
|
75
|
+
\t<key>WorkingDirectory</key>
|
|
76
|
+
\t<string>${xmlEscape(spec.workspacePath)}</string>
|
|
77
|
+
\t<key>RunAtLoad</key>
|
|
78
|
+
\t<true/>
|
|
79
|
+
\t<key>KeepAlive</key>
|
|
80
|
+
\t<true/>
|
|
81
|
+
\t<key>ThrottleInterval</key>
|
|
82
|
+
\t<integer>30</integer>
|
|
83
|
+
\t<key>ExitTimeOut</key>
|
|
84
|
+
\t<integer>20</integer>
|
|
85
|
+
\t<key>StandardOutPath</key>
|
|
86
|
+
\t<string>${xmlEscape(spec.paths.stdoutPath)}</string>
|
|
87
|
+
\t<key>StandardErrorPath</key>
|
|
88
|
+
\t<string>${xmlEscape(spec.paths.stderrPath)}</string>
|
|
89
|
+
\t<key>EnvironmentVariables</key>
|
|
90
|
+
\t<dict>
|
|
91
|
+
\t\t<key>NODE_ENV</key>
|
|
92
|
+
\t\t<string>production</string>
|
|
93
|
+
\t</dict>
|
|
94
|
+
</dict>
|
|
95
|
+
</plist>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
function systemdUnit(spec) {
|
|
99
|
+
const execStart = [spec.nodePath, spec.cliPath, "run", spec.workspacePath].map(systemdQuote).join(" ");
|
|
100
|
+
return `[Unit]
|
|
101
|
+
Description=Familiar companion agent
|
|
102
|
+
After=network-online.target
|
|
103
|
+
Wants=network-online.target
|
|
104
|
+
StartLimitIntervalSec=300
|
|
105
|
+
StartLimitBurst=5
|
|
106
|
+
|
|
107
|
+
[Service]
|
|
108
|
+
Type=simple
|
|
109
|
+
WorkingDirectory=${systemdQuote(spec.workspacePath)}
|
|
110
|
+
ExecStart=${execStart}
|
|
111
|
+
Restart=on-failure
|
|
112
|
+
RestartSec=5
|
|
113
|
+
SuccessExitStatus=75
|
|
114
|
+
RestartForceExitStatus=75
|
|
115
|
+
Environment=NODE_ENV=production
|
|
116
|
+
StandardOutput=append:${spec.paths.stdoutPath}
|
|
117
|
+
StandardError=append:${spec.paths.stderrPath}
|
|
118
|
+
|
|
119
|
+
[Install]
|
|
120
|
+
WantedBy=default.target
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
async function commandExists(command) {
|
|
124
|
+
try {
|
|
125
|
+
await execFileAsync(command, ["--version"]);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function hasCommand(command, options) {
|
|
133
|
+
return options.commandExists ? options.commandExists(command) : commandExists(command);
|
|
134
|
+
}
|
|
135
|
+
async function run(command, args, options) {
|
|
136
|
+
if (options.runCommand) {
|
|
137
|
+
await options.runCommand(command, args);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await execFileAsync(command, args);
|
|
141
|
+
}
|
|
142
|
+
async function capture(command, args, options) {
|
|
143
|
+
if (options.captureCommand)
|
|
144
|
+
return options.captureCommand(command, args);
|
|
145
|
+
const { stdout } = await execFileAsync(command, args);
|
|
146
|
+
return stdout;
|
|
147
|
+
}
|
|
148
|
+
async function runOptional(command, args, options) {
|
|
149
|
+
try {
|
|
150
|
+
await run(command, args, options);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Best-effort cleanup for stale service registrations.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function guiDomain() {
|
|
157
|
+
return `gui/${userInfo().uid}`;
|
|
158
|
+
}
|
|
159
|
+
function unsupported(platformName) {
|
|
160
|
+
return {
|
|
161
|
+
title: "Service management is not supported on this platform yet.",
|
|
162
|
+
details: [
|
|
163
|
+
`platform: ${platformName}`,
|
|
164
|
+
"Windows users should keep Familiar running in a foreground terminal for now.",
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export async function installService(workspacePath, options = {}) {
|
|
169
|
+
const spec = buildSpec(workspacePath, options);
|
|
170
|
+
if (spec.platform !== "darwin" && spec.platform !== "linux")
|
|
171
|
+
return unsupported(spec.platform);
|
|
172
|
+
await mkdir(dirname(spec.paths.servicePath), { recursive: true });
|
|
173
|
+
await mkdir(spec.paths.logDir, { recursive: true });
|
|
174
|
+
const serviceText = spec.platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
|
|
175
|
+
await writeFile(spec.paths.servicePath, serviceText, "utf8");
|
|
176
|
+
if (spec.platform === "darwin") {
|
|
177
|
+
await runOptional("launchctl", ["bootout", guiDomain(), spec.paths.servicePath], options);
|
|
178
|
+
await run("launchctl", ["bootstrap", guiDomain(), spec.paths.servicePath], options);
|
|
179
|
+
await run("launchctl", ["kickstart", "-k", `${guiDomain()}/${SERVICE_LABEL}`], options);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (!(await hasCommand("systemctl", options))) {
|
|
183
|
+
throw new Error("systemctl is required to install the Linux user service.");
|
|
184
|
+
}
|
|
185
|
+
await run("systemctl", ["--user", "daemon-reload"], options);
|
|
186
|
+
await run("systemctl", ["--user", "enable", "--now", SYSTEMD_SERVICE], options);
|
|
187
|
+
}
|
|
188
|
+
const details = [
|
|
189
|
+
`workspace: ${spec.workspacePath}`,
|
|
190
|
+
`service: ${spec.paths.servicePath}`,
|
|
191
|
+
`stdout: ${spec.paths.stdoutPath}`,
|
|
192
|
+
`stderr: ${spec.paths.stderrPath}`,
|
|
193
|
+
];
|
|
194
|
+
const pathWarning = versionManagedPathWarning(spec);
|
|
195
|
+
if (pathWarning)
|
|
196
|
+
details.push(pathWarning);
|
|
197
|
+
return {
|
|
198
|
+
title: "Familiar service installed.",
|
|
199
|
+
details,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export async function uninstallService(workspacePath, options = {}) {
|
|
203
|
+
const spec = buildSpec(workspacePath, options);
|
|
204
|
+
if (spec.platform !== "darwin" && spec.platform !== "linux")
|
|
205
|
+
return unsupported(spec.platform);
|
|
206
|
+
if (spec.platform === "darwin") {
|
|
207
|
+
await runOptional("launchctl", ["bootout", guiDomain(), spec.paths.servicePath], options);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
if (await hasCommand("systemctl", options)) {
|
|
211
|
+
await runOptional("systemctl", ["--user", "disable", "--now", SYSTEMD_SERVICE], options);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (existsSync(spec.paths.servicePath))
|
|
215
|
+
await rm(spec.paths.servicePath);
|
|
216
|
+
if (spec.platform === "linux" && (await hasCommand("systemctl", options))) {
|
|
217
|
+
await runOptional("systemctl", ["--user", "daemon-reload"], options);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
title: "Familiar service uninstalled.",
|
|
221
|
+
details: [`service: ${spec.paths.servicePath}`],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
export async function serviceStatus(workspacePath, options = {}) {
|
|
225
|
+
const spec = buildSpec(workspacePath, options);
|
|
226
|
+
if (spec.platform !== "darwin" && spec.platform !== "linux")
|
|
227
|
+
return unsupported(spec.platform);
|
|
228
|
+
const details = [
|
|
229
|
+
`workspace: ${spec.workspacePath}`,
|
|
230
|
+
`service: ${spec.paths.servicePath}`,
|
|
231
|
+
`service_file: ${existsSync(spec.paths.servicePath) ? "present" : "missing"}`,
|
|
232
|
+
`supervisor_state: ${await supervisorState(spec, options)}`,
|
|
233
|
+
`stdout: ${spec.paths.stdoutPath}`,
|
|
234
|
+
`stderr: ${spec.paths.stderrPath}`,
|
|
235
|
+
];
|
|
236
|
+
if (existsSync(spec.paths.servicePath)) {
|
|
237
|
+
const serviceFile = await stat(spec.paths.servicePath);
|
|
238
|
+
details.push(`service_file_mtime: ${serviceFile.mtime.toISOString()}`);
|
|
239
|
+
}
|
|
240
|
+
return { title: "Familiar service status.", details };
|
|
241
|
+
}
|
|
242
|
+
async function supervisorState(spec, options) {
|
|
243
|
+
try {
|
|
244
|
+
if (spec.platform === "darwin") {
|
|
245
|
+
await capture("launchctl", ["print", `${guiDomain()}/${SERVICE_LABEL}`], options);
|
|
246
|
+
return "loaded";
|
|
247
|
+
}
|
|
248
|
+
const state = (await capture("systemctl", ["--user", "is-active", SYSTEMD_SERVICE], options)).trim();
|
|
249
|
+
return state || "unknown";
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return "not-loaded";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function upgradeFamiliar(options = {}) {
|
|
256
|
+
const currentPlatform = options.platform ?? platform();
|
|
257
|
+
const npmCommand = currentPlatform === "win32" ? "npm.cmd" : "npm";
|
|
258
|
+
await new Promise((resolveUpgrade, rejectUpgrade) => {
|
|
259
|
+
const child = spawn(npmCommand, ["install", "-g", "@qearlyao/familiar@latest"], {
|
|
260
|
+
shell: currentPlatform === "win32",
|
|
261
|
+
stdio: "inherit",
|
|
262
|
+
});
|
|
263
|
+
child.on("exit", (code) => {
|
|
264
|
+
if (code === 0)
|
|
265
|
+
resolveUpgrade();
|
|
266
|
+
else
|
|
267
|
+
rejectUpgrade(new Error(`npm upgrade failed with exit code ${code ?? "unknown"}`));
|
|
268
|
+
});
|
|
269
|
+
child.on("error", rejectUpgrade);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
export function formatServiceResult(result) {
|
|
273
|
+
return [result.title, ...result.details].join("\n");
|
|
274
|
+
}
|
|
275
|
+
export const __serviceTest = {
|
|
276
|
+
SERVICE_LABEL,
|
|
277
|
+
SYSTEMD_SERVICE,
|
|
278
|
+
buildSpec,
|
|
279
|
+
launchdPlist,
|
|
280
|
+
systemdUnit,
|
|
281
|
+
systemdQuote,
|
|
282
|
+
xmlEscape,
|
|
283
|
+
versionManagedPathWarning,
|
|
284
|
+
};
|
package/dist/web-auth.js
CHANGED
|
@@ -16,7 +16,10 @@ function parseCookies(header) {
|
|
|
16
16
|
return cookies;
|
|
17
17
|
}
|
|
18
18
|
function decodeTotpSecret(secret) {
|
|
19
|
-
const normalized = secret
|
|
19
|
+
const normalized = secret
|
|
20
|
+
.replace(/\s/g, "")
|
|
21
|
+
.replace(/={1,8}$/, "")
|
|
22
|
+
.toUpperCase();
|
|
20
23
|
if (!/^[A-Z2-7]+$/.test(normalized))
|
|
21
24
|
return Buffer.from(secret, "utf8");
|
|
22
25
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
@@ -58,7 +61,7 @@ function readBearerToken(request) {
|
|
|
58
61
|
const header = request.headers.authorization;
|
|
59
62
|
if (!header)
|
|
60
63
|
return undefined;
|
|
61
|
-
const match = header.match(/^Bearer
|
|
64
|
+
const match = header.match(/^Bearer (.+)$/i);
|
|
62
65
|
return match?.[1];
|
|
63
66
|
}
|
|
64
67
|
export function createAuth(config) {
|
package/dist/web.js
CHANGED
|
@@ -322,7 +322,7 @@ function sessionDto(session) {
|
|
|
322
322
|
isDefault: session.isDefault,
|
|
323
323
|
};
|
|
324
324
|
}
|
|
325
|
-
export async function startWebDaemon(config, familiarAgent, discordDaemon) {
|
|
325
|
+
export async function startWebDaemon(config, familiarAgent, discordDaemon, options = {}) {
|
|
326
326
|
const persona = await loadPersona(config);
|
|
327
327
|
const personaName = parsePersonaName(persona.soul);
|
|
328
328
|
const auth = createAuth(config);
|
|
@@ -565,6 +565,11 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon) {
|
|
|
565
565
|
if (control.command === "reload") {
|
|
566
566
|
return familiarAgent.reload();
|
|
567
567
|
}
|
|
568
|
+
if (control.command === "restart") {
|
|
569
|
+
return options.restart
|
|
570
|
+
? await options.restart()
|
|
571
|
+
: "Restart requested, but no restart handler is configured. Please restart the Familiar process manually.";
|
|
572
|
+
}
|
|
568
573
|
if (control.command === "model") {
|
|
569
574
|
return control.args
|
|
570
575
|
? await familiarAgent.setModel(runtime.channelKey, control.args)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qearlyao/familiar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"USER.md",
|
|
20
20
|
"MEMORY.md",
|
|
21
21
|
"HEARTBEAT.md",
|
|
22
|
+
"skills/**",
|
|
23
|
+
"scripts/install.sh",
|
|
24
|
+
"scripts/install.ps1",
|
|
22
25
|
"README.md",
|
|
23
26
|
"LICENSE"
|
|
24
27
|
],
|
|
@@ -29,6 +32,7 @@
|
|
|
29
32
|
"clean": "node -e \"const fs=require('node:fs'); for (const p of ['dist','web/dist']) fs.rmSync(p,{recursive:true,force:true});\"",
|
|
30
33
|
"dev": "tsx watch src/cli.ts run",
|
|
31
34
|
"build": "npm run clean && tsc -p tsconfig.build.json && npm --prefix web run build",
|
|
35
|
+
"prepack": "npm run build",
|
|
32
36
|
"lint": "biome check",
|
|
33
37
|
"format": "biome format --write",
|
|
34
38
|
"payload:pretty": "tsx scripts/pretty-payload.ts",
|
|
@@ -39,7 +43,7 @@
|
|
|
39
43
|
"@earendil-works/pi-agent-core": "^0.74.1",
|
|
40
44
|
"@earendil-works/pi-ai": "^0.74.1",
|
|
41
45
|
"@earendil-works/pi-coding-agent": "^0.74.1",
|
|
42
|
-
"better-sqlite3": "^12.
|
|
46
|
+
"better-sqlite3": "^12.10.0",
|
|
43
47
|
"discord.js": "^14.26.3",
|
|
44
48
|
"dotenv": "^16.4.5",
|
|
45
49
|
"sharp": "^0.34.5",
|
|
@@ -48,10 +52,10 @@
|
|
|
48
52
|
"typebox": "^1.1.38"
|
|
49
53
|
},
|
|
50
54
|
"devDependencies": {
|
|
51
|
-
"@biomejs/biome": "2.
|
|
55
|
+
"@biomejs/biome": "2.4.15",
|
|
52
56
|
"@types/better-sqlite3": "^7.6.13",
|
|
53
|
-
"@types/node": "^
|
|
54
|
-
"tsx": "^4.
|
|
57
|
+
"@types/node": "^25.8.0",
|
|
58
|
+
"tsx": "^4.22.1",
|
|
55
59
|
"typescript": "^5.9.2"
|
|
56
60
|
},
|
|
57
61
|
"engines": {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Workspace = (Join-Path $HOME ".familiar"),
|
|
3
|
+
[string]$Package = "@qearlyao/familiar@latest",
|
|
4
|
+
[string]$BrowserHarnessDir = (Join-Path (Join-Path $HOME "Developer") "browser-harness"),
|
|
5
|
+
[switch]$WithBrowser,
|
|
6
|
+
[switch]$InstallBrowserDeps,
|
|
7
|
+
[switch]$SkipInit
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
$ErrorActionPreference = "Stop"
|
|
11
|
+
|
|
12
|
+
function Require-Command($Name) {
|
|
13
|
+
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
|
14
|
+
throw "Missing required command: $Name"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Update-BrowserDepPath {
|
|
19
|
+
$candidates = @((Join-Path $HOME ".local\bin"), (Join-Path $HOME ".cargo\bin"))
|
|
20
|
+
foreach ($candidate in $candidates) {
|
|
21
|
+
if ((Test-Path $candidate) -and (($env:PATH -split [IO.Path]::PathSeparator) -notcontains $candidate)) {
|
|
22
|
+
$env:PATH = "$candidate$([IO.Path]::PathSeparator)$env:PATH"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Confirm-BrowserDepInstall($Message) {
|
|
28
|
+
if ($InstallBrowserDeps) {
|
|
29
|
+
return $true
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
$answer = Read-Host "$Message Install it now? [y/N]"
|
|
33
|
+
return $answer -match '^(y|yes)$'
|
|
34
|
+
} catch {
|
|
35
|
+
return $false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Install-Uv {
|
|
40
|
+
Write-Host "Installing uv for browser-harness..."
|
|
41
|
+
irm https://astral.sh/uv/install.ps1 | iex
|
|
42
|
+
Update-BrowserDepPath
|
|
43
|
+
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
44
|
+
throw "uv installer finished, but uv is not on PATH. Open a new terminal or add $HOME\.local\bin to PATH."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Ensure-Uv {
|
|
49
|
+
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (Confirm-BrowserDepInstall "uv is required for browser-harness but was not found.") {
|
|
53
|
+
Install-Uv
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
throw "Missing required command: uv. Rerun with -WithBrowser -InstallBrowserDeps to install uv and Python 3.11 automatically."
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Test-Python311($Command, $PythonArgs = @()) {
|
|
60
|
+
& $Command @PythonArgs -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" *> $null
|
|
61
|
+
return $LASTEXITCODE -eq 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Resolve-Python311 {
|
|
65
|
+
$python = Get-Command python -ErrorAction SilentlyContinue
|
|
66
|
+
if ($python -and (Test-Python311 $python.Source)) {
|
|
67
|
+
return @{ Command = $python.Source; Args = @(); UvPython = $python.Source }
|
|
68
|
+
}
|
|
69
|
+
$python3 = Get-Command python3 -ErrorAction SilentlyContinue
|
|
70
|
+
if ($python3 -and (Test-Python311 $python3.Source)) {
|
|
71
|
+
return @{ Command = $python3.Source; Args = @(); UvPython = $python3.Source }
|
|
72
|
+
}
|
|
73
|
+
$py = Get-Command py -ErrorAction SilentlyContinue
|
|
74
|
+
if ($py -and (Test-Python311 $py.Source @("-3.11"))) {
|
|
75
|
+
return @{ Command = $py.Source; Args = @("-3.11"); UvPython = "3.11" }
|
|
76
|
+
}
|
|
77
|
+
return $null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Ensure-Python311 {
|
|
81
|
+
$python311 = Resolve-Python311
|
|
82
|
+
if ($python311) {
|
|
83
|
+
return $python311
|
|
84
|
+
}
|
|
85
|
+
if (Confirm-BrowserDepInstall "Python 3.11+ is required for browser-harness but was not found.") {
|
|
86
|
+
Write-Host "Installing Python 3.11 with uv for browser-harness..."
|
|
87
|
+
& uv python install 3.11
|
|
88
|
+
if ($LASTEXITCODE -ne 0) {
|
|
89
|
+
throw "Python 3.11 install failed."
|
|
90
|
+
}
|
|
91
|
+
return @{ Command = "uv"; Args = @("python", "find", "3.11"); UvPython = "3.11" }
|
|
92
|
+
}
|
|
93
|
+
throw "browser-harness requires Python 3.11 or newer. Rerun with -WithBrowser -InstallBrowserDeps to install uv-managed Python 3.11 automatically."
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Require-Command node
|
|
97
|
+
Require-Command npm
|
|
98
|
+
if ($WithBrowser) {
|
|
99
|
+
Require-Command git
|
|
100
|
+
Ensure-Uv
|
|
101
|
+
$Python311 = Ensure-Python311
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$nodeVersion = (& node -p "process.versions.node").Trim()
|
|
105
|
+
$nodeMajor = [int](& node -p "Number(process.versions.node.split('.')[0])")
|
|
106
|
+
if ($nodeMajor -lt 22) {
|
|
107
|
+
throw "Familiar requires Node.js 22 or newer. Found Node.js $nodeVersion. Node.js 24 LTS is recommended."
|
|
108
|
+
}
|
|
109
|
+
if ($nodeMajor -lt 24) {
|
|
110
|
+
Write-Host "Found Node.js $nodeVersion. Familiar supports Node.js 22+, but Node.js 24 LTS is recommended."
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Write-Host "Installing $Package globally..."
|
|
114
|
+
& npm install -g $Package
|
|
115
|
+
if ($LASTEXITCODE -ne 0) {
|
|
116
|
+
throw "npm install failed."
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if ($WithBrowser) {
|
|
120
|
+
Write-Host "Installing optional OpenCLI browser helper..."
|
|
121
|
+
& npm install -g "@jackwener/opencli"
|
|
122
|
+
if ($LASTEXITCODE -ne 0) {
|
|
123
|
+
throw "browser helper install failed."
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Write-Host "Installing optional browser-harness helper into $BrowserHarnessDir..."
|
|
127
|
+
$gitDir = Join-Path $BrowserHarnessDir ".git"
|
|
128
|
+
if (Test-Path $gitDir) {
|
|
129
|
+
& git -C $BrowserHarnessDir pull --ff-only
|
|
130
|
+
if ($LASTEXITCODE -ne 0) {
|
|
131
|
+
throw "browser-harness update failed."
|
|
132
|
+
}
|
|
133
|
+
} elseif (Test-Path $BrowserHarnessDir) {
|
|
134
|
+
throw "Cannot install browser-harness: $BrowserHarnessDir already exists and is not a git checkout."
|
|
135
|
+
} else {
|
|
136
|
+
$parentDir = Split-Path -Parent $BrowserHarnessDir
|
|
137
|
+
New-Item -ItemType Directory -Force -Path $parentDir | Out-Null
|
|
138
|
+
& git clone https://github.com/browser-use/browser-harness $BrowserHarnessDir
|
|
139
|
+
if ($LASTEXITCODE -ne 0) {
|
|
140
|
+
throw "browser-harness clone failed."
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
Push-Location $BrowserHarnessDir
|
|
144
|
+
$previousUvPython = $env:UV_PYTHON
|
|
145
|
+
try {
|
|
146
|
+
$env:UV_PYTHON = $Python311.UvPython
|
|
147
|
+
& uv tool install -e .
|
|
148
|
+
if ($LASTEXITCODE -ne 0) {
|
|
149
|
+
throw "browser-harness install failed."
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
if ($null -eq $previousUvPython) {
|
|
153
|
+
Remove-Item Env:\UV_PYTHON -ErrorAction SilentlyContinue
|
|
154
|
+
} else {
|
|
155
|
+
$env:UV_PYTHON = $previousUvPython
|
|
156
|
+
}
|
|
157
|
+
Pop-Location
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (-not (Get-Command familiar -ErrorAction SilentlyContinue)) {
|
|
162
|
+
throw "Installed package, but familiar is not on PATH. Check your npm global bin directory and rerun: familiar init `"$Workspace`""
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (-not $SkipInit) {
|
|
166
|
+
Write-Host "Initializing or refreshing workspace defaults at $Workspace..."
|
|
167
|
+
& familiar init $Workspace
|
|
168
|
+
if ($LASTEXITCODE -ne 0) {
|
|
169
|
+
throw "familiar init failed."
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
Write-Host ""
|
|
174
|
+
Write-Host "Familiar is installed."
|
|
175
|
+
Write-Host ""
|
|
176
|
+
Write-Host "Next steps:"
|
|
177
|
+
Write-Host " 1. Edit $Workspace\.env"
|
|
178
|
+
Write-Host " 2. Edit $Workspace\config.toml"
|
|
179
|
+
Write-Host " 3. Run: familiar run `"$Workspace`""
|
|
180
|
+
Write-Host ""
|
|
181
|
+
Write-Host "Optional browser helpers:"
|
|
182
|
+
Write-Host " & ([scriptblock]::Create((irm https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.ps1))) -WithBrowser"
|
|
183
|
+
Write-Host ""
|
|
184
|
+
Write-Host "browser-harness checkout:"
|
|
185
|
+
Write-Host " $BrowserHarnessDir"
|