@gentbajko/slopify 1.1.1 → 1.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 CHANGED
@@ -30,6 +30,14 @@ slopify
30
30
 
31
31
  Both commands launch the same local app.
32
32
 
33
+ For Docker on Linux, use `slopify --docker` or
34
+ `npx @gentbajko/slopify@latest --docker`. The launcher detects installed Codex,
35
+ Claude Code, and Gemini CLIs and mounts them read-only. It runs in the background,
36
+ restarts with Docker, and keeps your data in `slopify-data`. Run it again after
37
+ upgrading a CLI to refresh its mounts. Add `--port 7070` to change the default port.
38
+ CLI credentials stay separate; sign in once inside the container. See the
39
+ [Docker setup guide](https://github.com/GentBajko/slopify#docker) for login commands.
40
+
33
41
  ## How to use it
34
42
 
35
43
  1. Add provider keys and voices in Settings. They stay in your local Slopify database.
@@ -72,6 +80,7 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
72
80
  | `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
73
81
  | `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
74
82
  | `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
83
+ | `--docker` | — | run locally; with the flag, launch Docker with host CLI detection |
75
84
  | — | `SLOPIFY_FFMPEG` | the bundled binary |
76
85
 
77
86
  There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
@@ -36,7 +36,13 @@ export function buildRegistry(deps) {
36
36
  readModels: () => nodeClaudeCodeModels(cliBinary(deps.db, "claude-code")),
37
37
  }),
38
38
  ],
39
- ["codex", codexLlm({ run: cliFor("codex"), readModels: () => nodeCodexModels() })],
39
+ [
40
+ "codex",
41
+ codexLlm({
42
+ run: cliFor("codex"),
43
+ readModels: () => nodeCodexModels(process.env, cliBinary(deps.db, "codex")),
44
+ }),
45
+ ],
40
46
  [
41
47
  "gemini",
42
48
  geminiLlm({
@@ -27,6 +27,8 @@ const responseSchema = z.object({
27
27
  value: modelId,
28
28
  resolvedModel: modelId.optional(),
29
29
  displayName: modelName,
30
+ description: z.string().max(4096).optional(),
31
+ disabled: z.boolean().optional(),
30
32
  supportedEffortLevels: z.array(z.string()).max(10).optional(),
31
33
  }))
32
34
  .min(1)
@@ -73,27 +75,36 @@ function choices(event, requestId) {
73
75
  throw unavailable();
74
76
  const models = new Map();
75
77
  for (const row of parsed.data.response.response.models) {
78
+ if (row.disabled)
79
+ continue;
76
80
  const modes = row.supportedEffortLevels?.filter((value) => supportedEfforts.includes(value));
77
81
  const thinking = modes?.length ? { thinkingModes: modes } : {};
78
- if (!models.has(row.value))
79
- models.set(row.value, {
82
+ const target = row.resolvedModel ?? row.value;
83
+ const context = /\[([^\]]+)\]/.exec(row.value)?.[1] ?? /\[([^\]]+)\]/.exec(target)?.[1];
84
+ const key = target.replace(/\[[^\]]+\]/g, "") + (context ? `[${context}]` : "");
85
+ // Default and Opus can resolve to the same model. Keep the named CLI choice,
86
+ // preserving its accepted value (including context modifiers) for generation.
87
+ if (!models.has(key) || models.get(key)?.id === "default")
88
+ models.set(key, {
80
89
  id: row.value,
81
- name: row.resolvedModel === undefined || row.resolvedModel === row.value
82
- ? row.displayName
83
- : `${row.displayName} → ${row.resolvedModel}`,
84
- ...thinking,
85
- });
86
- // Claude advertises stable aliases and their current concrete targets.
87
- // Offer both so the user can choose a moving alias or pin a version.
88
- if (row.resolvedModel !== undefined && !models.has(row.resolvedModel))
89
- models.set(row.resolvedModel, {
90
- id: row.resolvedModel,
91
- name: `${row.resolvedModel} (exact model)`,
90
+ name: claudeModelName(target, row.description, row.displayName) +
91
+ (context ? ` (${context.toUpperCase()} context)` : ""),
92
92
  ...thinking,
93
93
  });
94
94
  }
95
+ if (models.size === 0)
96
+ throw unavailable();
95
97
  return [...models.values()];
96
98
  }
99
+ function claudeModelName(target, description, fallback) {
100
+ const version = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?=-|\[|$))?/i.exec(target);
101
+ if (version) {
102
+ const family = version[1] ?? "";
103
+ return `${family[0]?.toUpperCase()}${family.slice(1)} ${version[2]}${version[3] ? `.${version[3]}` : ""}`;
104
+ }
105
+ const advertised = /\b(?:Opus|Sonnet|Haiku|Fable) \d+(?:\.\d+)?\b/i.exec(description ?? "");
106
+ return advertised?.[0] ?? fallback.replace(/\s*\([^)]*context\)/i, "");
107
+ }
97
108
  // The installed CLI has no model-list command; initialize is a no-prompt
98
109
  // stream-JSON control exchange. Treat protocol drift as unavailable metadata.
99
110
  export async function nodeClaudeCodeModels(binary, timeoutMs = 15_000) {
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { readCatalogueFile } from "./catalogue-files.js";
5
+ import { codexModelName, nodeCodexServerModels } from "./codex-server-models.js";
5
6
  const safeText = z
6
7
  .string()
7
8
  .trim()
@@ -22,10 +23,11 @@ const model = z.object({
22
23
  .max(20)
23
24
  .optional(),
24
25
  });
25
- export async function nodeCodexModels(env = process.env) {
26
+ export async function nodeCodexModels(env = process.env, binary = "codex", timeoutMs = 15_000) {
26
27
  try {
27
28
  const directory = env.CODEX_HOME?.trim() || join(homedir(), ".codex");
28
- const source = await readCatalogueFile(join(directory, "models_cache.json"), 8 * 1024 * 1024);
29
+ const path = env.SLOPIFY_CODEX_MODELS_FILE?.trim() || join(directory, "models_cache.json");
30
+ const source = await readCatalogueFile(path, 8 * 1024 * 1024);
29
31
  const parsed = cache.parse(JSON.parse(source));
30
32
  const models = parsed.models
31
33
  .flatMap((entry) => {
@@ -41,7 +43,7 @@ export async function nodeCodexModels(env = process.env) {
41
43
  .filter((level) => ["off", "low", "medium", "high", "xhigh"].includes(level));
42
44
  unique.set(item.slug, {
43
45
  id: item.slug,
44
- name: item.display_name ?? item.slug,
46
+ name: codexModelName(item.display_name ?? item.slug),
45
47
  ...(thinkingModes?.length ? { thinkingModes } : {}),
46
48
  });
47
49
  }
@@ -51,7 +53,12 @@ export async function nodeCodexModels(env = process.env) {
51
53
  return [...unique.values()];
52
54
  }
53
55
  catch {
54
- // Do not expose cache contents, home paths or raw JSON errors to the browser.
55
- throw new Error("Codex model metadata is unavailable. Open Codex once to refresh its model list, or enter a custom model ID.");
56
+ try {
57
+ return await nodeCodexServerModels(binary, env, timeoutMs);
58
+ }
59
+ catch {
60
+ // Do not expose cache contents, home paths or raw JSON errors to the browser.
61
+ throw new Error("Codex model metadata is unavailable. Check the CLI and refresh, or enter a custom model ID.");
62
+ }
56
63
  }
57
64
  }
@@ -0,0 +1,150 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { cliCommand } from "../../kernel/cli-command.js";
7
+ const text = z
8
+ .string()
9
+ .trim()
10
+ .min(1)
11
+ .max(256)
12
+ .refine((value) => !/[\p{Cc}]/u.test(value));
13
+ const pageSchema = z.object({
14
+ data: z
15
+ .array(z.object({
16
+ model: text.regex(/^\S+$/),
17
+ displayName: text,
18
+ hidden: z.boolean().optional(),
19
+ supportedReasoningEfforts: z
20
+ .array(z.object({ reasoningEffort: z.string() }))
21
+ .max(20)
22
+ .optional(),
23
+ }))
24
+ .max(1000),
25
+ nextCursor: z.string().max(4096).nullish(),
26
+ });
27
+ // model/list is a metadata-only app-server exchange: no thread, turn or tool is started.
28
+ export async function nodeCodexServerModels(binary, env, timeoutMs = 15_000) {
29
+ const command = cliCommand(binary);
30
+ const directory = await mkdtemp(join(tmpdir(), "slopify-codex-models-"));
31
+ const child = spawn(command.file, [...command.args, "app-server", "--listen", "stdio://"], {
32
+ cwd: directory,
33
+ env: { ...env, PWD: directory },
34
+ stdio: ["pipe", "pipe", "pipe"],
35
+ windowsHide: true,
36
+ detached: process.platform !== "win32",
37
+ });
38
+ child.on("error", () => { });
39
+ child.stdin.on("error", () => { });
40
+ child.stderr.resume();
41
+ const closed = new Promise((resolve) => child.once("close", () => resolve()));
42
+ function terminate(signal) {
43
+ if (process.platform !== "win32" && child.pid !== undefined) {
44
+ try {
45
+ process.kill(-child.pid, signal);
46
+ return;
47
+ }
48
+ catch {
49
+ /* The process may already have exited. */
50
+ }
51
+ }
52
+ child.kill(signal);
53
+ }
54
+ const timer = setTimeout(() => terminate("SIGKILL"), timeoutMs);
55
+ const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
56
+ let expectedId = 1;
57
+ let pages = 0;
58
+ let bytes = 0;
59
+ let buffer = "";
60
+ const models = new Map();
61
+ const cursors = new Set();
62
+ try {
63
+ send({
64
+ id: expectedId,
65
+ method: "initialize",
66
+ params: {
67
+ clientInfo: { name: "slopify_model_discovery", version: "1.0" },
68
+ },
69
+ });
70
+ child.stdout.setEncoding("utf8");
71
+ for await (const piece of child.stdout) {
72
+ bytes += Buffer.byteLength(piece);
73
+ if (bytes > 4 * 1024 * 1024)
74
+ throw new Error("Oversized metadata");
75
+ buffer += piece;
76
+ let newline = buffer.indexOf("\n");
77
+ while (newline >= 0) {
78
+ const line = buffer.slice(0, newline).trim();
79
+ buffer = buffer.slice(newline + 1);
80
+ newline = buffer.indexOf("\n");
81
+ if (line === "")
82
+ continue;
83
+ const event = z
84
+ .object({
85
+ id: z.unknown().optional(),
86
+ result: z.unknown().optional(),
87
+ error: z.unknown().optional(),
88
+ })
89
+ .parse(JSON.parse(line));
90
+ if (event.id !== expectedId)
91
+ continue;
92
+ if (event.error !== undefined)
93
+ throw new Error("Discovery refused");
94
+ let cursor;
95
+ if (expectedId === 1) {
96
+ send({ method: "initialized" });
97
+ }
98
+ else {
99
+ const page = pageSchema.parse(event.result);
100
+ for (const row of page.data) {
101
+ if (row.hidden === true || models.has(row.model))
102
+ continue;
103
+ const thinkingModes = row.supportedReasoningEfforts
104
+ ?.map((item) => item.reasoningEffort)
105
+ .filter((effort) => ["off", "low", "medium", "high", "xhigh"].includes(effort));
106
+ models.set(row.model, {
107
+ id: row.model,
108
+ name: codexModelName(row.displayName),
109
+ ...(thinkingModes?.length ? { thinkingModes } : {}),
110
+ });
111
+ }
112
+ cursor = page.nextCursor;
113
+ if (!cursor) {
114
+ if (models.size === 0)
115
+ throw new Error("No visible models");
116
+ return [...models.values()];
117
+ }
118
+ if (cursors.has(cursor) || ++pages >= 20)
119
+ throw new Error("Invalid pagination");
120
+ cursors.add(cursor);
121
+ }
122
+ send({
123
+ id: ++expectedId,
124
+ method: "model/list",
125
+ params: { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) },
126
+ });
127
+ }
128
+ }
129
+ throw new Error("Discovery ended before its reply");
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ terminate("SIGTERM");
134
+ let grace;
135
+ await Promise.race([
136
+ closed,
137
+ new Promise((resolve) => {
138
+ grace = setTimeout(() => {
139
+ terminate("SIGKILL");
140
+ resolve();
141
+ }, 500);
142
+ }),
143
+ ]);
144
+ clearTimeout(grace);
145
+ await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
146
+ }
147
+ }
148
+ export function codexModelName(name) {
149
+ return name.replace(/^(GPT-\d+(?:\.\d+)?)-/i, "$1 ");
150
+ }
@@ -179,7 +179,7 @@ export function codexLlm(deps) {
179
179
  id: "codex",
180
180
  // Prose arrives as whole messages; other JSONL events carry activity separately.
181
181
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
182
- models: deps.readModels ?? (() => nodeCodexModels()),
182
+ models: deps.readModels ?? (() => nodeCodexModels(process.env, binary)),
183
183
  complete,
184
184
  };
185
185
  }
@@ -67,9 +67,6 @@ async function bundledModels(entry) {
67
67
  }
68
68
  function parseGeminiModels(source) {
69
69
  const models = new Map();
70
- let pro = false;
71
- let flash = false;
72
- let flashLite = false;
73
70
  // Read literal model constants only. Never import or execute an
74
71
  // installed package, parse comments as entries, or inspect login/settings.
75
72
  const declarations = /^(?:export\s+)?(?:const|let|var)\s+([A-Z][A-Z0-9_]*)\s*=\s*(['"])([^'"\r\n]+)\2\s*;/gm;
@@ -81,27 +78,34 @@ function parseGeminiModels(source) {
81
78
  id === undefined ||
82
79
  !name.includes("MODEL") ||
83
80
  name.includes("EMBEDDING") ||
84
- !/^(?:gemini|gemma)-[a-z0-9.-]+$/.test(id))
81
+ !/^gemini-\d+(?:\.\d+)?-(?:pro|flash|flash-lite)(?:-preview(?:-\d{2}-\d{2})?)?$/.test(id))
85
82
  continue;
86
- if (!/^(?:(?:PREVIEW|DEFAULT|SECONDARY)_GEMINI_|GEMMA_)/.test(name))
83
+ // SECONDARY and CUSTOM_TOOLS constants are routing aliases, not separate
84
+ // picker choices. Gemma and embedding models use different availability rules.
85
+ // In 0.61+, DEFAULT_FLASH points at a BASE constant while new releases
86
+ // use LATEST constants. Read both literal sources without evaluating JS.
87
+ if (!/^(?:PREVIEW|DEFAULT|BASE|LATEST)_GEMINI_/.test(name) || name.includes("CUSTOM_TOOLS"))
87
88
  continue;
88
- models.set(id, { id, name: id });
89
- if (name.includes("FLASH_LITE") || id.includes("flash-lite"))
90
- flashLite = true;
91
- else if (name.includes("FLASH") || id.includes("flash"))
92
- flash = true;
93
- else if (name.includes("PRO") || id.includes("-pro"))
94
- pro = true;
89
+ const parts = /^gemini-(\d+(?:\.\d+)?)-(pro|flash-lite|flash)/.exec(id);
90
+ if (!parts)
91
+ continue;
92
+ const tier = parts[2] === "pro" ? "Pro" : parts[2] === "flash-lite" ? "Flash-Lite" : "Flash";
93
+ models.set(id, {
94
+ id,
95
+ name: `Gemini ${parts[1]} ${tier}${id.includes("-preview") ? " (Preview)" : ""}`,
96
+ });
95
97
  }
96
98
  if (models.size === 0)
97
99
  return [];
98
- return [
99
- { id: "auto", name: "Gemini Auto (CLI default)" },
100
- ...(pro ? [{ id: "pro", name: "Gemini Pro (CLI alias)" }] : []),
101
- ...(flash ? [{ id: "flash", name: "Gemini Flash (CLI alias)" }] : []),
102
- ...(flashLite ? [{ id: "flash-lite", name: "Gemini Flash-Lite (CLI alias)" }] : []),
103
- ...models.values(),
104
- ];
100
+ const sorted = [...models.values()].sort((a, b) => b.id.localeCompare(a.id, "en", { numeric: true }));
101
+ const tiers = new Set();
102
+ const choices = sorted.map((model) => {
103
+ const tier = /-(pro|flash-lite|flash)(?:-|$)/.exec(model.id)?.[1] ?? model.id;
104
+ const group = tiers.has(tier) ? "Other versions" : "Latest in installed CLI";
105
+ tiers.add(tier);
106
+ return { ...model, group };
107
+ });
108
+ return [{ id: "auto", name: "Automatic (CLI default)" }, ...choices];
105
109
  }
106
110
  async function executablePath(binary) {
107
111
  if (isAbsolute(binary) || /[\\/]/.test(binary))
package/dist/edge/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { parseArgs } from "node:util";
3
5
  import { configFrom } from "../kernel/config/index.js";
4
6
  import { readVersion } from "../kernel/version.js";
@@ -12,9 +14,24 @@ const { values } = parseArgs({
12
14
  host: { type: "string" },
13
15
  "data-dir": { type: "string" },
14
16
  "no-open": { type: "boolean" },
17
+ docker: { type: "boolean" },
15
18
  },
16
19
  });
17
20
  try {
21
+ if (values.docker) {
22
+ if (values.host !== undefined || values["data-dir"] !== undefined)
23
+ throw new Error("Docker stores data in the slopify-data volume and binds to localhost. Use --port to change its port.");
24
+ const result = spawnSync("bash", [fileURLToPath(new URL("../../scripts/docker-run.sh", import.meta.url))], {
25
+ stdio: "inherit",
26
+ env: {
27
+ ...process.env,
28
+ ...(values.port === undefined ? {} : { SLOPIFY_DOCKER_HOST_PORT: values.port }),
29
+ },
30
+ });
31
+ if (result.error)
32
+ throw result.error;
33
+ process.exit(result.status ?? 1);
34
+ }
18
35
  const config = configFrom(values, process.env);
19
36
  const forwarded = await forwardManagedUpdate(config.dataDir, readVersion(), process.argv.slice(2));
20
37
  if (forwarded !== undefined)
package/dist/main.js CHANGED
@@ -220,12 +220,16 @@ export async function boot(config) {
220
220
  currentVersion: version,
221
221
  previousUpdateFailed: process.env.SLOPIFY_UPDATE_FAILED === "1",
222
222
  now: () => Date.now(),
223
- latest: () => publishedVersion(globalThis.fetch),
224
- unsupported: () => !existsSync(oldEntry) || !existsSync(workerEntry)
225
- ? "Run Slopify from its installed package to use in-app updates."
226
- : npm === undefined
227
- ? "npm is unavailable. Install Node.js with npm to use in-app updates."
228
- : undefined,
223
+ latest: () => process.env.SLOPIFY_DISABLE_UPDATES === "1"
224
+ ? Promise.resolve(version)
225
+ : publishedVersion(globalThis.fetch),
226
+ unsupported: () => process.env.SLOPIFY_DISABLE_UPDATES === "1"
227
+ ? "This container is updated by pulling a new image and recreating it."
228
+ : !existsSync(oldEntry) || !existsSync(workerEntry)
229
+ ? "Run Slopify from its installed package to use in-app updates."
230
+ : npm === undefined
231
+ ? "npm is unavailable. Install Node.js with npm to use in-app updates."
232
+ : undefined,
229
233
  busy: () => updateDb.prepare("SELECT 1 FROM stages WHERE state = 'running' LIMIT 1").get() !==
230
234
  undefined ||
231
235
  updateDb
@@ -82,9 +82,9 @@ function uniqueModels(models) {
82
82
  }
83
83
  function catalogNotice(provider) {
84
84
  const notices = {
85
- codex: "Models come from Codex's local catalogue. Open Codex to refresh that catalogue, then refresh this list.",
86
- gemini: "Models and aliases come from the installed Gemini CLI. Update that CLI to discover newer models.",
87
- "claude-code": "These aliases follow Claude Code's model families. You can also enter an exact model ID.",
85
+ codex: "Models come from your Codex installation. Refresh to check again.",
86
+ gemini: "Models come from your installed Gemini CLI, not an account access check. Missing a new model? Update Gemini CLI, rerun the Docker launcher if applicable, then refresh this list.",
87
+ "claude-code": "Models come from Claude Code. Missing a new model? Update Claude Code, rerun the Docker launcher if applicable, then refresh this list. You can also enter an exact model ID.",
88
88
  inworld: "These are Inworld’s documented TTS models. You can also enter a compatible model ID.",
89
89
  cartesia: "Cartesia has no model-list API. These are bundled compatible choices; you can enter a newer model ID.",
90
90
  fal: "These models have input formats supported by Slopify's fal adapter.",