@audiofool/moondog 0.2.0-beta.6 → 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/README.md +5 -4
- package/assets/brand/moondog-logo.svg +1 -0
- package/package.json +2 -2
- package/scripts/moondog.mjs +30 -6
- package/src/core/application-update.mjs +5 -3
- package/src/core/first-run.mjs +53 -0
- package/src/i18n/screen/es.mjs +62 -4
- package/src/i18n/screen/ja.mjs +62 -4
- package/src/i18n/screen/pt.mjs +62 -4
- package/src/i18n/screen/zh.mjs +62 -4
- package/src/integrations/netease/ncm-cli-player.mjs +29 -13
- package/src/memory/reflection-worker.mjs +1 -1
- package/src/runtime/pi/authentication.mjs +31 -10
- package/src/runtime/pi/configured-runtime.mjs +3 -3
- package/src/runtime/pi/custom-endpoints.mjs +229 -0
- package/src/runtime/pi/model-catalog.mjs +7 -3
- package/src/runtime/pi/model-list.mjs +122 -0
- package/src/runtime/pi/model-settings.mjs +131 -20
- package/src/runtime/pi/model-transport.mjs +2 -2
- package/src/runtime/pi/persistent-credential-store.mjs +31 -0
- package/src/runtime/pi/provider-registry.mjs +3 -9
- package/src/runtime/pi/public-model-catalog.mjs +12 -4
- package/src/runtime/pi/runtime-selection.mjs +9 -0
- package/src/runtime/pi/runtime-settings.mjs +37 -0
- package/src/surfaces/brand/logo.mjs +237 -0
- package/src/surfaces/cli/auth-command.mjs +25 -56
- package/src/surfaces/cli/brand-components.mjs +1 -1
- package/src/surfaces/cli/command-completions.mjs +14 -0
- package/src/surfaces/cli/endpoint-command.mjs +106 -0
- package/src/surfaces/cli/first-run-view.mjs +138 -0
- package/src/surfaces/cli/format-output.mjs +1 -1
- package/src/surfaces/cli/terminal-art.mjs +24 -66
- package/src/surfaces/cli/tui.mjs +320 -33
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Moondog's logo, drawn from numbers. Every shape is measured from the selected 2026-09-02 picture
|
|
2
|
+
// (assets/brand/moondog-lunar-record): the dog's outline, the headphones, the record's grooves and
|
|
3
|
+
// craters, and the tonearm. The picture's paper and its bar mosaic stay with that key visual; the
|
|
4
|
+
// logo is one ink on nothing, so it sits on any background.
|
|
5
|
+
//
|
|
6
|
+
// Coordinates are the picture's 1024 px square with the record's centre moved to (512, 512).
|
|
7
|
+
// scripts/generate-logo.mjs writes the files in assets/brand, the app icons and the site's images
|
|
8
|
+
// from this module; the window's title-bar mark and the terminal's record draw the same dog.
|
|
9
|
+
|
|
10
|
+
const f = (n) => Number(n.toFixed(2));
|
|
11
|
+
const pt = ([x, y]) => `${f(x)} ${f(y)}`;
|
|
12
|
+
|
|
13
|
+
export const RECORD = Object.freeze({ cx: 512, cy: 512, rim: 426.7, inner: 222, pitch: 9.75 });
|
|
14
|
+
|
|
15
|
+
// The earcup is a small record of its own, its rings on the record's own pitch.
|
|
16
|
+
const CUP = Object.freeze({ cx: 362.5, cy: 504, r: 67, rings: [5, 14, 23, 32, 41, 50, 59] });
|
|
17
|
+
|
|
18
|
+
// The band, its outer edge first, from inside the earcup up over the crown to the ear's tip, then
|
|
19
|
+
// back down its inner edge. Its foot reaches into the earcup, so the gap round the cup cuts it.
|
|
20
|
+
const BAND_OUTER = [[334, 452], [334.6, 418], [335.2, 396.1], [339.3, 371.8], [345.7, 353.1], [353.5, 336.1], [365.6, 317.1],
|
|
21
|
+
[379.8, 300.7], [389.7, 291], [400.7, 282.9], [413.5, 275.6], [426.9, 267.9], [436, 262.3]];
|
|
22
|
+
const BAND_TIP = [[441, 262.4], [444.6, 267.6], [447.6, 276.4]];
|
|
23
|
+
const BAND_INNER = [[446.3, 281.5], [436.7, 284.8], [419.5, 296], [401.3, 311.1], [389, 324.5], [380.3, 338], [373.4, 351.8],
|
|
24
|
+
[367.4, 368], [363.2, 386], [361.1, 405], [361, 452]];
|
|
25
|
+
|
|
26
|
+
// The head, clockwise from the ear's tip: down the ear, the brow and the muzzle, round the nose, back
|
|
27
|
+
// under the jaw, down the throat and the chest, along the bottom and up the back of the neck into
|
|
28
|
+
// the earcup's gap. The corners are the ear, the throat, the chest's step and the bottom's two ends.
|
|
29
|
+
const HEAD = [
|
|
30
|
+
[447.6, 281], [469.5, 347.5],
|
|
31
|
+
[481, 359.8], [494, 377.2], [512, 389.2], [542, 401.2], [577, 417], [603, 435.2],
|
|
32
|
+
[611.5, 449], [620.5, 461], [629, 473.5], [641, 483.6], [655, 490.4], [681, 501], [704.4, 508.6], [720, 512],
|
|
33
|
+
[729, 515.8], [733, 522.5], [733.4, 530.6], [731, 539.5],
|
|
34
|
+
[724.3, 553.2], [719.4, 565.9], [712.5, 577.5], [696.5, 593.6], [679.4, 604.9], [657.5, 612.9], [638, 617],
|
|
35
|
+
[592.5, 618.1], [578.2, 621.5], [568.6, 628.9], [563.5, 636],
|
|
36
|
+
[563.3, 661], [565.6, 690], [566.3, 728.3],
|
|
37
|
+
[546.2, 740.3], [528, 746.8],
|
|
38
|
+
[525.2, 790], [527.5, 838.8],
|
|
39
|
+
[480, 833.5], [434, 825], [392, 813.6], [350, 800], [312, 775], [275, 750], [245, 727], [220.3, 699.5],
|
|
40
|
+
[236, 664], [252, 630], [265, 600], [276, 566], [287.7, 533.7], [292, 522],
|
|
41
|
+
];
|
|
42
|
+
const HEAD_CORNERS = [0, 1, 30, 33, 35, 37, 45];
|
|
43
|
+
|
|
44
|
+
// Craters, each an inner arc, a ring and one or two dashed rings.
|
|
45
|
+
const CRATERS = [
|
|
46
|
+
{ cx: 321.5, cy: 236.7, r: 39 }, { cx: 712.8, cy: 277.2, r: 36 }, { cx: 212, cy: 490.9, r: 27.5 },
|
|
47
|
+
{ cx: 816.5, cy: 618.4, r: 16.5 }, { cx: 256.5, cy: 786.9, r: 30.5 }, { cx: 640.8, cy: 848.7, r: 40 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// The tonearm in its own coordinates: t runs down the arm from the headshell's middle, s across it.
|
|
51
|
+
const ARM = Object.freeze({ origin: [900.3, 201.2], down: [-0.61433, 0.78905] });
|
|
52
|
+
|
|
53
|
+
// A centripetal Catmull-Rom curve through the nodes, as cubic Béziers: it passes through every node
|
|
54
|
+
// and never loops at a sharp turn. A node in `corners` keeps its point; the curve meets it straight.
|
|
55
|
+
function segments(nodes, { closed = true, corners = [] } = {}) {
|
|
56
|
+
const sharp = new Set(corners);
|
|
57
|
+
const count = nodes.length;
|
|
58
|
+
const at = (index) => (closed ? nodes[(index + count) % count] : nodes[Math.max(0, Math.min(count - 1, index))]);
|
|
59
|
+
const tangent = (a, b, c, index) => {
|
|
60
|
+
if (sharp.has((index + count) % count)) return [0, 0];
|
|
61
|
+
const before = Math.hypot(b[0] - a[0], b[1] - a[1]) ** 0.5 || 1e-6;
|
|
62
|
+
const after = Math.hypot(c[0] - b[0], c[1] - b[1]) ** 0.5 || 1e-6;
|
|
63
|
+
return [0, 1].map((axis) => (b[axis] - a[axis]) / before - (c[axis] - a[axis]) / (before + after) + (c[axis] - b[axis]) / after);
|
|
64
|
+
};
|
|
65
|
+
const list = [];
|
|
66
|
+
for (let index = 0; index < (closed ? count : count - 1); index += 1) {
|
|
67
|
+
const [p0, p1, p2, p3] = [at(index - 1), at(index), at(index + 1), at(index + 2)];
|
|
68
|
+
const span = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) ** 0.5;
|
|
69
|
+
const [m1, m2] = [tangent(p0, p1, p2, index), tangent(p1, p2, p3, index + 1)];
|
|
70
|
+
list.push([p1, [p1[0] + m1[0] * span / 3, p1[1] + m1[1] * span / 3], [p2[0] - m2[0] * span / 3, p2[1] - m2[1] * span / 3], p2]);
|
|
71
|
+
}
|
|
72
|
+
return { list, closed };
|
|
73
|
+
}
|
|
74
|
+
const toPath = ({ list, closed }) => `M${pt(list[0][0])}${list.map(([, c1, c2, end]) => `C${pt(c1)} ${pt(c2)} ${pt(end)}`).join("")}${closed ? "Z" : ""}`;
|
|
75
|
+
function toPoints({ list }, steps = 8) {
|
|
76
|
+
const points = [];
|
|
77
|
+
for (const [a, b, c, d] of list) {
|
|
78
|
+
for (let step = 0; step < steps; step += 1) {
|
|
79
|
+
const t = step / steps, s = 1 - t;
|
|
80
|
+
points.push([0, 1].map((axis) => s ** 3 * a[axis] + 3 * s * s * t * b[axis] + 3 * s * t * t * c[axis] + t ** 3 * d[axis]));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
points.push(list.at(-1)[3]);
|
|
84
|
+
return points;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Moves each node of an open line sideways by `by`: positive is to the right of the way it runs.
|
|
88
|
+
function offset(nodes, by) {
|
|
89
|
+
return nodes.map((point, index) => {
|
|
90
|
+
const a = nodes[Math.max(0, index - 1)], b = nodes[Math.min(nodes.length - 1, index + 1)];
|
|
91
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
92
|
+
return [point[0] - ((b[1] - a[1]) / length) * by, point[1] + ((b[0] - a[0]) / length) * by];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The dog's three shapes and the gap between band and head. The head closes through the earcup and
|
|
97
|
+
// just inside the band, so head and band overlap without a seam; the gaps are cut from all three.
|
|
98
|
+
function figure(gap) {
|
|
99
|
+
const band = segments([...BAND_OUTER, ...BAND_TIP, ...BAND_INNER], { corners: [0, BAND_OUTER.length + BAND_TIP.length + BAND_INNER.length - 1] });
|
|
100
|
+
const along = offset([...BAND_INNER].reverse(), -1.5).slice(0, -1);
|
|
101
|
+
const head = segments([...HEAD, [330, 505], [358, 455], ...along], {
|
|
102
|
+
corners: [...HEAD_CORNERS, HEAD.length - 1, HEAD.length, HEAD.length + 1, HEAD.length + along.length - 1],
|
|
103
|
+
});
|
|
104
|
+
// The band's gap runs beside its inner edge, from the middle of the earcup's gap out past the ear.
|
|
105
|
+
const edge = [...BAND_INNER.slice(0, -1), [361.1, CUP.cy - CUP.r - gap / 2]];
|
|
106
|
+
const [first, second] = edge;
|
|
107
|
+
const bandGap = segments(offset([[first[0] + (first[0] - second[0]) * 1.2, first[1] + (first[1] - second[1]) * 1.2], ...edge], -gap / 2), { closed: false });
|
|
108
|
+
return { head, band, bandGap };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const toArm = (t, s) => [ARM.origin[0] + t * ARM.down[0] + s * ARM.down[1], ARM.origin[1] + t * ARM.down[1] - s * ARM.down[0]];
|
|
112
|
+
const quad = (corners) => `M${corners.map((corner) => pt(toArm(...corner))).join("L")}Z`;
|
|
113
|
+
// The full tonearm: tube, collar, headshell, the block under it, finger lift, cartridge, stylus.
|
|
114
|
+
function tonearm(reach) {
|
|
115
|
+
return {
|
|
116
|
+
parts: [
|
|
117
|
+
quad([[-reach, -7.25], [-reach, 7.25], [-38, 7.25], [-38, -7.25]]), quad([[-36.5, -11], [-36.5, 11], [-28, 11], [-28, -11]]),
|
|
118
|
+
quad([[-26.5, -17.5], [-26.5, 17.5], [25, 17.5], [25, -20]]), quad([[27, -26], [27, 21.5], [70, 19.5], [70, -28]]),
|
|
119
|
+
quad([[21.75, 17], [21.75, 54], [30.25, 54], [30.25, 17]]),
|
|
120
|
+
quad([[71.5, -13.5], [71.5, 8.5], [97.5, 6.25], [97.5, -11.25]]), quad([[97.5, -6.5], [97.5, 1.5], [104, 0], [104, -5]]),
|
|
121
|
+
],
|
|
122
|
+
liftEnd: toArm(26, 54),
|
|
123
|
+
screw: toArm(-11.25, 0),
|
|
124
|
+
holes: [toArm(50, -12.5), toArm(50, 7)],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// The small version, for 64 px and below: the rim and two heavy grooves, the clear margin they keep
|
|
128
|
+
// from the dog, gaps wide enough to last, and the earcup's one ring and its middle.
|
|
129
|
+
const SMALL = Object.freeze({ gap: 30, halo: 22, grooves: [[RECORD.rim - 15, 30], [362, 22], [312, 22]], cupRing: [34, 14], cupDot: 14 });
|
|
130
|
+
// Its arm is one tube, one headshell and a cartridge, heavy enough to show at 32 px.
|
|
131
|
+
const heavyArm = (reach) => [[[-reach, -15], [-reach, 15], [-30, 15], [-30, -15]], [[-26, -30], [-26, 26], [74, 24], [74, -32]],
|
|
132
|
+
[[78, -17], [78, 11], [104, 8], [104, -14]]];
|
|
133
|
+
|
|
134
|
+
function arc(cx, cy, r, from, to) {
|
|
135
|
+
const point = (angle) => [cx + r * Math.cos((angle * Math.PI) / 180), cy + r * Math.sin((angle * Math.PI) / 180)];
|
|
136
|
+
return `M${pt(point(from))}A${f(r)} ${f(r)} 0 ${to - from > 180 ? 1 : 0} 1 ${pt(point(to))}`;
|
|
137
|
+
}
|
|
138
|
+
const ring = (cx, cy, r, width, paint, more = "") =>
|
|
139
|
+
`<circle cx="${f(cx)}" cy="${f(cy)}" r="${f(r)}" fill="none" stroke="${paint}" stroke-width="${f(width)}"${more}/>`;
|
|
140
|
+
const crater = ({ cx, cy, r }, ink, line) => {
|
|
141
|
+
const large = r > 25;
|
|
142
|
+
return [
|
|
143
|
+
`<path d="${arc(cx, cy, r * 0.27, 200, 470)}" fill="none" stroke="${ink}" stroke-width="${line}" stroke-linecap="round"/>`,
|
|
144
|
+
ring(cx, cy, r * 0.46, line * 1.15, ink),
|
|
145
|
+
ring(cx, cy, r * (large ? 0.72 : 1), line, ink, ` stroke-dasharray="${f(r * 0.5)} ${f(r * 0.12)}" stroke-dashoffset="${f(r * 0.2)}"`),
|
|
146
|
+
large ? ring(cx, cy, r, line * 0.85, ink, ` stroke-dasharray="${f(r * 0.28)} ${f(r * 0.1)} ${f(r * 0.06)} ${f(r * 0.1)}"`) : "",
|
|
147
|
+
].join("");
|
|
148
|
+
};
|
|
149
|
+
// A standalone file takes the reader's light or dark; drawn inside a page, the logo takes the page's colour.
|
|
150
|
+
const SCHEME = "<style>svg{color:#141414}@media (prefers-color-scheme:dark){svg{color:#f2f2f2}}</style>";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The logo as SVG. The full logo has every groove, the craters and the whole tonearm. `small` is
|
|
154
|
+
* the one for 64 px and below: the rim, two heavy grooves, a heavy tonearm, and gaps wide enough
|
|
155
|
+
* to last. `ink` paints it; `standalone` adds the light and dark colours a file on its own needs.
|
|
156
|
+
* `id` keeps the masks of several logos on one page apart.
|
|
157
|
+
*/
|
|
158
|
+
export function logoSvg({ small = false, ink = "currentColor", standalone = false, id = "moondog-logo", reach = 200, line = 1.4 } = {}) {
|
|
159
|
+
const gap = small ? SMALL.gap : 11;
|
|
160
|
+
const halo = small ? SMALL.halo : 6;
|
|
161
|
+
const { head, band, bandGap } = figure(gap);
|
|
162
|
+
const arm = tonearm(reach);
|
|
163
|
+
const armParts = small ? heavyArm(reach + 60).map(quad) : arm.parts;
|
|
164
|
+
const liftEnd = small ? "" : `<circle cx="${f(arm.liftEnd[0])}" cy="${f(arm.liftEnd[1])}" r="4.25"/>`;
|
|
165
|
+
const dog = `<path d="${toPath(head)}"/><path d="${toPath(band)}"/><circle cx="${CUP.cx}" cy="${CUP.cy}" r="${CUP.r}"/>`;
|
|
166
|
+
const grooves = [];
|
|
167
|
+
if (small) grooves.push(...SMALL.grooves);
|
|
168
|
+
else for (let r = RECORD.rim, index = 0; r >= RECORD.inner - 0.01; r -= RECORD.pitch, index += 1) grooves.push([r, index ? line : line * 1.6]);
|
|
169
|
+
const cupRings = small
|
|
170
|
+
? `${ring(CUP.cx, CUP.cy, ...SMALL.cupRing, "#000")}<circle cx="${CUP.cx}" cy="${CUP.cy}" r="${SMALL.cupDot}" fill="#000"/>`
|
|
171
|
+
: CUP.rings.map((r) => ring(CUP.cx, CUP.cy, r, 3.2, "#000")).join("");
|
|
172
|
+
const mask = (name, body) => `<mask id="${id}-${name}" maskUnits="userSpaceOnUse" x="0" y="0" width="1024" height="1024"><rect width="1024" height="1024" fill="#fff"/>${body}</mask>`;
|
|
173
|
+
// Grooves stop short of the dog, the arm and the craters; the gaps cut the dog into head, band and cup.
|
|
174
|
+
const clear = `<g fill="#000" stroke="#000" stroke-width="${halo * 2}" stroke-linejoin="round">${dog}${armParts.map((d) => `<path d="${d}"/>`).join("")}${liftEnd}</g>`
|
|
175
|
+
+ (small ? "" : CRATERS.map((c) => `<circle cx="${c.cx}" cy="${c.cy}" r="${c.r + 10}" fill="#000"/>`).join(""));
|
|
176
|
+
const cuts = `${ring(CUP.cx, CUP.cy, CUP.r + gap / 2, gap, "#000")}<path d="${toPath(bandGap)}" fill="none" stroke="#000" stroke-width="${gap}"/>${cupRings}`;
|
|
177
|
+
const armDetail = small ? "" : ring(...arm.screw, 7.5, 1.8, "#000") + arm.holes.map(([x, y]) => `<circle cx="${f(x)}" cy="${f(y)}" r="5.5" fill="#000"/>`).join("")
|
|
178
|
+
+ `<path d="M${pt(arm.holes[0])}L${pt(arm.holes[1])}" stroke="#000" stroke-width="1.6"/>`;
|
|
179
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${small ? "40 40 944 944" : "0 0 1024 1024"}">${standalone ? SCHEME : ""}`
|
|
180
|
+
+ `<defs>${mask("clear", clear)}${mask("cut", cuts)}${small ? "" : mask("arm", armDetail)}</defs>`
|
|
181
|
+
+ `<g mask="url(#${id}-clear)">${grooves.map(([r, width]) => ring(RECORD.cx, RECORD.cy, r, width, ink)).join("")}</g>`
|
|
182
|
+
+ (small ? "" : CRATERS.map((c) => crater(c, ink, line)).join(""))
|
|
183
|
+
+ `<g mask="url(#${id}-cut)" fill="${ink}">${dog}</g>`
|
|
184
|
+
+ `<g fill="${ink}"${small ? "" : ` mask="url(#${id}-arm)"`}>${armParts.map((d) => `<path d="${d}"/>`).join("")}${liftEnd}</g></svg>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The app icon's tile on its 1024 canvas: a superellipse, the shape of a macOS icon, `half` from the
|
|
189
|
+
* center to each side. Larger halves draw the same shape round the tile, as a ring that follows it.
|
|
190
|
+
*/
|
|
191
|
+
export function iconShape(half = 412) {
|
|
192
|
+
const corner = Array.from({ length: 160 }, (_, index) => {
|
|
193
|
+
const angle = (index / 160) * 2 * Math.PI, cos = Math.cos(angle), sin = Math.sin(angle);
|
|
194
|
+
return [512 + half * Math.sign(cos) * Math.abs(cos) ** 0.4, 512 + half * Math.sign(sin) * Math.abs(sin) ** 0.4];
|
|
195
|
+
});
|
|
196
|
+
return `M${corner.map(pt).join("L")}Z`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The Mac app icon: the full logo on a rounded square of Apple's 824 px grid, its tonearm reaching in
|
|
201
|
+
* from beyond the tile's corner. Light is the app's own icon; dark is the one the listener can choose.
|
|
202
|
+
*/
|
|
203
|
+
export function appIconSvg({ dark = false, id = "moondog-icon" } = {}) {
|
|
204
|
+
const [tile, ink] = dark ? ["#151515", "#f2f2f2"] : ["#ffffff", "#141414"];
|
|
205
|
+
const shape = iconShape();
|
|
206
|
+
const art = logoSvg({ ink, id, reach: 340, line: 1.8 }).replace(/^<svg[^>]*>/u, "").replace(/<\/svg>$/u, "");
|
|
207
|
+
// The soft shadow under the tile that Mac icons draw in their own margin.
|
|
208
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><clipPath id="${id}-tile"><path d="${shape}"/></clipPath>`
|
|
209
|
+
+ `<filter id="${id}-shadow" x="-10%" y="-10%" width="120%" height="125%"><feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.28"/></filter></defs>`
|
|
210
|
+
+ `<path d="${shape}" fill="${tile}" filter="url(#${id}-shadow)"/><g clip-path="url(#${id}-tile)"><g transform="translate(512 512) scale(0.74) translate(-512 -512)">${art}</g></g></svg>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The small logo for surfaces that draw it themselves, such as the window's title-bar mark and the
|
|
215
|
+
* terminal's record, in the record's unit coordinates (the disc has radius 1 around 0, 0): the dog's
|
|
216
|
+
* outlines as points, its gaps, the rim and grooves as [radius, width], the margin they keep from the
|
|
217
|
+
* dog, and the heavy arm's parts, its tube `reach` long.
|
|
218
|
+
*/
|
|
219
|
+
export function logoFigure({ reach = 190 } = {}) {
|
|
220
|
+
const { head, band, bandGap } = figure(SMALL.gap);
|
|
221
|
+
const scale = (length) => length / RECORD.rim;
|
|
222
|
+
const unit = ([x, y]) => [scale(x - RECORD.cx), scale(y - RECORD.cy)];
|
|
223
|
+
return {
|
|
224
|
+
// The head to fill, and its outer edge alone, from the ear's tip round to the back of the neck.
|
|
225
|
+
head: toPoints(head).map(unit),
|
|
226
|
+
headEdge: toPoints(segments(HEAD, { closed: false, corners: HEAD_CORNERS })).map(unit),
|
|
227
|
+
// The band to fill, and the line down its middle, from the ear's tip to the earcup.
|
|
228
|
+
band: toPoints(band).map(unit),
|
|
229
|
+
bandCenter: toPoints(segments(offset(BAND_INNER, 12.5), { closed: false })).map(unit),
|
|
230
|
+
bandGap: toPoints(bandGap, 4).map(unit),
|
|
231
|
+
gap: scale(SMALL.gap),
|
|
232
|
+
cup: { center: unit([CUP.cx, CUP.cy]), radius: scale(CUP.r), ring: scale(SMALL.cupRing[0]), ringWidth: scale(SMALL.cupRing[1]), dot: scale(SMALL.cupDot) },
|
|
233
|
+
grooves: SMALL.grooves.map(([radius, width]) => [scale(radius), scale(width)]),
|
|
234
|
+
halo: scale(SMALL.halo),
|
|
235
|
+
tonearm: heavyArm(reach).map((corners) => corners.map((corner) => unit(toArm(...corner)))),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -8,22 +8,22 @@ import {
|
|
|
8
8
|
} from "../../runtime/pi/authentication.mjs";
|
|
9
9
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
10
10
|
|
|
11
|
-
const DEFAULT_AUTH_PROVIDER = "openai
|
|
11
|
+
const DEFAULT_AUTH_PROVIDER = "openai";
|
|
12
12
|
|
|
13
13
|
export function authHelpText() {
|
|
14
14
|
return `# Moondog authentication
|
|
15
15
|
|
|
16
16
|
Commands:
|
|
17
17
|
|
|
18
|
-
- \`moondog auth login openai
|
|
19
|
-
- \`moondog auth login openai-codex --device-code\` - use headless device login
|
|
18
|
+
- \`moondog auth login openai --chatgpt\` - sign in with your ChatGPT subscription in the browser
|
|
20
19
|
- \`moondog auth login <provider>\` - save an API key using hidden terminal input
|
|
21
20
|
- \`moondog auth status [provider] [--json]\` - inspect saved or environment credentials
|
|
22
21
|
- \`moondog auth logout <provider> [--json]\` - remove that provider's saved credential
|
|
23
22
|
|
|
24
|
-
API key providers: ${supportedAuthProviderIds.filter((id) =>
|
|
23
|
+
API key providers: ${supportedAuthProviderIds.filter((id) => !serviceKeyProviders.some((service) => service.id === id)).join(", ")}.
|
|
25
24
|
Web search keys: ${serviceKeyProviders.map((service) => `${service.id} for ${service.name} (or ${service.variable})`).join(", ")}; a deepseek key also searches and reads pages.
|
|
26
|
-
Use
|
|
25
|
+
Use openai for GPT, anthropic for Claude, google for Gemini, xai for Grok, zai for GLM, and moonshotai or moonshotai-cn for Kimi.
|
|
26
|
+
OpenAI takes either a ChatGPT sign-in or an API key; the newer one replaces the other.
|
|
27
27
|
Moondog keeps credentials in its own private local store.
|
|
28
28
|
API keys are entered interactively, never as command arguments.
|
|
29
29
|
It does not read, modify, or log out the Codex CLI or ChatGPT desktop session.`;
|
|
@@ -39,19 +39,11 @@ function parseAuthArguments(args) {
|
|
|
39
39
|
action: "help",
|
|
40
40
|
provider: undefined,
|
|
41
41
|
extra: [],
|
|
42
|
-
|
|
42
|
+
chatgpt: false,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const deviceCode = deviceCodeIndex !== -1;
|
|
48
|
-
const browser = browserIndex !== -1;
|
|
49
|
-
if (deviceCode) values.splice(deviceCodeIndex, 1);
|
|
50
|
-
const adjustedBrowserIndex = values.indexOf("--browser");
|
|
51
|
-
if (adjustedBrowserIndex !== -1) values.splice(adjustedBrowserIndex, 1);
|
|
52
|
-
if (deviceCode && browser) {
|
|
53
|
-
throw new Error("Choose either --browser or --device-code, not both.");
|
|
54
|
-
}
|
|
45
|
+
const chatgptIndex = values.indexOf("--chatgpt");
|
|
46
|
+
if (chatgptIndex !== -1) values.splice(chatgptIndex, 1);
|
|
55
47
|
const unknownOption = values.find((value) => value.startsWith("-"));
|
|
56
48
|
if (unknownOption) {
|
|
57
49
|
throw new Error(
|
|
@@ -62,7 +54,7 @@ function parseAuthArguments(args) {
|
|
|
62
54
|
action: values[0],
|
|
63
55
|
provider: values[1],
|
|
64
56
|
extra: values.slice(2),
|
|
65
|
-
|
|
57
|
+
chatgpt: chatgptIndex !== -1,
|
|
66
58
|
};
|
|
67
59
|
}
|
|
68
60
|
|
|
@@ -80,7 +72,8 @@ function formatStatus(value) {
|
|
|
80
72
|
return `${value.provider}: not configured`;
|
|
81
73
|
}
|
|
82
74
|
|
|
83
|
-
|
|
75
|
+
// A readline output that can go quiet, so a key typed into it is never echoed.
|
|
76
|
+
export function createReadlineOutput(output) {
|
|
84
77
|
let muted = false;
|
|
85
78
|
const stream = new Writable({
|
|
86
79
|
write(chunk, encoding, callback) {
|
|
@@ -105,7 +98,6 @@ function createReadlineOutput(output) {
|
|
|
105
98
|
function createAuthInteraction({
|
|
106
99
|
input,
|
|
107
100
|
output,
|
|
108
|
-
loginMethod,
|
|
109
101
|
signal,
|
|
110
102
|
interactive,
|
|
111
103
|
apiKeyLogin,
|
|
@@ -126,15 +118,9 @@ function createAuthInteraction({
|
|
|
126
118
|
signal,
|
|
127
119
|
async prompt(prompt) {
|
|
128
120
|
if (prompt.type === "select") {
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
throw new Error(
|
|
122
|
+
"The installed Pi provider asked for a login choice Moondog doesn't offer.",
|
|
131
123
|
);
|
|
132
|
-
if (!selected) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
"The installed Pi provider does not support the requested login method.",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
return selected.id;
|
|
138
124
|
}
|
|
139
125
|
if (prompt.type === "secret" && !interactive) {
|
|
140
126
|
throw new Error(
|
|
@@ -175,12 +161,6 @@ function createAuthInteraction({
|
|
|
175
161
|
writeLine(output, event.url);
|
|
176
162
|
if (event.instructions) writeLine(output, event.instructions);
|
|
177
163
|
break;
|
|
178
|
-
case "device_code":
|
|
179
|
-
writeLine(output);
|
|
180
|
-
writeLine(output, "Open this URL to sign in with ChatGPT:");
|
|
181
|
-
writeLine(output, event.verificationUri);
|
|
182
|
-
writeLine(output, `Device code: ${event.userCode}`);
|
|
183
|
-
break;
|
|
184
164
|
case "info":
|
|
185
165
|
writeLine(output, event.message);
|
|
186
166
|
for (const link of event.links ?? []) {
|
|
@@ -202,7 +182,7 @@ function createAuthInteraction({
|
|
|
202
182
|
// Pi's login errors can carry provider response bodies. Every surface that signs
|
|
203
183
|
// in shows only these fixed messages and codes.
|
|
204
184
|
export function wrapLoginError(error, provider) {
|
|
205
|
-
const name = provider === DEFAULT_AUTH_PROVIDER ? "OpenAI
|
|
185
|
+
const name = provider === DEFAULT_AUTH_PROVIDER ? "OpenAI" : provider;
|
|
206
186
|
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
|
|
207
187
|
const cancelled = new Error(`${name} login was cancelled.`);
|
|
208
188
|
cancelled.code = "auth_login_cancelled";
|
|
@@ -248,11 +228,8 @@ export async function runAuthCommand({
|
|
|
248
228
|
if (!["login", "status", "logout"].includes(options.action)) {
|
|
249
229
|
throw new Error("Unknown auth command. Use login, status, logout, or help.");
|
|
250
230
|
}
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
(options.loginMethod !== "browser" || args.includes("--browser"))
|
|
254
|
-
) {
|
|
255
|
-
throw new Error("Login method options are valid only for auth login.");
|
|
231
|
+
if (options.action !== "login" && options.chatgpt) {
|
|
232
|
+
throw new Error("--chatgpt is valid only for auth login.");
|
|
256
233
|
}
|
|
257
234
|
|
|
258
235
|
const provider = options.provider ??
|
|
@@ -265,15 +242,10 @@ export async function runAuthCommand({
|
|
|
265
242
|
`Unsupported auth provider. Choose ${supportedAuthProviderIds.join(", ")}.`,
|
|
266
243
|
);
|
|
267
244
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
apiKeyLogin &&
|
|
271
|
-
(args.includes("--device-code") || args.includes("--browser"))
|
|
272
|
-
) {
|
|
273
|
-
throw new Error(
|
|
274
|
-
"Browser and device-code options apply only to openai-codex OAuth login.",
|
|
275
|
-
);
|
|
245
|
+
if (options.chatgpt && provider !== DEFAULT_AUTH_PROVIDER) {
|
|
246
|
+
throw new Error("Only openai signs in with ChatGPT; other providers take an API key.");
|
|
276
247
|
}
|
|
248
|
+
const apiKeyLogin = !options.chatgpt;
|
|
277
249
|
|
|
278
250
|
if (options.action === "status") {
|
|
279
251
|
const authentication = await createAuthentication({ environment });
|
|
@@ -299,9 +271,7 @@ export async function runAuthCommand({
|
|
|
299
271
|
writeLine(output, formatStatus(status));
|
|
300
272
|
writeLine(
|
|
301
273
|
output,
|
|
302
|
-
|
|
303
|
-
? "Moondog's saved API key was removed. Environment credentials remain available until unset."
|
|
304
|
-
: "Moondog's local credential was removed. Codex CLI and ChatGPT desktop login were not changed.",
|
|
274
|
+
"Moondog's saved credential was removed. Environment credentials remain available until unset; the Codex CLI and ChatGPT desktop sign-ins were not changed.",
|
|
305
275
|
);
|
|
306
276
|
}
|
|
307
277
|
return;
|
|
@@ -315,9 +285,9 @@ export async function runAuthCommand({
|
|
|
315
285
|
"API key login requires an interactive terminal with hidden input. For headless use, set the provider's API key environment variable.",
|
|
316
286
|
);
|
|
317
287
|
}
|
|
318
|
-
if (!apiKeyLogin &&
|
|
288
|
+
if (!apiKeyLogin && !interactive) {
|
|
319
289
|
throw new Error(
|
|
320
|
-
"
|
|
290
|
+
"ChatGPT sign-in requires an interactive terminal. For headless use, set OPENAI_API_KEY instead.",
|
|
321
291
|
);
|
|
322
292
|
}
|
|
323
293
|
|
|
@@ -328,7 +298,6 @@ export async function runAuthCommand({
|
|
|
328
298
|
const login = createAuthInteraction({
|
|
329
299
|
input,
|
|
330
300
|
output: progressOutput,
|
|
331
|
-
loginMethod: options.loginMethod,
|
|
332
301
|
signal: controller.signal,
|
|
333
302
|
interactive,
|
|
334
303
|
apiKeyLogin,
|
|
@@ -336,7 +305,7 @@ export async function runAuthCommand({
|
|
|
336
305
|
try {
|
|
337
306
|
let status;
|
|
338
307
|
try {
|
|
339
|
-
status = await authentication.login(provider, login.interaction);
|
|
308
|
+
status = await authentication.login(provider, login.interaction, { method: apiKeyLogin ? "api_key" : "oauth" });
|
|
340
309
|
} catch (error) {
|
|
341
310
|
throw wrapLoginError(error, provider);
|
|
342
311
|
}
|
|
@@ -352,8 +321,8 @@ export async function runAuthCommand({
|
|
|
352
321
|
: "Next, return to Moondog and run /model.");
|
|
353
322
|
if (!apiKeyLogin) {
|
|
354
323
|
writeLine(output, "For a non-interactive launch, use environment overrides:");
|
|
355
|
-
writeLine(output, " export MOONDOG_PROVIDER=openai
|
|
356
|
-
writeLine(output, " export MOONDOG_MODEL=gpt-
|
|
324
|
+
writeLine(output, " export MOONDOG_PROVIDER=openai");
|
|
325
|
+
writeLine(output, " export MOONDOG_MODEL=gpt-6.1-sol");
|
|
357
326
|
writeLine(output, " moondog");
|
|
358
327
|
}
|
|
359
328
|
} finally {
|
|
@@ -564,5 +564,5 @@ function wrapDescription(text, width) {
|
|
|
564
564
|
}
|
|
565
565
|
|
|
566
566
|
export function listeningHelp() {
|
|
567
|
-
return tr("# Your listening room\n\n## 01 / Listen and discover\n\nJust type to talk about music, ask for a playlist, or go exploring.\nTo talk, Moondog needs a model: `/auth` signs you in, then `/model` picks one.\nWhile an answer is coming, each step shows in the conversation, and the footer names the one in progress.\n\n- `/taste` - your profile: open any song or artist to see why it's there\n- `/taste report` - the whole picture on one page\n- `/import` - bring in your history, your library, or a playlist\n- `/lyrics` - the lyrics on the sleeve; `/lyrics sync` finds more\n- `/spotify` - play, queue, and make playlists in Spotify\n- Ask to move playback onto your iPhone, computer, or another Spotify device by name\n- `/web search <query>` or `/web read <url>` - look things up on public music sites\n\n## 02 / Make it yours\n\n- `/profile` - the same profile as `/taste`\n- `/profile corrections` - everything you've told me\n- `/profile retract <id>` - undo one of those\n- `/profile help` - the full set of profile commands\n- `/remember <text>`, `/memory`, `/forget <id>` - what I remember between conversations\n\n## 03 / Around the room\n\n- `/home` - back to the record sleeve; the conversation stays\n- `/theme paper|charcoal|terminal|auto` - change the look\n- `/art braille|ascii|off` - change the artwork\n- `/motion on|off` - turn the animation on or off\n- `/commands` or Ctrl+P - search every command; your message stays put\n- Page Up and Page Down scroll the conversation. The header and your draft stay put\n- Up and Down in the draft recall what you typed in this conversation\n- `/resume` - pick up a saved conversation (type to filter, Enter opens)\n- `/new` - start fresh; this conversation stays saved\n- `/status`, `/sources`, `/tools`, `/doctor` - see what's connected and working\n- `/reload` - reload model settings and sign-ins\n- `/update [--check] [--channel latest|beta]` - check for a release, or close this room and install it\n- `/help all` - every command, including the ones for the shell\n- `/language` - choose the language Moondog uses\n- `/quit` - leave\n\nTab moves to the menu on the home screen. Esc goes back to typing.\nEnter sends, and Shift+Enter starts a new line.\nTab or Enter accepts a suggestion.\nCtrl+C stops a model or web request, or leaves when nothing is running.\nIn the profile, type to filter, Tab switches lists, and Enter shows what you can do.\nCtrl+R refreshes it and Ctrl+O opens the full report.\nYour profile works without a model.\nPut quotes around paths, titles, and names that have spaces.");
|
|
567
|
+
return tr("# Your listening room\n\n## 01 / Listen and discover\n\nJust type to talk about music, ask for a playlist, or go exploring.\nTo talk, Moondog needs a model: `/auth` signs you in, then `/model` picks one.\nWhile an answer is coming, each step shows in the conversation, and the footer names the one in progress.\n\n- `/taste` - your profile: open any song or artist to see why it's there\n- `/taste report` - the whole picture on one page\n- `/import` - bring in your history, your library, or a playlist\n- `/lyrics` - the lyrics on the sleeve; `/lyrics sync` finds more\n- `/service` - choose where I play music: Spotify, Apple Music, or NetEase\n- `/spotify` - play, queue, and make playlists in Spotify\n- Ask to move playback onto your iPhone, computer, or another Spotify device by name\n- `/web search <query>` or `/web read <url>` - look things up on public music sites\n\n## 02 / Make it yours\n\n- `/profile` - the same profile as `/taste`\n- `/profile corrections` - everything you've told me\n- `/profile retract <id>` - undo one of those\n- `/profile help` - the full set of profile commands\n- `/remember <text>`, `/memory`, `/forget <id>` - what I remember between conversations\n\n## 03 / Around the room\n\n- `/home` - back to the record sleeve; the conversation stays\n- `/theme paper|charcoal|terminal|auto` - change the look\n- `/art braille|ascii|off` - change the artwork\n- `/motion on|off` - turn the animation on or off\n- `/commands` or Ctrl+P - search every command; your message stays put\n- Page Up and Page Down scroll the conversation. The header and your draft stay put\n- Up and Down in the draft recall what you typed in this conversation\n- `/resume` - pick up a saved conversation (type to filter, Enter opens)\n- `/new` - start fresh; this conversation stays saved\n- `/status`, `/sources`, `/tools`, `/doctor` - see what's connected and working\n- `/reload` - reload model settings and sign-ins\n- `/update [--check] [--channel latest|beta]` - check for a release, or close this room and install it\n- `/help all` - every command, including the ones for the shell\n- `/language` - choose the language Moondog uses\n- `/quit` - leave\n\nTab moves to the menu on the home screen. Esc goes back to typing.\nEnter sends, and Shift+Enter starts a new line.\nTab or Enter accepts a suggestion.\nCtrl+C stops a model or web request, or leaves when nothing is running.\nIn the profile, type to filter, Tab switches lists, and Enter shows what you can do.\nCtrl+R refreshes it and Ctrl+O opens the full report.\nYour profile works without a model.\nPut quotes around paths, titles, and names that have spaces.");
|
|
568
568
|
}
|
|
@@ -19,6 +19,11 @@ const artChoices = choices([
|
|
|
19
19
|
["auto", N_("Choose artwork for this terminal")],
|
|
20
20
|
]);
|
|
21
21
|
const motionChoices = choices([["on", N_("Animate the character artwork")], ["off", N_("Keep the artwork still")]]);
|
|
22
|
+
const serviceChoices = choices([
|
|
23
|
+
["spotify", N_("Whole songs on your Spotify devices")],
|
|
24
|
+
["apple_music", N_("30-second previews, played here")],
|
|
25
|
+
["netease", N_("Whole songs through ncm-cli")],
|
|
26
|
+
]);
|
|
22
27
|
const webChoices = choices([
|
|
23
28
|
["search", N_("Search public music sites")],
|
|
24
29
|
["read", N_("Read a public page")],
|
|
@@ -87,6 +92,7 @@ export function withCommandCompletions(commands, {
|
|
|
87
92
|
theme: { argumentHint: "[paper|charcoal|terminal|auto]", choices: firstArgument(themeChoices) },
|
|
88
93
|
art: { argumentHint: "[braille|ascii|off|auto]", choices: firstArgument(artChoices) },
|
|
89
94
|
motion: { argumentHint: "[on|off]", choices: firstArgument(motionChoices) },
|
|
95
|
+
service: { argumentHint: "[spotify|apple_music|netease]", choices: firstArgument(serviceChoices) },
|
|
90
96
|
lyrics: { argumentHint: "[sync]", choices: firstArgument(choices([["sync", N_("Check profile songs for lyrics")]])) },
|
|
91
97
|
web: { argumentHint: "search <query> | read <url> | status", choices: firstArgument(webChoices) },
|
|
92
98
|
spotify: {
|
|
@@ -94,6 +100,14 @@ export function withCommandCompletions(commands, {
|
|
|
94
100
|
choices: (previous) => previous.length === 0 ? spotifyChoices
|
|
95
101
|
: previous.length === 1 ? spotifyArguments.get(previous[0]) ?? [] : [],
|
|
96
102
|
},
|
|
103
|
+
endpoint: {
|
|
104
|
+
argumentHint: "[add | remove <id>]",
|
|
105
|
+
choices: (previous) => previous.length === 0
|
|
106
|
+
? choices([["add", N_("Add an endpoint by its address")], ["remove", N_("Remove an endpoint and its key")]])
|
|
107
|
+
: previous.length === 1 && previous[0] === "remove"
|
|
108
|
+
? providers().filter((provider) => provider.id.startsWith("endpoint-")).map((provider) => ({ value: provider.id, label: provider.id, description: provider.name }))
|
|
109
|
+
: [],
|
|
110
|
+
},
|
|
97
111
|
auth: {
|
|
98
112
|
argumentHint: "[provider]",
|
|
99
113
|
choices: (previous) => previous.length === 0 ? providers()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
import { ENDPOINT_FORMATS, readEndpoints } from "../../runtime/pi/custom-endpoints.mjs";
|
|
4
|
+
import { createModelSettings } from "../../runtime/pi/model-settings.mjs";
|
|
5
|
+
import { createReadlineOutput } from "./auth-command.mjs";
|
|
6
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
7
|
+
|
|
8
|
+
// Endpoints from the terminal, as Settings › Model adds them in the window: any service that speaks
|
|
9
|
+
// OpenAI's Chat Completions or Anthropic's Messages, by its address, an optional key and its model IDs.
|
|
10
|
+
export function endpointHelpText() {
|
|
11
|
+
return `# Moondog endpoints
|
|
12
|
+
|
|
13
|
+
- \`moondog endpoint\` - list the endpoints you added
|
|
14
|
+
- \`moondog endpoint add\` - add one; Moondog asks for its name, address, format, key and model IDs
|
|
15
|
+
- \`moondog endpoint remove <id>\` - remove one with its key
|
|
16
|
+
|
|
17
|
+
The key is typed out of sight and kept in Moondog's private endpoints.json, never in a command.
|
|
18
|
+
Leave the model IDs empty and Moondog asks the endpoint for its list.
|
|
19
|
+
Then pick a model with /model <id>.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const line = (stream, value = "") => stream.write(`${sanitizeTerminalText(value)}\n`);
|
|
23
|
+
const host = (address) => { try { return new URL(address).host; } catch { return address; } };
|
|
24
|
+
|
|
25
|
+
export function describeEndpoints(endpoints) {
|
|
26
|
+
if (!endpoints.length) return "No endpoints yet. Add one with moondog endpoint add, or /endpoint add in Moondog.";
|
|
27
|
+
return endpoints.map((endpoint) => `${endpoint.id} - ${endpoint.name}, ${endpoint.format === "anthropic" ? "Anthropic" : "OpenAI"} format at ${host(endpoint.baseUrl)}, ` +
|
|
28
|
+
`${endpoint.models.length} model${endpoint.models.length === 1 ? "" : "s"}${endpoint.key ? ", key saved" : ""}`).join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The questions an endpoint needs, one by one, on a paused terminal. The key is asked with the output
|
|
32
|
+
// muted; an empty answer keeps the endpoint keyless, as a server on this Mac often is.
|
|
33
|
+
async function askEndpoint({ input, output, signal }) {
|
|
34
|
+
const quiet = createReadlineOutput(output);
|
|
35
|
+
const readline = createInterface({ input, output: quiet.stream, terminal: Boolean(input.isTTY) });
|
|
36
|
+
const ask = (question) => readline.question(`${sanitizeTerminalText(question)} `, { signal });
|
|
37
|
+
try {
|
|
38
|
+
line(output);
|
|
39
|
+
line(output, "Add an endpoint. Press Ctrl+C to stop.");
|
|
40
|
+
const name = (await ask("Name (for example DeepSeek):")).trim();
|
|
41
|
+
const baseUrl = (await ask("Address (for example https://api.deepseek.com):")).trim();
|
|
42
|
+
const said = (await ask("Format, openai or anthropic [openai]:")).trim().toLowerCase();
|
|
43
|
+
const format = said.startsWith("a") ? "anthropic" : "openai";
|
|
44
|
+
if (said && !ENDPOINT_FORMATS.some((value) => value.startsWith(said[0]))) throw Object.assign(new Error("Answer openai or anthropic."), { code: "endpoint_format_invalid" });
|
|
45
|
+
line(output, "Key (hidden; leave empty for none):");
|
|
46
|
+
quiet.setMuted(true);
|
|
47
|
+
let key;
|
|
48
|
+
try { key = (await readline.question("", { signal })).trim(); } finally { quiet.setMuted(false); line(output); }
|
|
49
|
+
const models = (await ask("Model IDs, separated by commas (leave empty to ask the endpoint):")).trim();
|
|
50
|
+
return { name, baseUrl, format, key, models };
|
|
51
|
+
} finally {
|
|
52
|
+
readline.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runEndpointCommand({
|
|
57
|
+
args = [],
|
|
58
|
+
json = false,
|
|
59
|
+
environment = process.env,
|
|
60
|
+
input = process.stdin,
|
|
61
|
+
output = process.stdout,
|
|
62
|
+
progressOutput = process.stderr,
|
|
63
|
+
signalTarget = process,
|
|
64
|
+
interactive = Boolean(input.isTTY && progressOutput.isTTY),
|
|
65
|
+
settings = createModelSettings({ environment }),
|
|
66
|
+
} = {}) {
|
|
67
|
+
const [action = "list", ...rest] = args;
|
|
68
|
+
if (action === "help" || action === "--help" || action === "-h") {
|
|
69
|
+
line(output, endpointHelpText());
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (action === "list") {
|
|
73
|
+
if (rest.length) throw new Error("moondog endpoint list takes nothing after it.");
|
|
74
|
+
const endpoints = await readEndpoints(environment);
|
|
75
|
+
line(output, json ? JSON.stringify(endpoints.map(({ key, ...shown }) => ({ ...shown, keySaved: Boolean(key) })), null, 2) : describeEndpoints(endpoints));
|
|
76
|
+
return endpoints;
|
|
77
|
+
}
|
|
78
|
+
if (action === "remove") {
|
|
79
|
+
const wanted = rest.join(" ").trim().toLowerCase();
|
|
80
|
+
if (!wanted) throw new Error("Name the endpoint to remove: moondog endpoint remove <id>.");
|
|
81
|
+
const endpoint = (await readEndpoints(environment)).find((entry) => entry.id === wanted || entry.id === `endpoint-${wanted}` || entry.name.toLowerCase() === wanted);
|
|
82
|
+
if (!endpoint) throw new Error("No endpoint has that id or name. moondog endpoint lists them.");
|
|
83
|
+
await settings.removeEndpoint(endpoint.id);
|
|
84
|
+
line(output, `Removed ${endpoint.name} and its key.`);
|
|
85
|
+
return { id: endpoint.id, name: endpoint.name, removed: true };
|
|
86
|
+
}
|
|
87
|
+
if (action !== "add") throw new Error("Unknown endpoint command. Use add, remove, list, or help.");
|
|
88
|
+
if (rest.length) throw new Error("moondog endpoint add asks for everything it needs; type the key only at its hidden prompt.");
|
|
89
|
+
if (!interactive) throw new Error("Adding an endpoint asks for its key out of sight, so it needs an interactive terminal.");
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const stop = () => controller.abort();
|
|
92
|
+
signalTarget.once("SIGINT", stop);
|
|
93
|
+
try {
|
|
94
|
+
const answers = await askEndpoint({ input, output: progressOutput, signal: controller.signal });
|
|
95
|
+
if (answers.models === "") line(progressOutput, "Asking the endpoint for its models...");
|
|
96
|
+
const saved = await settings.saveEndpoint(answers);
|
|
97
|
+
line(output, `Saved ${saved.name} as ${saved.id}, with ${saved.models.length} model${saved.models.length === 1 ? "" : "s"}${saved.keySaved ? " and its key" : ""}.`);
|
|
98
|
+
line(output, `Next, pick one with /model ${saved.id}.`);
|
|
99
|
+
return saved;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (controller.signal.aborted) throw Object.assign(new Error("Adding the endpoint was cancelled."), { code: "endpoint_cancelled" });
|
|
102
|
+
throw error;
|
|
103
|
+
} finally {
|
|
104
|
+
signalTarget.removeListener("SIGINT", stop);
|
|
105
|
+
}
|
|
106
|
+
}
|