@audiofool/moondog 0.2.0-beta.7 → 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.
@@ -21,6 +21,7 @@ const LOCK_TIMEOUT_MS = 30_000;
21
21
  // API keys for services other than models, such as Brave Search for web lookups.
22
22
  export const serviceApiKeyIds = Object.freeze(["brave"]);
23
23
  const apiKeyProviderIds = Object.freeze([...apiKeyPiProviderIds, ...serviceApiKeyIds]);
24
+ // openai-codex is no longer offered, but a store that still holds its sign-in keeps loading.
24
25
  const SUPPORTED_CREDENTIAL_PROVIDERS = new Set([
25
26
  "openai-codex",
26
27
  "spotify",
@@ -105,6 +106,33 @@ function validateOpenAICodexCredential(value) {
105
106
  return structuredClone(value);
106
107
  }
107
108
 
109
+ // Sign in with ChatGPT, through Pi's OpenAI provider: a token for api.openai.com and its refresh.
110
+ function validateOpenAIChatGPTCredential(value) {
111
+ assertPlainRecord(value, "openai credential");
112
+ assertExactKeys(
113
+ value,
114
+ ["access", "clientId", "expires", "refresh", "type", ...(value.scopes === undefined ? [] : ["scopes"])],
115
+ "openai credential shape",
116
+ );
117
+ assertSecretString(value.access, "OAuth access token");
118
+ assertSecretString(value.refresh, "OAuth refresh token");
119
+ assertSecretString(value.clientId, "OAuth client identifier");
120
+ if (!Number.isFinite(value.expires) || value.expires <= 0) {
121
+ throw safeError(
122
+ "credential_store_corrupt",
123
+ "Moondog credential storage contains an invalid OAuth expiry.",
124
+ );
125
+ }
126
+ if (value.scopes !== undefined && (!Array.isArray(value.scopes) || value.scopes.length > 50 ||
127
+ value.scopes.some((scope) => typeof scope !== "string" || !/^[\x21-\x7e]{1,200}$/u.test(scope)))) {
128
+ throw safeError(
129
+ "credential_store_corrupt",
130
+ "Moondog credential storage contains invalid OAuth scopes.",
131
+ );
132
+ }
133
+ return structuredClone(value);
134
+ }
135
+
108
136
  function validateSpotifyCredential(value) {
109
137
  assertPlainRecord(value, "spotify credential");
110
138
  assertExactKeys(
@@ -173,6 +201,9 @@ function validateCredential(providerId, value) {
173
201
  if (providerId === "spotify") {
174
202
  return validateSpotifyCredential(value);
175
203
  }
204
+ if (providerId === "openai" && value?.type === "oauth") {
205
+ return validateOpenAIChatGPTCredential(value);
206
+ }
176
207
  if (apiKeyProviderIds.includes(providerId)) {
177
208
  assertPlainRecord(value, `${providerId} credential`);
178
209
  assertExactKeys(value, ["type", "key"], `${providerId} credential shape`);
@@ -35,12 +35,6 @@ const providerLoaders = {
35
35
  );
36
36
  return openaiProvider();
37
37
  },
38
- "openai-codex": async () => {
39
- const { openaiCodexProvider } = await import(
40
- "@earendil-works/pi-ai/providers/openai-codex"
41
- );
42
- return openaiCodexProvider();
43
- },
44
38
  openrouter: async () => {
45
39
  const { openrouterProvider } = await import(
46
40
  "@earendil-works/pi-ai/providers/openrouter"
@@ -63,9 +57,9 @@ export const supportedPiProviderIds = Object.freeze(
63
57
  Object.keys(providerLoaders),
64
58
  );
65
59
 
66
- export const apiKeyPiProviderIds = Object.freeze(
67
- supportedPiProviderIds.filter((providerId) => providerId !== "openai-codex"),
68
- );
60
+ // Every provider takes a key; OpenAI also takes Sign in with ChatGPT.
61
+ export const apiKeyPiProviderIds = supportedPiProviderIds;
62
+ export const chatgptProviderId = "openai";
69
63
 
70
64
  export async function loadPiProvider(providerId) {
71
65
  if (!Object.hasOwn(providerLoaders, providerId)) return undefined;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { addEndpoints, readEndpoints } from "./custom-endpoints.mjs";
5
6
  import { createPiModels, listPiModels, listPiProviders } from "./model-catalog.mjs";
6
7
  import { acquirePrivateFileLock, resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
7
8
  import { supportedPiProviderIds } from "./provider-registry.mjs";
@@ -159,15 +160,21 @@ async function writeCache(root, file, document, signal) {
159
160
  export class PublicPiModelCatalog {
160
161
  constructor({ environment = process.env, fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = 4000 } = {}) {
161
162
  this.root = path.join(path.dirname(resolveMoondogAuthFile(environment)), "model-catalog");
163
+ this.environment = environment;
162
164
  this.fetch = fetchImpl; this.now = now; this.timeoutMs = timeoutMs;
163
- this.baseline = createPiModels(); this.records = new Map(); this.pending = new Set();
165
+ this.baseline = createPiModels(); this.records = new Map(); this.pending = new Set(); this.endpoints = [];
164
166
  this.snapshot = this.baseline;
165
167
  this.ready = this.restore();
166
168
  }
167
169
 
168
170
  file(id) { return path.join(this.root, `${id}.json`); }
169
171
  publish() { this.snapshot = this.createModels(); }
170
- createModels(options) { return createPiModels({ ...options, catalogModels: new Map([...this.records].map(([id, record]) => [id, record.models])) }); }
172
+ // The listener's own endpoints join Pi's providers; their models are theirs, not from pi.dev.
173
+ createModels(options) { return addEndpoints(createPiModels({ ...options, catalogModels: new Map([...this.records].map(([id, record]) => [id, record.models])) }), this.endpoints); }
174
+ async readEndpoints() {
175
+ try { this.endpoints = await readEndpoints(this.environment); } catch { this.endpoints = []; }
176
+ this.publish();
177
+ }
171
178
  providers() { return listPiProviders(this.snapshot); }
172
179
  models(id) {
173
180
  const record = this.records.get(id);
@@ -277,12 +284,13 @@ export async function getPublicPiModelCatalog(environment = process.env) {
277
284
  const key = path.dirname(resolveMoondogAuthFile(environment));
278
285
  if (!catalogs.has(key)) {
279
286
  const catalog = new PublicPiModelCatalog({ environment });
280
- catalogs.set(key, catalog); await catalog.ready; return catalog;
287
+ catalogs.set(key, catalog); await catalog.ready; await catalog.readEndpoints(); return catalog;
281
288
  }
282
289
  const catalog = catalogs.get(key);
283
290
  await catalog.ready;
284
- // Another running app can persist a new selection and its catalog entry.
291
+ // Another running app can persist a new selection, its catalog entry or an endpoint.
285
292
  // Reload public cache only; do not fetch or touch the active model object.
286
293
  await catalog.restore();
294
+ await catalog.readEndpoints();
287
295
  return catalog;
288
296
  }
@@ -0,0 +1,9 @@
1
+ // Whether an agent built earlier is behind the saved choice: another model or effort, or an agent
2
+ // that could not answer then, which a sign-in since may let answer. Token refreshes rewrite
3
+ // auth.json often, so a working agent on the saved model is kept. Kept apart from the settings
4
+ // file, so the window code the project page runs in the browser can use it.
5
+ export function runtimeBehindSelection(status, selection) {
6
+ if (status?.state !== "configured") return true;
7
+ return status.provider !== selection?.provider || status.model !== selection?.model ||
8
+ (status.effort ?? undefined) !== selection?.effort;
9
+ }
@@ -1,11 +1,27 @@
1
+ import { unwatchFile, watchFile } from "node:fs";
1
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
 
5
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
6
+
4
7
  import { resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
5
8
 
6
9
  const SETTINGS_VERSION = 1;
7
10
  // Pi's thinking levels. Each model supports a subset; the runtime clamps to it.
8
11
  export const EFFORT_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
12
+ // The scale the window and /effort offer for every model that thinks.
13
+ export const EFFORT_SCALE = Object.freeze(["off", "low", "medium", "high", "xhigh", "max"]);
14
+
15
+ // A level the model lacks steps down to the nearest one it has, so a choice never costs more than it
16
+ // said, but never down to no thinking; with nothing below, it takes the lightest level the model has.
17
+ export function clampEffort(model, level) {
18
+ const supported = getSupportedThinkingLevels(model);
19
+ if (supported.includes(level)) return level;
20
+ const at = EFFORT_LEVELS.indexOf(level);
21
+ for (let index = at - 1; index > 0; index -= 1) if (supported.includes(EFFORT_LEVELS[index])) return EFFORT_LEVELS[index];
22
+ for (let index = Math.max(at + 1, 1); index < EFFORT_LEVELS.length; index += 1) if (supported.includes(EFFORT_LEVELS[index])) return EFFORT_LEVELS[index];
23
+ return supported[0] ?? "off";
24
+ }
9
25
 
10
26
  function settingsError(message, cause) {
11
27
  const error = new Error(message, cause ? { cause } : undefined);
@@ -87,3 +103,24 @@ export async function writePiRuntimeSelection(
87
103
  );
88
104
  return normalized;
89
105
  }
106
+
107
+ // The terminal and the window each build their agent from settings.json and auth.json. A model
108
+ // chosen, or a sign-in made, on one side reaches the other through this watch. onChange hears the
109
+ // saved selection, or undefined when none is saved; the surface decides whether to rebuild, with
110
+ // runtimeBehindSelection.
111
+ // The second argument says which file changed: "settings" (the default), "auth" (a sign-in),
112
+ // "endpoints" (an endpoint added, edited or removed) or "models" (the window's menu list).
113
+ export function watchRuntimeSettings({ environment = process.env, interval = 2000, onChange } = {}) {
114
+ const directory = path.dirname(resolveMoondogAuthFile(environment));
115
+ const files = { settings: resolveMoondogSettingsFile(environment), auth: resolveMoondogAuthFile(environment),
116
+ endpoints: path.join(directory, "endpoints.json"), models: path.join(directory, "models.json") };
117
+ const listeners = Object.entries(files).map(([changed, file]) => {
118
+ const listener = (now, before) => {
119
+ if (now.mtimeMs === before.mtimeMs) return;
120
+ void readPiRuntimeSelection(environment).then((selection) => onChange?.(selection, changed), () => {});
121
+ };
122
+ watchFile(file, { interval, persistent: false }, listener);
123
+ return [file, listener];
124
+ });
125
+ return () => { for (const [file, listener] of listeners) unwatchFile(file, listener); };
126
+ }
@@ -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-codex";
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-codex\` - start a browser OAuth login
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) => id !== DEFAULT_AUTH_PROVIDER && !serviceKeyProviders.some((service) => service.id === id)).join(", ")}.
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 zai for GLM, moonshotai or moonshotai-cn for Kimi, xai for Grok, openai for GPT, and anthropic for Claude.
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
- loginMethod: "browser",
42
+ chatgpt: false,
43
43
  };
44
44
  }
45
- const deviceCodeIndex = values.indexOf("--device-code");
46
- const browserIndex = values.indexOf("--browser");
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
- loginMethod: deviceCode ? "device_code" : "browser",
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
- function createReadlineOutput(output) {
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
- const selected = prompt.options.find(
130
- (option) => option.id === loginMethod,
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 Codex" : provider;
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
- options.action !== "login" &&
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
- const apiKeyLogin = provider !== DEFAULT_AUTH_PROVIDER;
269
- if (
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
- apiKeyLogin
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 && options.loginMethod === "browser" && !interactive) {
288
+ if (!apiKeyLogin && !interactive) {
319
289
  throw new Error(
320
- "Browser OAuth login requires an interactive terminal. Use --device-code for a headless session.",
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-codex");
356
- writeLine(output, " export MOONDOG_MODEL=gpt-5.6-terra");
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
+ }