context-lens 0.1.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.
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$" />
5
+ <orderEntry type="jdk" jdkName="Python 3.9" jdkType="Python SDK" />
6
+ <orderEntry type="sourceFolder" forTests="false" />
7
+ </component>
8
+ <component name="PyDocumentationSettings">
9
+ <option name="format" value="PLAIN" />
10
+ <option name="myDocStringFormat" value="Plain" />
11
+ </component>
12
+ </module>
@@ -0,0 +1,17 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
5
+ <inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
6
+ <option name="myValues">
7
+ <value>
8
+ <list size="1">
9
+ <item index="0" class="java.lang.String" itemvalue="className" />
10
+ </list>
11
+ </value>
12
+ </option>
13
+ <option name="myCustomValuesEnabled" value="true" />
14
+ </inspection_tool>
15
+ <inspection_tool class="JSHint" enabled="true" level="ERROR" enabled_by_default="true" />
16
+ </profile>
17
+ </component>
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="MaterialThemeProjectNewConfig">
4
+ <option name="metadata">
5
+ <MTProjectMetadataState>
6
+ <option name="userId" value="-6abbd721:19c326b38fc:-66b2" />
7
+ </MTProjectMetadataState>
8
+ </option>
9
+ </component>
10
+ </project>
package/.idea/misc.xml ADDED
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.9" />
5
+ </component>
6
+ <component name="KubernetesApiPersistence">{}</component>
7
+ <component name="KubernetesApiProvider"><![CDATA[{
8
+ "isMigrated": true
9
+ }]]></component>
10
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9" project-jdk-type="Python SDK" />
11
+ </project>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/context-lens.iml" filepath="$PROJECT_DIR$/.idea/context-lens.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
package/CHANGELOG.md ADDED
@@ -0,0 +1,104 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2026-02-07
4
+
5
+ ### Added - CLI Wrapper with Multi-Client Support
6
+
7
+ #### Major Features
8
+ - **CLI Wrapper** (`cli.js`) - Launch any tool through Context Lens with automatic setup
9
+ - Usage: `npx context-lens <tool> [args...]`
10
+ - Automatically starts proxy and web UI
11
+ - Sets environment variables for the tool
12
+ - Opens browser at http://localhost:4041
13
+ - Full TTY/interactivity support
14
+
15
+ - **Multi-Client Support** - Run multiple tools simultaneously sharing one proxy
16
+ - First client starts the proxy
17
+ - Subsequent clients detect and attach to existing proxy
18
+ - Reference counting via `/tmp/context-lens.lock`
19
+ - Only last client to exit shuts down the proxy
20
+ - Each client shows attachment status and active client count
21
+
22
+ - **Source Tagging** - Identify which tool made each request
23
+ - Tool name encoded in base URL path: `http://localhost:4040/<tool-name>`
24
+ - Proxy extracts source and stores with each request
25
+ - Web UI displays source badge for each request
26
+ - Example: `[anthropic] [claude] claude-sonnet-4`
27
+
28
+ #### Technical Changes
29
+ - `cli.js` (new):
30
+ - Port detection via TCP connect
31
+ - Reference counting with atomic file operations
32
+ - Smart shutdown logic
33
+ - Signal handling (SIGINT/SIGTERM)
34
+ - Tool-specific environment variable mapping
35
+
36
+ - `server.js`:
37
+ - Added `extractSource()` function for URL path parsing
38
+ - Updated `storeRequest()` to accept source parameter
39
+ - Modified `handleProxy()` to extract and pass source
40
+ - Enhanced web UI to display source badges
41
+ - Added CSS styling for source tags
42
+
43
+ - `package.json`:
44
+ - Added `bin` field for `npx` support
45
+
46
+ #### Documentation
47
+ - **README.md** - Updated with CLI-first usage examples
48
+ - **CLI-IMPLEMENTATION.md** - Technical implementation details
49
+ - **MULTI-CLIENT.md** - Comprehensive multi-client guide
50
+ - **CHANGELOG.md** - This file
51
+
52
+ #### Testing
53
+ - āœ… Single client mode
54
+ - āœ… Multi-client attach/detach
55
+ - āœ… Reference counting accuracy
56
+ - āœ… Source tag extraction and display
57
+ - āœ… Graceful shutdown
58
+ - āœ… Lockfile cleanup
59
+ - āœ… Port conflict handling
60
+
61
+ #### Known Limitations
62
+ - Lockfile at `/tmp/` may not work on all systems (e.g., Windows without WSL)
63
+ - No IPC - relies on filesystem for coordination
64
+ - Manual cleanup needed if clients crash without cleanup
65
+ - Browser auto-open uses platform detection (may fail on exotic systems)
66
+
67
+ #### Example Usage
68
+
69
+ **Single Tool:**
70
+ ```bash
71
+ npx context-lens claude "Explain quantum computing"
72
+ # Starts proxy, opens UI, runs claude
73
+ ```
74
+
75
+ **Multiple Tools (different terminals):**
76
+ ```bash
77
+ # Terminal 1
78
+ npx context-lens claude "task 1"
79
+ # → Starts proxy
80
+
81
+ # Terminal 2 (while T1 running)
82
+ npx context-lens codex "task 2"
83
+ # → Attaches to proxy
84
+
85
+ # Terminal 3 (while T1+T2 running)
86
+ npx context-lens -- python agent.py
87
+ # → Also attaches
88
+
89
+ # All requests visible in shared UI with source tags
90
+ ```
91
+
92
+ ---
93
+
94
+ ## [0.1.0] - 2026-02-07
95
+
96
+ ### Initial Release
97
+ - HTTP proxy server (port 4040)
98
+ - Web UI dashboard (port 4041)
99
+ - Request capture and analysis
100
+ - Token estimation (char/4 heuristic)
101
+ - Provider detection (Anthropic/OpenAI)
102
+ - Context usage visualization
103
+ - Streaming response support
104
+ - Zero external dependencies
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # Context Lens
2
+
3
+ See what's actually in your LLM's context window. A zero-dependency HTTP proxy that intercepts API calls and visualizes token usage in real-time.
4
+
5
+ > **Early Development** — Expect rough edges. Contributions welcome.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx context-lens claude "your prompt"
11
+ npx context-lens codex "your prompt"
12
+ npx context-lens aider --model claude-sonnet-4
13
+ npx context-lens -- python my_agent.py
14
+ ```
15
+
16
+ This starts the proxy (port 4040), opens the web UI (http://localhost:4041), sets the right env vars, and runs your command. Multiple tools can share one proxy — just open more terminals.
17
+
18
+ ## What You Get
19
+
20
+ - **Token breakdown** — system prompts, tools, messages with visual context bar
21
+ - **Conversation threading** — groups API calls by session/conversation, shows agents and turns
22
+ - **Content formatting** — collapsible system prompts, typed message blocks (text/tool_use/tool_result), per-tool schemas
23
+ - **Auto-detection** — recognizes Claude Code, Codex, aider, and others by source tag or system prompt
24
+ - **Streaming support** — passes through SSE chunks in real-time
25
+
26
+ ## Manual Mode
27
+
28
+ ```bash
29
+ node server.js
30
+ # Port 4040 = proxy, port 4041 = web UI
31
+
32
+ ANTHROPIC_BASE_URL=http://localhost:4040 claude "your prompt"
33
+ OPENAI_BASE_URL=http://localhost:4040 codex "your prompt"
34
+ ```
35
+
36
+ ### Source Tagging
37
+
38
+ Add a path prefix to tag requests by tool:
39
+
40
+ ```bash
41
+ ANTHROPIC_BASE_URL=http://localhost:4040/claude claude "prompt"
42
+ OPENAI_BASE_URL=http://localhost:4040/aider aider "prompt"
43
+ ```
44
+
45
+ ### Codex Subscription Mode
46
+
47
+ Codex with a ChatGPT subscription needs mitmproxy for HTTPS interception (Cloudflare blocks reverse proxies). The CLI handles this automatically — just make sure `mitmdump` is installed:
48
+
49
+ ```bash
50
+ pipx install mitmproxy
51
+ npx context-lens codex "your prompt"
52
+ ```
53
+
54
+ ## Data
55
+
56
+ All captured requests are logged to `data/requests.jsonl` and kept in memory (last 100). Restart clears the in-memory state; the JSONL log persists.
57
+
58
+ ## License
59
+
60
+ MIT
package/cli.js ADDED
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const { join } = require('path');
5
+ const { platform } = require('os');
6
+ const fs = require('fs');
7
+ const net = require('net');
8
+
9
+ // Known tool config: env vars for the child process, extra CLI args, server env vars, and whether mitmproxy is needed
10
+ const PROXY_URL = 'http://localhost:4040';
11
+ const MITM_PORT = 8080;
12
+ const MITM_PROXY_URL = `http://localhost:${MITM_PORT}`;
13
+
14
+ const TOOL_CONFIG = {
15
+ 'claude': {
16
+ childEnv: { ANTHROPIC_BASE_URL: PROXY_URL },
17
+ extraArgs: [],
18
+ serverEnv: {},
19
+ needsMitm: false,
20
+ },
21
+ 'codex': {
22
+ // Codex subscription uses chatgpt.com with Cloudflare — needs forward proxy (mitmproxy)
23
+ // to intercept HTTPS traffic without breaking TLS fingerprinting.
24
+ childEnv: {
25
+ https_proxy: MITM_PROXY_URL,
26
+ SSL_CERT_FILE: join(process.env.HOME || '', '.mitmproxy', 'mitmproxy-ca-cert.pem'),
27
+ },
28
+ extraArgs: [],
29
+ serverEnv: {},
30
+ needsMitm: true,
31
+ },
32
+ 'aider': {
33
+ childEnv: { ANTHROPIC_BASE_URL: PROXY_URL, OPENAI_BASE_URL: PROXY_URL },
34
+ extraArgs: [],
35
+ serverEnv: {},
36
+ needsMitm: false,
37
+ },
38
+ };
39
+
40
+ function getToolConfig(toolName) {
41
+ return TOOL_CONFIG[toolName] || {
42
+ childEnv: { ANTHROPIC_BASE_URL: PROXY_URL, OPENAI_BASE_URL: PROXY_URL },
43
+ extraArgs: [],
44
+ serverEnv: {},
45
+ needsMitm: false,
46
+ };
47
+ }
48
+
49
+ const LOCKFILE = '/tmp/context-lens.lock';
50
+
51
+ // Parse command line arguments
52
+ const args = process.argv.slice(2);
53
+
54
+ if (args.length === 0) {
55
+ // Standalone mode: just start the proxy server
56
+ const serverPath = join(__dirname, 'server.js');
57
+ const server = spawn('node', [serverPath], { stdio: 'inherit' });
58
+ server.on('exit', (code) => process.exit(code || 0));
59
+ process.on('SIGINT', () => server.kill('SIGINT'));
60
+ process.on('SIGTERM', () => server.kill('SIGTERM'));
61
+ // Prevent early exit
62
+ process.stdin.resume();
63
+ return;
64
+ }
65
+
66
+ // Skip '--' separator if present
67
+ let commandArgs = args;
68
+ if (args[0] === '--') {
69
+ commandArgs = args.slice(1);
70
+ }
71
+
72
+ if (commandArgs.length === 0) {
73
+ console.error('Error: No command specified after --');
74
+ process.exit(1);
75
+ }
76
+
77
+ const commandName = commandArgs[0];
78
+ const commandArguments = commandArgs.slice(1);
79
+
80
+ // Get tool-specific config
81
+ const toolConfig = getToolConfig(commandName);
82
+
83
+ // Check if proxy is already running
84
+ function isProxyRunning() {
85
+ return new Promise((resolve) => {
86
+ const socket = net.connect({ port: 4040, host: 'localhost' }, () => {
87
+ socket.end();
88
+ resolve(true);
89
+ });
90
+ socket.on('error', () => resolve(false));
91
+ socket.setTimeout(1000, () => {
92
+ socket.destroy();
93
+ resolve(false);
94
+ });
95
+ });
96
+ }
97
+
98
+ // Increment reference count in lockfile
99
+ function incrementRefCount() {
100
+ try {
101
+ let count = 0;
102
+ if (fs.existsSync(LOCKFILE)) {
103
+ const data = fs.readFileSync(LOCKFILE, 'utf8');
104
+ count = parseInt(data) || 0;
105
+ }
106
+ fs.writeFileSync(LOCKFILE, String(count + 1));
107
+ return count + 1;
108
+ } catch (err) {
109
+ console.error('Warning: failed to update lockfile:', err.message);
110
+ return 1;
111
+ }
112
+ }
113
+
114
+ // If the proxy isn't actually running but a lockfile exists, it's stale (e.g. prior crash).
115
+ function clearStaleLockfile() {
116
+ try {
117
+ if (fs.existsSync(LOCKFILE)) fs.unlinkSync(LOCKFILE);
118
+ } catch (err) {
119
+ console.error('Warning: failed to clear stale lockfile:', err.message);
120
+ }
121
+ }
122
+
123
+ // Decrement reference count in lockfile
124
+ function decrementRefCount() {
125
+ try {
126
+ if (!fs.existsSync(LOCKFILE)) return 0;
127
+ const data = fs.readFileSync(LOCKFILE, 'utf8');
128
+ const count = Math.max(0, (parseInt(data) || 1) - 1);
129
+ if (count === 0) {
130
+ fs.unlinkSync(LOCKFILE);
131
+ } else {
132
+ fs.writeFileSync(LOCKFILE, String(count));
133
+ }
134
+ return count;
135
+ } catch (err) {
136
+ console.error('Warning: failed to update lockfile:', err.message);
137
+ return 0;
138
+ }
139
+ }
140
+
141
+ let serverProcess = null;
142
+ let mitmProcess = null;
143
+ let serverReady = false;
144
+ let mitmReady = false;
145
+ let childProcess = null;
146
+ let shouldShutdownProxy = false;
147
+
148
+ // Start proxy or attach to existing one
149
+ async function initializeProxy() {
150
+ const alreadyRunning = await isProxyRunning();
151
+
152
+ if (alreadyRunning) {
153
+ console.log('šŸ” Context Lens proxy already running, attaching to it...');
154
+ incrementRefCount();
155
+ serverReady = true;
156
+ shouldShutdownProxy = false;
157
+ maybeStartMitmThenChild();
158
+ } else {
159
+ console.log('šŸ” Starting Context Lens proxy and web UI...');
160
+ // No proxy is listening on :4040. Any existing lockfile is stale and would prevent shutdown later.
161
+ clearStaleLockfile();
162
+ incrementRefCount();
163
+ shouldShutdownProxy = true;
164
+
165
+ const serverPath = join(__dirname, 'server.js');
166
+ serverProcess = spawn('node', [serverPath], {
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ detached: false,
169
+ env: { ...toolConfig.serverEnv, ...process.env, CONTEXT_LENS_CLI: '1' },
170
+ });
171
+
172
+ // Wait for server to be ready, then suppress output (visible in web UI at :4041)
173
+ serverProcess.stdout.on('data', (data) => {
174
+ const output = data.toString();
175
+ if (!serverReady) {
176
+ process.stderr.write(output);
177
+ }
178
+ if (output.includes('Context Lens Web UI running') && !serverReady) {
179
+ serverReady = true;
180
+ maybeStartMitmThenChild();
181
+ }
182
+ });
183
+
184
+ serverProcess.stderr.on('data', (data) => {
185
+ if (!serverReady) {
186
+ process.stderr.write(data);
187
+ }
188
+ });
189
+
190
+ serverProcess.on('error', (err) => {
191
+ console.error('Failed to start server:', err);
192
+ decrementRefCount();
193
+ process.exit(1);
194
+ });
195
+
196
+ serverProcess.on('exit', (code) => {
197
+ if (!serverReady) {
198
+ console.error('Server exited unexpectedly');
199
+ decrementRefCount();
200
+ process.exit(code || 1);
201
+ }
202
+ });
203
+
204
+ // Open browser after a short delay (only when starting new server)
205
+ setTimeout(() => {
206
+ openBrowser('http://localhost:4041');
207
+ }, 1000);
208
+ }
209
+ }
210
+
211
+ initializeProxy();
212
+
213
+ // Start mitmproxy if needed, then start the child
214
+ function maybeStartMitmThenChild() {
215
+ if (!toolConfig.needsMitm) {
216
+ return startChild();
217
+ }
218
+
219
+ const addonPath = join(__dirname, 'mitm_addon.py');
220
+ console.log('šŸ”’ Starting mitmproxy (forward proxy for HTTPS interception)...');
221
+
222
+ mitmProcess = spawn('mitmdump', ['-s', addonPath, '--quiet', '--listen-port', String(MITM_PORT)], {
223
+ stdio: ['ignore', 'pipe', 'pipe'],
224
+ });
225
+
226
+ mitmProcess.on('error', (err) => {
227
+ console.error('Failed to start mitmproxy:', err.message);
228
+ console.error('Install it: pipx install mitmproxy');
229
+ cleanup(1);
230
+ });
231
+
232
+ mitmProcess.on('exit', (code) => {
233
+ if (!mitmReady) {
234
+ console.error('mitmproxy exited unexpectedly');
235
+ cleanup(code || 1);
236
+ }
237
+ });
238
+
239
+ // Poll until mitmproxy is accepting connections
240
+ const pollMitm = setInterval(() => {
241
+ const socket = net.connect({ port: MITM_PORT, host: 'localhost' }, () => {
242
+ socket.end();
243
+ if (!mitmReady) {
244
+ mitmReady = true;
245
+ clearInterval(pollMitm);
246
+ console.log(`šŸ”’ mitmproxy listening on port ${MITM_PORT}`);
247
+ startChild();
248
+ }
249
+ });
250
+ socket.on('error', () => {}); // not ready yet
251
+ socket.setTimeout(500, () => socket.destroy());
252
+ }, 200);
253
+ }
254
+
255
+ // Start the child command
256
+ function startChild() {
257
+ // Inject extra args (e.g. codex -c chatgpt_base_url=...) before user args
258
+ const allArgs = [...toolConfig.extraArgs, ...commandArguments];
259
+ console.log(`\nšŸš€ Launching: ${commandName} ${allArgs.join(' ')}\n`);
260
+
261
+ const childEnv = {
262
+ ...process.env,
263
+ ...toolConfig.childEnv,
264
+ };
265
+
266
+ // Spawn the child process with inherited stdio (interactive)
267
+ // No shell: true — avoids intermediate process that breaks signal delivery
268
+ childProcess = spawn(commandName, allArgs, {
269
+ stdio: 'inherit',
270
+ env: childEnv,
271
+ });
272
+
273
+ childProcess.on('error', (err) => {
274
+ console.error(`\nFailed to start ${commandName}:`, err.message);
275
+ cleanup(1);
276
+ });
277
+
278
+ // When the child exits (however it happens), clean up and mirror its exit code
279
+ childProcess.on('exit', (code, signal) => {
280
+ cleanup(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code || 0));
281
+ });
282
+ }
283
+
284
+ // Open browser (cross-platform)
285
+ function openBrowser(url) {
286
+ const cmd = platform() === 'darwin' ? 'open'
287
+ : platform() === 'win32' ? 'start'
288
+ : 'xdg-open';
289
+
290
+ const browserProcess = spawn(cmd, [url], {
291
+ stdio: 'ignore',
292
+ detached: true,
293
+ });
294
+
295
+ browserProcess.unref(); // Don't wait for browser to close
296
+ }
297
+
298
+ // Cleanup on exit
299
+ function cleanup(exitCode) {
300
+ if (cleanup._didRun) return;
301
+ cleanup._didRun = true;
302
+
303
+ const remainingRefs = decrementRefCount();
304
+
305
+ if (mitmProcess && !mitmProcess.killed) {
306
+ mitmProcess.kill();
307
+ }
308
+
309
+ if (remainingRefs === 0 && shouldShutdownProxy && serverProcess && !serverProcess.killed) {
310
+ serverProcess.kill();
311
+ }
312
+
313
+ process.exit(exitCode);
314
+ }
315
+
316
+ // Ignore SIGINT in the parent — let it flow to the child (claude/codex) naturally.
317
+ // The child handles Ctrl+C itself; when it eventually exits, cleanup runs via the 'exit' handler.
318
+ process.on('SIGINT', () => {});
319
+
320
+ // SIGTERM: external shutdown request — forward to child
321
+ process.on('SIGTERM', () => {
322
+ if (childProcess && !childProcess.killed) childProcess.kill('SIGTERM');
323
+ });
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "context-lens",
3
+ "version": "0.1.0",
4
+ "description": "Local HTTP proxy that intercepts LLM API calls and visualizes context windows",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "context-lens": "./cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node server.js"
11
+ },
12
+ "keywords": ["llm", "proxy", "context", "anthropic", "openai", "visualization"],
13
+ "author": "Lars de Ridder",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/larsderidder/context-lens"
17
+ },
18
+ "license": "MIT"
19
+ }