klovys99 0.1.0-main.10

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Korbicorp
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,403 @@
1
+ # klovys99
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+
5
+ klovys99 is a local reverse proxy that anonymizes sensitive prompt data before
6
+ forwarding requests to Anthropic or OpenAI APIs.
7
+
8
+ It is designed to sit between coding clients such as Claude Code or Codex and
9
+ their upstream API, replacing detected personal or sensitive values with stable
10
+ pseudonym tokens before the request leaves the machine.
11
+
12
+ ## Features
13
+
14
+ - Local reverse proxy for Anthropic and OpenAI-compatible JSON requests.
15
+ - `npm install` workflow that downloads a prebuilt binary for the current OS
16
+ and architecture and exposes a `klovys99` command.
17
+ - Client configuration helpers for Codex and Claude Code.
18
+ - Built-in deterministic detectors for common PII and sensitive identifiers.
19
+ - Dynamic detector loading from the official Gitleaks and Microsoft Presidio
20
+ rule sources.
21
+ - Optional local LLM extraction through Ollama for contextual names, addresses,
22
+ dates, and vehicle plates.
23
+ - Stable pseudonym tokens for the lifetime of the proxy process.
24
+ - Structured logs with anonymization counters instead of raw prompt values.
25
+ - Disk cache for downloaded external rules to avoid repeated network fetches on
26
+ every startup.
27
+
28
+ ## Requirements
29
+
30
+ - Node.js 18 or newer.
31
+ - Network access on first startup to download the default Gitleaks and Presidio
32
+ rule sources.
33
+ - An Anthropic API key, Claude subscription, or OpenAI API key depending on the
34
+ client you route through Klovys99.
35
+ - Ollama, only when `KLOVIS_LLM_ENABLED=true`.
36
+
37
+ Go 1.25 or newer is only required if you work from a source checkout or build
38
+ release binaries yourself.
39
+
40
+ Check your local tooling:
41
+
42
+ ```sh
43
+ node -v
44
+ npm -v
45
+ go version
46
+ ```
47
+
48
+ Optional LLM mode requires a local Ollama model:
49
+
50
+ ```sh
51
+ ollama --version
52
+ ollama pull mistral
53
+ ```
54
+
55
+ ## Installation
56
+
57
+ From the repository root:
58
+
59
+ ```sh
60
+ npm install
61
+ ```
62
+
63
+ `npm install klovys99` runs a `postinstall` step that downloads the matching
64
+ binary from the GitHub release for the package version into `dist/` and exposes
65
+ the CLI entrypoints `klovys99` and `klovis`. `klovys99` is the preferred name
66
+ and `klovis` remains available for compatibility.
67
+
68
+ Supported prebuilt targets:
69
+
70
+ - macOS `arm64`
71
+ - macOS `x64`
72
+ - Linux `arm64`
73
+ - Linux `x64`
74
+ - Windows `arm64`
75
+ - Windows `x64`
76
+
77
+ For local execution from an unpublished checkout, use:
78
+
79
+ ```sh
80
+ npm install
81
+ npm run cli -- configure claude
82
+ ```
83
+
84
+ If you want the install step to also update your client configuration
85
+ immediately:
86
+
87
+ ```sh
88
+ KLOVIS_CLIENT=claude npm install
89
+ ```
90
+
91
+ Supported values are `codex`, `claude`, and `both`.
92
+
93
+ ## Quick Start
94
+
95
+ Configure one or both clients to point to Klovys99:
96
+
97
+ ```sh
98
+ npx klovys99 configure codex
99
+ npx klovys99 configure claude
100
+ ```
101
+
102
+ The historical command name still works:
103
+
104
+ ```sh
105
+ npx klovis configure claude
106
+ ```
107
+
108
+ From a local checkout that is not published to npm, prefer:
109
+
110
+ ```sh
111
+ npm run cli -- configure claude
112
+ ```
113
+
114
+ Or configure both at once:
115
+
116
+ ```sh
117
+ npx klovys99 configure both
118
+ ```
119
+
120
+ Then start the proxy:
121
+
122
+ ```sh
123
+ npx klovys99 start
124
+ ```
125
+
126
+ By default, Klovys99 listens on `http://127.0.0.1:8080` and exposes these local
127
+ routes:
128
+
129
+ - `http://127.0.0.1:8080/anthropic` for Claude Code and other Anthropic clients
130
+ - `http://127.0.0.1:8080/openai/v1` for Codex and other OpenAI-compatible
131
+ clients
132
+
133
+ The historical unprefixed route still exists and forwards to
134
+ `KLOVIS_TARGET_URL`, which defaults to `https://api.anthropic.com`.
135
+
136
+ ## Client Configuration
137
+
138
+ ### Codex
139
+
140
+ ```sh
141
+ npx klovys99 configure codex
142
+ ```
143
+
144
+ This updates `~/.codex/config.toml` and sets:
145
+
146
+ ```toml
147
+ openai_base_url = "http://127.0.0.1:8080/openai/v1"
148
+ ```
149
+
150
+ ### Claude Code
151
+
152
+ ```sh
153
+ npx klovys99 configure claude
154
+ ```
155
+
156
+ This updates `~/.claude/settings.json` and sets:
157
+
158
+ ```json
159
+ {
160
+ "env": {
161
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8080/anthropic"
162
+ }
163
+ }
164
+ ```
165
+
166
+ If you want another listen URL written into both clients, pass `--base-url`:
167
+
168
+ ```sh
169
+ npx klovys99 configure both --base-url http://127.0.0.1:9090
170
+ ```
171
+
172
+ ## Quick API Checks
173
+
174
+ Anthropic-style request through Klovys99:
175
+
176
+ ```sh
177
+ curl http://127.0.0.1:8080/anthropic/v1/messages \
178
+ -H "x-api-key: $ANTHROPIC_API_KEY" \
179
+ -H "anthropic-version: 2023-06-01" \
180
+ -H "content-type: application/json" \
181
+ -d '{
182
+ "model": "claude-sonnet-4-5",
183
+ "max_tokens": 128,
184
+ "messages": [
185
+ {
186
+ "role": "user",
187
+ "content": "Email Alice at alice@example.com"
188
+ }
189
+ ]
190
+ }'
191
+ ```
192
+
193
+ OpenAI Responses-style request through Klovys99:
194
+
195
+ ```sh
196
+ curl http://127.0.0.1:8080/openai/v1/responses \
197
+ -H "authorization: Bearer $OPENAI_API_KEY" \
198
+ -H "content-type: application/json" \
199
+ -d '{
200
+ "model": "gpt-5",
201
+ "input": "Email Alice at alice@example.com"
202
+ }'
203
+ ```
204
+
205
+ Upstream providers receive the same request shape, with sensitive values
206
+ replaced by pseudonym tokens such as `[EMAIL_1]`.
207
+
208
+ ## How It Works
209
+
210
+ Klovys99 reads each incoming JSON request body, anonymizes supported prompt
211
+ content, then forwards the modified request to the configured upstream.
212
+
213
+ The proxy anonymizes:
214
+
215
+ - every `<session>...</session>` block found anywhere in a JSON request body;
216
+ - text content in prompts, system messages, `<system-reminder>` blocks, text
217
+ file context, and tool results;
218
+ - text document sources where `source.type` is `text`.
219
+
220
+ Structural metadata such as model names, roles, content block types, tool IDs,
221
+ tool names, media types, cache-control values, and base64 document data is left
222
+ unchanged so the upstream request shape remains valid.
223
+
224
+ For a single proxy process, repeated values are mapped to stable tokens. For
225
+ example, the same email address is replaced by the same `[EMAIL_N]` token across
226
+ requests handled by that process.
227
+
228
+ When matches overlap, the detector with the highest priority wins. If priorities
229
+ are equal, the longest match wins.
230
+
231
+ ## Configuration
232
+
233
+ Klovys99 runtime is configured with environment variables.
234
+
235
+ | Variable | Default | Description |
236
+ | --- | --- | --- |
237
+ | `KLOVIS_ADDR` | `127.0.0.1:8080` | Listen address for the local proxy. |
238
+ | `KLOVIS_TARGET_URL` | `https://api.anthropic.com` | Upstream used by legacy unprefixed routes such as `/v1/messages`. |
239
+ | `KLOVIS_ANTHROPIC_TARGET_URL` | `https://api.anthropic.com` | Upstream used by `/anthropic/...` routes. |
240
+ | `KLOVIS_OPENAI_TARGET_URL` | `https://api.openai.com` | Upstream used by `/openai/...` routes. |
241
+ | `KLOVIS_PROXY_DEBUG` | `false` | Enables debug traffic body logging when set to `true`. |
242
+ | `KLOVIS_LOG_TO_FILE` | `false` | Writes logs to `proxy.log` instead of stdout when set to `true`. |
243
+ | `KLOVIS_LLM_ENABLED` | `false` | Enables optional local LLM extraction through Ollama. |
244
+ | `KLOVIS_LLM_URL` | `http://localhost:11434` | Ollama base URL. |
245
+ | `KLOVIS_LLM_MODEL` | `mistral` | Ollama model used for entity extraction. |
246
+ | `KLOVIS_LLM_TIMEOUT` | `30s` | Startup and request timeout for LLM calls. |
247
+ | `KLOVIS_LLM_MAX_CHARS` | `1000` | Maximum input bytes sent to the LLM per chunk. |
248
+ | `KLOVIS_LLM_AUTOSTART` | `false` | Starts `ollama serve` automatically when the Ollama URL is local and not already reachable. |
249
+
250
+ The npm wrapper also honors:
251
+
252
+ | Variable | Description |
253
+ | --- | --- |
254
+ | `KLOVIS_CLIENT` | Client to configure during `npm install`: `codex`, `claude`, or `both`. |
255
+ | `KLOVIS_BASE_URL` | Base URL written by `klovys99 configure` or `npm install` auto-configuration. |
256
+ | `KLOVIS_SKIP_DOWNLOAD` | Skips the prebuilt binary download during `postinstall` when set to `true`. |
257
+ | `KLOVIS_SKIP_BUILD` | Skips the local Go build fallback during `postinstall` when set to `true`. |
258
+ | `KLOVIS_SKIP_CONFIGURE` | Skips client configuration during `postinstall` when set to `true`. |
259
+
260
+ Boolean variables accept only `true` or `false`.
261
+
262
+ ## Logs
263
+
264
+ Klovys99 writes structured application logs to stdout by default. To write logs to
265
+ `proxy.log` instead, enable file logging:
266
+
267
+ ```sh
268
+ KLOVIS_LOG_TO_FILE=true npx klovys99 start
269
+ ```
270
+
271
+ To inspect request bodies before and after anonymization, enable debug logging:
272
+
273
+ ```sh
274
+ KLOVIS_LOG_TO_FILE=true KLOVIS_PROXY_DEBUG=true npx klovys99 start
275
+ ```
276
+
277
+ Use debug mode carefully, because it records both the original incoming request
278
+ body and the anonymized upstream request body in whichever log destination is
279
+ configured.
280
+
281
+ ## Optional LLM Extraction
282
+
283
+ LLM extraction is disabled by default. Enable it with:
284
+
285
+ ```sh
286
+ KLOVIS_LLM_ENABLED=true npx klovys99 start
287
+ ```
288
+
289
+ When enabled, Klovys99 checks the Ollama connection during startup and runs a small
290
+ extraction probe before accepting traffic. If startup verification fails, the
291
+ proxy exits.
292
+
293
+ By default, Klovys99 does not start Ollama for you. Start Ollama separately before
294
+ enabling LLM extraction, or opt in to local autostart:
295
+
296
+ ```sh
297
+ KLOVIS_LLM_ENABLED=true KLOVIS_LLM_AUTOSTART=true npx klovys99 start
298
+ ```
299
+
300
+ Autostart only applies to local Ollama URLs such as `http://localhost:11434` or
301
+ loopback IP addresses. Remote Ollama URLs are never started by Klovys99.
302
+
303
+ Deterministic detectors remain the baseline. LLM matches are added when
304
+ available and have lower priority than deterministic regex, Gitleaks, and
305
+ Presidio matches. If the LLM fails during a request, Klovys99 logs the technical
306
+ error and continues with deterministic anonymization.
307
+
308
+ ## Detectors
309
+
310
+ Klovys99 combines built-in detectors with external rules loaded at startup.
311
+ External rule payloads are cached for 24 hours in the user cache directory under
312
+ `klovys99/external-rules`.
313
+
314
+ | Category | Source | Priority | Description |
315
+ | --- | --- | ---: | --- |
316
+ | `EMAIL` | Built-in / Presidio | 1000 / 600 | Email addresses, normalized in lowercase for stable tokens. |
317
+ | `NIR` | Built-in | 1000 | French social security numbers, including spaced formats and Corsica departments `2A` and `2B`. |
318
+ | `IBAN` | Built-in / Presidio | 1000 / 600 | IBAN-like account identifiers, normalized by removing separators. |
319
+ | `IP` | Built-in / Presidio | 900 / 600 | IPv4 and IPv6 addresses. |
320
+ | `CREDIT_CARD` | Built-in / Presidio | 900 / 600 | Credit card-like digit sequences. |
321
+ | `MAC_ADDRESS` | Built-in / Presidio | 900 / 600 | MAC addresses with `:` or `-` separators. |
322
+ | `PHONE` | Built-in | 700 | French and common international phone numbers. |
323
+ | `DATE` | Built-in / Presidio / LLM | 600 / external / 50 | Conservatively labelled birth dates and supported contextual dates. |
324
+ | `BLOOD_TYPE` | Built-in | 600 | Contextual blood groups such as `Groupe sanguin O+`. |
325
+ | `SECRET` | Gitleaks | 600 | Secrets loaded dynamically from the official Gitleaks config. |
326
+ | `CRYPTO` | Presidio | 600 | Cryptocurrency wallet identifiers loaded from supported Presidio recognizers. |
327
+ | `ADDRESS` | Built-in / LLM | 900 / 700 / 50 | French postal addresses, labelled addresses, and optional contextual LLM matches. |
328
+ | `NAME` | Built-in | 900 | Contextual names following strong French or English cues and form labels. |
329
+ | `FIRST_NAME` | Built-in | 500 | Conservatively labelled first names. |
330
+ | `LAST_NAME` | Built-in | 500 | Conservatively labelled last names. |
331
+ | `NUMERIC_ID` | Built-in | 100 | Generic long numeric IDs. |
332
+ | `REFERENCE_ID` | Built-in | 100 | Labelled alphanumeric references requiring letters and digits. |
333
+ | `PERSON_NAME` | LLM | 50 | Contextual full names found by the local model. |
334
+ | `DATE` | LLM / Presidio | 50 / 600 | Dates tied to identity, family, documents, health, work, or events. |
335
+ | `VEHICLE_PLATE` | LLM | 50 | Vehicle registration plates found by the local model. |
336
+
337
+ ## Claude Code Notes
338
+
339
+ When Claude Code uses a non-first-party `ANTHROPIC_BASE_URL`, some Claude
340
+ features behave differently upstream. In practice:
341
+
342
+ - Remote Control is disabled by Claude Code when the base URL does not point to
343
+ `api.anthropic.com`.
344
+ - Tool search behavior changes when routing through a proxy. If you need
345
+ deferred tool references, set `ENABLE_TOOL_SEARCH=true` in your Claude
346
+ environment because Klovys99 forwards `tool_reference` blocks unchanged.
347
+
348
+ ## Development
349
+
350
+ Clone the repository and install both Node and Go dependencies:
351
+
352
+ ```sh
353
+ git clone https://github.com/Korbicorp/klovys99.git
354
+ cd klovys99
355
+ npm install
356
+ go mod download
357
+ ```
358
+
359
+ Tagged releases build one binary per supported OS and architecture in GitHub
360
+ Actions. If `NPM_TOKEN_KLOVYS` is configured in repository secrets, the same tag
361
+ workflow also publishes the npm package after uploading the release assets.
362
+
363
+ Run the test suites:
364
+
365
+ ```sh
366
+ go test ./...
367
+ node --test npm/test/*.test.js
368
+ ```
369
+
370
+ Run the proxy locally without npm:
371
+
372
+ ```sh
373
+ go run ./cmd/klovys99
374
+ ```
375
+
376
+ Format Go code before submitting changes:
377
+
378
+ ```sh
379
+ gofmt -w ./cmd ./internal
380
+ ```
381
+
382
+ ## Security Notes
383
+
384
+ Klovys99 reduces the amount of sensitive data sent upstream, but it is not a
385
+ formal data-loss-prevention guarantee. Review detector coverage for your own
386
+ threat model before using it with production data.
387
+
388
+ External Gitleaks and Presidio rules are loaded from their upstream repositories
389
+ by default. Cached copies are reused for 24 hours and stale cache entries may be
390
+ used as a fallback if a refresh fails.
391
+
392
+ ## Contributing
393
+
394
+ Issues and pull requests are welcome. For code changes, please include focused
395
+ tests that cover the behavior being changed.
396
+
397
+ Useful checks before opening a pull request:
398
+
399
+ ```sh
400
+ go test ./...
401
+ node --test npm/test/*.test.js
402
+ gofmt -w ./cmd ./internal
403
+ ```
package/npm/cli.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawnSync } = require("node:child_process");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const {
8
+ DEFAULT_BASE_URL,
9
+ configureClients,
10
+ normalizeBaseUrl,
11
+ } = require("./lib/configure");
12
+
13
+ const packageRoot = path.resolve(__dirname, "..");
14
+
15
+ function main(argv) {
16
+ const [command = "start", ...rest] = argv;
17
+ switch (command) {
18
+ case "configure":
19
+ return runConfigure(rest);
20
+ case "start":
21
+ case "serve":
22
+ return runBinary(rest);
23
+ case "help":
24
+ case "--help":
25
+ case "-h":
26
+ printHelp();
27
+ return 0;
28
+ default:
29
+ return runBinary([command, ...rest]);
30
+ }
31
+ }
32
+
33
+ function runConfigure(args) {
34
+ const options = parseConfigureArgs(args);
35
+ const results = configureClients(options);
36
+ for (const result of results) {
37
+ process.stdout.write(
38
+ `Configured ${result.client} to use ${result.baseUrl} via ${result.path}\n`,
39
+ );
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ function parseConfigureArgs(args) {
45
+ let client = process.env.KLOVIS_CLIENT || "";
46
+ let baseUrl = process.env.KLOVIS_BASE_URL || DEFAULT_BASE_URL;
47
+
48
+ for (let index = 0; index < args.length; index += 1) {
49
+ const value = args[index];
50
+ if (value === "--base-url") {
51
+ const next = args[index + 1];
52
+ if (!next) {
53
+ throw new Error("missing value for --base-url");
54
+ }
55
+ baseUrl = next;
56
+ index += 1;
57
+ continue;
58
+ }
59
+ if (value.startsWith("--base-url=")) {
60
+ baseUrl = value.slice("--base-url=".length);
61
+ continue;
62
+ }
63
+ if (!client) {
64
+ client = value;
65
+ continue;
66
+ }
67
+ throw new Error(`unexpected argument ${JSON.stringify(value)}`);
68
+ }
69
+
70
+ if (!client) {
71
+ throw new Error("missing client, expected codex, claude, or both");
72
+ }
73
+
74
+ return {
75
+ client,
76
+ baseUrl: normalizeBaseUrl(baseUrl),
77
+ };
78
+ }
79
+
80
+ function runBinary(args) {
81
+ const binaryPath = resolveBinaryPath();
82
+ const result = spawnSync(binaryPath, args, {
83
+ cwd: process.cwd(),
84
+ stdio: "inherit",
85
+ env: process.env,
86
+ });
87
+
88
+ if (result.error) {
89
+ throw result.error;
90
+ }
91
+
92
+ return result.status || 0;
93
+ }
94
+
95
+ function resolveBinaryPath() {
96
+ const binaryName = process.platform === "win32" ? "klovys99.exe" : "klovys99";
97
+ const binaryPath = path.join(packageRoot, "dist", binaryName);
98
+ if (!fs.existsSync(binaryPath)) {
99
+ throw new Error(
100
+ `missing installed binary at ${binaryPath}. Run npm install again. ` +
101
+ `From a source checkout, you can also build with go build -o dist/${binaryName} ./cmd/klovys99.`,
102
+ );
103
+ }
104
+ return binaryPath;
105
+ }
106
+
107
+ function printHelp() {
108
+ process.stdout.write(`Klovys99
109
+
110
+ Usage:
111
+ klovys99 start
112
+ klovys99 configure codex [--base-url http://127.0.0.1:8080]
113
+ klovys99 configure claude [--base-url http://127.0.0.1:8080]
114
+ klovys99 configure both [--base-url http://127.0.0.1:8080]
115
+ `);
116
+ }
117
+
118
+ try {
119
+ process.exitCode = main(process.argv.slice(2));
120
+ } catch (error) {
121
+ process.stderr.write(`${error.message}\n`);
122
+ process.exitCode = 1;
123
+ }
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ const DEFAULT_BASE_URL = "http://127.0.0.1:8080";
8
+ const KLOVIS_BEGIN_MARKER = "# BEGIN KLOVIS";
9
+ const KLOVIS_END_MARKER = "# END KLOVIS";
10
+
11
+ function normalizeBaseUrl(baseUrl = DEFAULT_BASE_URL) {
12
+ const trimmed = String(baseUrl).trim().replace(/\/+$/, "");
13
+ let parsed;
14
+ try {
15
+ parsed = new URL(trimmed);
16
+ } catch (error) {
17
+ throw new Error(`invalid base URL ${JSON.stringify(baseUrl)}: ${error.message}`);
18
+ }
19
+ if (!parsed.protocol || !parsed.host) {
20
+ throw new Error(`invalid base URL ${JSON.stringify(baseUrl)}: missing protocol or host`);
21
+ }
22
+ return parsed.toString().replace(/\/+$/, "");
23
+ }
24
+
25
+ function codexProxyBaseUrl(baseUrl) {
26
+ return `${normalizeBaseUrl(baseUrl)}/openai/v1`;
27
+ }
28
+
29
+ function claudeProxyBaseUrl(baseUrl) {
30
+ return `${normalizeBaseUrl(baseUrl)}/anthropic`;
31
+ }
32
+
33
+ function configureClients({ client, baseUrl, homeDir = os.homedir() }) {
34
+ switch (client) {
35
+ case "codex":
36
+ return [configureCodex({ baseUrl, homeDir })];
37
+ case "claude":
38
+ return [configureClaude({ baseUrl, homeDir })];
39
+ case "both":
40
+ return [
41
+ configureCodex({ baseUrl, homeDir }),
42
+ configureClaude({ baseUrl, homeDir }),
43
+ ];
44
+ default:
45
+ throw new Error(`unsupported client ${JSON.stringify(client)}`);
46
+ }
47
+ }
48
+
49
+ function configureCodex({ baseUrl, homeDir = os.homedir() }) {
50
+ const configPath = path.join(homeDir, ".codex", "config.toml");
51
+ const desiredBaseUrl = codexProxyBaseUrl(baseUrl);
52
+ const existing = readFileIfExists(configPath);
53
+ const updated = updateCodexConfig(existing, desiredBaseUrl);
54
+ writeTextFile(configPath, updated);
55
+ return {
56
+ client: "codex",
57
+ path: configPath,
58
+ baseUrl: desiredBaseUrl,
59
+ };
60
+ }
61
+
62
+ function configureClaude({ baseUrl, homeDir = os.homedir() }) {
63
+ const configPath = path.join(homeDir, ".claude", "settings.json");
64
+ const desiredBaseUrl = claudeProxyBaseUrl(baseUrl);
65
+ const existing = readFileIfExists(configPath);
66
+ const updated = updateClaudeConfig(existing, desiredBaseUrl);
67
+ writeTextFile(configPath, `${JSON.stringify(updated, null, 2)}\n`);
68
+ return {
69
+ client: "claude",
70
+ path: configPath,
71
+ baseUrl: desiredBaseUrl,
72
+ };
73
+ }
74
+
75
+ function updateCodexConfig(content, desiredBaseUrl) {
76
+ const managedBlock = [
77
+ KLOVIS_BEGIN_MARKER,
78
+ `openai_base_url = ${tomlString(desiredBaseUrl)}`,
79
+ KLOVIS_END_MARKER,
80
+ ].join("\n");
81
+
82
+ if (!content.trim()) {
83
+ return `${managedBlock}\n`;
84
+ }
85
+
86
+ const managedBlockPattern = new RegExp(
87
+ `${escapeRegExp(KLOVIS_BEGIN_MARKER)}[\\s\\S]*?${escapeRegExp(KLOVIS_END_MARKER)}`,
88
+ "m",
89
+ );
90
+ if (managedBlockPattern.test(content)) {
91
+ return ensureTrailingNewline(content.replace(managedBlockPattern, managedBlock));
92
+ }
93
+
94
+ const openAIBaseURLPattern = /^openai_base_url\s*=.*$/m;
95
+ if (openAIBaseURLPattern.test(content)) {
96
+ return ensureTrailingNewline(
97
+ content.replace(openAIBaseURLPattern, `openai_base_url = ${tomlString(desiredBaseUrl)}`),
98
+ );
99
+ }
100
+
101
+ const trimmed = content.replace(/\s+$/, "");
102
+ return `${trimmed}\n\n${managedBlock}\n`;
103
+ }
104
+
105
+ function updateClaudeConfig(content, desiredBaseUrl) {
106
+ let parsed;
107
+ if (content.trim() === "") {
108
+ parsed = {};
109
+ } else {
110
+ try {
111
+ parsed = JSON.parse(content);
112
+ } catch (error) {
113
+ throw new Error(`invalid Claude settings JSON: ${error.message}`);
114
+ }
115
+ }
116
+
117
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
118
+ throw new Error("invalid Claude settings JSON: top-level value must be an object");
119
+ }
120
+
121
+ const env = parsed.env;
122
+ if (env !== undefined && (env === null || Array.isArray(env) || typeof env !== "object")) {
123
+ throw new Error("invalid Claude settings JSON: env must be an object");
124
+ }
125
+
126
+ parsed.env = { ...(env || {}), ANTHROPIC_BASE_URL: desiredBaseUrl };
127
+ return parsed;
128
+ }
129
+
130
+ function readFileIfExists(filePath) {
131
+ if (!fs.existsSync(filePath)) {
132
+ return "";
133
+ }
134
+ return fs.readFileSync(filePath, "utf8");
135
+ }
136
+
137
+ function writeTextFile(filePath, content) {
138
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
139
+ fs.writeFileSync(filePath, content, "utf8");
140
+ }
141
+
142
+ function tomlString(value) {
143
+ return JSON.stringify(String(value));
144
+ }
145
+
146
+ function ensureTrailingNewline(content) {
147
+ return content.endsWith("\n") ? content : `${content}\n`;
148
+ }
149
+
150
+ function escapeRegExp(value) {
151
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152
+ }
153
+
154
+ module.exports = {
155
+ DEFAULT_BASE_URL,
156
+ claudeProxyBaseUrl,
157
+ codexProxyBaseUrl,
158
+ configureClaude,
159
+ configureClients,
160
+ configureCodex,
161
+ normalizeBaseUrl,
162
+ updateClaudeConfig,
163
+ updateCodexConfig,
164
+ };
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const https = require("node:https");
5
+ const path = require("node:path");
6
+ const { pipeline } = require("node:stream/promises");
7
+
8
+ const RELEASE_HOST = "https://github.com";
9
+
10
+ function detectTarget(platform = process.platform, arch = process.arch) {
11
+ const key = `${platform}/${arch}`;
12
+ switch (key) {
13
+ case "darwin/arm64":
14
+ return { os: "darwin", arch: "arm64", extension: "" };
15
+ case "darwin/x64":
16
+ return { os: "darwin", arch: "amd64", extension: "" };
17
+ case "linux/arm64":
18
+ return { os: "linux", arch: "arm64", extension: "" };
19
+ case "linux/x64":
20
+ return { os: "linux", arch: "amd64", extension: "" };
21
+ case "win32/arm64":
22
+ return { os: "windows", arch: "arm64", extension: ".exe" };
23
+ case "win32/x64":
24
+ return { os: "windows", arch: "amd64", extension: ".exe" };
25
+ default:
26
+ throw new Error(
27
+ `unsupported platform ${JSON.stringify(platform)} and architecture ${JSON.stringify(arch)}`,
28
+ );
29
+ }
30
+ }
31
+
32
+ function releaseTag(version) {
33
+ const trimmed = String(version || "").trim();
34
+ if (!trimmed) {
35
+ throw new Error("missing package version");
36
+ }
37
+ return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
38
+ }
39
+
40
+ function normalizedVersion(version) {
41
+ return releaseTag(version).slice(1);
42
+ }
43
+
44
+ function binaryFileName(platform = process.platform) {
45
+ return platform === "win32" ? "klovys99.exe" : "klovys99";
46
+ }
47
+
48
+ function releaseAssetName(version, target) {
49
+ return `klovys99_${normalizedVersion(version)}_${target.os}_${target.arch}${target.extension}`;
50
+ }
51
+
52
+ function releaseAssetUrl(version, target, repository = defaultRepository()) {
53
+ const tag = releaseTag(version);
54
+ const assetName = releaseAssetName(version, target);
55
+ return `${RELEASE_HOST}/${repository.owner}/${repository.name}/releases/download/${tag}/${assetName}`;
56
+ }
57
+
58
+ function readPackageManifest(packageRoot) {
59
+ const manifestPath = path.join(packageRoot, "package.json");
60
+ return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
61
+ }
62
+
63
+ function readPackageVersion(packageRoot) {
64
+ return readPackageManifest(packageRoot).version;
65
+ }
66
+
67
+ function readRepository(packageRoot) {
68
+ const manifest = readPackageManifest(packageRoot);
69
+ const value =
70
+ typeof manifest.repository === "string"
71
+ ? manifest.repository
72
+ : manifest.repository && typeof manifest.repository.url === "string"
73
+ ? manifest.repository.url
74
+ : "";
75
+ return parseRepository(value);
76
+ }
77
+
78
+ function defaultRepository() {
79
+ return {
80
+ owner: "Korbicorp",
81
+ name: "klovys99",
82
+ };
83
+ }
84
+
85
+ function parseRepository(value) {
86
+ const trimmed = String(value || "").trim();
87
+ if (!trimmed) {
88
+ return defaultRepository();
89
+ }
90
+
91
+ const normalized = trimmed
92
+ .replace(/^git\+/, "")
93
+ .replace(/^git@github\.com:/, "https://github.com/")
94
+ .replace(/\.git$/, "");
95
+ const match = normalized.match(/github\.com\/([^/]+)\/([^/]+)$/);
96
+ if (!match) {
97
+ return defaultRepository();
98
+ }
99
+
100
+ return {
101
+ owner: match[1],
102
+ name: match[2],
103
+ };
104
+ }
105
+
106
+ async function installReleaseBinary({
107
+ version,
108
+ packageRoot,
109
+ platform = process.platform,
110
+ arch = process.arch,
111
+ }) {
112
+ const target = detectTarget(platform, arch);
113
+ const binaryPath = path.join(packageRoot, "dist", binaryFileName(platform));
114
+ const assetUrl = releaseAssetUrl(version, target, readRepository(packageRoot));
115
+
116
+ await fs.promises.mkdir(path.dirname(binaryPath), { recursive: true });
117
+ await downloadToFile(assetUrl, binaryPath);
118
+
119
+ if (platform !== "win32") {
120
+ await fs.promises.chmod(binaryPath, 0o755);
121
+ }
122
+
123
+ return {
124
+ assetUrl,
125
+ binaryPath,
126
+ target,
127
+ };
128
+ }
129
+
130
+ async function downloadToFile(url, destinationPath) {
131
+ const tempPath = `${destinationPath}.tmp`;
132
+ try {
133
+ await downloadWithRedirects(url, tempPath, 5);
134
+ await fs.promises.rename(tempPath, destinationPath);
135
+ } catch (error) {
136
+ await fs.promises.rm(tempPath, { force: true });
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ async function downloadWithRedirects(url, destinationPath, redirectsRemaining) {
142
+ const response = await request(url);
143
+
144
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
145
+ if (redirectsRemaining <= 0) {
146
+ response.resume();
147
+ throw new Error(`too many redirects while downloading ${url}`);
148
+ }
149
+ const redirectedUrl = new URL(response.headers.location, url).toString();
150
+ response.resume();
151
+ return downloadWithRedirects(redirectedUrl, destinationPath, redirectsRemaining - 1);
152
+ }
153
+
154
+ if (response.statusCode !== 200) {
155
+ const body = await readResponseBody(response);
156
+ throw new Error(
157
+ `download ${url} failed with status ${response.statusCode}${body ? `: ${body}` : ""}`,
158
+ );
159
+ }
160
+
161
+ const file = fs.createWriteStream(destinationPath, { mode: 0o755 });
162
+ await pipeline(response, file);
163
+ }
164
+
165
+ function request(url) {
166
+ return new Promise((resolve, reject) => {
167
+ const req = https.get(
168
+ url,
169
+ {
170
+ headers: {
171
+ "user-agent": "klovys99-installer",
172
+ },
173
+ },
174
+ resolve,
175
+ );
176
+ req.on("error", reject);
177
+ });
178
+ }
179
+
180
+ async function readResponseBody(response) {
181
+ let body = "";
182
+ response.setEncoding("utf8");
183
+ for await (const chunk of response) {
184
+ body += chunk;
185
+ if (body.length > 512) {
186
+ body = `${body.slice(0, 512)}...`;
187
+ break;
188
+ }
189
+ }
190
+ return body.trim();
191
+ }
192
+
193
+ module.exports = {
194
+ binaryFileName,
195
+ defaultRepository,
196
+ detectTarget,
197
+ installReleaseBinary,
198
+ normalizedVersion,
199
+ parseRepository,
200
+ readPackageVersion,
201
+ readRepository,
202
+ releaseAssetName,
203
+ releaseAssetUrl,
204
+ releaseTag,
205
+ };
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { spawnSync } = require("node:child_process");
7
+ const { configureClients, normalizeBaseUrl } = require("./lib/configure");
8
+ const {
9
+ binaryFileName,
10
+ installReleaseBinary,
11
+ readPackageVersion,
12
+ } = require("./lib/install");
13
+
14
+ const packageRoot = path.resolve(__dirname, "..");
15
+
16
+ async function main() {
17
+ await ensureBinaryInstalled();
18
+
19
+ const client = process.env.KLOVIS_CLIENT || process.env.KLOVIS_SETUP_CLIENT;
20
+ if (!client || process.env.KLOVIS_SKIP_CONFIGURE === "true") {
21
+ return;
22
+ }
23
+
24
+ const baseUrl = normalizeBaseUrl(process.env.KLOVIS_BASE_URL || "http://127.0.0.1:8080");
25
+ const results = configureClients({ client, baseUrl });
26
+ for (const result of results) {
27
+ process.stdout.write(
28
+ `Configured ${result.client} to use ${result.baseUrl} via ${result.path}\n`,
29
+ );
30
+ }
31
+ }
32
+
33
+ async function ensureBinaryInstalled() {
34
+ if (process.env.KLOVIS_SKIP_DOWNLOAD === "true") {
35
+ process.stdout.write("Skipping Klovys99 binary download because KLOVIS_SKIP_DOWNLOAD=true\n");
36
+ if (canBuildFromSource()) {
37
+ buildBinary();
38
+ return;
39
+ }
40
+ throw new Error(
41
+ "KLOVIS_SKIP_DOWNLOAD=true but no local Go source checkout is available for fallback build",
42
+ );
43
+ }
44
+
45
+ const version = readPackageVersion(packageRoot);
46
+ try {
47
+ const result = await installReleaseBinary({ version, packageRoot });
48
+ process.stdout.write(`Installed Klovys99 binary from ${result.assetUrl}\n`);
49
+ return;
50
+ } catch (error) {
51
+ if (!canBuildFromSource()) {
52
+ throw new Error(
53
+ `unable to install Klovys99 prebuilt binary: ${error.message}. ` +
54
+ "This package expects a published GitHub release for the current version.",
55
+ );
56
+ }
57
+ process.stdout.write(
58
+ `Prebuilt Klovys99 binary download failed (${error.message}). Falling back to local Go build.\n`,
59
+ );
60
+ }
61
+
62
+ buildBinary();
63
+ }
64
+
65
+ function canBuildFromSource() {
66
+ return (
67
+ process.env.KLOVIS_SKIP_BUILD !== "true" &&
68
+ fs.existsSync(path.join(packageRoot, "cmd", "klovys99"))
69
+ );
70
+ }
71
+
72
+ function buildBinary() {
73
+ const binaryName = binaryFileName(process.platform);
74
+ const binaryPath = path.join(packageRoot, "dist", binaryName);
75
+ fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
76
+
77
+ const result = spawnSync("go", ["build", "-o", binaryPath, "./cmd/klovys99"], {
78
+ cwd: packageRoot,
79
+ stdio: "inherit",
80
+ env: process.env,
81
+ });
82
+
83
+ if (result.error) {
84
+ throw new Error(`unable to run go build: ${result.error.message}`);
85
+ }
86
+ if (result.status !== 0) {
87
+ throw new Error(`go build failed with exit code ${result.status}`);
88
+ }
89
+ }
90
+
91
+ try {
92
+ Promise.resolve(main()).catch((error) => {
93
+ process.stderr.write(`${error.message}\n`);
94
+ process.exitCode = 1;
95
+ });
96
+ } catch (error) {
97
+ process.stderr.write(`${error.message}\n`);
98
+ process.exitCode = 1;
99
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "klovys99",
3
+ "version": "0.1.0-main.10",
4
+ "description": "Local prompt anonymizing proxy for Codex and Claude Code",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Korbicorp/klovys99.git"
9
+ },
10
+ "homepage": "https://github.com/Korbicorp/klovys99",
11
+ "bugs": {
12
+ "url": "https://github.com/Korbicorp/klovys99/issues"
13
+ },
14
+ "files": [
15
+ "LICENSE",
16
+ "README.md",
17
+ "npm/cli.js",
18
+ "npm/postinstall.js",
19
+ "npm/lib/"
20
+ ],
21
+ "bin": {
22
+ "klovis": "npm/cli.js",
23
+ "klovys99": "npm/cli.js"
24
+ },
25
+ "scripts": {
26
+ "cli": "node npm/cli.js",
27
+ "postinstall": "node npm/postinstall.js",
28
+ "test:node": "node --test npm/test/*.test.js"
29
+ }
30
+ }