@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/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 || !Number.isFinite(lastHeartbeatAt) || lastHeartbeatAt <= options.lastUserInteractionAt) {
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);
@@ -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("&", "&amp;")
51
+ .replaceAll("<", "&lt;")
52
+ .replaceAll(">", "&gt;")
53
+ .replaceAll('"', "&quot;")
54
+ .replaceAll("'", "&apos;");
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.replace(/\s+/g, "").replace(/=+$/g, "").toUpperCase();
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\s+(.+)$/i);
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.0",
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.9.0",
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.3.5",
55
+ "@biomejs/biome": "2.4.15",
52
56
  "@types/better-sqlite3": "^7.6.13",
53
- "@types/node": "^24.3.0",
54
- "tsx": "^4.20.3",
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"