@qearlyao/familiar 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web.js CHANGED
@@ -1,9 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { createServer } from "node:http";
4
+ import { join } from "node:path";
5
+ import { getProviders } from "@earendil-works/pi-ai";
6
+ import { addModel, loadAddedModels, removeModel, setAddedModelsPath } from "./added-models.js";
3
7
  import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
8
+ import { clearConfigOverride, loadConfigOverrides, setConfigOverride } from "./config-overrides.js";
9
+ import { CONFIG_KEYS, CONFIG_REGISTRY, getConfigDefault, isConfigKey } from "./config-registry.js";
10
+ import { getContactNickname, refreshContactNote, setContactNotePath } from "./contact-note.js";
4
11
  import { publicAttachmentPath } from "./generated-media.js";
5
12
  import { materializeInboundAttachments } from "./inbound-attachments.js";
6
- import { supportedThinkingLevels } from "./models.js";
13
+ import { PROVIDER_DEFAULTS, parseModelRef, supportedThinkingLevels } from "./models.js";
7
14
  import { loadPersona, parsePersonaName } from "./persona.js";
8
15
  import { createAuth, sessionCookie, verifyTotp } from "./web-auth.js";
9
16
  import { acceptWebSocket, decodeFrames, encodeFrame, replayEvents } from "./web-events.js";
@@ -23,6 +30,30 @@ function messageId(prefix = "msg") {
23
30
  function isUserVisibleRuntimeRecord(record) {
24
31
  return record.type !== "runtime" || !["armed", "reset", "stopped"].includes(record.event);
25
32
  }
33
+ function parseMemeCatalog(markdown) {
34
+ const families = [];
35
+ let currentFamily;
36
+ for (const line of markdown.split(/\r?\n/)) {
37
+ const familyMatch = line.match(/^## (.+)$/);
38
+ if (familyMatch) {
39
+ currentFamily = { name: familyMatch[1]?.trim() ?? "", memes: [] };
40
+ families.push(currentFamily);
41
+ continue;
42
+ }
43
+ if (!currentFamily || !line.startsWith("- ") || !line.includes(" — "))
44
+ continue;
45
+ const separator = line.indexOf(" — ");
46
+ const name = line.slice(2, separator).trim();
47
+ const suffix = line.slice(separator + " — ".length).trim();
48
+ if (!name || !suffix)
49
+ continue;
50
+ currentFamily.memes.push({ name, url: `https://files.catbox.moe/${suffix}` });
51
+ }
52
+ return families;
53
+ }
54
+ function memeCatalogPath(config) {
55
+ return join(config.workspacePath, "skills", "memes", "SKILL.md");
56
+ }
26
57
  function isWebUploadAttachment(value) {
27
58
  return !!value && typeof value === "object" && Buffer.isBuffer(value.buffer);
28
59
  }
@@ -258,7 +289,7 @@ function webMessageFromRecord(config, record, assistantName) {
258
289
  return {
259
290
  id: record.messageId,
260
291
  role: "user",
261
- who: record.authorName || WEB_USER_NAME,
292
+ who: record.authorName || getContactNickname(WEB_USER_NAME),
262
293
  text: [record.text, attachmentText].filter(Boolean).join("\n"),
263
294
  attachments: webAttachments(config, record.attachments),
264
295
  ts: toUnixMs(record.ts),
@@ -323,6 +354,9 @@ function sessionDto(session) {
323
354
  };
324
355
  }
325
356
  export async function startWebDaemon(config, familiarAgent, discordDaemon, options = {}) {
357
+ setAddedModelsPath(config.workspace.dataDir);
358
+ setContactNotePath(config.persona.contact);
359
+ await refreshContactNote();
326
360
  const persona = await loadPersona(config);
327
361
  const personaName = parsePersonaName(persona.soul);
328
362
  const auth = createAuth(config);
@@ -397,7 +431,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
397
431
  channelKey: runtime.channelKey,
398
432
  messageId: record.messageId,
399
433
  role: "user",
400
- who: record.authorName || WEB_USER_NAME,
434
+ who: record.authorName || getContactNickname(WEB_USER_NAME),
401
435
  ts: toUnixMs(record.ts),
402
436
  });
403
437
  publishDelta(runtime.channelKey, record.messageId, "text", record.text, toUnixMs(record.ts));
@@ -465,11 +499,50 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
465
499
  return body.channelKey;
466
500
  return undefined;
467
501
  };
502
+ const getAgentModelsPayload = () => {
503
+ const models = [];
504
+ const added = [];
505
+ const seen = new Set();
506
+ for (const model of config.models.allow) {
507
+ if (seen.has(model))
508
+ continue;
509
+ seen.add(model);
510
+ models.push(model);
511
+ }
512
+ for (const model of loadAddedModels()) {
513
+ if (seen.has(model))
514
+ continue;
515
+ seen.add(model);
516
+ models.push(model);
517
+ added.push(model);
518
+ }
519
+ return { models, added };
520
+ };
521
+ const getConfigPayload = () => {
522
+ const overrides = loadConfigOverrides();
523
+ const values = {};
524
+ for (const key of CONFIG_KEYS) {
525
+ const entry = CONFIG_REGISTRY[key];
526
+ values[key] = {
527
+ value: entry.read(config),
528
+ source: key in overrides ? "override" : "config",
529
+ };
530
+ }
531
+ return { values };
532
+ };
533
+ const parseRequestedModel = (value) => {
534
+ if (typeof value !== "string")
535
+ return { ok: false, error: "format must be provider/model-id" };
536
+ const ref = parseModelRef(value);
537
+ if (!ref)
538
+ return { ok: false, error: "format must be provider/model-id" };
539
+ return { ok: true, model: ref.key, ref };
540
+ };
468
541
  const replay = (client, channelKey, lastEventId) => {
469
542
  const events = eventsByChannel.get(channelKey) ?? [];
470
543
  replayEvents(client, events, lastEventId, () => publish({ type: "replay_window_lost", channelKey }));
471
544
  };
472
- const promptForRuntime = async (runtime, jobId, prompt, attachments = []) => {
545
+ const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onTurnEnd) => {
473
546
  const assistantMessageId = messageId();
474
547
  const summary = { thinking: "" };
475
548
  const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobId, assistantMessageId, storedEvent, { notify: false }));
@@ -486,7 +559,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
486
559
  runtime.publishAgentEvent(jobId, assistantMessageId, storedEvent);
487
560
  await recorder.record(storedEvent);
488
561
  }
489
- });
562
+ }, onTurnEnd);
490
563
  }
491
564
  finally {
492
565
  await recorder.flush();
@@ -524,7 +597,13 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
524
597
  if (!dispatch)
525
598
  return;
526
599
  try {
527
- const reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments);
600
+ const reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments, () => {
601
+ publish({
602
+ type: "status",
603
+ channelKey: runtime.channelKey,
604
+ kind: "idle",
605
+ });
606
+ });
528
607
  await runtime.completeActiveJob({
529
608
  text: reply.text,
530
609
  messageIds: [reply.messageId],
@@ -547,15 +626,8 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
547
626
  };
548
627
  const applyControlCommand = async (runtime, control) => {
549
628
  if (control.command === "stop") {
550
- discordDaemon.abortWebRuntime(runtime);
551
- await runtime.resetConversation("stop requested");
552
- publish({
553
- type: "status",
554
- channelKey: runtime.channelKey,
555
- kind: "idle",
556
- detail: "Stopped current work and cleared the chat queue.",
557
- });
558
- return "Stopped current work and cleared the chat queue.";
629
+ familiarAgent.requestSoftStop(runtime.channelKey);
630
+ return "Stopped after current step. Conversation preserved.";
559
631
  }
560
632
  if (control.command === "new") {
561
633
  await familiarAgent.reset(runtime.channelKey);
@@ -628,7 +700,114 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
628
700
  return true;
629
701
  }
630
702
  if (request.method === "GET" && url.pathname === "/api/web/agent/models") {
631
- sendJson(response, 200, { models: config.models.allow });
703
+ sendJson(response, 200, getAgentModelsPayload());
704
+ return true;
705
+ }
706
+ if (request.method === "POST" && url.pathname === "/api/web/agent/models") {
707
+ const body = await readJsonBody(request);
708
+ if (!isObject(body)) {
709
+ sendJson(response, 400, { error: "body is required" });
710
+ return true;
711
+ }
712
+ const parsed = parseRequestedModel(body.model);
713
+ if (!parsed.ok) {
714
+ sendJson(response, 400, { error: parsed.error });
715
+ return true;
716
+ }
717
+ if (!Object.hasOwn(PROVIDER_DEFAULTS, parsed.ref.provider) &&
718
+ !getProviders().includes(parsed.ref.provider)) {
719
+ sendJson(response, 400, { error: `unsupported provider: ${parsed.ref.provider}` });
720
+ return true;
721
+ }
722
+ if (config.models.allow.includes(parsed.model) || loadAddedModels().includes(parsed.model)) {
723
+ sendJson(response, 200, getAgentModelsPayload());
724
+ return true;
725
+ }
726
+ await addModel(parsed.model);
727
+ sendJson(response, 200, getAgentModelsPayload());
728
+ return true;
729
+ }
730
+ if (request.method === "DELETE" && url.pathname === "/api/web/agent/models") {
731
+ const body = await readJsonBody(request);
732
+ if (!isObject(body)) {
733
+ sendJson(response, 400, { error: "body is required" });
734
+ return true;
735
+ }
736
+ const parsed = parseRequestedModel(body.model);
737
+ if (!parsed.ok) {
738
+ sendJson(response, 400, { error: parsed.error });
739
+ return true;
740
+ }
741
+ if (!loadAddedModels().includes(parsed.model)) {
742
+ sendJson(response, 400, { error: "model is not user-added" });
743
+ return true;
744
+ }
745
+ await removeModel(parsed.model);
746
+ sendJson(response, 200, getAgentModelsPayload());
747
+ return true;
748
+ }
749
+ if (request.method === "GET" && url.pathname === "/api/web/config") {
750
+ sendJson(response, 200, getConfigPayload());
751
+ return true;
752
+ }
753
+ if (request.method === "POST" && url.pathname === "/api/web/config") {
754
+ const body = await readJsonBody(request);
755
+ if (!isObject(body) || typeof body.key !== "string") {
756
+ sendJson(response, 400, { error: "key is required" });
757
+ return true;
758
+ }
759
+ if (!isConfigKey(body.key)) {
760
+ sendJson(response, 400, { error: `unknown config key: ${body.key}` });
761
+ return true;
762
+ }
763
+ const entry = CONFIG_REGISTRY[body.key];
764
+ try {
765
+ const validated = entry.validate(body.value, config);
766
+ entry.write(config, validated);
767
+ await setConfigOverride(body.key, validated);
768
+ await entry.apply?.({ config, discordDaemon });
769
+ }
770
+ catch (error) {
771
+ const message = error instanceof Error ? error.message : String(error);
772
+ sendJson(response, 400, { error: message });
773
+ return true;
774
+ }
775
+ sendJson(response, 200, getConfigPayload());
776
+ return true;
777
+ }
778
+ if (request.method === "DELETE" && url.pathname === "/api/web/config") {
779
+ const body = await readJsonBody(request);
780
+ if (!isObject(body) || typeof body.key !== "string") {
781
+ sendJson(response, 400, { error: "key is required" });
782
+ return true;
783
+ }
784
+ if (!isConfigKey(body.key)) {
785
+ sendJson(response, 400, { error: `unknown config key: ${body.key}` });
786
+ return true;
787
+ }
788
+ const entry = CONFIG_REGISTRY[body.key];
789
+ try {
790
+ const fallback = getConfigDefault(body.key);
791
+ entry.write(config, fallback);
792
+ await clearConfigOverride(body.key);
793
+ await entry.apply?.({ config, discordDaemon });
794
+ }
795
+ catch (error) {
796
+ const message = error instanceof Error ? error.message : String(error);
797
+ sendJson(response, 400, { error: message });
798
+ return true;
799
+ }
800
+ sendJson(response, 200, getConfigPayload());
801
+ return true;
802
+ }
803
+ if (request.method === "GET" && url.pathname === "/api/web/memes") {
804
+ try {
805
+ const markdown = await readFile(memeCatalogPath(config), "utf8");
806
+ sendJson(response, 200, { families: parseMemeCatalog(markdown) });
807
+ }
808
+ catch {
809
+ sendJson(response, 500, { error: "memes catalog unavailable" });
810
+ }
632
811
  return true;
633
812
  }
634
813
  if (request.method === "POST" && url.pathname === "/api/web/send") {
@@ -659,7 +838,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
659
838
  const input = {
660
839
  messageId: id,
661
840
  authorId: config.discord.ownerId,
662
- authorName: WEB_USER_NAME,
841
+ authorName: getContactNickname(WEB_USER_NAME),
663
842
  text: body.text,
664
843
  isBot: false,
665
844
  mentionedBot: true,
@@ -728,7 +907,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
728
907
  const input = {
729
908
  messageId: messageId("control"),
730
909
  authorId: config.discord.ownerId,
731
- authorName: WEB_USER_NAME,
910
+ authorName: getContactNickname(WEB_USER_NAME),
732
911
  text: `/${body.command}${args ? ` ${args}` : ""}`,
733
912
  isBot: false,
734
913
  mentionedBot: true,
@@ -804,14 +983,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
804
983
  }
805
984
  if (isObject(message) && message.type === "abort") {
806
985
  void getRuntime(client.channelKey).then(async (runtime) => {
807
- discordDaemon.abortWebRuntime(runtime);
808
- await runtime.resetConversation("web abort requested");
809
- publish({
810
- type: "error",
811
- channelKey: runtime.channelKey,
812
- code: "abort",
813
- message: "Aborted current work.",
814
- });
986
+ familiarAgent.requestSoftStop(runtime.channelKey);
815
987
  });
816
988
  }
817
989
  }
@@ -847,3 +1019,7 @@ export async function startWebDaemon(config, familiarAgent, discordDaemon, optio
847
1019
  },
848
1020
  };
849
1021
  }
1022
+ export const __webTest = {
1023
+ memeCatalogPath,
1024
+ parseMemeCatalog,
1025
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qearlyao/familiar",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  "USER.md",
20
20
  "MEMORY.md",
21
21
  "HEARTBEAT.md",
22
+ "CONTACT.md",
22
23
  "skills/**",
23
24
  "scripts/install.sh",
24
25
  "scripts/install.ps1",
@@ -40,9 +41,9 @@
40
41
  "typecheck": "tsc --noEmit"
41
42
  },
42
43
  "dependencies": {
43
- "@earendil-works/pi-agent-core": "^0.74.1",
44
- "@earendil-works/pi-ai": "^0.74.1",
45
- "@earendil-works/pi-coding-agent": "^0.74.1",
44
+ "@earendil-works/pi-agent-core": "^0.75.3",
45
+ "@earendil-works/pi-ai": "^0.75.3",
46
+ "@earendil-works/pi-coding-agent": "^0.75.3",
46
47
  "better-sqlite3": "^12.10.0",
47
48
  "discord.js": "^14.26.3",
48
49
  "dotenv": "^16.4.5",
@@ -3,6 +3,7 @@ param(
3
3
  [string]$Package = "@qearlyao/familiar@latest",
4
4
  [string]$BrowserHarnessDir = (Join-Path (Join-Path $HOME "Developer") "browser-harness"),
5
5
  [switch]$WithBrowser,
6
+ [switch]$InstallBrowserDeps,
6
7
  [switch]$SkipInit
7
8
  )
8
9
 
@@ -14,6 +15,47 @@ function Require-Command($Name) {
14
15
  }
15
16
  }
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
+
17
59
  function Test-Python311($Command, $PythonArgs = @()) {
18
60
  & $Command @PythonArgs -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" *> $null
19
61
  return $LASTEXITCODE -eq 0
@@ -32,15 +74,31 @@ function Resolve-Python311 {
32
74
  if ($py -and (Test-Python311 $py.Source @("-3.11"))) {
33
75
  return @{ Command = $py.Source; Args = @("-3.11"); UvPython = "3.11" }
34
76
  }
35
- throw "browser-harness requires Python 3.11 or newer. Install Python 3.11+ and rerun with -WithBrowser."
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."
36
94
  }
37
95
 
38
96
  Require-Command node
39
97
  Require-Command npm
40
98
  if ($WithBrowser) {
41
99
  Require-Command git
42
- Require-Command uv
43
- $Python311 = Resolve-Python311
100
+ Ensure-Uv
101
+ $Python311 = Ensure-Python311
44
102
  }
45
103
 
46
104
  $nodeVersion = (& node -p "process.versions.node").Trim()
@@ -105,15 +163,10 @@ if (-not (Get-Command familiar -ErrorAction SilentlyContinue)) {
105
163
  }
106
164
 
107
165
  if (-not $SkipInit) {
108
- $configPath = Join-Path $Workspace "config.toml"
109
- if (Test-Path $configPath) {
110
- Write-Host "Workspace already exists at $Workspace; leaving files unchanged."
111
- } else {
112
- Write-Host "Initializing workspace at $Workspace..."
113
- & familiar init $Workspace
114
- if ($LASTEXITCODE -ne 0) {
115
- throw "familiar init failed."
116
- }
166
+ Write-Host "Initializing or refreshing workspace defaults at $Workspace..."
167
+ & familiar init $Workspace
168
+ if ($LASTEXITCODE -ne 0) {
169
+ throw "familiar init failed."
117
170
  }
118
171
  }
119
172
 
@@ -5,6 +5,7 @@ PACKAGE="@qearlyao/familiar@latest"
5
5
  WORKSPACE="${HOME}/.familiar"
6
6
  BROWSER_HARNESS_DIR="${HOME}/Developer/browser-harness"
7
7
  WITH_BROWSER=0
8
+ INSTALL_BROWSER_DEPS=0
8
9
  SKIP_INIT=0
9
10
 
10
11
  usage() {
@@ -14,6 +15,8 @@ Usage: install.sh [options]
14
15
  Options:
15
16
  --workspace <path> Workspace path to initialize. Defaults to ~/.familiar.
16
17
  --with-browser Also install optional OpenCLI and browser-harness helpers.
18
+ --install-browser-deps
19
+ With --with-browser, install missing uv/Python 3.11 browser deps without prompting.
17
20
  --skip-init Install familiar but do not run familiar init.
18
21
  --package <spec> npm package spec to install. Defaults to @qearlyao/familiar@latest.
19
22
  Advanced: installs the exact npm spec provided; use trusted specs only.
@@ -35,6 +38,10 @@ while [ "$#" -gt 0 ]; do
35
38
  WITH_BROWSER=1
36
39
  shift
37
40
  ;;
41
+ --install-browser-deps)
42
+ INSTALL_BROWSER_DEPS=1
43
+ shift
44
+ ;;
38
45
  --skip-init)
39
46
  SKIP_INIT=1
40
47
  shift
@@ -66,6 +73,63 @@ need_command() {
66
73
  fi
67
74
  }
68
75
 
76
+ refresh_browser_dep_path() {
77
+ for candidate in "${HOME}/.local/bin" "${HOME}/.cargo/bin"; do
78
+ if [ -d "$candidate" ]; then
79
+ case ":${PATH}:" in
80
+ *":${candidate}:"*) ;;
81
+ *) PATH="${candidate}:${PATH}" ;;
82
+ esac
83
+ fi
84
+ done
85
+ export PATH
86
+ }
87
+
88
+ confirm_browser_dep_install() {
89
+ if [ "$INSTALL_BROWSER_DEPS" -eq 1 ]; then
90
+ return 0
91
+ fi
92
+ if [ -r /dev/tty ] && [ -w /dev/tty ]; then
93
+ printf "%s Install it now? [y/N] " "$1" >/dev/tty
94
+ read -r answer </dev/tty || answer=""
95
+ case "$answer" in
96
+ y | Y | yes | YES) return 0 ;;
97
+ esac
98
+ fi
99
+ return 1
100
+ }
101
+
102
+ install_uv() {
103
+ echo "Installing uv for browser-harness..."
104
+ if command -v curl >/dev/null 2>&1; then
105
+ curl -LsSf https://astral.sh/uv/install.sh | sh
106
+ elif command -v wget >/dev/null 2>&1; then
107
+ wget -qO- https://astral.sh/uv/install.sh | sh
108
+ else
109
+ echo "Missing curl or wget, which is required to install uv automatically." >&2
110
+ echo "Install uv manually from https://docs.astral.sh/uv/ and rerun with --with-browser." >&2
111
+ exit 1
112
+ fi
113
+ refresh_browser_dep_path
114
+ if ! command -v uv >/dev/null 2>&1; then
115
+ echo "uv installer finished, but uv is not on PATH. Open a new terminal or add ~/.local/bin to PATH." >&2
116
+ exit 1
117
+ fi
118
+ }
119
+
120
+ ensure_uv() {
121
+ if command -v uv >/dev/null 2>&1; then
122
+ return 0
123
+ fi
124
+ if confirm_browser_dep_install "uv is required for browser-harness but was not found."; then
125
+ install_uv
126
+ return 0
127
+ fi
128
+ echo "Missing required command: uv" >&2
129
+ echo "Rerun with --with-browser --install-browser-deps to install uv and Python 3.11 automatically." >&2
130
+ exit 1
131
+ }
132
+
69
133
  find_python() {
70
134
  PYTHON_PATH=""
71
135
  for candidate in python3 python; do
@@ -77,7 +141,21 @@ find_python() {
77
141
  fi
78
142
  fi
79
143
  done
80
- echo "browser-harness requires Python 3.11 or newer. Install Python 3.11+ and rerun with --with-browser." >&2
144
+ return 1
145
+ }
146
+
147
+ ensure_python() {
148
+ if find_python; then
149
+ return 0
150
+ fi
151
+ if confirm_browser_dep_install "Python 3.11+ is required for browser-harness but was not found."; then
152
+ echo "Installing Python 3.11 with uv for browser-harness..."
153
+ uv python install 3.11
154
+ PYTHON_PATH="3.11"
155
+ return 0
156
+ fi
157
+ echo "browser-harness requires Python 3.11 or newer." >&2
158
+ echo "Rerun with --with-browser --install-browser-deps to install uv-managed Python 3.11 automatically." >&2
81
159
  exit 1
82
160
  }
83
161
 
@@ -85,8 +163,8 @@ need_command node
85
163
  need_command npm
86
164
  if [ "$WITH_BROWSER" -eq 1 ]; then
87
165
  need_command git
88
- need_command uv
89
- find_python
166
+ ensure_uv
167
+ ensure_python
90
168
  fi
91
169
 
92
170
  NODE_VERSION="$(node -p "process.versions.node")"
@@ -127,12 +205,8 @@ if ! command -v familiar >/dev/null 2>&1; then
127
205
  fi
128
206
 
129
207
  if [ "$SKIP_INIT" -eq 0 ]; then
130
- if [ -f "${WORKSPACE}/config.toml" ]; then
131
- echo "Workspace already exists at ${WORKSPACE}; leaving files unchanged."
132
- else
133
- echo "Initializing workspace at ${WORKSPACE}..."
134
- familiar init "$WORKSPACE"
135
- fi
208
+ echo "Initializing or refreshing workspace defaults at ${WORKSPACE}..."
209
+ familiar init "$WORKSPACE"
136
210
  fi
137
211
 
138
212
  cat <<EOF