layerx1 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/bin/layerx1.js +3 -0
- package/package.json +50 -0
- package/src/cli.js +332 -0
- package/src/targets.js +310 -0
- package/src/util.js +177 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Layer X1
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# layerx1
|
|
2
|
+
|
|
3
|
+
Point your coding agent at **Layer X1** in one command. Configures
|
|
4
|
+
[Claude Code](https://claude.com/claude-code), [OpenAI Codex CLI](https://developers.openai.com/codex),
|
|
5
|
+
[Aider](https://aider.chat), [Continue](https://continue.dev), [Cline](https://cline.bot),
|
|
6
|
+
[Cursor](https://cursor.com), and [Windsurf](https://windsurf.com) to send their requests
|
|
7
|
+
to the Layer X1 API — one endpoint, one key, one model catalog.
|
|
8
|
+
|
|
9
|
+
Zero dependencies. Needs Node ≥ 18.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# interactive — asks for your key, model, and which tools to set up
|
|
15
|
+
npx layerx1
|
|
16
|
+
|
|
17
|
+
# or non-interactive
|
|
18
|
+
npx layerx1 setup --key lx1_your_key --tool all
|
|
19
|
+
npx layerx1 setup --key lx1_your_key --tool claude-code,codex --model lx1-glm-5.2
|
|
20
|
+
|
|
21
|
+
# verify (reuses the key you just configured — no flag needed)
|
|
22
|
+
npx layerx1 test
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Get an `lx1_` key from your Layer X1 dashboard.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
| Command | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `npx layerx1` / `npx layerx1 init` | Interactive setup. |
|
|
32
|
+
| `npx layerx1 setup --key <k> [--tool <ids>]` | Configure one or more tools non-interactively. |
|
|
33
|
+
| `npx layerx1 status` | Show which tools are pointed at Layer X1. |
|
|
34
|
+
| `npx layerx1 models [--url <u>]` | List the model catalog with pricing. |
|
|
35
|
+
| `npx layerx1 test [--key <k>]` | Send one request to verify everything works. Without `--key` it reuses `LAYERX1_API_KEY` or the key already written to Claude Code's settings. |
|
|
36
|
+
| `npx layerx1 unset [--tool <ids>]` | Remove Layer X1 config (previous files are kept as timestamped backups). |
|
|
37
|
+
| `npx layerx1 --version` | Print the CLI version. |
|
|
38
|
+
|
|
39
|
+
### Flags
|
|
40
|
+
|
|
41
|
+
| Flag | Default |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `--key <k>` | — (required for `setup`) |
|
|
44
|
+
| `--url <u>` | `https://layerx1-gateway.orbitweb00.workers.dev` |
|
|
45
|
+
| `--model <m>` | `lx1-gpt-oss-120b` |
|
|
46
|
+
| `--small-model <m>` | `lx1-glm-4.7-flash` |
|
|
47
|
+
| `--tool <ids>` | comma-separated: `claude-code,codex,aider,continue,cline,cursor,windsurf` or `all` |
|
|
48
|
+
| `--print` | Print the config instead of writing files. |
|
|
49
|
+
| `--persist-key` / `--no-persist-key` | Save `LAYERX1_API_KEY` for new shells without asking / never touch env or shell profile. |
|
|
50
|
+
| `--home <dir>` | Write configs under `<dir>` instead of your home directory (sandboxes, tests). Also settable via `LAYERX1_HOME`. |
|
|
51
|
+
|
|
52
|
+
Model ids are always `lx1-*` — run `npx layerx1 models` for the full catalog.
|
|
53
|
+
|
|
54
|
+
## What it writes
|
|
55
|
+
|
|
56
|
+
| Tool | File / step | Notes |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| **Claude Code** | `~/.claude/settings.json` (`env` block) | `ANTHROPIC_BASE_URL` (origin, no `/v1`), `ANTHROPIC_AUTH_TOKEN`, model tiers. Run `/logout` first if you were logged in. |
|
|
59
|
+
| **Codex CLI** | `~/.codex/config.toml` | Top-level keys (`model`, `model_provider`, context window) at the head of the file, the `[model_providers.layerx1]` table at the end — your own keys are never disturbed. `wire_api="responses"` → Layer X1's `/v1/responses`. Key via `LAYERX1_API_KEY` (see below). |
|
|
60
|
+
| **Aider** | `~/.aider.conf.yml` | OpenAI-compatible route (`model: openai/<m>`, `openai-api-base`, key). |
|
|
61
|
+
| **Continue** | `~/.continue/config.yaml` | Adds an `openai`-provider model with your `apiBase`. (Prints the entry to merge if a config already exists.) |
|
|
62
|
+
| **Cline** | prints values | GUI-only — paste Base URL / API Key / Model into its settings panel. |
|
|
63
|
+
| **Cursor** | prints steps | GUI-only — Settings → Models: paste your key as the OpenAI API key, enable *Override OpenAI Base URL* → `<gateway>/v1`, add `lx1-*` model ids. |
|
|
64
|
+
| **Windsurf** | prints steps | GUI-only — add an OpenAI-compatible provider with the same Base URL / key / model. |
|
|
65
|
+
|
|
66
|
+
Existing files are backed up to `<file>.layerx1.bak-<timestamp>` before any change. File
|
|
67
|
+
configs use managed blocks (`# >>> layerx1 … <<<`) so re-running is idempotent and
|
|
68
|
+
`unset` cleanly removes only our additions.
|
|
69
|
+
|
|
70
|
+
## Your key for Codex (`LAYERX1_API_KEY`)
|
|
71
|
+
|
|
72
|
+
Codex reads its API key from the environment. After `setup`, the CLI offers to persist it
|
|
73
|
+
for you (skip with `--no-persist-key`, force with `--persist-key`):
|
|
74
|
+
|
|
75
|
+
- **Windows (PowerShell)** — saved to your User environment, so every new terminal has it:
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
[Environment]::SetEnvironmentVariable('LAYERX1_API_KEY','lx1_your_key','User')
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **macOS / Linux** — appended to your shell profile (`~/.zshrc`, `~/.bashrc`, or
|
|
82
|
+
`~/.config/fish/config.fish`, detected from `$SHELL`) inside a managed block that
|
|
83
|
+
`npx layerx1 unset` removes again:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export LAYERX1_API_KEY="lx1_your_key"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Open a new terminal (or `source` your profile) afterwards.
|
|
90
|
+
|
|
91
|
+
## Notes
|
|
92
|
+
|
|
93
|
+
- **Claude Code** uses the host root for `ANTHROPIC_BASE_URL` (the SDK appends `/v1/messages`); every other tool uses the `/v1` base. The installer derives both from `--url` automatically.
|
|
94
|
+
- **Codex** requires the OpenAI **Responses** API — Layer X1 serves `/v1/responses`, so it works out of the box.
|
|
95
|
+
- `npx layerx1 test` uses `max_tokens: 512` so models that think before they answer still return visible text.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/bin/layerx1.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "layerx1",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Point your coding agent (Claude Code, Codex, Aider, Continue, Cline, Cursor, Windsurf) at Layer X1 in one command.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"layerx1": "bin/layerx1.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node bin/layerx1.js",
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"prepublishOnly": "node --test"
|
|
22
|
+
},
|
|
23
|
+
"author": "Layer X1",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/ATOM00blue/layerx1-cli.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://layerx1.vercel.app",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/ATOM00blue/layerx1-cli/issues"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"layerx1",
|
|
34
|
+
"llm",
|
|
35
|
+
"gateway",
|
|
36
|
+
"ai",
|
|
37
|
+
"cli",
|
|
38
|
+
"installer",
|
|
39
|
+
"claude-code",
|
|
40
|
+
"codex",
|
|
41
|
+
"aider",
|
|
42
|
+
"continue",
|
|
43
|
+
"cline",
|
|
44
|
+
"cursor",
|
|
45
|
+
"windsurf",
|
|
46
|
+
"openai",
|
|
47
|
+
"anthropic"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT"
|
|
50
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// `layerx1` — point your coding agent at the Layer X1 gateway in one command.
|
|
2
|
+
// npx layerx1 interactive setup
|
|
3
|
+
// npx layerx1 setup --tool claude-code --key lx1_... [--url ...] [--model ...]
|
|
4
|
+
// npx layerx1 status | models | test | unset --tool <id>
|
|
5
|
+
"use strict";
|
|
6
|
+
const U = require("./util");
|
|
7
|
+
const { TARGETS, byId } = require("./targets");
|
|
8
|
+
const pkg = require("../package.json");
|
|
9
|
+
|
|
10
|
+
const DEFAULT_URL = "https://layerx1-gateway.orbitweb00.workers.dev";
|
|
11
|
+
// Catalog defaults — MUST be canonical lx1-* ids from GET /v1/models (the gateway serves
|
|
12
|
+
// its own default for an unknown id, silently — a typo here would misconfigure every user).
|
|
13
|
+
const DEFAULT_MODEL = "lx1-gpt-oss-120b"; // the gateway default — fast, tool-capable
|
|
14
|
+
const DEFAULT_SMALL = "lx1-glm-4.7-flash"; // quick background/small-task model
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const flags = {};
|
|
18
|
+
const positional = [];
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (a.startsWith("--")) {
|
|
22
|
+
const eq = a.indexOf("=");
|
|
23
|
+
if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
24
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) flags[a.slice(2)] = argv[++i];
|
|
25
|
+
else flags[a.slice(2)] = true;
|
|
26
|
+
} else positional.push(a);
|
|
27
|
+
}
|
|
28
|
+
return { cmd: positional[0], rest: positional.slice(1), flags };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ctxFrom(flags, { url, key, model, smallModel } = {}) {
|
|
32
|
+
const origin = U.normalizeOrigin(url || flags.url || DEFAULT_URL);
|
|
33
|
+
return {
|
|
34
|
+
origin,
|
|
35
|
+
v1: U.withV1(origin),
|
|
36
|
+
key: key || flags.key || flags.token || "",
|
|
37
|
+
model: model || flags.model || DEFAULT_MODEL,
|
|
38
|
+
smallModel: smallModel || flags["small-model"] || DEFAULT_SMALL,
|
|
39
|
+
print: !!flags.print,
|
|
40
|
+
// Where tool configs live. Overridable (--home / LAYERX1_HOME) so tests and sandboxes
|
|
41
|
+
// can exercise every writer against a scratch dir instead of the real home directory.
|
|
42
|
+
home: (typeof flags.home === "string" && flags.home) || process.env.LAYERX1_HOME || U.HOME,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function banner() {
|
|
47
|
+
U.log(U.c.bold(`\n Layer X1 ${U.c.dim("· configure your coding agent")}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printResult(t, res) {
|
|
51
|
+
if (res.error) return U.err(`${t.label}: ${res.error}`);
|
|
52
|
+
if (res.noop) {
|
|
53
|
+
U.info(`${t.label}: nothing to remove`);
|
|
54
|
+
} else if (res.wrote) {
|
|
55
|
+
U.ok(`${t.label} → wrote ${U.c.cyan(res.wrote)}`);
|
|
56
|
+
if (res.backup) U.info(`backed up previous file to ${U.c.dim(res.backup)}`);
|
|
57
|
+
} else if (res.printed) {
|
|
58
|
+
U.warn(`${t.label}: manual step —`);
|
|
59
|
+
U.log("\n" + res.printed.split("\n").map((l) => " " + l).join("\n") + "\n");
|
|
60
|
+
}
|
|
61
|
+
for (const n of res.notes || []) U.info(n);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveTools(sel) {
|
|
65
|
+
if (!sel || sel === "all") return TARGETS;
|
|
66
|
+
const ids = String(sel).split(",").map((s) => s.trim()).filter(Boolean);
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const id of ids) {
|
|
69
|
+
if (byId[id]) out.push(byId[id]);
|
|
70
|
+
else U.warn(`unknown tool "${id}" (known: ${TARGETS.map((t) => t.id).join(", ")})`);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- key persistence ---------------------------------------------------------
|
|
76
|
+
// Codex (and anything else reading LAYERX1_API_KEY) needs the key in the ENVIRONMENT, not
|
|
77
|
+
// a file we write — so after setup we OFFER to persist it, per-OS, and never force it:
|
|
78
|
+
// win32 → the User environment via PowerShell (survives new terminals, no profile edit)
|
|
79
|
+
// POSIX → a managed block appended to the detected shell profile (unset removes it)
|
|
80
|
+
// Explicit --persist-key / --no-persist-key decide non-interactively; otherwise we only
|
|
81
|
+
// prompt on a real TTY (CI/pipes get the printed guidance from the codex notes instead).
|
|
82
|
+
function escapePwshSingle(s) {
|
|
83
|
+
return String(s).replace(/'/g, "''");
|
|
84
|
+
}
|
|
85
|
+
function escapeShDouble(s) {
|
|
86
|
+
return String(s).replace(/(["\\$`])/g, "\\$1");
|
|
87
|
+
}
|
|
88
|
+
async function offerPersistKey(ctx, tools, flags) {
|
|
89
|
+
if (ctx.print || !ctx.key) return;
|
|
90
|
+
if (!tools.some((t) => t.id === "codex")) return; // only codex consumes LAYERX1_API_KEY
|
|
91
|
+
const declined = flags["no-persist-key"] === true || flags["persist-key"] === "false";
|
|
92
|
+
if (declined) return;
|
|
93
|
+
const forced = flags["persist-key"] === true || flags["persist-key"] === "true";
|
|
94
|
+
if (!forced) {
|
|
95
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return; // guidance already printed
|
|
96
|
+
const where =
|
|
97
|
+
process.platform === "win32" ? "your User environment" : U.shellProfile(ctx.home);
|
|
98
|
+
const a = await U.prompt(`Persist LAYERX1_API_KEY to ${where} now? (Y/n)`, "Y");
|
|
99
|
+
if (!/^y/i.test(a)) return;
|
|
100
|
+
}
|
|
101
|
+
if (process.platform === "win32") {
|
|
102
|
+
// Array-args spawn (no shell string interpolation) + single-quote escaping: the key
|
|
103
|
+
// never passes through a shell parser.
|
|
104
|
+
const { spawnSync } = require("child_process");
|
|
105
|
+
const r = spawnSync(
|
|
106
|
+
"powershell.exe",
|
|
107
|
+
[
|
|
108
|
+
"-NoProfile",
|
|
109
|
+
"-NonInteractive",
|
|
110
|
+
"-Command",
|
|
111
|
+
`[Environment]::SetEnvironmentVariable('LAYERX1_API_KEY','${escapePwshSingle(ctx.key)}','User')`,
|
|
112
|
+
],
|
|
113
|
+
{ stdio: "ignore" },
|
|
114
|
+
);
|
|
115
|
+
if (r.status === 0) U.ok("LAYERX1_API_KEY saved to your User environment — open a NEW terminal to pick it up.");
|
|
116
|
+
else U.err("could not set the User environment variable — run the PowerShell command above manually.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const profile = U.shellProfile(ctx.home);
|
|
120
|
+
const line = /config\.fish$/.test(profile)
|
|
121
|
+
? `set -gx LAYERX1_API_KEY "${escapeShDouble(ctx.key)}"`
|
|
122
|
+
: `export LAYERX1_API_KEY="${escapeShDouble(ctx.key)}"`;
|
|
123
|
+
const next = U.upsertManagedBlock(U.readFileSafe(profile) || "", line, { label: "env", position: "end" });
|
|
124
|
+
U.writeFileSafe(profile, next);
|
|
125
|
+
U.ok(`LAYERX1_API_KEY added to ${U.c.cyan(profile)} — restart your shell (or source it) to pick it up.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Undo of offerPersistKey, run by `unset`: our profile block is marker-identified so it is
|
|
129
|
+
* safe to remove automatically; the win32 User-env var may predate us, so only print how. */
|
|
130
|
+
function unsetPersistedKey(ctx) {
|
|
131
|
+
if (process.platform === "win32") {
|
|
132
|
+
U.info(
|
|
133
|
+
`If you persisted LAYERX1_API_KEY, clear it with: [Environment]::SetEnvironmentVariable('LAYERX1_API_KEY',$null,'User')`,
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const profile = U.shellProfile(ctx.home);
|
|
138
|
+
const content = U.readFileSafe(profile);
|
|
139
|
+
if (content && U.hasManagedBlock(content, "env")) {
|
|
140
|
+
U.writeFileSafe(profile, U.removeManagedBlock(content));
|
|
141
|
+
U.ok(`removed LAYERX1_API_KEY from ${U.c.cyan(profile)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The key `test` should use, in trust order: explicit --key → LAYERX1_API_KEY env → the key
|
|
146
|
+
* setup already wrote into Claude Code's settings. The settings token is reused ONLY when
|
|
147
|
+
* its base URL matches the gateway being tested — never send a token for one host to another. */
|
|
148
|
+
function resolveKey(ctx) {
|
|
149
|
+
if (ctx.key) return { key: ctx.key, source: "--key" };
|
|
150
|
+
if (process.env.LAYERX1_API_KEY) return { key: process.env.LAYERX1_API_KEY, source: "LAYERX1_API_KEY" };
|
|
151
|
+
const cfg = U.readJsonSafe(U.path.join(ctx.home, ".claude", "settings.json"));
|
|
152
|
+
const env = cfg && cfg.env;
|
|
153
|
+
if (env && env.ANTHROPIC_AUTH_TOKEN && U.normalizeOrigin(env.ANTHROPIC_BASE_URL) === ctx.origin) {
|
|
154
|
+
return { key: env.ANTHROPIC_AUTH_TOKEN, source: "~/.claude/settings.json" };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function cmdSetup(flags, toolsSel) {
|
|
160
|
+
const ctx = ctxFrom(flags);
|
|
161
|
+
if (!ctx.key) return U.err("missing --key (your lx1_ gateway key). Get one from the dashboard.");
|
|
162
|
+
const tools = resolveTools(toolsSel || flags.tool);
|
|
163
|
+
if (!tools.length) return U.err("no tools selected (use --tool claude-code,codex,aider,continue,cline,cursor,windsurf or all)");
|
|
164
|
+
U.head(`Configuring ${tools.length} tool(s) → ${U.c.cyan(ctx.origin)} model=${U.c.cyan(ctx.model)}`);
|
|
165
|
+
for (const t of tools) printResult(t, t.apply(ctx));
|
|
166
|
+
await offerPersistKey(ctx, tools, flags);
|
|
167
|
+
U.log("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function cmdInit(flags) {
|
|
171
|
+
banner();
|
|
172
|
+
U.log(U.c.dim(" Press enter to accept the [default].\n"));
|
|
173
|
+
const url = await U.prompt("Gateway URL:", U.normalizeOrigin(flags.url || DEFAULT_URL));
|
|
174
|
+
let key = flags.key || "";
|
|
175
|
+
while (!key) key = await U.prompt("API key (lx1_…):", "");
|
|
176
|
+
const model = await U.prompt("Default model:", flags.model || DEFAULT_MODEL);
|
|
177
|
+
U.head("Which tools? (comma-separated numbers, or 'all')");
|
|
178
|
+
TARGETS.forEach((t, i) => U.log(` ${i + 1}. ${t.label} ${U.c.dim("· " + t.protocol)}`));
|
|
179
|
+
const pick = await U.prompt("Selection:", "all");
|
|
180
|
+
let tools;
|
|
181
|
+
if (pick.trim() === "all" || !pick.trim()) tools = TARGETS;
|
|
182
|
+
else
|
|
183
|
+
tools = pick
|
|
184
|
+
.split(",")
|
|
185
|
+
.map((s) => TARGETS[parseInt(s.trim(), 10) - 1])
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
if (!tools.length) return U.err("nothing selected.");
|
|
188
|
+
const ctx = ctxFrom(flags, { url, key, model });
|
|
189
|
+
U.head(`Configuring → ${U.c.cyan(ctx.origin)} model=${U.c.cyan(ctx.model)}`);
|
|
190
|
+
for (const t of tools) printResult(t, t.apply(ctx));
|
|
191
|
+
await offerPersistKey(ctx, tools, flags);
|
|
192
|
+
U.log("\n " + U.c.green("Done.") + " Run `npx layerx1 test` to verify the gateway.\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function cmdStatus(flags) {
|
|
196
|
+
const ctx = ctxFrom(flags);
|
|
197
|
+
U.head(`Layer X1 config status ${U.c.dim("(gateway " + ctx.origin + ")")}`);
|
|
198
|
+
for (const t of TARGETS) {
|
|
199
|
+
const s = t.status(ctx);
|
|
200
|
+
const mark = s.configured === true ? U.c.green("✓") : s.configured === null ? U.c.yellow("?") : U.c.dim("·");
|
|
201
|
+
U.log(` ${mark} ${t.label.padEnd(30)} ${U.c.dim(s.detail)}`);
|
|
202
|
+
}
|
|
203
|
+
U.log("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function cmdModels(flags) {
|
|
207
|
+
const ctx = ctxFrom(flags);
|
|
208
|
+
const url = ctx.v1 + "/models";
|
|
209
|
+
U.head(`Models @ ${U.c.cyan(url)}`);
|
|
210
|
+
try {
|
|
211
|
+
const r = await fetch(url);
|
|
212
|
+
if (!r.ok) return U.err(`GET /v1/models → ${r.status}`);
|
|
213
|
+
const data = (await r.json()).data || [];
|
|
214
|
+
U.ok(`${data.length} models`);
|
|
215
|
+
for (const m of data) {
|
|
216
|
+
// Pad the RAW strings, then colorize: ANSI escapes count toward .length, so padding
|
|
217
|
+
// a colored string under-pads it and the columns drift.
|
|
218
|
+
const name = String(m.id || "").padEnd(28);
|
|
219
|
+
const extras = m.aliases && m.aliases.length ? `(${m.aliases.join(", ")}) ` : "";
|
|
220
|
+
const tier = String(m.tier || "").padEnd(16);
|
|
221
|
+
const price = m.pricing_usd_per_mtok || {};
|
|
222
|
+
const priceStr =
|
|
223
|
+
price.input != null ? `$${price.input}/$${price.output} per Mtok` : "";
|
|
224
|
+
U.log(` ${U.c.cyan(name)} ${U.c.dim(tier)} ${U.c.dim(extras + priceStr)}`);
|
|
225
|
+
}
|
|
226
|
+
U.log("");
|
|
227
|
+
} catch (e) {
|
|
228
|
+
U.err(`could not reach ${url}: ${e.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function cmdTest(flags) {
|
|
233
|
+
const ctx = ctxFrom(flags);
|
|
234
|
+
const found = resolveKey(ctx);
|
|
235
|
+
if (!found) return U.err("no key found — pass --key, set LAYERX1_API_KEY, or run `npx layerx1 setup` first.");
|
|
236
|
+
if (found.source !== "--key") U.info(`using the key from ${found.source}`);
|
|
237
|
+
const url = ctx.v1 + "/messages";
|
|
238
|
+
U.head(`Test → ${U.c.cyan(url)} model=${U.c.cyan(ctx.model)}`);
|
|
239
|
+
try {
|
|
240
|
+
const t0 = Date.now();
|
|
241
|
+
const r = await fetch(url, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "content-type": "application/json", "x-api-key": found.key },
|
|
244
|
+
body: JSON.stringify({
|
|
245
|
+
// 512, not less: reasoning models spend output budget on hidden thinking before any
|
|
246
|
+
// visible text — a smaller cap can come back 200-with-empty-content and look broken.
|
|
247
|
+
model: ctx.model,
|
|
248
|
+
max_tokens: 512,
|
|
249
|
+
messages: [{ role: "user", content: "Reply with exactly: pong" }],
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
const served = r.headers.get("x1-model");
|
|
253
|
+
const body = await r.json();
|
|
254
|
+
if (!r.ok) return U.err(`${r.status} ${JSON.stringify(body).slice(0, 200)}`);
|
|
255
|
+
const text = (body.content || []).map((b) => b.text || "").join("").trim();
|
|
256
|
+
U.ok(`200 OK model=${U.c.cyan(served || body.model || "?")} ${Date.now() - t0}ms`);
|
|
257
|
+
U.info(`reply: ${JSON.stringify(text).slice(0, 80)}`);
|
|
258
|
+
U.log("");
|
|
259
|
+
} catch (e) {
|
|
260
|
+
U.err(`request failed: ${e.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function cmdUnset(flags, toolsSel) {
|
|
265
|
+
const ctx = ctxFrom(flags);
|
|
266
|
+
const tools = resolveTools(toolsSel || flags.tool || "all");
|
|
267
|
+
U.head(`Removing Layer X1 config from ${tools.length} tool(s)`);
|
|
268
|
+
for (const t of tools) printResult(t, t.unset(ctx));
|
|
269
|
+
if (tools.some((t) => t.id === "codex")) unsetPersistedKey(ctx);
|
|
270
|
+
U.log("");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function help() {
|
|
274
|
+
banner();
|
|
275
|
+
U.log(`
|
|
276
|
+
${U.c.bold("Usage")}
|
|
277
|
+
npx layerx1 interactive setup
|
|
278
|
+
npx layerx1 setup --key <lx1_…> [--tool <ids>] [--url <u>] [--model <m>]
|
|
279
|
+
npx layerx1 status show which tools are configured
|
|
280
|
+
npx layerx1 models [--url <u>] list the model catalog
|
|
281
|
+
npx layerx1 test [--key <k>] send one request to verify (reuses your configured key)
|
|
282
|
+
npx layerx1 unset [--tool <ids>]
|
|
283
|
+
npx layerx1 --version
|
|
284
|
+
|
|
285
|
+
${U.c.bold("Tools")} (--tool, comma-separated, or "all")
|
|
286
|
+
${TARGETS.map((t) => ` ${t.id.padEnd(12)} ${U.c.dim(t.label + " — " + t.protocol)}`).join("\n")}
|
|
287
|
+
|
|
288
|
+
${U.c.bold("Flags")}
|
|
289
|
+
--key <k> your lx1_ gateway API key (required for setup)
|
|
290
|
+
--url <u> gateway URL ${U.c.dim("(default " + DEFAULT_URL + ")")}
|
|
291
|
+
--model <m> default model ${U.c.dim("(default " + DEFAULT_MODEL + ")")}
|
|
292
|
+
--small-model <m> background model ${U.c.dim("(default " + DEFAULT_SMALL + ")")}
|
|
293
|
+
--print show config instead of writing files
|
|
294
|
+
--persist-key save LAYERX1_API_KEY for new shells without asking
|
|
295
|
+
--no-persist-key never touch env/shell profile
|
|
296
|
+
--home <dir> write configs under <dir> instead of your home ${U.c.dim("(sandboxes/tests)")}
|
|
297
|
+
`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function main(argv) {
|
|
301
|
+
const { cmd, rest, flags } = parseArgs(argv);
|
|
302
|
+
if (flags.version || flags.V || cmd === "version") return U.log(pkg.version);
|
|
303
|
+
if (flags.help || flags.h || cmd === "help") return help();
|
|
304
|
+
try {
|
|
305
|
+
switch (cmd) {
|
|
306
|
+
case undefined:
|
|
307
|
+
case "init":
|
|
308
|
+
return await cmdInit(flags);
|
|
309
|
+
case "setup":
|
|
310
|
+
case "configure":
|
|
311
|
+
return await cmdSetup(flags, rest[0]);
|
|
312
|
+
case "status":
|
|
313
|
+
return await cmdStatus(flags);
|
|
314
|
+
case "models":
|
|
315
|
+
return await cmdModels(flags);
|
|
316
|
+
case "test":
|
|
317
|
+
return await cmdTest(flags);
|
|
318
|
+
case "unset":
|
|
319
|
+
case "remove":
|
|
320
|
+
return await cmdUnset(flags, rest[0]);
|
|
321
|
+
default:
|
|
322
|
+
U.err(`unknown command "${cmd}"`);
|
|
323
|
+
return help();
|
|
324
|
+
}
|
|
325
|
+
} catch (e) {
|
|
326
|
+
U.err(e && e.stack ? e.stack : String(e));
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// parseArgs/defaults exported for the test suite — bin/layerx1.js only calls main().
|
|
332
|
+
module.exports = { main, parseArgs, ctxFrom, DEFAULT_URL, DEFAULT_MODEL, DEFAULT_SMALL };
|
package/src/targets.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// One configurator per coding tool. Each returns a result describing what it did:
|
|
2
|
+
// { wrote, backup, notes } — a config file was written (backup path if it replaced one)
|
|
3
|
+
// { printed, notes } — manual step (GUI tool, or an existing config we won't clobber)
|
|
4
|
+
// { error } — couldn't proceed safely
|
|
5
|
+
//
|
|
6
|
+
// ctx = { origin, v1, key, model, smallModel, print, home }
|
|
7
|
+
// origin = gateway root WITHOUT /v1 (Claude Code's ANTHROPIC_BASE_URL — SDK adds /v1/messages)
|
|
8
|
+
// v1 = origin + "/v1" (OpenAI-style tools)
|
|
9
|
+
// home = directory the tool configs live under — os.homedir() in real use; tests point it
|
|
10
|
+
// at a scratch dir so the suite never touches this machine's actual configs.
|
|
11
|
+
"use strict";
|
|
12
|
+
const U = require("./util");
|
|
13
|
+
const path = U.path;
|
|
14
|
+
|
|
15
|
+
// Codex reads its key from the environment, so setup must also get LAYERX1_API_KEY to
|
|
16
|
+
// persist across shells. The exact incantation is per-OS; setup OFFERS to run it (cli.js),
|
|
17
|
+
// these strings are the always-printed fallback guidance.
|
|
18
|
+
function keyPersistNotes(key) {
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
return [
|
|
21
|
+
`Persist your key for new shells (PowerShell): ${U.c.bold(
|
|
22
|
+
`[Environment]::SetEnvironmentVariable('LAYERX1_API_KEY','${key}','User')`,
|
|
23
|
+
)}`,
|
|
24
|
+
`Current shell only: ${U.c.dim(`$env:LAYERX1_API_KEY = '${key}'`)}`,
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
`Persist your key: add ${U.c.bold(`export LAYERX1_API_KEY="${key}"`)} to your shell profile (setup offers to do this for you).`,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MANAGED_KC = [
|
|
33
|
+
"ANTHROPIC_BASE_URL",
|
|
34
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
35
|
+
"ANTHROPIC_MODEL",
|
|
36
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
37
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
38
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
39
|
+
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const claudeCode = {
|
|
43
|
+
id: "claude-code",
|
|
44
|
+
label: "Claude Code",
|
|
45
|
+
protocol: "Anthropic /v1/messages",
|
|
46
|
+
apply(ctx) {
|
|
47
|
+
const p = path.join(ctx.home, ".claude", "settings.json");
|
|
48
|
+
let cfg = U.readJsonSafe(p);
|
|
49
|
+
if (cfg === undefined) return { error: `${p} exists but is not valid JSON — fix/remove it first` };
|
|
50
|
+
cfg = cfg || {};
|
|
51
|
+
// Deep-merge into the existing settings: only OUR env keys are touched — the user's
|
|
52
|
+
// permissions/hooks/other env entries pass through untouched.
|
|
53
|
+
const env = (cfg.env = cfg.env || {});
|
|
54
|
+
env.ANTHROPIC_BASE_URL = ctx.origin; // root, no /v1
|
|
55
|
+
env.ANTHROPIC_AUTH_TOKEN = ctx.key; // Bearer (avoids the "custom API key?" prompt)
|
|
56
|
+
env.ANTHROPIC_MODEL = ctx.model;
|
|
57
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = ctx.model;
|
|
58
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = ctx.model;
|
|
59
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = ctx.smallModel;
|
|
60
|
+
env.ANTHROPIC_SMALL_FAST_MODEL = ctx.smallModel;
|
|
61
|
+
const notes = [];
|
|
62
|
+
if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") {
|
|
63
|
+
notes.push("Unset CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX — they override the base URL.");
|
|
64
|
+
}
|
|
65
|
+
notes.push("If you previously logged in to Claude, run `/logout` once so the gateway key is used.");
|
|
66
|
+
notes.push("Then just run `claude`.");
|
|
67
|
+
if (ctx.print) return { printed: `# ${p}\n${JSON.stringify(cfg, null, 2)}`, notes };
|
|
68
|
+
return { wrote: p, backup: U.writeFileSafe(p, JSON.stringify(cfg, null, 2) + "\n"), notes };
|
|
69
|
+
},
|
|
70
|
+
status(ctx) {
|
|
71
|
+
const cfg = U.readJsonSafe(path.join(ctx.home, ".claude", "settings.json"));
|
|
72
|
+
const base = cfg && cfg.env && cfg.env.ANTHROPIC_BASE_URL;
|
|
73
|
+
return { configured: !!base, detail: base ? `ANTHROPIC_BASE_URL=${base}` : "not configured" };
|
|
74
|
+
},
|
|
75
|
+
unset(ctx) {
|
|
76
|
+
const p = path.join(ctx.home, ".claude", "settings.json");
|
|
77
|
+
const cfg = U.readJsonSafe(p);
|
|
78
|
+
if (!cfg || !cfg.env) return { noop: true };
|
|
79
|
+
for (const k of MANAGED_KC) delete cfg.env[k];
|
|
80
|
+
return { wrote: p, backup: U.writeFileSafe(p, JSON.stringify(cfg, null, 2) + "\n") };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The slice of a TOML document BEFORE its first [table] header — the only region where a
|
|
85
|
+
* bare `key = value` is top-level. Conflict checks run on this slice, not the whole file:
|
|
86
|
+
* a `model =` inside someone's [profiles.x] table is a DIFFERENT key and must not stop us
|
|
87
|
+
* from writing the real top-level one. */
|
|
88
|
+
function tomlTopLevel(content) {
|
|
89
|
+
return String(content).split(/^\s*\[/m)[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const codex = {
|
|
93
|
+
id: "codex",
|
|
94
|
+
label: "OpenAI Codex CLI",
|
|
95
|
+
protocol: "OpenAI Responses /v1/responses",
|
|
96
|
+
apply(ctx) {
|
|
97
|
+
const p = path.join(ctx.home, ".codex", "config.toml");
|
|
98
|
+
const existing = U.readFileSafe(p) || "";
|
|
99
|
+
// Rebuild from the user's own content (all our blocks stripped, whatever version wrote
|
|
100
|
+
// them) so re-runs are deterministic: head block → user content → provider table at EOF.
|
|
101
|
+
const user = U.removeManagedBlock(existing);
|
|
102
|
+
const top = tomlTopLevel(user);
|
|
103
|
+
const hasModel = /^\s*model\s*=/m.test(top);
|
|
104
|
+
const hasProvider = /^\s*model_provider\s*=/m.test(top);
|
|
105
|
+
const hasCtxWindow = /^\s*model_context_window\s*=/m.test(top);
|
|
106
|
+
const hasCompact = /^\s*model_auto_compact_token_limit\s*=/m.test(top);
|
|
107
|
+
// Top-level keys — MUST precede any [table] header or TOML scopes them into that table.
|
|
108
|
+
// Only keys the user hasn't set themselves (a TOML duplicate key is a hard parse error).
|
|
109
|
+
const head = [];
|
|
110
|
+
if (!hasModel) head.push(`model = "${ctx.model}"`);
|
|
111
|
+
if (!hasProvider) head.push(`model_provider = "layerx1"`);
|
|
112
|
+
// Window/compact parity so Codex compacts before the context limit instead of erroring.
|
|
113
|
+
if (!hasCtxWindow) head.push(`model_context_window = 200000`);
|
|
114
|
+
if (!hasCompact) head.push(`model_auto_compact_token_limit = 160000`);
|
|
115
|
+
const table = [
|
|
116
|
+
`[model_providers.layerx1]`,
|
|
117
|
+
`name = "Layer X1"`,
|
|
118
|
+
`base_url = "${ctx.v1}"`, // Codex base_url INCLUDES /v1
|
|
119
|
+
`env_key = "LAYERX1_API_KEY"`,
|
|
120
|
+
`wire_api = "responses"`, // the only value Codex accepts as of 2026
|
|
121
|
+
].join("\n");
|
|
122
|
+
let next = user;
|
|
123
|
+
if (head.length) next = U.upsertManagedBlock(next, head.join("\n"), { label: "head", position: "head" });
|
|
124
|
+
// The table goes at EOF: comments don't reset TOML table scope, so anywhere else it
|
|
125
|
+
// would capture whatever top-level keys follow it into [model_providers.layerx1].
|
|
126
|
+
next = U.upsertManagedBlock(next, table, { label: "provider", position: "end" });
|
|
127
|
+
const notes = [...keyPersistNotes(ctx.key)];
|
|
128
|
+
if (hasProvider) {
|
|
129
|
+
notes.push(`Your config already sets model_provider — switch it to "layerx1" (or run \`codex -c model_provider=layerx1\`).`);
|
|
130
|
+
}
|
|
131
|
+
notes.push("Codex requires the Responses API — Layer X1 serves /v1/responses.");
|
|
132
|
+
notes.push("Then run `codex`.");
|
|
133
|
+
if (ctx.print) return { printed: `# ${p}\n${next}`, notes };
|
|
134
|
+
return { wrote: p, backup: U.writeFileSafe(p, next), notes };
|
|
135
|
+
},
|
|
136
|
+
status(ctx) {
|
|
137
|
+
const content = U.readFileSafe(path.join(ctx.home, ".codex", "config.toml")) || "";
|
|
138
|
+
const has = content.includes("[model_providers.layerx1]");
|
|
139
|
+
return { configured: has, detail: has ? "model_providers.layerx1 present" : "not configured" };
|
|
140
|
+
},
|
|
141
|
+
unset(ctx) {
|
|
142
|
+
const p = path.join(ctx.home, ".codex", "config.toml");
|
|
143
|
+
const content = U.readFileSafe(p);
|
|
144
|
+
if (!content) return { noop: true };
|
|
145
|
+
return {
|
|
146
|
+
wrote: p,
|
|
147
|
+
backup: U.writeFileSafe(p, U.removeManagedBlock(content)),
|
|
148
|
+
notes: ['If you set model_provider="layerx1" manually, revert it.'],
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const aider = {
|
|
154
|
+
id: "aider",
|
|
155
|
+
label: "Aider",
|
|
156
|
+
protocol: "OpenAI /v1/chat/completions (via LiteLLM)",
|
|
157
|
+
apply(ctx) {
|
|
158
|
+
const p = path.join(ctx.home, ".aider.conf.yml");
|
|
159
|
+
const existing = U.readFileSafe(p) || "";
|
|
160
|
+
const userContent = U.removeManagedBlock(existing);
|
|
161
|
+
const conflict = /^\s*(model|openai-api-base|openai-api-key)\s*:/m.test(userContent);
|
|
162
|
+
const body = [
|
|
163
|
+
`model: openai/${ctx.model}`, // openai/ prefix → LiteLLM OpenAI-compatible route
|
|
164
|
+
`openai-api-base: ${ctx.v1}`,
|
|
165
|
+
`openai-api-key: ${ctx.key}`,
|
|
166
|
+
].join("\n");
|
|
167
|
+
const notes = ["Run `aider` in your repo (add --no-show-model-warnings to silence unknown-model notices)."];
|
|
168
|
+
if (conflict && !U.hasManagedBlock(existing)) {
|
|
169
|
+
return {
|
|
170
|
+
printed: `# merge into ${p}:\n${body}`,
|
|
171
|
+
notes: ["Your ~/.aider.conf.yml already sets model/openai-api-base — merge the above manually to avoid duplicate keys.", ...notes],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const next = U.upsertManagedBlock(existing, body);
|
|
175
|
+
if (ctx.print) return { printed: `# ${p}\n${next}`, notes };
|
|
176
|
+
return { wrote: p, backup: U.writeFileSafe(p, next), notes };
|
|
177
|
+
},
|
|
178
|
+
status(ctx) {
|
|
179
|
+
const content = U.readFileSafe(path.join(ctx.home, ".aider.conf.yml")) || "";
|
|
180
|
+
const has = U.hasManagedBlock(content);
|
|
181
|
+
return { configured: has, detail: has ? "managed block present" : "not configured" };
|
|
182
|
+
},
|
|
183
|
+
unset(ctx) {
|
|
184
|
+
const p = path.join(ctx.home, ".aider.conf.yml");
|
|
185
|
+
const content = U.readFileSafe(p);
|
|
186
|
+
if (!content) return { noop: true };
|
|
187
|
+
return { wrote: p, backup: U.writeFileSafe(p, U.removeManagedBlock(content)) };
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const continueDev = {
|
|
192
|
+
id: "continue",
|
|
193
|
+
label: "Continue (VS Code / JetBrains)",
|
|
194
|
+
protocol: "OpenAI /v1/chat/completions",
|
|
195
|
+
apply(ctx) {
|
|
196
|
+
const p = path.join(ctx.home, ".continue", "config.yaml");
|
|
197
|
+
const existing = U.readFileSafe(p);
|
|
198
|
+
const entry = [
|
|
199
|
+
` - name: Layer X1 (${ctx.model})`,
|
|
200
|
+
` provider: openai`,
|
|
201
|
+
` model: ${ctx.model}`,
|
|
202
|
+
` apiBase: ${ctx.v1}`,
|
|
203
|
+
` apiKey: ${ctx.key}`,
|
|
204
|
+
` roles: [chat, edit, apply]`,
|
|
205
|
+
].join("\n");
|
|
206
|
+
const fresh = `name: Layer X1\nversion: 0.0.1\nschema: v1\nmodels:\n${entry}\n`;
|
|
207
|
+
const notes = ["Reload VS Code (or the Continue extension) to pick up the model."];
|
|
208
|
+
if (existing == null) {
|
|
209
|
+
if (ctx.print) return { printed: `# ${p}\n${fresh}`, notes };
|
|
210
|
+
return { wrote: p, backup: U.writeFileSafe(p, fresh), notes };
|
|
211
|
+
}
|
|
212
|
+
// A config already exists — merging into a YAML list without a parser risks corruption,
|
|
213
|
+
// so print the entry for the user to add under their existing `models:` list.
|
|
214
|
+
return {
|
|
215
|
+
printed: `# add under the existing 'models:' list in ${p}:\n${entry}`,
|
|
216
|
+
notes: ["You already have a Continue config — add the entry above under its `models:` list.", ...notes],
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
status(ctx) {
|
|
220
|
+
const content = U.readFileSafe(path.join(ctx.home, ".continue", "config.yaml")) || "";
|
|
221
|
+
const has = content.includes("Layer X1");
|
|
222
|
+
return { configured: has, detail: has ? "Layer X1 model present" : "not configured" };
|
|
223
|
+
},
|
|
224
|
+
unset() {
|
|
225
|
+
return { noop: true, notes: ["Remove the 'Layer X1' entry from ~/.continue/config.yaml manually."] };
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const cline = {
|
|
230
|
+
id: "cline",
|
|
231
|
+
label: "Cline (VS Code)",
|
|
232
|
+
protocol: "OpenAI /v1/chat/completions",
|
|
233
|
+
apply(ctx) {
|
|
234
|
+
const printed = [
|
|
235
|
+
`Cline is configured in its UI. Open the Cline panel → gear (Settings) →`,
|
|
236
|
+
`API Provider: "OpenAI Compatible", then set:`,
|
|
237
|
+
``,
|
|
238
|
+
` Base URL: ${ctx.v1}`,
|
|
239
|
+
` API Key: ${ctx.key}`,
|
|
240
|
+
` Model ID: ${ctx.model}`,
|
|
241
|
+
].join("\n");
|
|
242
|
+
return { printed, notes: ["Cline is GUI-configured — paste the values above into its settings panel."] };
|
|
243
|
+
},
|
|
244
|
+
status() {
|
|
245
|
+
return { configured: null, detail: "GUI-configured (cannot be auto-detected)" };
|
|
246
|
+
},
|
|
247
|
+
unset() {
|
|
248
|
+
return { noop: true, notes: ['Remove the "OpenAI Compatible" provider in Cline’s settings panel.'] };
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Cursor stores model settings inside the app (encrypted database, no config file we could
|
|
253
|
+
// safely write) — so this target is print-only: exact clicks + the values to paste.
|
|
254
|
+
const cursor = {
|
|
255
|
+
id: "cursor",
|
|
256
|
+
label: "Cursor",
|
|
257
|
+
protocol: "OpenAI /v1/chat/completions",
|
|
258
|
+
apply(ctx) {
|
|
259
|
+
const printed = [
|
|
260
|
+
`Cursor is configured in its UI (its settings store is encrypted — no file to write):`,
|
|
261
|
+
``,
|
|
262
|
+
` 1. Cursor Settings (Ctrl/Cmd+Shift+J) → Models`,
|
|
263
|
+
` 2. In the OpenAI API Key section, paste your key: ${ctx.key}`,
|
|
264
|
+
` 3. Enable "Override OpenAI Base URL" and set it to: ${ctx.v1}`,
|
|
265
|
+
` 4. Click "+ Add model" and add: ${ctx.model}`,
|
|
266
|
+
` (add any other lx1-* ids you want — see \`npx layerx1 models\`)`,
|
|
267
|
+
` 5. Click "Verify", then pick ${ctx.model} in the model list.`,
|
|
268
|
+
].join("\n");
|
|
269
|
+
return { printed, notes: ["Cursor is GUI-configured — follow the steps above inside Cursor."] };
|
|
270
|
+
},
|
|
271
|
+
status() {
|
|
272
|
+
return { configured: null, detail: "GUI-configured (cannot be auto-detected)" };
|
|
273
|
+
},
|
|
274
|
+
unset() {
|
|
275
|
+
return { noop: true, notes: ['In Cursor Settings → Models, disable "Override OpenAI Base URL" and remove the key.'] };
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Windsurf likewise keeps provider settings in the app — print-only.
|
|
280
|
+
const windsurf = {
|
|
281
|
+
id: "windsurf",
|
|
282
|
+
label: "Windsurf",
|
|
283
|
+
protocol: "OpenAI /v1/chat/completions",
|
|
284
|
+
apply(ctx) {
|
|
285
|
+
const printed = [
|
|
286
|
+
`Windsurf is configured in its UI:`,
|
|
287
|
+
``,
|
|
288
|
+
` 1. Windsurf Settings → Advanced Settings (or the settings panel in Cascade)`,
|
|
289
|
+
` 2. Add an "OpenAI-compatible" provider / enable the OpenAI base-URL override:`,
|
|
290
|
+
``,
|
|
291
|
+
` Base URL: ${ctx.v1}`,
|
|
292
|
+
` API Key: ${ctx.key}`,
|
|
293
|
+
` Model ID: ${ctx.model}`,
|
|
294
|
+
``,
|
|
295
|
+
` (Menu names vary by Windsurf version — look for "custom provider" or "base URL".)`,
|
|
296
|
+
].join("\n");
|
|
297
|
+
return { printed, notes: ["Windsurf is GUI-configured — paste the values above into its settings."] };
|
|
298
|
+
},
|
|
299
|
+
status() {
|
|
300
|
+
return { configured: null, detail: "GUI-configured (cannot be auto-detected)" };
|
|
301
|
+
},
|
|
302
|
+
unset() {
|
|
303
|
+
return { noop: true, notes: ["Remove the custom provider entry in Windsurf's settings."] };
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const TARGETS = [claudeCode, codex, aider, continueDev, cline, cursor, windsurf];
|
|
308
|
+
const byId = Object.fromEntries(TARGETS.map((t) => [t.id, t]));
|
|
309
|
+
|
|
310
|
+
module.exports = { TARGETS, byId };
|
package/src/util.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Shared helpers for the Layer X1 installer. Zero runtime dependencies — pure Node, so
|
|
2
|
+
// `npx layerx1` works with nothing installed.
|
|
3
|
+
"use strict";
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const readline = require("readline");
|
|
8
|
+
|
|
9
|
+
const HOME = os.homedir();
|
|
10
|
+
|
|
11
|
+
// --- colors (disabled when not a TTY or NO_COLOR is set) --------------------
|
|
12
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
13
|
+
const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
14
|
+
const c = {
|
|
15
|
+
bold: wrap("1"),
|
|
16
|
+
dim: wrap("2"),
|
|
17
|
+
red: wrap("31"),
|
|
18
|
+
green: wrap("32"),
|
|
19
|
+
yellow: wrap("33"),
|
|
20
|
+
blue: wrap("34"),
|
|
21
|
+
cyan: wrap("36"),
|
|
22
|
+
gray: wrap("90"),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const log = (s = "") => process.stdout.write(s + "\n");
|
|
26
|
+
const ok = (s) => log(` ${c.green("✓")} ${s}`);
|
|
27
|
+
const info = (s) => log(` ${c.cyan("•")} ${s}`);
|
|
28
|
+
const warn = (s) => log(` ${c.yellow("!")} ${s}`);
|
|
29
|
+
const err = (s) => log(` ${c.red("✗")} ${s}`);
|
|
30
|
+
const head = (s) => log(`\n${c.bold(s)}`);
|
|
31
|
+
|
|
32
|
+
// --- URL handling -----------------------------------------------------------
|
|
33
|
+
// Accept a gateway URL in any form and produce both the ORIGIN (no /v1, for the
|
|
34
|
+
// Anthropic SDK which appends /v1/messages) and the /v1 base (for OpenAI-style tools).
|
|
35
|
+
function normalizeOrigin(url) {
|
|
36
|
+
let u = String(url || "").trim();
|
|
37
|
+
if (!u) return "";
|
|
38
|
+
if (!/^https?:\/\//i.test(u)) u = "https://" + u;
|
|
39
|
+
u = u.replace(/\/+$/, ""); // trailing slashes
|
|
40
|
+
u = u.replace(/\/v1$/i, ""); // a trailing /v1
|
|
41
|
+
return u;
|
|
42
|
+
}
|
|
43
|
+
const withV1 = (origin) => origin + "/v1";
|
|
44
|
+
|
|
45
|
+
// --- files ------------------------------------------------------------------
|
|
46
|
+
function readFileSafe(p) {
|
|
47
|
+
try {
|
|
48
|
+
return fs.readFileSync(p, "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readJsonSafe(p) {
|
|
54
|
+
const raw = readFileSafe(p);
|
|
55
|
+
if (raw == null) return null;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(raw);
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined; // exists but unparseable — caller must decide
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function ensureDir(p) {
|
|
63
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
/** Back up an existing file to <path>.layerx1.bak-<ts>; returns the backup path or null. */
|
|
66
|
+
function backup(p) {
|
|
67
|
+
if (!fs.existsSync(p)) return null;
|
|
68
|
+
const bak = `${p}.layerx1.bak-${Date.now()}`;
|
|
69
|
+
fs.copyFileSync(p, bak);
|
|
70
|
+
return bak;
|
|
71
|
+
}
|
|
72
|
+
/** Write content, backing up any existing file first. Best-effort 0600 perms. */
|
|
73
|
+
function writeFileSafe(p, content) {
|
|
74
|
+
ensureDir(p);
|
|
75
|
+
const bak = backup(p);
|
|
76
|
+
fs.writeFileSync(p, content);
|
|
77
|
+
try {
|
|
78
|
+
fs.chmodSync(p, 0o600);
|
|
79
|
+
} catch {
|
|
80
|
+
/* windows / no-op */
|
|
81
|
+
}
|
|
82
|
+
return bak;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- managed-block (for TOML/YAML text configs) -----------------------------
|
|
86
|
+
// Blocks are LABELED (e.g. "head", "provider") because one file may need two of them at
|
|
87
|
+
// different positions. Concretely: TOML comments do NOT reset table scope, so a
|
|
88
|
+
// `[model_providers.layerx1]` table prepended above a user's config would capture their
|
|
89
|
+
// pre-existing TOP-LEVEL keys into our table (silent config corruption). The fix is
|
|
90
|
+
// structural: top-level keys go in a "head" block at the FILE HEAD (a block of bare
|
|
91
|
+
// `key = value` lines captures nothing), and table blocks go at EOF (nothing follows
|
|
92
|
+
// them, so nothing can be captured).
|
|
93
|
+
const MARK_PREFIX = "# >>> layerx1";
|
|
94
|
+
const MARK_END = "# <<< layerx1 <<<";
|
|
95
|
+
const markStart = (label) =>
|
|
96
|
+
`${MARK_PREFIX}${label ? ":" + label : ""} (managed block — do not edit between markers) >>>`;
|
|
97
|
+
const MARK_START = markStart(""); // unlabeled form — kept for pre-0.2.0 files in the wild
|
|
98
|
+
function escapeRe(s) {
|
|
99
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
}
|
|
101
|
+
/** Matches EVERY layerx1 block regardless of label — including pre-0.2.0 unlabeled ones. */
|
|
102
|
+
const ANY_BLOCK_RE = () =>
|
|
103
|
+
new RegExp(`${escapeRe(MARK_PREFIX)}[^\\n]*>>>\\n[\\s\\S]*?${escapeRe(MARK_END)}\\n?`, "g");
|
|
104
|
+
const blockRe = (label) =>
|
|
105
|
+
new RegExp(`${escapeRe(markStart(label))}\\n[\\s\\S]*?${escapeRe(MARK_END)}\\n?`, "g");
|
|
106
|
+
function hasManagedBlock(content, label = "") {
|
|
107
|
+
return content != null && content.includes(markStart(label));
|
|
108
|
+
}
|
|
109
|
+
/** Insert/replace the block with this label. If it already exists it is swapped in place
|
|
110
|
+
* (idempotent re-runs); otherwise it is placed per `position` — "end" (default, safe for
|
|
111
|
+
* TOML tables) or "head" (top-level keys that must precede any table header). */
|
|
112
|
+
function upsertManagedBlock(existing, body, { label = "", position = "end" } = {}) {
|
|
113
|
+
const block = `${markStart(label)}\n${body.trimEnd()}\n${MARK_END}\n`;
|
|
114
|
+
if (hasManagedBlock(existing, label)) return existing.replace(blockRe(label), block);
|
|
115
|
+
if (!existing || !existing.trim()) return block;
|
|
116
|
+
return position === "head"
|
|
117
|
+
? `${block}\n${existing}`
|
|
118
|
+
: `${existing.replace(/\n*$/, "")}\n\n${block}`;
|
|
119
|
+
}
|
|
120
|
+
/** Strip ALL our blocks (any label, any vintage), leaving only the user's own content. */
|
|
121
|
+
function removeManagedBlock(existing) {
|
|
122
|
+
if (!existing) return existing;
|
|
123
|
+
return existing
|
|
124
|
+
.replace(ANY_BLOCK_RE(), "")
|
|
125
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
126
|
+
.replace(/^\n+/, "");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- shell profile detection (POSIX key persistence) --------------------------
|
|
130
|
+
/** The interactive-shell profile we offer to append LAYERX1_API_KEY to. Detection is by
|
|
131
|
+
* $SHELL basename only — good enough for an OFFERED (never forced) edit, and the managed
|
|
132
|
+
* block makes a wrong guess harmless + removable. */
|
|
133
|
+
function shellProfile(home, shellEnv = process.env.SHELL) {
|
|
134
|
+
const sh = String(shellEnv || "").split("/").pop();
|
|
135
|
+
if (sh === "zsh") return path.join(home, ".zshrc");
|
|
136
|
+
if (sh === "fish") return path.join(home, ".config", "fish", "config.fish");
|
|
137
|
+
return path.join(home, ".bashrc"); // bash and anything unrecognized
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- interactive prompt -----------------------------------------------------
|
|
141
|
+
async function prompt(question, def) {
|
|
142
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
143
|
+
const suffix = def ? c.dim(` [${def}]`) : "";
|
|
144
|
+
const answer = await new Promise((res) => rl.question(` ${question}${suffix} `, res));
|
|
145
|
+
rl.close();
|
|
146
|
+
const v = answer.trim();
|
|
147
|
+
return v || def || "";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
fs,
|
|
152
|
+
path,
|
|
153
|
+
HOME,
|
|
154
|
+
c,
|
|
155
|
+
log,
|
|
156
|
+
ok,
|
|
157
|
+
info,
|
|
158
|
+
warn,
|
|
159
|
+
err,
|
|
160
|
+
head,
|
|
161
|
+
normalizeOrigin,
|
|
162
|
+
withV1,
|
|
163
|
+
readFileSafe,
|
|
164
|
+
readJsonSafe,
|
|
165
|
+
ensureDir,
|
|
166
|
+
backup,
|
|
167
|
+
writeFileSafe,
|
|
168
|
+
MARK_START,
|
|
169
|
+
MARK_END,
|
|
170
|
+
markStart,
|
|
171
|
+
hasManagedBlock,
|
|
172
|
+
upsertManagedBlock,
|
|
173
|
+
removeManagedBlock,
|
|
174
|
+
shellProfile,
|
|
175
|
+
prompt,
|
|
176
|
+
homePath: (...parts) => path.join(HOME, ...parts),
|
|
177
|
+
};
|