portable-agent-layer 0.89.1 → 0.91.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/package.json +3 -2
- package/src/cli/doctor/agents.ts +41 -7
- package/src/cli/doctor/environment.ts +16 -8
- package/src/cli/doctor/finding.ts +7 -2
- package/src/cli/doctor/inference.ts +5 -1
- package/src/cli/doctor/render.ts +231 -50
- package/src/cli/doctor/run.ts +33 -15
- package/src/cli/index.ts +46 -165
- package/src/cli/install-flow.ts +331 -0
- package/src/cli/reinstall.ts +5 -1
- package/src/cli/setup-attribution.ts +9 -9
- package/src/cli/setup-auto-update.ts +8 -7
- package/src/cli/setup-identity.ts +10 -15
- package/src/cli/ui/banner.ts +50 -0
- package/src/cli/ui/live.ts +112 -0
- package/src/cli/ui/rail.ts +119 -0
- package/src/cli/ui/style.ts +132 -0
- package/src/cli/ui/terminal.ts +81 -0
- package/src/cli/ui/wrap.ts +19 -0
- package/src/cli/update-flow.ts +98 -0
- package/src/cli/whats-new.ts +108 -0
- package/src/hooks/handlers/rating.ts +21 -10
- package/src/hooks/lib/inference.ts +1 -1
- package/src/hooks/lib/models.ts +23 -2
- package/src/hooks/lib/reaction-rating.ts +81 -1
- package/src/targets/lib.ts +49 -6
|
@@ -21,10 +21,10 @@ export function timezoneProblem(input: string | undefined): string | undefined {
|
|
|
21
21
|
return `Not a timezone Intl recognises: ${input}`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Prompt for missing identity fields
|
|
25
|
-
export async function promptIdentity(): Promise<
|
|
24
|
+
/** Prompt for missing identity fields; true when it asked anything. */
|
|
25
|
+
export async function promptIdentity(): Promise<boolean> {
|
|
26
26
|
// Skip interactive prompts in non-TTY environments (tests, CI)
|
|
27
|
-
if (!process.stdin.isTTY) return;
|
|
27
|
+
if (!process.stdin.isTTY) return false;
|
|
28
28
|
|
|
29
29
|
const settings: PalSettingsData = { ...readSettings() };
|
|
30
30
|
settings.identity ??= {};
|
|
@@ -40,12 +40,7 @@ export async function promptIdentity(): Promise<void> {
|
|
|
40
40
|
const needsCatchphrase = !ai.catchphrase;
|
|
41
41
|
const needsTimezone = !principal.timezone;
|
|
42
42
|
|
|
43
|
-
if (!needsPrincipal && !needsAi && !needsCatchphrase && !needsTimezone)
|
|
44
|
-
clack.log.info("Identity already configured");
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
clack.intro("Identity Setup");
|
|
43
|
+
if (!needsPrincipal && !needsAi && !needsCatchphrase && !needsTimezone) return false;
|
|
49
44
|
|
|
50
45
|
if (needsPrincipal) {
|
|
51
46
|
const name = await clack.text({
|
|
@@ -55,20 +50,20 @@ export async function promptIdentity(): Promise<void> {
|
|
|
55
50
|
});
|
|
56
51
|
if (clack.isCancel(name)) {
|
|
57
52
|
clack.cancel("Setup cancelled");
|
|
58
|
-
return;
|
|
53
|
+
return true;
|
|
59
54
|
}
|
|
60
55
|
principal.name = name;
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
if (needsAi) {
|
|
64
59
|
const name = await clack.text({
|
|
65
|
-
message: "Name your
|
|
60
|
+
message: "Name your assistant",
|
|
66
61
|
defaultValue: "Assistant",
|
|
67
62
|
placeholder: "e.g. Jarvis, Friday, Atlas",
|
|
68
63
|
});
|
|
69
64
|
if (clack.isCancel(name)) {
|
|
70
65
|
clack.cancel("Setup cancelled");
|
|
71
|
-
return;
|
|
66
|
+
return true;
|
|
72
67
|
}
|
|
73
68
|
ai.name = name;
|
|
74
69
|
ai.fullName = `${name} — Personal AI`;
|
|
@@ -83,7 +78,7 @@ export async function promptIdentity(): Promise<void> {
|
|
|
83
78
|
});
|
|
84
79
|
if (clack.isCancel(catchphrase)) {
|
|
85
80
|
clack.cancel("Setup cancelled");
|
|
86
|
-
return;
|
|
81
|
+
return true;
|
|
87
82
|
}
|
|
88
83
|
ai.catchphrase = catchphrase;
|
|
89
84
|
}
|
|
@@ -97,11 +92,11 @@ export async function promptIdentity(): Promise<void> {
|
|
|
97
92
|
});
|
|
98
93
|
if (clack.isCancel(tz)) {
|
|
99
94
|
clack.cancel("Setup cancelled");
|
|
100
|
-
return;
|
|
95
|
+
return true;
|
|
101
96
|
}
|
|
102
97
|
principal.timezone = tz;
|
|
103
98
|
}
|
|
104
99
|
|
|
105
100
|
writeSettings(settings);
|
|
106
|
-
|
|
101
|
+
return true;
|
|
107
102
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mix, PALETTE, type Style } from "./style";
|
|
2
|
+
|
|
3
|
+
const WORDMARK = [
|
|
4
|
+
"██████╗ █████╗ ██╗ ",
|
|
5
|
+
"██╔══██╗██╔══██╗██║ ",
|
|
6
|
+
"██████╔╝███████║██║ ",
|
|
7
|
+
"██╔═══╝ ██╔══██║██║ ",
|
|
8
|
+
"██║ ██║ ██║███████╗",
|
|
9
|
+
"╚═╝ ╚═╝ ╚═╝╚══════╝",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const FULL_BANNER_WIDTH = 64;
|
|
13
|
+
const TAGLINE = "Your context, in every agent.";
|
|
14
|
+
|
|
15
|
+
function wordmarkRow(style: Style, row: string): string {
|
|
16
|
+
const chars = [...row];
|
|
17
|
+
const span = chars.length - 1;
|
|
18
|
+
return chars
|
|
19
|
+
.map((ch, i) => {
|
|
20
|
+
if (ch === " ") return ch;
|
|
21
|
+
const color = mix(PALETTE.violet, PALETTE.cyan, i / span);
|
|
22
|
+
return style.paint(ch === "█" ? color : mix(color, PALETTE.shade, 0.55), ch);
|
|
23
|
+
})
|
|
24
|
+
.join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function compactBanner(style: Style, version: string): string {
|
|
28
|
+
const mark = `${style.glyph.ask} PAL`;
|
|
29
|
+
const name = `${style.glyph.dot} Portable Agent Layer`;
|
|
30
|
+
return `${style.gradient(mark)} ${style.soft(version)} ${style.dim(name)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function banner(style: Style, version: string): string[] {
|
|
34
|
+
const { term } = style;
|
|
35
|
+
if (!term.unicode || term.width < FULL_BANNER_WIDTH)
|
|
36
|
+
return ["", compactBanner(style, version), ""];
|
|
37
|
+
const side = [
|
|
38
|
+
"",
|
|
39
|
+
style.bold(style.gradient("Portable Agent Layer")),
|
|
40
|
+
style.soft(`v${version}`),
|
|
41
|
+
style.italic(style.soft(TAGLINE)),
|
|
42
|
+
"",
|
|
43
|
+
"",
|
|
44
|
+
];
|
|
45
|
+
return [
|
|
46
|
+
"",
|
|
47
|
+
...WORDMARK.map((row, i) => ` ${wordmarkRow(style, row)} ${side[i]}`.trimEnd()),
|
|
48
|
+
"",
|
|
49
|
+
];
|
|
50
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { mark, railNotes, railRow, seconds } from "./rail";
|
|
2
|
+
import type { Style } from "./style";
|
|
3
|
+
|
|
4
|
+
type NoteLevel = "fail" | "warn" | "info";
|
|
5
|
+
|
|
6
|
+
export interface Note {
|
|
7
|
+
level: NoteLevel;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Done {
|
|
12
|
+
detail: string[];
|
|
13
|
+
notes?: Note[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Work = () => Promise<Done | null> | Done | null;
|
|
17
|
+
|
|
18
|
+
const FRAME_MS = 90;
|
|
19
|
+
const CLEAR_LINE = "\r\x1b[2K";
|
|
20
|
+
|
|
21
|
+
const INSTANT_MS = 50;
|
|
22
|
+
|
|
23
|
+
export const stepTime = (ms: number) => (ms < INSTANT_MS ? "" : seconds(ms));
|
|
24
|
+
|
|
25
|
+
function errorText(error: unknown): string {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prints the steps of a long command. At a terminal each step spins while it
|
|
31
|
+
* runs and settles into one line; anywhere else it prints one plain line when done.
|
|
32
|
+
*/
|
|
33
|
+
export class LiveRail {
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly style: Style,
|
|
36
|
+
private readonly write: (text: string) => void = (text) => {
|
|
37
|
+
process.stdout.write(text);
|
|
38
|
+
}
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
line(text = ""): void {
|
|
42
|
+
this.write(`${text}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async step(name: string, pending: string, work: Work): Promise<Done | null> {
|
|
46
|
+
return this.style.term.rich
|
|
47
|
+
? this.richStep(name, pending, work)
|
|
48
|
+
: this.plainStep(name, work);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private noteLines(note: Note): string[] {
|
|
52
|
+
if (note.level === "info") return railNotes(this.style, note.text, this.style.dim);
|
|
53
|
+
return railNotes(
|
|
54
|
+
this.style,
|
|
55
|
+
note.text,
|
|
56
|
+
(text) => text,
|
|
57
|
+
`${mark(this.style, note.level)} `
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async richStep(
|
|
62
|
+
name: string,
|
|
63
|
+
pending: string,
|
|
64
|
+
work: Work
|
|
65
|
+
): Promise<Done | null> {
|
|
66
|
+
let frame = 0;
|
|
67
|
+
const draw = () =>
|
|
68
|
+
this.write(
|
|
69
|
+
`${CLEAR_LINE}${railRow(this.style, "spin", name, pending, "", frame)[0]}`
|
|
70
|
+
);
|
|
71
|
+
draw();
|
|
72
|
+
const spinner = setInterval(() => {
|
|
73
|
+
frame += 1;
|
|
74
|
+
draw();
|
|
75
|
+
}, FRAME_MS);
|
|
76
|
+
const started = performance.now();
|
|
77
|
+
try {
|
|
78
|
+
const done = await work();
|
|
79
|
+
clearInterval(spinner);
|
|
80
|
+
this.write(CLEAR_LINE);
|
|
81
|
+
if (done) this.settle(name, done, stepTime(performance.now() - started));
|
|
82
|
+
return done;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
clearInterval(spinner);
|
|
85
|
+
this.write(CLEAR_LINE);
|
|
86
|
+
this.settle(name, { detail: [errorText(error)] }, "", "fail");
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private settle(name: string, done: Done, time: string, kind: "ok" | "fail" = "ok") {
|
|
92
|
+
const detail = done.detail.join(` ${this.style.glyph.dot} `);
|
|
93
|
+
for (const row of railRow(this.style, kind, name, detail, time)) this.line(row);
|
|
94
|
+
for (const note of done.notes ?? [])
|
|
95
|
+
for (const line of this.noteLines(note)) this.line(line);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async plainStep(name: string, work: Work): Promise<Done | null> {
|
|
99
|
+
try {
|
|
100
|
+
const done = await work();
|
|
101
|
+
if (done) {
|
|
102
|
+
this.line(`ok ${name}: ${done.detail.join(", ")}`);
|
|
103
|
+
for (const note of done.notes ?? [])
|
|
104
|
+
this.line(`${note.level.padEnd(4)} ${note.text}`);
|
|
105
|
+
}
|
|
106
|
+
return done;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.line(`fail ${name}: ${errorText(error)}`);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { PALETTE, type Style, visibleWidth } from "./style";
|
|
2
|
+
import { wrapWords } from "./wrap";
|
|
3
|
+
|
|
4
|
+
const RAIL_WIDTH = 60;
|
|
5
|
+
const NAME_WIDTH = 13;
|
|
6
|
+
|
|
7
|
+
export type Mark = "ok" | "fail" | "warn" | "info" | "ask" | "todo" | "spin";
|
|
8
|
+
|
|
9
|
+
export function mark(style: Style, kind: Mark, frame = 0): string {
|
|
10
|
+
const { glyph, paint } = style;
|
|
11
|
+
switch (kind) {
|
|
12
|
+
case "ok":
|
|
13
|
+
return paint(PALETTE.green, glyph.ok);
|
|
14
|
+
case "fail":
|
|
15
|
+
return paint(PALETTE.rose, glyph.fail);
|
|
16
|
+
case "warn":
|
|
17
|
+
return paint(PALETTE.amber, glyph.warn);
|
|
18
|
+
case "info":
|
|
19
|
+
return paint(PALETTE.indigo, glyph.info);
|
|
20
|
+
case "ask":
|
|
21
|
+
return paint(PALETTE.cyan, glyph.ask);
|
|
22
|
+
case "todo":
|
|
23
|
+
return style.dim(glyph.todo);
|
|
24
|
+
case "spin":
|
|
25
|
+
return paint(PALETTE.violet, glyph.spinner[frame % glyph.spinner.length]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const railGlyph = (style: Style, glyph: string) => style.paint(PALETTE.rail, glyph);
|
|
30
|
+
|
|
31
|
+
export const railTop = (style: Style, title: string) =>
|
|
32
|
+
`${railGlyph(style, style.glyph.top)} ${style.bold(title)}`;
|
|
33
|
+
|
|
34
|
+
export const railGap = (style: Style) => railGlyph(style, style.glyph.rail);
|
|
35
|
+
|
|
36
|
+
export const railNote = (style: Style, text: string) => `${railGap(style)} ${text}`;
|
|
37
|
+
|
|
38
|
+
export const railEnd = (style: Style, text: string) =>
|
|
39
|
+
`${railGlyph(style, style.glyph.end)} ${text}`;
|
|
40
|
+
|
|
41
|
+
function padBetween(left: string, right: string, width: number): string {
|
|
42
|
+
const room = width - visibleWidth(left) - visibleWidth(right);
|
|
43
|
+
return right ? `${left}${" ".repeat(Math.max(2, room))}${right}` : left;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One finished step: what it did on the same line, or under the name when the terminal is too narrow. */
|
|
47
|
+
export function railRow(
|
|
48
|
+
style: Style,
|
|
49
|
+
kind: Mark,
|
|
50
|
+
name: string,
|
|
51
|
+
detail: string,
|
|
52
|
+
time = "",
|
|
53
|
+
frame = 0
|
|
54
|
+
): string[] {
|
|
55
|
+
const head = `${mark(style, kind, frame)} ${name.padEnd(NAME_WIDTH)}`;
|
|
56
|
+
const width = Math.min(style.term.width - 2, RAIL_WIDTH);
|
|
57
|
+
const wide = `${head}${style.soft(detail)}`;
|
|
58
|
+
if (visibleWidth(wide) + time.length + 2 <= width)
|
|
59
|
+
return [padBetween(wide, style.dim(time), width)];
|
|
60
|
+
const first = padBetween(
|
|
61
|
+
`${mark(style, kind, frame)} ${name}`,
|
|
62
|
+
style.dim(time),
|
|
63
|
+
width
|
|
64
|
+
);
|
|
65
|
+
return [first, ...railNotes(style, detail, style.dim)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Text under a step, wrapped to the terminal so the rail stays unbroken. */
|
|
69
|
+
export function railNotes(
|
|
70
|
+
style: Style,
|
|
71
|
+
text: string,
|
|
72
|
+
paint: (text: string) => string,
|
|
73
|
+
lead = ""
|
|
74
|
+
): string[] {
|
|
75
|
+
const width = Math.max(10, style.term.width - 3 - visibleWidth(lead));
|
|
76
|
+
const pad = " ".repeat(visibleWidth(lead));
|
|
77
|
+
return wrapWords(text, width).map((chunk, i) =>
|
|
78
|
+
railNote(style, `${i === 0 ? lead : pad}${paint(chunk.text)}`)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface HealthCount {
|
|
83
|
+
fails: number;
|
|
84
|
+
warns: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function healthBadge(
|
|
88
|
+
style: Style,
|
|
89
|
+
{ fails, warns }: HealthCount,
|
|
90
|
+
withGlyph = false
|
|
91
|
+
) {
|
|
92
|
+
const { glyph } = style;
|
|
93
|
+
const lead = (g: string) => (withGlyph ? `${g} ` : "");
|
|
94
|
+
if (fails > 0) return style.badge(PALETTE.rose, `${lead(glyph.fail)}${fails} FAILING`);
|
|
95
|
+
if (warns > 0)
|
|
96
|
+
return style.badge(
|
|
97
|
+
PALETTE.amber,
|
|
98
|
+
`${lead(glyph.warn)}${warns} WARNING${warns === 1 ? "" : "S"}`
|
|
99
|
+
);
|
|
100
|
+
return style.badge(PALETTE.green, `${lead(glyph.healthy)}HEALTHY`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A card when it fits; on a narrow terminal the same lines, unframed. */
|
|
104
|
+
export function box(style: Style, lines: string[], inner = 50): string[] {
|
|
105
|
+
if (style.term.width < inner + 4) return lines.map((line) => ` ${line}`.trimEnd());
|
|
106
|
+
const { box: b } = style.glyph;
|
|
107
|
+
const edge = (text: string) => railGlyph(style, text);
|
|
108
|
+
const padded = (line: string) =>
|
|
109
|
+
`${line}${" ".repeat(Math.max(0, inner - 2 - visibleWidth(line)))}`;
|
|
110
|
+
return [
|
|
111
|
+
edge(` ${b.tl}${b.h.repeat(inner)}${b.tr}`),
|
|
112
|
+
...lines.map((line) => ` ${edge(b.v)} ${padded(line)}${edge(b.v)}`),
|
|
113
|
+
edge(` ${b.bl}${b.h.repeat(inner)}${b.br}`),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function seconds(ms: number): string {
|
|
118
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { detectTerminal, type Terminal } from "./terminal";
|
|
3
|
+
|
|
4
|
+
export type Rgb = readonly [number, number, number];
|
|
5
|
+
|
|
6
|
+
export const PALETTE = {
|
|
7
|
+
violet: [167, 139, 250],
|
|
8
|
+
indigo: [129, 140, 248],
|
|
9
|
+
cyan: [34, 211, 238],
|
|
10
|
+
green: [52, 211, 153],
|
|
11
|
+
amber: [251, 191, 36],
|
|
12
|
+
rose: [251, 113, 133],
|
|
13
|
+
dim: [100, 116, 139],
|
|
14
|
+
rail: [71, 85, 105],
|
|
15
|
+
soft: [148, 163, 184],
|
|
16
|
+
ink: [15, 23, 42],
|
|
17
|
+
shade: [11, 17, 32],
|
|
18
|
+
} as const satisfies Record<string, Rgb>;
|
|
19
|
+
|
|
20
|
+
const UNICODE_GLYPHS = {
|
|
21
|
+
ok: "✓",
|
|
22
|
+
fail: "✖",
|
|
23
|
+
warn: "▲",
|
|
24
|
+
healthy: "●",
|
|
25
|
+
info: "◇",
|
|
26
|
+
ask: "◆",
|
|
27
|
+
todo: "○",
|
|
28
|
+
spinner: ["◐", "◓", "◑", "◒"],
|
|
29
|
+
rail: "│",
|
|
30
|
+
top: "┌",
|
|
31
|
+
end: "└",
|
|
32
|
+
arrow: "→",
|
|
33
|
+
sparkle: "✦",
|
|
34
|
+
dot: "·",
|
|
35
|
+
dash: "—",
|
|
36
|
+
box: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Glyphs = typeof UNICODE_GLYPHS;
|
|
40
|
+
|
|
41
|
+
const ASCII_GLYPHS: Glyphs = {
|
|
42
|
+
ok: "v",
|
|
43
|
+
fail: "x",
|
|
44
|
+
warn: "!",
|
|
45
|
+
healthy: "*",
|
|
46
|
+
info: "*",
|
|
47
|
+
ask: "*",
|
|
48
|
+
todo: "o",
|
|
49
|
+
spinner: ["-", "\\", "|", "/"],
|
|
50
|
+
rail: "|",
|
|
51
|
+
top: "+",
|
|
52
|
+
end: "+",
|
|
53
|
+
arrow: "->",
|
|
54
|
+
sparkle: "*",
|
|
55
|
+
dot: "-",
|
|
56
|
+
dash: "-",
|
|
57
|
+
box: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const RESET = "\x1b[0m";
|
|
61
|
+
|
|
62
|
+
export function visibleWidth(text: string): number {
|
|
63
|
+
return Bun.stringWidth(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mix(from: Rgb, to: Rgb, t: number): Rgb {
|
|
67
|
+
return [0, 1, 2].map((i) =>
|
|
68
|
+
Math.round(from[i] + (to[i] - from[i]) * t)
|
|
69
|
+
) as unknown as Rgb;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cubeLevel(channel: number): number {
|
|
73
|
+
return Math.round((channel / 255) * 5);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The nearest colour in the 6x6x6 cube of a 256-colour terminal. */
|
|
77
|
+
export function to256([r, g, b]: Rgb): number {
|
|
78
|
+
return 16 + 36 * cubeLevel(r) + 6 * cubeLevel(g) + cubeLevel(b);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sgr(term: Terminal, layer: 38 | 48, rgb: Rgb): string {
|
|
82
|
+
if (term.color === "truecolor") return `\x1b[${layer};2;${rgb.join(";")}m`;
|
|
83
|
+
return `\x1b[${layer};5;${to256(rgb)}m`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function homeShortened(text: string, home = homedir()): string {
|
|
87
|
+
return home.length > 1 ? text.replaceAll(home, "~") : text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Style {
|
|
91
|
+
term: Terminal;
|
|
92
|
+
glyph: Glyphs;
|
|
93
|
+
paint: (rgb: Rgb, text: string) => string;
|
|
94
|
+
badge: (rgb: Rgb, text: string) => string;
|
|
95
|
+
bold: (text: string) => string;
|
|
96
|
+
italic: (text: string) => string;
|
|
97
|
+
gradient: (text: string) => string;
|
|
98
|
+
dim: (text: string) => string;
|
|
99
|
+
soft: (text: string) => string;
|
|
100
|
+
cmd: (text: string) => string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createStyle(term: Terminal = detectTerminal()): Style {
|
|
104
|
+
const colored = term.color !== "none";
|
|
105
|
+
const wrap = (open: string, text: string) =>
|
|
106
|
+
colored && text ? `${open}${text}${RESET}` : text;
|
|
107
|
+
const paint = (rgb: Rgb, text: string) => wrap(sgr(term, 38, rgb), text);
|
|
108
|
+
const gradientChar = (ch: string, i: number, span: number) =>
|
|
109
|
+
ch === " "
|
|
110
|
+
? ch
|
|
111
|
+
: `${sgr(term, 38, mix(PALETTE.violet, PALETTE.cyan, i / span))}${ch}`;
|
|
112
|
+
return {
|
|
113
|
+
term,
|
|
114
|
+
glyph: term.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS,
|
|
115
|
+
paint,
|
|
116
|
+
badge: (rgb, text) =>
|
|
117
|
+
colored
|
|
118
|
+
? `${sgr(term, 48, rgb)}${sgr(term, 38, PALETTE.ink)}\x1b[1m ${text} ${RESET}`
|
|
119
|
+
: `[${text}]`,
|
|
120
|
+
bold: (text) => wrap("\x1b[1m", text),
|
|
121
|
+
italic: (text) => wrap("\x1b[3m", text),
|
|
122
|
+
gradient: (text) => {
|
|
123
|
+
if (!colored) return text;
|
|
124
|
+
const chars = [...text];
|
|
125
|
+
const span = Math.max(1, chars.length - 1);
|
|
126
|
+
return `${chars.map((ch, i) => gradientChar(ch, i, span)).join("")}${RESET}`;
|
|
127
|
+
},
|
|
128
|
+
dim: (text) => paint(PALETTE.dim, text),
|
|
129
|
+
soft: (text) => paint(PALETTE.soft, text),
|
|
130
|
+
cmd: (text) => paint(PALETTE.cyan, text),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the terminal PAL is printing to can show. Rich output (the banner, the
|
|
3
|
+
* rail, colours) is for a person at a terminal; anything else, an agent's tool
|
|
4
|
+
* call or a CI log, gets short plain lines.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type ColorDepth = "none" | "256" | "truecolor";
|
|
8
|
+
|
|
9
|
+
export interface Terminal {
|
|
10
|
+
rich: boolean;
|
|
11
|
+
color: ColorDepth;
|
|
12
|
+
unicode: boolean;
|
|
13
|
+
width: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TerminalProbe {
|
|
17
|
+
env: Record<string, string | undefined>;
|
|
18
|
+
isTTY: boolean;
|
|
19
|
+
columns?: number;
|
|
20
|
+
platform: NodeJS.Platform;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TRUECOLOR_PROGRAMS = new Set([
|
|
24
|
+
"iTerm.app",
|
|
25
|
+
"vscode",
|
|
26
|
+
"WezTerm",
|
|
27
|
+
"ghostty",
|
|
28
|
+
"Hyper",
|
|
29
|
+
]);
|
|
30
|
+
const TRUECOLOR_TERMS = /truecolor|24bit|direct|kitty|alacritty|ghostty|wezterm/;
|
|
31
|
+
|
|
32
|
+
function forcedColor(env: TerminalProbe["env"]): ColorDepth | undefined {
|
|
33
|
+
const force = env.FORCE_COLOR;
|
|
34
|
+
if (force === undefined) return undefined;
|
|
35
|
+
if (force === "0" || force === "false") return "none";
|
|
36
|
+
return force === "3" ? "truecolor" : "256";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function supportsTruecolor({ env }: TerminalProbe): boolean {
|
|
40
|
+
return (
|
|
41
|
+
env.COLORTERM === "truecolor" ||
|
|
42
|
+
env.COLORTERM === "24bit" ||
|
|
43
|
+
env.WT_SESSION !== undefined ||
|
|
44
|
+
TRUECOLOR_PROGRAMS.has(env.TERM_PROGRAM ?? "") ||
|
|
45
|
+
TRUECOLOR_TERMS.test(env.TERM ?? "")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function colorDepth(probe: TerminalProbe, rich: boolean): ColorDepth {
|
|
50
|
+
if (probe.env.NO_COLOR) return "none";
|
|
51
|
+
const forced = forcedColor(probe.env);
|
|
52
|
+
if (forced === "none") return "none";
|
|
53
|
+
if (!rich && !forced) return "none";
|
|
54
|
+
if (forced === "truecolor" || supportsTruecolor(probe)) return "truecolor";
|
|
55
|
+
return "256";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The legacy Windows console draws box and block characters as question marks. */
|
|
59
|
+
function supportsUnicode({ env, platform }: TerminalProbe): boolean {
|
|
60
|
+
if (platform !== "win32") return true;
|
|
61
|
+
return Boolean(env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuTask || env.TERM);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function detectTerminal(probe: TerminalProbe = currentProbe()): Terminal {
|
|
65
|
+
const rich = probe.isTTY && probe.env.TERM !== "dumb";
|
|
66
|
+
return {
|
|
67
|
+
rich,
|
|
68
|
+
color: colorDepth(probe, rich),
|
|
69
|
+
unicode: supportsUnicode(probe),
|
|
70
|
+
width: probe.columns && probe.columns > 0 ? probe.columns : 80,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function currentProbe(): TerminalProbe {
|
|
75
|
+
return {
|
|
76
|
+
env: process.env,
|
|
77
|
+
isTTY: process.stdout.isTTY === true,
|
|
78
|
+
columns: process.stdout.columns,
|
|
79
|
+
platform: process.platform,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Chunk {
|
|
2
|
+
text: string;
|
|
3
|
+
start: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function wrapWords(text: string, width: number): Chunk[] {
|
|
7
|
+
const chunks: Chunk[] = [];
|
|
8
|
+
let line: Chunk | null = null;
|
|
9
|
+
for (const word of text.matchAll(/\S+/g)) {
|
|
10
|
+
const start = word.index ?? 0;
|
|
11
|
+
if (line && start + word[0].length - line.start <= width) {
|
|
12
|
+
line.text = text.slice(line.start, start + word[0].length);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
line = { text: word[0], start };
|
|
16
|
+
chunks.push(line);
|
|
17
|
+
}
|
|
18
|
+
return chunks;
|
|
19
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pal cli update`: the version jump, the download, then a fresh process that
|
|
3
|
+
* reinstalls and continues the same rail.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { UpdateCache } from "../hooks/handlers/update-check";
|
|
7
|
+
import { palPkg } from "../hooks/lib/paths";
|
|
8
|
+
import { type Captured, runCaptured } from "./install-flow";
|
|
9
|
+
import { type Done, LiveRail } from "./ui/live";
|
|
10
|
+
import { mark, railGap, railTop } from "./ui/rail";
|
|
11
|
+
import { createStyle, type Style } from "./ui/style";
|
|
12
|
+
|
|
13
|
+
const PACKAGE = "portable-agent-layer";
|
|
14
|
+
|
|
15
|
+
function upToDate(style: Style, version: string): string {
|
|
16
|
+
if (!style.term.rich) return `Already up to date: PAL ${version} is the latest`;
|
|
17
|
+
const checked = `${style.glyph.dot} checked just now`;
|
|
18
|
+
return ` ${mark(style, "ok")} ${style.gradient("PAL")} ${style.bold(version)} ${style.soft("is the latest")} ${style.dim(checked)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function versionJump(style: Style, from: string, to: string): string {
|
|
22
|
+
if (!style.term.rich) return `PAL ${from} -> ${to}`;
|
|
23
|
+
const arrow = style.dim(style.glyph.arrow);
|
|
24
|
+
const pal = `${style.glyph.ask} PAL`;
|
|
25
|
+
return ` ${style.gradient(pal)} ${style.soft(from)} ${arrow} ${style.bold(style.gradient(to))}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Run = (cmd: string, args: string[], cwd?: string) => Promise<Captured>;
|
|
29
|
+
|
|
30
|
+
export interface UpdateSteps {
|
|
31
|
+
check: () => Promise<UpdateCache>;
|
|
32
|
+
reinstall: (env: Record<string, string>) => number;
|
|
33
|
+
run?: Run;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `bun update -g` keeps the caret range, and on 0.x a caret allows only patch bumps. */
|
|
37
|
+
export function downloadCommand(
|
|
38
|
+
update: UpdateCache
|
|
39
|
+
): [string, string[], string | undefined] {
|
|
40
|
+
return update.mode === "repo"
|
|
41
|
+
? ["git", ["pull", "--ff-only"], palPkg()]
|
|
42
|
+
: ["bun", ["add", "-g", `${PACKAGE}@${update.latest}`], undefined];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class DownloadFailed extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
update: UpdateCache,
|
|
48
|
+
readonly output: string
|
|
49
|
+
) {
|
|
50
|
+
super(
|
|
51
|
+
update.mode === "repo"
|
|
52
|
+
? "git pull failed — local changes? Pull manually"
|
|
53
|
+
: `try: bun add -g ${PACKAGE}@${update.latest}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function download(update: UpdateCache, run: Run): Promise<Done> {
|
|
59
|
+
const [cmd, args, cwd] = downloadCommand(update);
|
|
60
|
+
const result = await run(cmd, args, cwd);
|
|
61
|
+
if (result.code !== 0) throw new DownloadFailed(update, result.output);
|
|
62
|
+
return { detail: [`${PACKAGE} ${update.latest}`] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function downloaded(rail: LiveRail, style: Style, update: UpdateCache, run: Run) {
|
|
66
|
+
try {
|
|
67
|
+
await rail.step("Download", "downloading…", () => download(update, run));
|
|
68
|
+
return true;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!(error instanceof DownloadFailed)) throw error;
|
|
71
|
+
for (const line of error.output.trim().split("\n").slice(-6))
|
|
72
|
+
rail.line(style.dim(` ${line}`));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Exit code for the parent; the reinstall runs in a fresh process that reads PAL_UPDATED_FROM. */
|
|
78
|
+
export async function runUpdate(
|
|
79
|
+
{ check, reinstall, run = runCaptured }: UpdateSteps,
|
|
80
|
+
style: Style = createStyle(),
|
|
81
|
+
rail: LiveRail = new LiveRail(style)
|
|
82
|
+
): Promise<number> {
|
|
83
|
+
const update = await check();
|
|
84
|
+
rail.line();
|
|
85
|
+
if (!update.available) {
|
|
86
|
+
rail.line(upToDate(style, update.current));
|
|
87
|
+
rail.line();
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
rail.line(versionJump(style, update.current, update.latest));
|
|
91
|
+
rail.line();
|
|
92
|
+
if (style.term.rich) {
|
|
93
|
+
rail.line(railTop(style, "Updating"));
|
|
94
|
+
rail.line(railGap(style));
|
|
95
|
+
}
|
|
96
|
+
if (!(await downloaded(rail, style, update, run))) return 1;
|
|
97
|
+
return reinstall({ PAL_UPDATED_FROM: update.current });
|
|
98
|
+
}
|