tokentrax-agent 0.0.0-stage → 1.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 +110 -2
- package/bin/agent.js +2 -0
- package/install.sh +125 -0
- package/package.json +46 -4
- package/src/adapters.js +123 -0
- package/src/adapters.test.js +135 -0
- package/src/cli.js +460 -0
- package/src/cli.test.js +308 -0
- package/src/cursor.js +67 -0
- package/src/cursor.test.js +89 -0
- package/src/env.js +122 -0
- package/src/env.test.js +131 -0
- package/src/hooks.js +350 -0
- package/src/hooks.test.js +474 -0
- package/src/machine-id.js +32 -0
- package/src/paths.js +72 -0
- package/src/paths.test.js +58 -0
- package/src/pi-jsonl.js +295 -0
- package/src/pi-jsonl.test.js +315 -0
- package/src/remote-api.js +119 -0
- package/src/session-parser.js +479 -0
- package/src/session-parser.test.js +981 -0
- package/src/setup.js +349 -0
- package/src/setup.test.js +254 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Josh Greene
|
|
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
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tokentrax-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The remote half of [TokenTrax](https://github.com/jeshuwah/TokenTrax). It runs
|
|
4
|
+
on any machine with a context-mode-enabled AI harness — pi, Claude Code,
|
|
5
|
+
Cursor, Codex, Gemini CLI, and 13 others — reads that machine's session
|
|
6
|
+
data, and reports it to a central TokenTrax dashboard.
|
|
7
|
+
|
|
8
|
+
The dashboard then shows one machine list with every machine's sessions,
|
|
9
|
+
tokens, cost and context-reduction percentage.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g tokentrax-agent
|
|
15
|
+
tokentrax-agent setup --server http://your-dashboard:7680 --key <sync-key>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`setup` does the rest: it writes the configuration, checks that the native
|
|
19
|
+
SQLite binding actually loads, installs a systemd user service and timer,
|
|
20
|
+
and enables the timer. The machine now reports its usage every 5 minutes.
|
|
21
|
+
|
|
22
|
+
No npm? Bootstrap straight from the repository:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
curl -fsSL https://raw.githubusercontent.com/jeshuwah/TokenTrax/main/agent/install.sh \
|
|
26
|
+
| bash -s -- --server http://your-dashboard:7680 --key <sync-key>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires Node.js 20 or newer.
|
|
30
|
+
|
|
31
|
+
### If the SQLite binding will not load
|
|
32
|
+
|
|
33
|
+
`better-sqlite3` is a native module. npm 12 and newer block dependency
|
|
34
|
+
install scripts by default, so the binding is never fetched or built — and
|
|
35
|
+
`require()` succeeds anyway, so the failure only shows up when a database is
|
|
36
|
+
opened.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install-scripts approve better-sqlite3
|
|
40
|
+
npm rebuild better-sqlite3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`tokentrax-agent setup` checks for this and tells you if it finds it.
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `setup` | Configure the machine, install the timer, enable it. |
|
|
50
|
+
| `sync` | Send new sessions to the server. `--auto` never fails the caller. |
|
|
51
|
+
| `status` | Show the current configuration, the machine id and the cursor. |
|
|
52
|
+
| `init` | Install post-session hooks (harnesses that support them). |
|
|
53
|
+
| `uninstall` | Remove post-session hooks. |
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Configuration lives in `~/.tokentrax/agent.env` (mode 0600). The agent
|
|
58
|
+
reads this file itself on every run — you do **not** need to `source` it.
|
|
59
|
+
That matters because a harness launched from a desktop launcher never
|
|
60
|
+
sources a shell profile, and the sync would then have no server URL.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
TOKENTRAX_SERVER_URL=http://your-dashboard:7680 # required
|
|
64
|
+
TOKENTRAX_SYNC_KEY=... # required, matches the server
|
|
65
|
+
TOKENTRAX_MACHINE_ID=my-laptop # optional, default: a hash
|
|
66
|
+
TOKENTRAX_TRACK_PROJECT=hash # hash | full | dirname | none
|
|
67
|
+
TOKENTRAX_ENV_FILE=/path/to/env # optional, override the path
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Real environment variables always win over the env file.
|
|
71
|
+
|
|
72
|
+
`TOKENTRAX_TRACK_PROJECT` controls how much of your project path is
|
|
73
|
+
reported. The default `hash` sends a SHA-256 prefix and nothing else; `full`
|
|
74
|
+
sends the absolute path.
|
|
75
|
+
|
|
76
|
+
## How it works
|
|
77
|
+
|
|
78
|
+
Two sources of data are joined per session:
|
|
79
|
+
|
|
80
|
+
- **context-mode SessionDB** (`<adapter-home>/context-mode/sessions/*.db`) —
|
|
81
|
+
bytes avoided and returned, tool calls, events, reduction percentage.
|
|
82
|
+
- **Harness transcripts** — for pi, `~/.pi/agent/sessions/<project>/*.jsonl`,
|
|
83
|
+
which carry per-turn `message.usage` with token counts and costs.
|
|
84
|
+
|
|
85
|
+
The two are matched on project directory and session start time. A
|
|
86
|
+
transcript that context-mode never recorded is still reported, with its
|
|
87
|
+
token and cost data.
|
|
88
|
+
|
|
89
|
+
## Sync mechanism
|
|
90
|
+
|
|
91
|
+
A **systemd user timer**, not a post-session hook.
|
|
92
|
+
|
|
93
|
+
Hooks are installed by `init` for harnesses that support them, but the
|
|
94
|
+
harness with the most data — pi — has no hook mechanism at all, so a hook
|
|
95
|
+
would silently never fire there. The timer works for every harness, present
|
|
96
|
+
and future, and keeps running when you are logged out (`Linger=yes`).
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
systemctl --user list-timers tokentrax-agent.timer
|
|
100
|
+
journalctl --user -u tokentrax-agent.service -n 20
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Privacy
|
|
104
|
+
|
|
105
|
+
Nothing leaves the machine except session metadata and usage numbers, and
|
|
106
|
+
only to the one dashboard URL you configure. With the default
|
|
107
|
+
`TOKENTRAX_TRACK_PROJECT=hash` the dashboard never sees a project path.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
package/bin/agent.js
ADDED
package/install.sh
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Bootstrap the TokenTrax remote agent on this machine.
|
|
4
|
+
#
|
|
5
|
+
# curl -fsSL https://raw.githubusercontent.com/jeshuwah/TokenTrax/main/agent/install.sh \
|
|
6
|
+
# | bash -s -- --server http://your-dashboard:7680 --key <sync-key>
|
|
7
|
+
#
|
|
8
|
+
# Or from a clone of the repository:
|
|
9
|
+
#
|
|
10
|
+
# ./agent/install.sh --server http://your-dashboard:7680 --key <sync-key>
|
|
11
|
+
#
|
|
12
|
+
# Installs the agent into ~/.tokentrax/agent, then runs `tokentrax-agent
|
|
13
|
+
# setup` to write the configuration and enable the sync timer.
|
|
14
|
+
#
|
|
15
|
+
# Options:
|
|
16
|
+
# --server <url> TokenTrax server URL (required)
|
|
17
|
+
# --key <key> sync key, matches the server (required)
|
|
18
|
+
# --machine-id <id> name this machine in the dashboard
|
|
19
|
+
# --interval <min> sync interval in minutes (default 5)
|
|
20
|
+
# --track <level> hash | full | dirname | none (default hash)
|
|
21
|
+
# --dir <path> install location (default ~/.tokentrax/agent)
|
|
22
|
+
# --no-timer configure only, do not install the systemd timer
|
|
23
|
+
# --help this message
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
REPO_URL="${TOKENTRAX_REPO_URL:-https://github.com/jeshuwah/TokenTrax.git}"
|
|
28
|
+
INSTALL_DIR="${TOKENTRAX_AGENT_DIR:-$HOME/.tokentrax/agent}"
|
|
29
|
+
|
|
30
|
+
SERVER=""
|
|
31
|
+
KEY=""
|
|
32
|
+
MACHINE_ID=""
|
|
33
|
+
INTERVAL=""
|
|
34
|
+
TRACK=""
|
|
35
|
+
NO_TIMER=""
|
|
36
|
+
|
|
37
|
+
die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }
|
|
38
|
+
info() { printf '\033[36m›\033[0m %s\n' "$*"; }
|
|
39
|
+
ok() { printf '\033[32m✓\033[0m %s\n' "$*"; }
|
|
40
|
+
|
|
41
|
+
while [ $# -gt 0 ]; do
|
|
42
|
+
case "$1" in
|
|
43
|
+
--server) SERVER="${2:-}"; shift 2 ;;
|
|
44
|
+
--key) KEY="${2:-}"; shift 2 ;;
|
|
45
|
+
--machine-id) MACHINE_ID="${2:-}"; shift 2 ;;
|
|
46
|
+
--interval) INTERVAL="${2:-}"; shift 2 ;;
|
|
47
|
+
--track) TRACK="${2:-}"; shift 2 ;;
|
|
48
|
+
--dir) INSTALL_DIR="${2:-}"; shift 2 ;;
|
|
49
|
+
--no-timer) NO_TIMER="1"; shift ;;
|
|
50
|
+
--server=*) SERVER="${1#*=}"; shift ;;
|
|
51
|
+
--key=*) KEY="${1#*=}"; shift ;;
|
|
52
|
+
--machine-id=*) MACHINE_ID="${1#*=}"; shift ;;
|
|
53
|
+
--interval=*) INTERVAL="${1#*=}"; shift ;;
|
|
54
|
+
--track=*) TRACK="${1#*=}"; shift ;;
|
|
55
|
+
--dir=*) INSTALL_DIR="${1#*=}"; shift ;;
|
|
56
|
+
-h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
57
|
+
*) die "unknown option: $1 (try --help)" ;;
|
|
58
|
+
esac
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
[ -n "$SERVER" ] || die "--server is required"
|
|
62
|
+
[ -n "$KEY" ] || die "--key is required"
|
|
63
|
+
|
|
64
|
+
# ── Node ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
command -v node >/dev/null 2>&1 || die "node is not installed. Install Node.js 20 or newer."
|
|
66
|
+
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
|
67
|
+
[ "$NODE_MAJOR" -ge 20 ] || die "node $(node -v) is too old — need 20 or newer"
|
|
68
|
+
ok "node $(node -v)"
|
|
69
|
+
|
|
70
|
+
# ── Source ───────────────────────────────────────────────────────────────────
|
|
71
|
+
# If we're running from inside a clone, use it. Otherwise clone.
|
|
72
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
73
|
+
if [ -f "$SCRIPT_DIR/package.json" ] && [ -d "$SCRIPT_DIR/src" ]; then
|
|
74
|
+
SRC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
75
|
+
info "using the repository at $SRC_DIR"
|
|
76
|
+
else
|
|
77
|
+
mkdir -p "$(dirname "$INSTALL_DIR")"
|
|
78
|
+
if [ -d "$INSTALL_DIR/.git" ]; then
|
|
79
|
+
info "updating $INSTALL_DIR"
|
|
80
|
+
git -C "$INSTALL_DIR" pull --ff-only
|
|
81
|
+
else
|
|
82
|
+
info "cloning into $INSTALL_DIR"
|
|
83
|
+
git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
|
|
84
|
+
fi
|
|
85
|
+
SRC_DIR="$INSTALL_DIR"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# ── Dependencies ─────────────────────────────────────────────────────────────
|
|
89
|
+
cd "$SRC_DIR/agent"
|
|
90
|
+
info "installing dependencies"
|
|
91
|
+
npm install --no-audit --no-fund
|
|
92
|
+
|
|
93
|
+
# npm 12+ blocks dependency install scripts by default, and better-sqlite3
|
|
94
|
+
# needs its install script to fetch or build the native binding. require()
|
|
95
|
+
# succeeds either way, so the failure only shows up when a database is
|
|
96
|
+
# opened.
|
|
97
|
+
if npm install-scripts ls >/dev/null 2>&1; then
|
|
98
|
+
info "approving better-sqlite3's install script (npm 12+ blocks these by default)"
|
|
99
|
+
npm install-scripts approve better-sqlite3 >/dev/null 2>&1 || true
|
|
100
|
+
npm rebuild better-sqlite3 >/dev/null 2>&1 || true
|
|
101
|
+
else
|
|
102
|
+
npm rebuild better-sqlite3 >/dev/null 2>&1 || true
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
if ! node -e "const D=require('better-sqlite3'); const d=new D(':memory:'); d.prepare('SELECT 1').get(); d.close();" 2>/dev/null; then
|
|
106
|
+
die "better-sqlite3 could not open a database. Run:
|
|
107
|
+
npm install-scripts approve better-sqlite3
|
|
108
|
+
npm rebuild better-sqlite3"
|
|
109
|
+
fi
|
|
110
|
+
ok "better-sqlite3 loads"
|
|
111
|
+
|
|
112
|
+
# ── Configure and enable ─────────────────────────────────────────────────────
|
|
113
|
+
SETUP_ARGS=(setup --server "$SERVER" --key "$KEY")
|
|
114
|
+
[ -n "$MACHINE_ID" ] && SETUP_ARGS+=(--machine-id "$MACHINE_ID")
|
|
115
|
+
[ -n "$INTERVAL" ] && SETUP_ARGS+=(--interval "$INTERVAL")
|
|
116
|
+
[ -n "$TRACK" ] && SETUP_ARGS+=(--track-project "$TRACK")
|
|
117
|
+
[ -n "$NO_TIMER" ] && SETUP_ARGS+=(--no-timer)
|
|
118
|
+
|
|
119
|
+
node "$SRC_DIR/agent/bin/agent.js" "${SETUP_ARGS[@]}"
|
|
120
|
+
|
|
121
|
+
ok "TokenTrax remote agent installed in $SRC_DIR/agent"
|
|
122
|
+
echo
|
|
123
|
+
echo "Next:"
|
|
124
|
+
echo " tokentrax-agent status # confirm it can see the server"
|
|
125
|
+
echo " systemctl --user list-timers tokentrax-agent.timer"
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokentrax-agent",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Remote agent for TokenTrax — reports AI-harness session usage from any machine to a central TokenTrax dashboard",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tokentrax-agent": "bin/agent.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"install.sh",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test src/*.test.js"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/jeshuwah/TokenTrax.git",
|
|
26
|
+
"directory": "agent"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"tokentrax",
|
|
30
|
+
"agent",
|
|
31
|
+
"telemetry",
|
|
32
|
+
"usage",
|
|
33
|
+
"tokens",
|
|
34
|
+
"cost",
|
|
35
|
+
"pi",
|
|
36
|
+
"codex",
|
|
37
|
+
"claude",
|
|
38
|
+
"cursor"
|
|
39
|
+
],
|
|
40
|
+
"author": "Josh Greene",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"better-sqlite3": "^12.0.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/adapters.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// Canonical adapter registry — single source of truth.
|
|
9
|
+
//
|
|
10
|
+
// Resolution is deferred to call time (resolveAdapterHome) so tests and
|
|
11
|
+
// future env overrides (TOKENTRAX_HOME) don't lock in stale paths.
|
|
12
|
+
//
|
|
13
|
+
// Names are the canonical identifiers used as `record.adapter` downstream.
|
|
14
|
+
// - `antigravity` and `antigravity-cli` both live under ~/.gemini but
|
|
15
|
+
// identify as separate harnesses to the dashboard.
|
|
16
|
+
// - `vscode-copilot` and `jetbrains-copilot` keep the `-copilot` suffix
|
|
17
|
+
// so downstream consumers can distinguish editor-embedded adapters from
|
|
18
|
+
// CLI-only ones (`copilot-cli`).
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Static descriptor — only stores `name` and a `homeRelative` path.
|
|
23
|
+
* The absolute `home` and `sessionsDir` are derived per call via
|
|
24
|
+
* `resolveAdapterHome` / `buildAdapterEntry` so that os.homedir() changes
|
|
25
|
+
* (e.g. test monkey-patches) are honored.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {Object} AdapterDescriptor
|
|
28
|
+
* @property {string} name Canonical adapter name
|
|
29
|
+
* @property {string} homeRel Path relative to homedir (may include subdirs)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const ADAPTER_DESCRIPTORS = Object.freeze([
|
|
33
|
+
{ name: 'claude-code', homeRel: '.claude' },
|
|
34
|
+
{ name: 'gemini-cli', homeRel: '.gemini' },
|
|
35
|
+
{ name: 'antigravity', homeRel: '.gemini' },
|
|
36
|
+
{ name: 'antigravity-cli', homeRel: '.gemini' },
|
|
37
|
+
{ name: 'openclaw', homeRel: '.openclaw' },
|
|
38
|
+
{ name: 'codex', homeRel: '.codex' },
|
|
39
|
+
{ name: 'cursor', homeRel: '.cursor' },
|
|
40
|
+
{ name: 'vscode-copilot', homeRel: '.vscode' },
|
|
41
|
+
{ name: 'copilot-cli', homeRel: '.copilot' },
|
|
42
|
+
{ name: 'kiro', homeRel: '.kiro' },
|
|
43
|
+
{ name: 'pi', homeRel: '.pi' },
|
|
44
|
+
{ name: 'omp', homeRel: '.omp' },
|
|
45
|
+
{ name: 'qwen-code', homeRel: '.qwen' },
|
|
46
|
+
{ name: 'kilo', homeRel: '.config/kilo' },
|
|
47
|
+
{ name: 'opencode', homeRel: '.config/opencode' },
|
|
48
|
+
{ name: 'zed', homeRel: '.config/zed' },
|
|
49
|
+
{ name: 'jetbrains-copilot', homeRel: '.config/JetBrains' },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the absolute home directory for a known adapter name.
|
|
54
|
+
* @param {string} name
|
|
55
|
+
* @returns {string|undefined} absolute path, or undefined if name is unknown
|
|
56
|
+
*/
|
|
57
|
+
function resolveAdapterHome(name) {
|
|
58
|
+
const desc = ADAPTER_DESCRIPTORS.find((d) => d.name === name);
|
|
59
|
+
if (!desc) return undefined;
|
|
60
|
+
return path.join(os.homedir(), desc.homeRel);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the absolute-path record for an adapter descriptor.
|
|
65
|
+
* @param {AdapterDescriptor} desc
|
|
66
|
+
* @returns {{name: string, home: string, sessionsDir: string}}
|
|
67
|
+
*/
|
|
68
|
+
function buildAdapterEntry(desc) {
|
|
69
|
+
const home = path.join(os.homedir(), desc.homeRel);
|
|
70
|
+
return {
|
|
71
|
+
name: desc.name,
|
|
72
|
+
home,
|
|
73
|
+
sessionsDir: path.join(home, 'context-mode', 'sessions'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Public, eagerly-computed ADAPTER_DIRS — kept for back-compat with the
|
|
79
|
+
* existing import in session-parser.js. Resolved once at module load, so
|
|
80
|
+
* if os.homedir() ever becomes dynamic, callers should use
|
|
81
|
+
* `resolveAdapterHome(name)` instead.
|
|
82
|
+
*/
|
|
83
|
+
const ADAPTER_DIRS = Object.freeze(ADAPTER_DESCRIPTORS.map(buildAdapterEntry));
|
|
84
|
+
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// Detection
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if an adapter is installed (has context-mode sessions or settings).
|
|
91
|
+
* Resolves home on each call so live filesystem changes are honored.
|
|
92
|
+
*/
|
|
93
|
+
function detectAdapter(adapter) {
|
|
94
|
+
const home = resolveAdapterHome(adapter.name) || adapter.home;
|
|
95
|
+
const sessionsDir = path.join(home, 'context-mode', 'sessions');
|
|
96
|
+
const settingsJson = path.join(home, 'settings.json');
|
|
97
|
+
const settingsToml = path.join(home, 'settings.toml');
|
|
98
|
+
// Codex-specific: settings live at <home>/hooks.json, not settings.json/toml
|
|
99
|
+
const hooksJson = path.join(home, 'hooks.json');
|
|
100
|
+
const hasSessions = fs.existsSync(sessionsDir);
|
|
101
|
+
const hasSettings =
|
|
102
|
+
fs.existsSync(settingsJson) ||
|
|
103
|
+
fs.existsSync(settingsToml) ||
|
|
104
|
+
(adapter.name === 'codex' && fs.existsSync(hooksJson));
|
|
105
|
+
return { ...adapter, home, sessionsDir, hasSessions, hasSettings };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Detect all installed adapters.
|
|
110
|
+
* Returns adapters that have EITHER context-mode sessions OR settings.
|
|
111
|
+
*/
|
|
112
|
+
function detectInstalled() {
|
|
113
|
+
return ADAPTER_DESCRIPTORS
|
|
114
|
+
.map((d) => detectAdapter(buildAdapterEntry(d)))
|
|
115
|
+
.filter((a) => a.hasSessions || a.hasSettings);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
ADAPTER_DIRS,
|
|
120
|
+
ADAPTER_DESCRIPTORS,
|
|
121
|
+
resolveAdapterHome,
|
|
122
|
+
detectInstalled,
|
|
123
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, describe } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
|
|
9
|
+
// Load the module under test
|
|
10
|
+
const { ADAPTER_DIRS, detectInstalled, resolveAdapterHome } = require('./adapters');
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// ADAPTER_DIRS — single source of truth
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe('ADAPTER_DIRS (canonical registry)', () => {
|
|
17
|
+
test('contains the documented set of 17 adapters', () => {
|
|
18
|
+
assert.strictEqual(ADAPTER_DIRS.length, 17,
|
|
19
|
+
`Expected 17 adapters, got ${ADAPTER_DIRS.length}: ${ADAPTER_DIRS.map(a => a.name).join(', ')}`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('every entry has name, home, sessionsDir', () => {
|
|
23
|
+
for (const entry of ADAPTER_DIRS) {
|
|
24
|
+
assert.ok(typeof entry.name === 'string' && entry.name.length > 0,
|
|
25
|
+
`name required (entry: ${JSON.stringify(entry)})`);
|
|
26
|
+
assert.ok(typeof entry.home === 'string' && entry.home.length > 0,
|
|
27
|
+
`home required for ${entry.name}`);
|
|
28
|
+
assert.ok(typeof entry.sessionsDir === 'string' && entry.sessionsDir.length > 0,
|
|
29
|
+
`sessionsDir required for ${entry.name}`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('sessionsDir always ends with context-mode/sessions', () => {
|
|
34
|
+
for (const entry of ADAPTER_DIRS) {
|
|
35
|
+
assert.ok(entry.sessionsDir.endsWith('context-mode/sessions'),
|
|
36
|
+
`${entry.name}: sessionsDir should end with context-mode/sessions, got: ${entry.sessionsDir}`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('home is absolute and uses homedir()', () => {
|
|
41
|
+
for (const entry of ADAPTER_DIRS) {
|
|
42
|
+
assert.ok(path.isAbsolute(entry.home),
|
|
43
|
+
`${entry.name}: home should be absolute, got: ${entry.home}`);
|
|
44
|
+
assert.ok(entry.home.startsWith(os.homedir()),
|
|
45
|
+
`${entry.name}: home should be under homedir(), got: ${entry.home}`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('includes the canonical required adapters', () => {
|
|
50
|
+
const names = ADAPTER_DIRS.map((e) => e.name);
|
|
51
|
+
for (const required of ['claude-code', 'pi', 'codex', 'gemini-cli', 'cursor', 'zed']) {
|
|
52
|
+
assert.ok(names.includes(required), `should include ${required}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('every adapter name is unique', () => {
|
|
57
|
+
const names = ADAPTER_DIRS.map((e) => e.name);
|
|
58
|
+
assert.strictEqual(new Set(names).size, names.length,
|
|
59
|
+
`duplicate adapter name in registry: ${names.join(', ')}`);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// resolveAdapterHome — deferred resolution so env/test overrides work
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('resolveAdapterHome', () => {
|
|
68
|
+
test('returns absolute path for a known adapter name', () => {
|
|
69
|
+
const home = resolveAdapterHome('pi');
|
|
70
|
+
assert.ok(path.isAbsolute(home));
|
|
71
|
+
assert.ok(home.startsWith(os.homedir()));
|
|
72
|
+
assert.ok(home.endsWith('.pi'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('returns undefined for an unknown adapter name', () => {
|
|
76
|
+
assert.strictEqual(resolveAdapterHome('nonexistent-adapter-xyz'), undefined);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('is consistent across calls (stable for stable homedir)', () => {
|
|
80
|
+
const a = resolveAdapterHome('pi');
|
|
81
|
+
const b = resolveAdapterHome('pi');
|
|
82
|
+
assert.strictEqual(a, b, 'resolveAdapterHome should return same value for stable homedir');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// detectInstalled
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('detectInstalled', () => {
|
|
91
|
+
test('returns array of adapter descriptors', () => {
|
|
92
|
+
const installed = detectInstalled();
|
|
93
|
+
assert.ok(Array.isArray(installed));
|
|
94
|
+
for (const a of installed) {
|
|
95
|
+
assert.ok(typeof a.name === 'string');
|
|
96
|
+
assert.ok(typeof a.home === 'string');
|
|
97
|
+
assert.ok(typeof a.hasSessions === 'boolean');
|
|
98
|
+
assert.ok(typeof a.hasSettings === 'boolean');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('every returned adapter has at least sessions or settings', () => {
|
|
103
|
+
const installed = detectInstalled();
|
|
104
|
+
for (const a of installed) {
|
|
105
|
+
assert.ok(a.hasSessions || a.hasSettings,
|
|
106
|
+
`detected adapter should have sessions or settings: ${a.name}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('does not return adapters whose home does not exist', () => {
|
|
111
|
+
const installed = detectInstalled();
|
|
112
|
+
for (const a of installed) {
|
|
113
|
+
assert.ok(fs.existsSync(a.home),
|
|
114
|
+
`detected adapter ${a.name} home should exist: ${a.home}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// Cross-module consistency: session-parser must use the same registry
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('session-parser uses canonical ADAPTER_DIRS', () => {
|
|
124
|
+
test('session-parser ADAPTER_DIRS export matches adapters.js ADAPTER_DIRS', () => {
|
|
125
|
+
const { ADAPTER_DIRS: parserDirs } = require('./session-parser');
|
|
126
|
+
assert.strictEqual(parserDirs.length, ADAPTER_DIRS.length,
|
|
127
|
+
'session-parser should re-export the canonical registry, not maintain its own');
|
|
128
|
+
for (let i = 0; i < parserDirs.length; i++) {
|
|
129
|
+
assert.strictEqual(parserDirs[i].name, ADAPTER_DIRS[i].name,
|
|
130
|
+
`name mismatch at index ${i}: ${parserDirs[i].name} vs ${ADAPTER_DIRS[i].name}`);
|
|
131
|
+
assert.strictEqual(parserDirs[i].home, ADAPTER_DIRS[i].home,
|
|
132
|
+
`home mismatch at index ${i}: ${parserDirs[i].home} vs ${ADAPTER_DIRS[i].home}`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|