@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/CONTACT.md +2 -0
- package/README.md +9 -2
- package/config.example.toml +7 -13
- package/dist/added-models.js +80 -0
- package/dist/agent.js +54 -18
- package/dist/browser-tools.js +100 -30
- package/dist/cli.js +4 -1
- package/dist/config-overrides.js +68 -0
- package/dist/config-registry.js +289 -0
- package/dist/config.js +23 -133
- package/dist/contact-note.js +41 -0
- package/dist/discord.js +17 -7
- package/dist/hot-reload.js +29 -3
- package/dist/models.js +3 -2
- package/dist/persona.js +1 -0
- package/dist/web-tools.js +1 -3
- package/dist/web.js +202 -26
- package/package.json +5 -4
- package/scripts/install.ps1 +65 -12
- package/scripts/install.sh +83 -9
- package/skills/memes/SKILL.md +238 -0
- package/web/dist/assets/index-CUvbIJKO.js +60 -0
- package/web/dist/assets/index-CcQ13VAY.css +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-ClgkMgaq.css +0 -2
- package/web/dist/assets/index-Cu2QquuR.js +0 -59
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
|
-
|
|
551
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
44
|
-
"@earendil-works/pi-ai": "^0.
|
|
45
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
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",
|
package/scripts/install.ps1
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
$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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|