@audiofool/moondog 0.2.0-beta.7 → 0.2.1
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 +4 -3
- package/package.json +1 -1
- package/scripts/moondog.mjs +30 -6
- package/src/core/application-update.mjs +30 -18
- 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/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/tui.mjs +320 -33
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { getKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
4
|
+
import { screenTranslator } from "../../i18n/index.mjs";
|
|
5
|
+
|
|
6
|
+
// NetEase plays through its own command line tool and mpv, set up from a terminal as the window shows it.
|
|
7
|
+
export const NETEASE_SETUP = "npm install -g @music163/ncm-cli && brew install mpv && ncm-cli login";
|
|
8
|
+
|
|
9
|
+
/** The command that makes NetEase ready, from ncm-cli's state: missing installs it, signed out signs in. */
|
|
10
|
+
export function neteaseSetupCommand(state) {
|
|
11
|
+
return state === "signed_out" ? "ncm-cli login" : NETEASE_SETUP;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function playbackServiceName(service, tr) {
|
|
15
|
+
return service === "netease" ? tr("NetEase Music") : service === "apple_music" ? "Apple Music" : "Spotify";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The music services the first run and /service offer, as the window does: Spotify and Apple Music,
|
|
20
|
+
* and NetEase once ncm-cli is installed, signed in or not. Choosing NetEase before it is signed in
|
|
21
|
+
* shows how to set it up.
|
|
22
|
+
*/
|
|
23
|
+
export function playbackServiceChoices(tr, { netease = "unavailable", current = null } = {}) {
|
|
24
|
+
const neteaseOffered = ["ready", "signed_out", "missing"].includes(netease) || current === "netease";
|
|
25
|
+
return [
|
|
26
|
+
{ value: "spotify", label: "Spotify", description: tr("Whole songs on your Spotify devices") },
|
|
27
|
+
{ value: "apple_music", label: "Apple Music", description: tr("30-second previews, played here") },
|
|
28
|
+
...(neteaseOffered ? [{
|
|
29
|
+
value: "netease", label: tr("NetEase Music"),
|
|
30
|
+
description: netease === "ready" ? tr("Whole songs through ncm-cli") : tr("Set up ncm-cli first"),
|
|
31
|
+
}] : []),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function clean(value) {
|
|
36
|
+
return sanitizeTerminalText(String(value ?? "")).replace(/\s+/gu, " ").trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function paint(value, width, theme) {
|
|
40
|
+
const clipped = truncateToWidth(value, width, "");
|
|
41
|
+
const line = clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
42
|
+
return theme.plain ? line.replace(/\u001b\[[\d;:]*m/g, "") : theme.background(theme.text(line));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The one-time first run: where music plays, then the model. It draws the step it is given and
|
|
47
|
+
* reports the listener's choice; the room saves it and runs Pi's own model picker and sign-in.
|
|
48
|
+
*/
|
|
49
|
+
export class FirstRunView {
|
|
50
|
+
constructor({ getTheme, getRows, onAction, getText = () => screenTranslator("en") }) {
|
|
51
|
+
this.getTheme = getTheme;
|
|
52
|
+
this.getRows = getRows;
|
|
53
|
+
this.getText = getText;
|
|
54
|
+
this.onAction = onAction;
|
|
55
|
+
this.state = { step: "service", steps: 2, services: [], model: {} };
|
|
56
|
+
this.selected = 0;
|
|
57
|
+
this.focused = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
invalidate() {}
|
|
61
|
+
|
|
62
|
+
setState(state) {
|
|
63
|
+
if (state.step !== this.state.step) this.selected = 0;
|
|
64
|
+
this.state = { ...this.state, ...state };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
actions() {
|
|
68
|
+
const tr = this.getText();
|
|
69
|
+
if (this.state.step === "service") return this.state.services.map((choice) => ({ type: "service", ...choice }));
|
|
70
|
+
const { provider, signIn } = this.state.model ?? {};
|
|
71
|
+
return signIn ? [
|
|
72
|
+
{ type: "auth", label: tr("Sign in to {provider}", { provider }), description: provider === "openai" ? tr("ChatGPT sign-in or API key") : tr("API key") },
|
|
73
|
+
{ type: "model", label: tr("Choose another model") },
|
|
74
|
+
] : [{ type: "model", label: tr("Choose a model"), description: tr("Pick a provider and model, then sign in") }];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleInput(data) {
|
|
78
|
+
const keys = getKeybindings();
|
|
79
|
+
const actions = this.actions();
|
|
80
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
81
|
+
this.onAction?.({ type: "skip" });
|
|
82
|
+
} else if (keys.matches(data, "tui.select.up") || keys.matches(data, "tui.select.down")) {
|
|
83
|
+
if (actions.length) this.selected = (this.selected + (keys.matches(data, "tui.select.up") ? -1 : 1) + actions.length) % actions.length;
|
|
84
|
+
} else if (keys.matches(data, "tui.select.confirm") && actions.length) {
|
|
85
|
+
const { type, value } = actions[Math.min(this.selected, actions.length - 1)];
|
|
86
|
+
this.onAction?.({ type, value });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(width) {
|
|
91
|
+
const columns = Math.max(1, Math.floor(width));
|
|
92
|
+
const rows = Number(this.getRows());
|
|
93
|
+
const height = Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 0;
|
|
94
|
+
if (!height) return [];
|
|
95
|
+
const theme = this.getTheme();
|
|
96
|
+
const tr = this.getText();
|
|
97
|
+
const inset = columns >= 8 ? 1 : 0;
|
|
98
|
+
const inner = Math.max(1, columns - inset * 2);
|
|
99
|
+
const wrap = (text, ink) => wrapTextWithAnsi(clean(text), inner).map(ink);
|
|
100
|
+
const { step, steps, note, setup, model = {} } = this.state;
|
|
101
|
+
const intro = steps > 1 ? tr("Two things before we start: where I play your music, and the model I talk through.")
|
|
102
|
+
: step === "service" ? tr("One thing before we start: where I play your music.") : tr("One thing before we start: the model I talk through.");
|
|
103
|
+
const question = step === "service" ? tr("Where should I play music?") : tr("Which model should I talk through?");
|
|
104
|
+
const help = step === "service" ? tr("/service changes it any time.")
|
|
105
|
+
: model.signIn ? tr("{model} is saved, but you're not signed in to {provider} yet.", { model: `${model.provider}/${model.model}`, provider: model.provider })
|
|
106
|
+
: tr("Sign in with ChatGPT, or use an API key from another provider. Your profile works without one.");
|
|
107
|
+
const notice = [
|
|
108
|
+
...(note?.text ? wrap(note.text, note.tone === "error" ? theme.error : theme.accent) : []),
|
|
109
|
+
...(setup ? [...wrap(tr("NetEase plays through its own command line tool. Run this in another terminal, then choose it again:"), theme.muted),
|
|
110
|
+
...wrap(setup, theme.text)] : []),
|
|
111
|
+
];
|
|
112
|
+
const actions = this.actions();
|
|
113
|
+
this.selected = Math.min(this.selected, Math.max(0, actions.length - 1));
|
|
114
|
+
const labelWidth = Math.max(0, ...actions.map(({ label }) => visibleWidth(clean(label)))) + 4;
|
|
115
|
+
const items = actions.map(({ label, description }, index) => {
|
|
116
|
+
const selected = index === this.selected;
|
|
117
|
+
const name = (selected ? "◉ " : " ") + clean(label);
|
|
118
|
+
const room = inner - labelWidth;
|
|
119
|
+
const line = description && room >= 12
|
|
120
|
+
? name + " ".repeat(Math.max(1, labelWidth - visibleWidth(name))) + theme.muted(truncateToWidth(clean(description), room, "…"))
|
|
121
|
+
: name;
|
|
122
|
+
return selected ? theme.selected(truncateToWidth(name, inner, "")) + line.slice(name.length) : theme.text(line);
|
|
123
|
+
});
|
|
124
|
+
// The choices always show. On a short screen the blank rows go first, then the introduction,
|
|
125
|
+
// then the line under the question; what happened last stays longest.
|
|
126
|
+
const title = theme.bold(tr("Welcome to the machine."));
|
|
127
|
+
const parts = (gaps, introduced, helped) => [
|
|
128
|
+
title, ...(introduced ? wrap(intro, theme.muted) : []), ...(gaps ? [""] : []),
|
|
129
|
+
...(notice.length ? [...notice, ...(gaps ? [""] : [])] : []),
|
|
130
|
+
theme.bold(question), ...(helped ? wrap(help, theme.muted) : []), ...(gaps ? [""] : []),
|
|
131
|
+
];
|
|
132
|
+
const space = height - items.length;
|
|
133
|
+
const above = [parts(true, true, true), parts(false, true, true), parts(false, false, true), parts(false, false, false)]
|
|
134
|
+
.find((candidate) => candidate.length <= space) ?? (space > 0 ? parts(false, false, false).slice(-space) : []);
|
|
135
|
+
const lines = [...above, ...items];
|
|
136
|
+
return Array.from({ length: height }, (_, row) => paint(" ".repeat(inset) + (lines[row] ?? ""), columns, theme));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -557,5 +557,5 @@ export function formatLocalResult(command, value, tr = screenTranslator("en")) {
|
|
|
557
557
|
}
|
|
558
558
|
|
|
559
559
|
export function helpText(tr = screenTranslator("en")) {
|
|
560
|
-
return tr("# Every command\n\nFrom your shell:\n\n- `moondog --version` - show the installed version\n- `moondog update [--check] [--channel latest|beta]` - check for or install a published release\n- `moondog web status|search|read [arguments] [--json]` - look things up on public music sites through DeepSeek, Brave Search or the Codex CLI\n- `moondog demo-history --output <absolute-file.zip> [--json]` - make a fictional Spotify history ZIP to try the importer with\n- `moondog auth login <provider>` - sign in to a model provider; the key is typed out of sight
|
|
560
|
+
return tr("# Every command\n\nFrom your shell:\n\n- `moondog --version` - show the installed version\n- `moondog update [--check] [--channel latest|beta]` - check for or install a published release\n- `moondog web status|search|read [arguments] [--json]` - look things up on public music sites through DeepSeek, Brave Search or the Codex CLI\n- `moondog demo-history --output <absolute-file.zip> [--json]` - make a fictional Spotify history ZIP to try the importer with\n- `moondog auth login <provider>` - sign in to a model provider; the key is typed out of sight; `openai --chatgpt` signs in with your ChatGPT subscription\n- `moondog auth status [provider]` - see which sign-ins are saved\n- `moondog auth logout <provider>` - sign out of a provider\n- `moondog spotify help` - set up and control Spotify\n- `moondog spotify login [client-id]` - connect Spotify in your browser\n- `moondog spotify now|devices|queue` - see what Spotify is playing, where, and what's next\n- `moondog spotify recent` - see what you played lately\n- `moondog spotify sync-recent` - add your latest plays to your profile\n- `moondog spotify sync-library` - add your Spotify library to your profile\n- `moondog spotify play|pause|next|previous` - control Spotify playback\n- `moondog listenbrainz import-history <listen-history.json>` - add a ListenBrainz listens file to your profile\n- `moondog catalog latest-single --artist <name> [--known-release <title> | --from <spotify-history.zip>]` - find an artist's latest single on Apple Music (US), no model needed\n- `moondog status [--json]` - see where things stand\n- `moondog taste [--json]` - your listening report\n- `moondog taste --html [--output <file.html>]` - save your report as a web page that works offline\n- `moondog taste --card [--output <file.html>]` - save a short recap you can check before sharing\n- `moondog taste --from <spotify-history.zip> --html` - a one-off report from a Spotify ZIP, without saving it\n- `moondog taste --from <account-data.zip> --from <extended-history.zip> --html` - a one-off report from both Spotify exports together\n- `moondog taste --from <spotify-history.zip> --save --html` - save the Spotify history, then show your full report\n- `moondog data inspect --scope <listening|apple|profile>` - see what's stored, and get the code needed to reset it\n- `moondog data export --scope <listening|apple|profile> --output <directory>` - export a copy, leaving the original alone\n- `moondog data reset --scope <listening|apple|profile> --confirm <token>` - set stored data aside (it is archived, not deleted)\n- `moondog profile build [--force]` - build or resume a saved profile with your connected model\n- `moondog profile saved [offset]` - read your saved profile\n- `moondog profile explain <number>` - trace a saved finding to its evidence\n- `moondog profile corrections [--all]` - everything you've told me, including earlier choices\n- `moondog profile correct --artist <name> (--like|--avoid)` - like an artist, or keep them out\n- `moondog profile correct --track <title> --by <artist> (--like|--avoid)` - like a track, or keep it out\n- `moondog profile retract <correction-id>` - undo one of your choices\n- `moondog demo --offline [--json]` - a demo with made-up data, no sign-in needed\n- `moondog demo [prompt] [--json]` - the same demo, talking through your model\n- `moondog ask <prompt>` - ask one question and exit\n- `moondog remember <text>` - ask me to remember something\n- `moondog forget <memory-id>` - forget something\n- `moondog memory reflect [--dry-run] [--json]` - let me think over recent conversations now\n- `moondog` - open the listening room\n\nInside the listening room:\n\n- `/update [--check] [--channel latest|beta]` - check for updates, or save and close this session to install one\n- `/status` or `/sources` - see where things stand\n- `/profile` or `/taste` - your profile, and why each song or artist is there\n- `/taste report` - the whole report on one page\n- `/profile build [--force]` - build or resume a saved reading; Ctrl+C keeps progress\n- `/profile saved [offset]` - read your saved profile\n- `/profile explain <number>` - inspect a saved finding's evidence\n- `/profile corrections [--all]` - everything you've told me\n- `/profile correct --track \"<title>\" --by \"<artist>\" (--like|--avoid)` - like a track, or keep it out\n- `/profile correct --artist \"<name>\" (--like|--avoid)` - like an artist, or keep them out\n- `/profile retract <correction-id>` - undo a choice\n- `/memory` - what I remember\n- `/tools` - what I can do right now\n- `/doctor` - check your setup\n- `/model` - pick the model I talk through\n- `/model <provider> <model>` - switch straight to a model\n- `/model refresh [provider]` - update public model choices; keep the selected model\n- `/auth [provider]` - sign in to a model provider\n- `/web status|search|read` - look things up on public music sites\n- `/import [path]` - bring in your history, library, or a playlist\n- `/language` - choose the language Moondog uses\n- `/service [spotify|apple_music|netease]` - choose where I play music\n- `/spotify` - Spotify commands\n- `/spotify login [client-id]` - connect Spotify\n- `/spotify now|devices|queue` - see what's playing, where, and what's next\n- `/spotify recent|sync-recent` - see or save your latest plays\n- `/spotify import-history \"/path/to/spotify-history.zip\"` - add a Spotify ZIP, no sign-in needed\n- `/skip` or `/stop` - move to the next song preview, or stop them\n- `/reload` - reload model settings and sign-ins\n- `/remember [kind] <text>` - ask me to remember something\n- `/forget <memory-id>` - forget something\n- `/rewind` or Alt+R - edit an earlier message in place; the messages after it are replaced\n- `/branch` - go on from here in a new conversation; this one stays saved\n- `/rewind latest` - edit the most recent message\n- `/resume` - pick up a saved conversation\n- `/resume <session-id>` - go straight to one\n- `/new` - start fresh; the last one stays saved\n- `/quit` - exit\n\nAnything you type that isn't a command goes to the model you picked with `/model`, or the one set in `MOONDOG_PROVIDER` and `MOONDOG_MODEL`.\nSign in with `/auth`, or set the provider's API key in your environment. `/auth openai` signs in with your ChatGPT subscription or an API key.\nOnce your profile is ready, I can search your library, explain what I see, and plan playlists with you.\nI can only control Spotify after `moondog spotify login`, and I never post, send messages, delete anything, or spend money.\nAsk me to move playback onto a device by name, such as your iPhone.");
|
|
561
561
|
}
|