@gentbajko/slopify 1.3.1 → 1.4.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 +12 -6
- package/dist/adapter-registry.js +11 -1
- package/dist/adapters/host-cli/index.js +192 -0
- package/dist/adapters/host-cli/transport.js +135 -0
- package/dist/adapters/image/codex.js +15 -6
- package/dist/adapters/llm/claude-code-models.js +1 -0
- package/dist/adapters/llm/claude-code.js +7 -0
- package/dist/adapters/llm/cli-login-error.js +16 -0
- package/dist/adapters/llm/codex.js +10 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/docker.js +109 -0
- package/dist/edge/host-cli.js +58 -0
- package/dist/edge/http/actions.js +1 -1
- package/dist/edge/http/diagnostics.js +5 -1
- package/dist/edge/http/host-cli.js +247 -0
- package/dist/edge/http/providers.js +5 -1
- package/dist/host-cli/environment.js +25 -0
- package/dist/host-cli/install.js +61 -0
- package/dist/host-cli/paths.js +68 -0
- package/dist/host-cli/runtime.js +78 -0
- package/dist/host-cli/server.js +98 -0
- package/dist/host-cli/service.js +200 -0
- package/dist/host-cli/status.js +134 -0
- package/dist/kernel/ports/host-cli.js +95 -0
- package/dist/kernel/ports/model.js +1 -0
- package/dist/kernel/runner/attempt.js +10 -1
- package/dist/main.js +7 -1
- package/dist/slices/play-drafts/readiness.js +2 -3
- package/dist/slices/rebuild/service-readiness.js +2 -3
- package/dist/slices/settings/cli-paths.js +10 -0
- package/dist/slices/settings/cli-status.js +2 -0
- package/dist/slices/settings/readiness.js +15 -1
- package/dist/web/assets/{index-DsumGtv6.js → index-CbGJtCio.js} +21 -21
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/scripts/docker-run.sh +8 -45
package/README.md
CHANGED
|
@@ -30,13 +30,19 @@ slopify
|
|
|
30
30
|
|
|
31
31
|
Both commands launch the same local app.
|
|
32
32
|
|
|
33
|
-
For Docker on Linux, use `slopify --docker` or
|
|
33
|
+
For Docker on Linux with systemd, use `slopify --docker` or
|
|
34
34
|
`npx @gentbajko/slopify@latest --docker`. The launcher detects installed Codex,
|
|
35
|
-
Claude Code
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
Claude Code and Gemini CLIs. It asks once before installing a private host helper
|
|
36
|
+
and enabling automatic startup, including user lingering. The CLIs run on your
|
|
37
|
+
host with their existing logins; credentials never move into Docker.
|
|
38
|
+
|
|
39
|
+
Slopify runs in the background, restarts with Docker and keeps your data in
|
|
40
|
+
`slopify-data`. Add `--port 7070` to change the default port. Rerun the launcher
|
|
41
|
+
after changing host CLI installations or search paths. Native installs need no helper.
|
|
42
|
+
Plain `docker run` is API-only without a configured helper. Use `--host-cli=off`
|
|
43
|
+
for API-only launcher setup, or `--accept-host-cli` to approve non-interactive setup.
|
|
44
|
+
See the [Docker setup guide](https://github.com/GentBajko/slopify#docker) for
|
|
45
|
+
updates and the dedicated helper's status/disable commands.
|
|
40
46
|
|
|
41
47
|
## How to use it
|
|
42
48
|
|
package/dist/adapter-registry.js
CHANGED
|
@@ -14,6 +14,7 @@ import { cartesiaTts } from "./adapters/tts/cartesia.js";
|
|
|
14
14
|
import { elevenLabsTts } from "./adapters/tts/elevenlabs.js";
|
|
15
15
|
import { inworldTts } from "./adapters/tts/inworld.js";
|
|
16
16
|
import { openAiTts } from "./adapters/tts/openai.js";
|
|
17
|
+
import { hostLlmIds } from "./kernel/ports/host-cli.js";
|
|
17
18
|
import { cliBinary } from "./slices/settings/cli-paths.js";
|
|
18
19
|
import { keyForAttempt } from "./slices/settings/keys.js";
|
|
19
20
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
@@ -74,13 +75,22 @@ export function buildRegistry(deps) {
|
|
|
74
75
|
["google-image", googleImage({ fetch: deps.fetch, key: keyOf("google-image") })],
|
|
75
76
|
["codex-image", codexImage({ run: cliFor("codex") })],
|
|
76
77
|
]);
|
|
78
|
+
if (deps.hostCli) {
|
|
79
|
+
for (const id of hostLlmIds)
|
|
80
|
+
llms.set(id, deps.hostCli.llm(id));
|
|
81
|
+
images.set("codex-image", deps.hostCli.image);
|
|
82
|
+
}
|
|
77
83
|
return {
|
|
78
84
|
llm: (id) => resolve(llms, "llm", id),
|
|
79
85
|
tts: (id) => resolve(ttses, "tts", id),
|
|
80
86
|
image: (id) => resolve(images, "image", id),
|
|
81
87
|
// Every supported provider, keyed or not, found or not, so Play
|
|
82
88
|
// can grey one out with a reason instead of hiding it.
|
|
83
|
-
list: async () => (await providerStatuses({
|
|
89
|
+
list: async () => (await providerStatuses({
|
|
90
|
+
db: deps.db,
|
|
91
|
+
probe: deps.probe,
|
|
92
|
+
hostCliStatus: deps.hostCli?.status,
|
|
93
|
+
})).map((status) => ({
|
|
84
94
|
family: status.family,
|
|
85
95
|
id: status.id,
|
|
86
96
|
name: status.displayName,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { redact } from "../../kernel/log.js";
|
|
2
|
+
import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostImageSchema, hostLlmSchema, hostModelsSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
|
|
3
|
+
import { isProviderError, providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { sniffImage } from "../image/bytes.js";
|
|
5
|
+
import { hostRequest, hostUnavailable, readHostBytes, } from "./transport.js";
|
|
6
|
+
export function createHostCliClient(options) {
|
|
7
|
+
async function request(input) {
|
|
8
|
+
if (!options.directory)
|
|
9
|
+
throw hostUnavailable();
|
|
10
|
+
const response = await hostRequest({ ...input, directory: options.directory });
|
|
11
|
+
if (response.statusCode !== 200) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = hostFaultSchema.safeParse(JSON.parse((await readHostBytes(response, bridgeLimits.status)).toString("utf8")));
|
|
14
|
+
if (parsed.success)
|
|
15
|
+
throw providerError({ kind: parsed.data.kind, message: redact(parsed.data.message) });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (isProviderError(error))
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
throw hostUnavailable(input.method === "POST");
|
|
22
|
+
}
|
|
23
|
+
return response;
|
|
24
|
+
}
|
|
25
|
+
async function metadata(path, schema, signal = AbortSignal.timeout(35_000)) {
|
|
26
|
+
const response = await request({ path, method: "GET", kind: "metadata", signal });
|
|
27
|
+
try {
|
|
28
|
+
if (response.headers["content-type"]?.split(";")[0] !== "application/json")
|
|
29
|
+
throw hostUnavailable();
|
|
30
|
+
const bytes = await readHostBytes(response, path.includes("/models/") ? bridgeLimits.models : bridgeLimits.status);
|
|
31
|
+
return schema.parse(JSON.parse(bytes.toString("utf8")));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw hostUnavailable();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
response.destroy();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function ready(signal) {
|
|
41
|
+
const health = await metadata("/v1/health", hostHealthSchema, signal);
|
|
42
|
+
if (!health.accepting)
|
|
43
|
+
throw hostUnavailable();
|
|
44
|
+
}
|
|
45
|
+
const models = async (id) => (await metadata(`/v1/models/${id}`, hostModelsSchema)).models.map((model) => ({
|
|
46
|
+
id: model.id,
|
|
47
|
+
name: model.name,
|
|
48
|
+
...(model.group === undefined ? {} : { group: model.group }),
|
|
49
|
+
...(model.thinkingModes === undefined ? {} : { thinkingModes: model.thinkingModes }),
|
|
50
|
+
}));
|
|
51
|
+
return {
|
|
52
|
+
status: async (id) => {
|
|
53
|
+
try {
|
|
54
|
+
const status = await metadata(`/v1/status/${id}`, hostStatusSchema);
|
|
55
|
+
if (status.id !== id)
|
|
56
|
+
throw hostUnavailable();
|
|
57
|
+
return {
|
|
58
|
+
id: status.id,
|
|
59
|
+
command: status.command,
|
|
60
|
+
installed: status.installed,
|
|
61
|
+
login: status.login,
|
|
62
|
+
...(status.version === undefined ? {} : { version: status.version }),
|
|
63
|
+
...(status.issueKind === undefined ? {} : { issueKind: status.issueKind }),
|
|
64
|
+
...(status.issue === undefined ? {} : { issue: status.issue }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
command: id === "claude-code" ? "claude" : id === "codex-image" ? "codex" : id,
|
|
71
|
+
installed: false,
|
|
72
|
+
login: "unknown",
|
|
73
|
+
issueKind: "bridge",
|
|
74
|
+
issue: hostUnavailable().message,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
llm: (id) => ({
|
|
79
|
+
id,
|
|
80
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
81
|
+
models: () => models(id),
|
|
82
|
+
complete: async function* (req) {
|
|
83
|
+
const parsed = hostLlmSchema.safeParse({
|
|
84
|
+
model: req.model,
|
|
85
|
+
messages: req.messages,
|
|
86
|
+
...(req.thinking === undefined ? {} : { thinking: req.thinking }),
|
|
87
|
+
...(req.webSearch === undefined ? {} : { webSearch: req.webSearch }),
|
|
88
|
+
});
|
|
89
|
+
if (!parsed.success)
|
|
90
|
+
throw providerError({ kind: "unsupported", message: "Invalid host LLM request." });
|
|
91
|
+
await ready(req.signal);
|
|
92
|
+
const response = await request({
|
|
93
|
+
path: `/v1/llm/${id}`,
|
|
94
|
+
method: "POST",
|
|
95
|
+
kind: "llm",
|
|
96
|
+
body: Buffer.from(JSON.stringify(parsed.data)),
|
|
97
|
+
signal: req.signal,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
if (response.headers["content-type"]?.split(";")[0] !== "application/x-ndjson")
|
|
101
|
+
throw hostUnavailable(true);
|
|
102
|
+
yield* readEvents(response);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (isProviderError(error))
|
|
106
|
+
throw error;
|
|
107
|
+
throw hostUnavailable(true);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
response.destroy();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
image: {
|
|
115
|
+
id: "codex-image",
|
|
116
|
+
models: () => models("codex-image"),
|
|
117
|
+
generate: async (req) => {
|
|
118
|
+
const parsed = hostImageSchema.safeParse({
|
|
119
|
+
model: req.model,
|
|
120
|
+
prompt: req.prompt,
|
|
121
|
+
aspect: req.aspect,
|
|
122
|
+
});
|
|
123
|
+
if (!parsed.success)
|
|
124
|
+
throw providerError({ kind: "unsupported", message: "Invalid host image request." });
|
|
125
|
+
await ready(req.signal);
|
|
126
|
+
const response = await request({
|
|
127
|
+
path: "/v1/image",
|
|
128
|
+
method: "POST",
|
|
129
|
+
kind: "image",
|
|
130
|
+
body: Buffer.from(JSON.stringify(parsed.data)),
|
|
131
|
+
signal: req.signal,
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
const mime = response.headers["content-type"];
|
|
135
|
+
if (mime !== "image/png" && mime !== "image/jpeg")
|
|
136
|
+
throw hostUnavailable(true);
|
|
137
|
+
const bytes = await readHostBytes(response, bridgeLimits.image);
|
|
138
|
+
if (sniffImage(bytes) !== mime)
|
|
139
|
+
throw hostUnavailable(true);
|
|
140
|
+
return { bytes, mime };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (isProviderError(error))
|
|
144
|
+
throw error;
|
|
145
|
+
throw hostUnavailable(true);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
response.destroy();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function* readEvents(response) {
|
|
155
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
156
|
+
let pending = "";
|
|
157
|
+
let total = 0;
|
|
158
|
+
let terminal;
|
|
159
|
+
for await (const bytes of response) {
|
|
160
|
+
if (!Buffer.isBuffer(bytes))
|
|
161
|
+
throw hostUnavailable(true);
|
|
162
|
+
total += bytes.byteLength;
|
|
163
|
+
if (total > bridgeLimits.stream)
|
|
164
|
+
throw hostUnavailable(true);
|
|
165
|
+
pending += decoder.decode(bytes, { stream: true });
|
|
166
|
+
let newline = pending.indexOf("\n");
|
|
167
|
+
while (newline !== -1) {
|
|
168
|
+
const line = pending.slice(0, newline);
|
|
169
|
+
pending = pending.slice(newline + 1);
|
|
170
|
+
if (Buffer.byteLength(line) + 1 > bridgeLimits.frame)
|
|
171
|
+
throw hostUnavailable(true);
|
|
172
|
+
if (line.trim() !== "") {
|
|
173
|
+
if (terminal)
|
|
174
|
+
throw hostUnavailable(true);
|
|
175
|
+
const event = hostFrameSchema.parse(JSON.parse(line));
|
|
176
|
+
if (event.type === "error")
|
|
177
|
+
throw providerError({ kind: event.kind, message: redact(event.message) });
|
|
178
|
+
if (event.type === "done")
|
|
179
|
+
terminal = event;
|
|
180
|
+
else
|
|
181
|
+
yield event;
|
|
182
|
+
}
|
|
183
|
+
newline = pending.indexOf("\n");
|
|
184
|
+
}
|
|
185
|
+
if (Buffer.byteLength(pending) >= bridgeLimits.frame)
|
|
186
|
+
throw hostUnavailable(true);
|
|
187
|
+
}
|
|
188
|
+
pending += decoder.decode();
|
|
189
|
+
if (!response.complete || pending.trim() !== "" || !terminal)
|
|
190
|
+
throw hostUnavailable(true);
|
|
191
|
+
yield terminal;
|
|
192
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { request } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { bridgeLimits } from "../../kernel/ports/host-cli.js";
|
|
6
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
export function hostUnavailable(submitted = false) {
|
|
8
|
+
return providerError({
|
|
9
|
+
kind: "unavailable",
|
|
10
|
+
message: submitted
|
|
11
|
+
? "The host CLI connection ended without a reliable result; the result may be uncertain. Review the affected rebuild before retrying."
|
|
12
|
+
: "Host helper unavailable. Run npx @gentbajko/slopify@latest --docker on the host to set it up or check its service.",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function tokenAt(directory) {
|
|
16
|
+
const file = await open(join(directory, "token"), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
17
|
+
try {
|
|
18
|
+
const stat = await file.stat();
|
|
19
|
+
if (!stat.isFile() || stat.size !== 64 || (stat.mode & 0o022) !== 0)
|
|
20
|
+
throw hostUnavailable();
|
|
21
|
+
const bytes = Buffer.alloc(65);
|
|
22
|
+
const { bytesRead } = await file.read(bytes, 0, 65, 0);
|
|
23
|
+
const value = bytes.subarray(0, bytesRead).toString("utf8");
|
|
24
|
+
if (!/^[a-f0-9]{64}$/.test(value))
|
|
25
|
+
throw hostUnavailable();
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await file.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function hostRequest(options) {
|
|
33
|
+
if (options.body && options.body.byteLength > bridgeLimits.request)
|
|
34
|
+
throw providerError({
|
|
35
|
+
kind: "unsupported",
|
|
36
|
+
message: "The host provider request exceeds 16 MiB.",
|
|
37
|
+
});
|
|
38
|
+
let token;
|
|
39
|
+
try {
|
|
40
|
+
options.signal.throwIfAborted();
|
|
41
|
+
token = await tokenAt(options.directory);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw hostUnavailable();
|
|
45
|
+
}
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let response;
|
|
48
|
+
let submitted = false;
|
|
49
|
+
const req = request({
|
|
50
|
+
socketPath: join(options.directory, "cli.sock"),
|
|
51
|
+
path: options.path,
|
|
52
|
+
method: options.method,
|
|
53
|
+
agent: false,
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${token}`,
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
...(options.body ? { "content-length": options.body.byteLength } : {}),
|
|
58
|
+
},
|
|
59
|
+
}, (incoming) => {
|
|
60
|
+
response = incoming;
|
|
61
|
+
const maximum = options.kind === "image"
|
|
62
|
+
? bridgeLimits.image
|
|
63
|
+
: options.kind === "llm"
|
|
64
|
+
? bridgeLimits.stream
|
|
65
|
+
: options.path.includes("/models/")
|
|
66
|
+
? bridgeLimits.models
|
|
67
|
+
: bridgeLimits.status;
|
|
68
|
+
const advertised = Number(incoming.headers["content-length"] ?? 0);
|
|
69
|
+
if (!Number.isFinite(advertised) || advertised > maximum) {
|
|
70
|
+
fail();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
incoming.once("error", () => { });
|
|
74
|
+
incoming.once("close", cleanup);
|
|
75
|
+
resolve(incoming);
|
|
76
|
+
});
|
|
77
|
+
const fail = () => {
|
|
78
|
+
const error = hostUnavailable(submitted);
|
|
79
|
+
response?.destroy(error);
|
|
80
|
+
req.destroy(error);
|
|
81
|
+
reject(error);
|
|
82
|
+
};
|
|
83
|
+
const connectTimer = setTimeout(fail, 5000);
|
|
84
|
+
const deadline = setTimeout(fail, options.kind === "image" ? 300_000 : options.kind === "metadata" ? 35_000 : 120_000);
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
clearTimeout(connectTimer);
|
|
87
|
+
clearTimeout(deadline);
|
|
88
|
+
options.signal.removeEventListener("abort", fail);
|
|
89
|
+
};
|
|
90
|
+
req.once("socket", (socket) => {
|
|
91
|
+
socket.once("connect", () => {
|
|
92
|
+
clearTimeout(connectTimer);
|
|
93
|
+
submitted = options.method === "POST";
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
req.on("error", () => {
|
|
97
|
+
cleanup();
|
|
98
|
+
const error = hostUnavailable(submitted);
|
|
99
|
+
response?.destroy(error);
|
|
100
|
+
reject(error);
|
|
101
|
+
});
|
|
102
|
+
if (options.kind === "llm") {
|
|
103
|
+
req.on("response", () => clearTimeout(deadline));
|
|
104
|
+
req.setTimeout(120_000, fail);
|
|
105
|
+
}
|
|
106
|
+
options.signal.addEventListener("abort", fail, { once: true });
|
|
107
|
+
if (options.signal.aborted)
|
|
108
|
+
fail();
|
|
109
|
+
else
|
|
110
|
+
req.end(options.body);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export async function readHostBytes(response, maximum) {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let length = 0;
|
|
116
|
+
try {
|
|
117
|
+
for await (const value of response) {
|
|
118
|
+
if (!Buffer.isBuffer(value))
|
|
119
|
+
throw hostUnavailable();
|
|
120
|
+
length += value.byteLength;
|
|
121
|
+
if (length > maximum)
|
|
122
|
+
throw providerError({
|
|
123
|
+
kind: "unavailable",
|
|
124
|
+
message: "Host response exceeded its size limit.",
|
|
125
|
+
});
|
|
126
|
+
chunks.push(value);
|
|
127
|
+
}
|
|
128
|
+
if (!response.complete)
|
|
129
|
+
throw hostUnavailable();
|
|
130
|
+
return Buffer.concat(chunks, length);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
response.destroy();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -2,7 +2,9 @@ import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, read
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { redact } from "../../kernel/log.js";
|
|
5
6
|
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
import { cliLoginError } from "../llm/cli-login-error.js";
|
|
6
8
|
import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
|
|
7
9
|
import { lines } from "../llm/sse-lines.js";
|
|
8
10
|
import { sniffImage } from "./bytes.js";
|
|
@@ -111,18 +113,24 @@ export function codexImage(deps) {
|
|
|
111
113
|
completed = true;
|
|
112
114
|
else if (event.type === "turn.failed") {
|
|
113
115
|
const message = cliShaped(binary, failure, event.value).error.message;
|
|
116
|
+
const login = cliLoginError("codex", message);
|
|
117
|
+
if (login)
|
|
118
|
+
throw login;
|
|
114
119
|
throw providerError({
|
|
115
120
|
kind: /refus|content.policy|safety/i.test(message) ? "refusal" : "other",
|
|
116
|
-
message:
|
|
121
|
+
message: redact(message),
|
|
117
122
|
});
|
|
118
123
|
}
|
|
119
124
|
else if (event.type === "error") {
|
|
120
125
|
const message = cliShaped(binary, errorEvent, event.value).message;
|
|
126
|
+
const login = cliLoginError("codex", message);
|
|
127
|
+
if (login)
|
|
128
|
+
throw login;
|
|
121
129
|
throw providerError({
|
|
122
130
|
kind: /image.generation|image tool|feature.*unavailable/i.test(message)
|
|
123
131
|
? "unsupported"
|
|
124
132
|
: "other",
|
|
125
|
-
message:
|
|
133
|
+
message: redact(message),
|
|
126
134
|
});
|
|
127
135
|
}
|
|
128
136
|
else if (event.type === "item.completed") {
|
|
@@ -138,10 +146,11 @@ export function codexImage(deps) {
|
|
|
138
146
|
if (ended.error !== null)
|
|
139
147
|
throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
|
|
140
148
|
if (ended.code !== 0 || !completed)
|
|
141
|
-
throw
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
throw (cliLoginError("codex", run.stderr()) ??
|
|
150
|
+
providerError({
|
|
151
|
+
kind: unavailable ? "unsupported" : "other",
|
|
152
|
+
message: endedWithout(binary, ended, run.stderr()),
|
|
153
|
+
}));
|
|
145
154
|
if (unavailable)
|
|
146
155
|
throw providerError({
|
|
147
156
|
kind: "unsupported",
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { redact } from "../../kernel/log.js";
|
|
3
3
|
import { providerError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { nodeClaudeCodeModels } from "./claude-code-models.js";
|
|
5
|
+
import { cliLoginError } from "./cli-login-error.js";
|
|
5
6
|
import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
|
|
6
7
|
import { lines } from "./sse-lines.js";
|
|
7
8
|
// The local-agent adapter for Claude Code: spawned non-interactively, authenticated by the
|
|
@@ -95,6 +96,9 @@ export function claudeCodeLlm(deps) {
|
|
|
95
96
|
}
|
|
96
97
|
const result = cliShaped(binary, resultEvent, event.value);
|
|
97
98
|
if (result.is_error === true || result.subtype !== "success") {
|
|
99
|
+
const login = cliLoginError("claude-code", result.result ?? result.subtype);
|
|
100
|
+
if (login)
|
|
101
|
+
throw login;
|
|
98
102
|
throw providerError({
|
|
99
103
|
kind: kindOf(result.api_error_status ?? null),
|
|
100
104
|
message: redact(result.result ?? result.subtype),
|
|
@@ -124,6 +128,9 @@ export function claudeCodeLlm(deps) {
|
|
|
124
128
|
// be reported as the provider failing.
|
|
125
129
|
req.signal.throwIfAborted();
|
|
126
130
|
// The stream ended with no result event at all.
|
|
131
|
+
const login = cliLoginError("claude-code", run.stderr());
|
|
132
|
+
if (login)
|
|
133
|
+
throw login;
|
|
127
134
|
throw providerError({
|
|
128
135
|
kind: "other",
|
|
129
136
|
message: ended === undefined
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
2
|
+
export function cliLoginError(provider, text) {
|
|
3
|
+
if (/invalid api key/i.test(text))
|
|
4
|
+
return undefined;
|
|
5
|
+
if (!/not logged in|please (?:run|use)\s+\/login|run\s+codex\s+login|authentication required|(?:access|refresh) token.*(?:expired|could not be refreshed|already used)/i.test(text))
|
|
6
|
+
return undefined;
|
|
7
|
+
const command = provider === "claude-code"
|
|
8
|
+
? "claude auth login"
|
|
9
|
+
: provider === "gemini"
|
|
10
|
+
? "gemini"
|
|
11
|
+
: "codex login";
|
|
12
|
+
return providerError({
|
|
13
|
+
kind: "missing_key",
|
|
14
|
+
message: `${provider}: sign in on the machine running the CLI with ${command}, then review the affected rebuild.`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { redact } from "../../kernel/log.js";
|
|
6
6
|
import { providerError } from "../../kernel/ports/model.js";
|
|
7
|
+
import { cliLoginError } from "./cli-login-error.js";
|
|
7
8
|
import { nodeCodexModels } from "./codex-models.js";
|
|
8
9
|
import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
|
|
9
10
|
import { lines } from "./sse-lines.js";
|
|
@@ -137,12 +138,18 @@ export function codexLlm(deps) {
|
|
|
137
138
|
return;
|
|
138
139
|
}
|
|
139
140
|
if (event.type === "turn.failed") {
|
|
141
|
+
const login = cliLoginError("codex", cliShaped(binary, turnFailed, event.value).error.message);
|
|
142
|
+
if (login)
|
|
143
|
+
throw login;
|
|
140
144
|
throw providerError({
|
|
141
145
|
kind: "other",
|
|
142
146
|
message: redact(cliShaped(binary, turnFailed, event.value).error.message),
|
|
143
147
|
});
|
|
144
148
|
}
|
|
145
149
|
if (event.type === "error") {
|
|
150
|
+
const login = cliLoginError("codex", cliShaped(binary, errorEvent, event.value).message);
|
|
151
|
+
if (login)
|
|
152
|
+
throw login;
|
|
146
153
|
throw providerError({
|
|
147
154
|
kind: "other",
|
|
148
155
|
message: redact(cliShaped(binary, errorEvent, event.value).message),
|
|
@@ -168,6 +175,9 @@ export function codexLlm(deps) {
|
|
|
168
175
|
// be reported as the provider failing.
|
|
169
176
|
req.signal.throwIfAborted();
|
|
170
177
|
// The stream ended with neither a completed turn nor a failure.
|
|
178
|
+
const login = cliLoginError("codex", run.stderr());
|
|
179
|
+
if (login)
|
|
180
|
+
throw login;
|
|
171
181
|
throw providerError({
|
|
172
182
|
kind: "other",
|
|
173
183
|
message: ended === undefined
|
package/dist/edge/cli.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
4
7
|
import { parseArgs } from "node:util";
|
|
5
8
|
import { configFrom } from "../kernel/config/index.js";
|
|
@@ -15,16 +18,49 @@ const { values } = parseArgs({
|
|
|
15
18
|
"data-dir": { type: "string" },
|
|
16
19
|
"no-open": { type: "boolean" },
|
|
17
20
|
docker: { type: "boolean" },
|
|
21
|
+
"host-cli": { type: "string" },
|
|
22
|
+
"accept-host-cli": { type: "boolean" },
|
|
18
23
|
},
|
|
19
24
|
});
|
|
20
25
|
try {
|
|
26
|
+
if ((values["host-cli"] !== undefined || values["accept-host-cli"] !== undefined) &&
|
|
27
|
+
!values.docker)
|
|
28
|
+
throw new Error("Host CLI options require --docker.");
|
|
29
|
+
if (values["host-cli"] !== undefined && values["host-cli"] !== "off")
|
|
30
|
+
throw new Error("The --host-cli override supports only off (API-only Docker).");
|
|
21
31
|
if (values.docker) {
|
|
22
32
|
if (values.host !== undefined || values["data-dir"] !== undefined)
|
|
23
33
|
throw new Error("Docker stores data in the slopify-data volume and binds to localhost. Use --port to change its port.");
|
|
34
|
+
const { prepareDockerHostCli } = await import("./docker.js");
|
|
35
|
+
const { nodeHostSetupRunner } = await import("../host-cli/install.js");
|
|
36
|
+
const bridge = await prepareDockerHostCli({
|
|
37
|
+
root: join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share"), "slopify/host-cli"),
|
|
38
|
+
version: readVersion(),
|
|
39
|
+
image: process.env.SLOPIFY_DOCKER_IMAGE ?? "ghcr.io/gentbajko/slopify:latest",
|
|
40
|
+
disabled: values["host-cli"] === "off",
|
|
41
|
+
accepted: values["accept-host-cli"] === true,
|
|
42
|
+
interactive: process.stdin.isTTY === true,
|
|
43
|
+
env: process.env,
|
|
44
|
+
signal: AbortSignal.timeout(20 * 60_000),
|
|
45
|
+
runner: nodeHostSetupRunner,
|
|
46
|
+
prompt: async (message) => {
|
|
47
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
48
|
+
try {
|
|
49
|
+
return /^(?:y|yes)$/i.test((await terminal.question(message)).trim());
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
terminal.close();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
console.log(bridge.directory
|
|
57
|
+
? "Host CLI helper ready. Existing CLI logins stay on the host."
|
|
58
|
+
: "Starting API-only Docker. Rerun the launcher after installing host CLIs to enable them.");
|
|
24
59
|
const result = spawnSync("bash", [fileURLToPath(new URL("../../scripts/docker-run.sh", import.meta.url))], {
|
|
25
60
|
stdio: "inherit",
|
|
26
61
|
env: {
|
|
27
62
|
...process.env,
|
|
63
|
+
SLOPIFY_HOST_CLI_DIR: bridge.directory ?? "",
|
|
28
64
|
...(values.port === undefined ? {} : { SLOPIFY_DOCKER_HOST_PORT: values.port }),
|
|
29
65
|
},
|
|
30
66
|
});
|