| name | junction-dev |
| description | How to run, build, and test junction. Use when starting work on junction, running the CLI, building packages, running tests, or setting up the dev environment. Grows as each increment adds runnable surface. |
Junction Dev
How to work on junction locally. This skill grows per increment — update it whenever a new runnable surface lands.
Prerequisites
- Node 22 LTS (floor: Node 20). ESM-only repo.
- pnpm (workspace manager).
Core commands
pnpm install
pnpm verify
pnpm verify:web
pnpm web:smoke
pnpm web:leakcheck
pnpm test
pnpm test:related
pnpm lint
pnpm format
pnpm build
pnpm verify now chains verify:web — it builds the web client + smoke-tests the running
server, catching the "green but blind" class (broken production build / dead SSR path) the plain
unit/typecheck gate cannot (docs/behaviours/verify-the-artifact.md). verify:web is also the CI
web-build job's contract and the junction-web-verify skill's automated layer. (It's slower —
deliberate. Runs on both Node 20 and 22 since the tsdown/Node-20 loader bug was fixed via the root
unrun dep — gotchas.md.)
Web QA — drive the real artifact (junction-web-verify skill + agent-browser)
For visual/interaction QA the smoke test can't assert from HTML (theme, sidebar-collapse
persistence, no-shake nav, reduced-motion, populated tables), use the junction-web-verify
skill, which drives the built server with agent-browser (/opt/homebrew/bin/agent-browser;
agent-browser -h, agent-browser skills get core --full). It can open/screenshot/eval,
set media dark|reduced-motion, cookies set, and diff screenshot --baseline. Always seed a
throwaway JUNCTION_HOME with realistic data first (see "Testing surfaces" below) so you verify
populated states, not just empty ones. Run this before accepting a builder's "done" on web work.
Per-package (once packages exist):
pnpm -F @junction/core test
pnpm -F @junction/core build
Config home
- Junction's home is
~/.junction (override with JUNCTION_HOME).
- In tests, always set
JUNCTION_HOME=<tmpdir> to isolate filesystem state.
Testing surfaces — the stable ./junction launcher vs throwaway QA homes
Two distinct flows; keep them separate:
-
Manual / dev testing → ./junction (repo-root launcher). Runs the built CLI against a stable dev home so your setup persists between commands:
pnpm build
./junction init
./junction platform list
./junction web
JUNCTION_HOME=~/.junction ./junction status
Defaults JUNCTION_HOME=<repo>/.junction — a persistent, gitignored dev home (survives reboots, mirrors the production ~/.junction layout). Override via the env var (e.g. ~/.junction for your real vault, or /tmp/jtNN for a throwaway). Rebuild after any source change: pnpm build.
-
Orchestrator QA (per increment) → ephemeral /tmp/jtNN. During automated QA of increment NN, use a fresh JUNCTION_HOME=/tmp/jtNN (e.g. /tmp/jt22) — isolated and disposable, so QA never pollutes the stable /tmp/jtest dev home. These are created/torn down per QA run, not committed.
CLI (once increment 3 lands)
pnpm -F junction dev -- <args>
node packages/cli/dist/index.js <args>
junction init
junction status
Persistence (increment 5)
The DB lives at ~/.junction/junction.db (SQLite, via Drizzle + better-sqlite3),
created and migrated on first use (junction init or the first profile command).
junction profile list
junction profile list --json
- Persistence (Drizzle schema, migrations, repository layer) lives in
@junction/core only; the CLI edge is thin.
- Migrations are committed + forward-only under
packages/core/src/db/migrations/ and copied into dist/ at build so the built CLI can migrate a fresh DB.
Platforms, credentials, and profile sources (increment 10)
Define a generic MCP source platform, add a bearer credential, and wire a profile source:
JUNCTION_HOME=/tmp/jt10 junction platform add \
--id my-server --display-name "My Server" \
--transport http --url https://api.example.com/mcp/ \
--auth-header Authorization --json
JUNCTION_HOME=/tmp/jt10 junction platform add \
--id local-mcp --display-name "Local MCP" \
--transport stdio --command npx \
--arg "-y" --arg "some-mcp-package" \
--token-env MY_TOKEN --json
junction platform list --json
echo "<token>" | junction credential add \
--platform my-server --account work --kind bearer --token-stdin --json
junction credential list --platform my-server --json
junction profile add-source \
--profile default --platform my-server \
--credential <credential-id> --namespace mcp_work \
--allow list_tools --allow get_info \
--json
Token security invariants (enforced by tests):
- The token NEVER appears in any command stdout or stderr.
- A whole-DB scan (
readFile(dbPath).toString("utf8")) finds NO trace of the token.
credential list emits metadata only: id, platformId, account, kind — never secretRef.
Source-agnostic: platforms are generic data rows. No if (platform === "github") logic
anywhere in core/cli/mcp. grep -ri github packages/core/src packages/cli/src packages/mcp
must hit only comments and example strings.
Override credential store backend (useful in tests/CI — avoids keyring access):
JUNCTION_STORE=file junction credential add ...
Debug a source — probe + call (increment 11, generalized in 17)
junction debug probe connects to any source (MCP or OpenAPI), lists tools, and prints
both raw and namespaced names (<namespace>__<tool>). junction debug call invokes a
single tool against any source and prints the result. Secret/URL never appear in output.
--credential is optional (omit for public/no-auth sources). Both have --json.
JUNCTION_HOME=/tmp/jt10 junction credential list --platform github --json
JUNCTION_HOME=/tmp/jt10 junction debug probe \
--platform github --credential <id>
JUNCTION_HOME=/tmp/jt10 junction debug probe --platform github --credential <id> --json
JUNCTION_HOME=/tmp/jt10 junction debug call \
--platform github --credential <id> --tool list_issues --args '{"state":"open"}'
Security invariants (enforced by tests):
- The bearer token NEVER appears in stdout, stderr, or any error message.
- The probe prints only tool names and counts — no credential values.
- The namespace is derived from
{platformId}_{credentialAccount} (e.g. github_work).
Source-agnostic: mcp-client knows transports (http / stdio), not vendors.
grep -rin github packages/mcp/client/src must hit only comments and test examples —
never control flow or hardcoded URLs/tool names.
MCP server (increment 7)
junction mcp serve speaks MCP over stdio — point any MCP client at it.
junction mcp serve
junction mcp serve --profile work
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| JUNCTION_HOME=/tmp/jt node packages/cli/dist/index.js mcp serve
CRITICAL: stdout is the MCP channel — nothing except JSON-RPC frames may appear on stdout.
Human-readable output always goes to stderr.
Profile proxy — full agent tool call (increment 12)
junction profile create creates a named profile. junction mcp serve --profile <name> then
serves it as a real proxy: namespaced tools (<namespace>__<tool>) are returned to agents, and
tool calls are proxied upstream with the credential injected at call-time. The credential never
reaches the agent.
junction profile create --name work --json
JUNCTION_HOME=/tmp/jt12 junction init --json
JUNCTION_HOME=/tmp/jt12 junction platform add \
--id my-server --display-name "My Server" \
--transport http --url https://api.example.com/mcp/ \
--auth-header Authorization --json
echo "my-bearer-token" | JUNCTION_HOME=/tmp/jt12 junction credential add \
--platform my-server --account work --kind bearer --token-stdin --json
JUNCTION_HOME=/tmp/jt12 junction profile create --name work --json
JUNCTION_HOME=/tmp/jt12 junction profile add-source \
--profile work --platform my-server --credential <credential-id> --namespace srv --json
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"srv__some_tool","arguments":{}}}' \
| JUNCTION_HOME=/tmp/jt12 node packages/cli/dist/index.js mcp serve --profile work
Architecture (injection, boundary-preserving):
mcp/server takes injected McpServerHandlers { listTools, callTool } — it knows nothing about credentials.
mcp/client exports createProfileProxy(sources, resolveSource) — it knows nothing about the DB.
- The cli is the composition root: it builds
resolveSource (from repos + credential store),
creates the proxy, adapts ResultAsync → Promise, and passes the handlers to serveStdio.
- Boundary:
mcp/server → core only; mcp/client → core only; cli → core + both mcp packages.
depcruise enforces this; do not edit .dependency-cruiser.cjs to add mcp/server → mcp/client.
Per-source resilience: listTools always returns Ok — failing sources are silently skipped.
callTool propagates errors as safe MCP error responses (no secret in the message).
Credential discipline: the secret flows resolveSource → sessionFactory → transport only.
It is never stored on the proxy, never returned in any result, error, or log.
safeUpstreamMessage (exported from @junction/mcp-server, used in cli) maps errors to safe strings.
toolFilter: allow/deny lists on a source are applied to UPSTREAM tool names (the part
after __). Set via profile add-source --allow <tool> --deny <tool> (repeatable flags).
Enforcement
- Git hooks (lefthook) run
pnpm verify pre-commit — a failing verify blocks the commit.
- Claude Code hooks (
.claude/settings.json) format on edit and guard package boundaries.
- See
docs/rules/ for the coding rules and docs/workflow.md for the per-increment loop.
TUI dashboard (increment 9)
Bare junction in an interactive terminal launches the full-screen Ink TUI dashboard.
The TUI shows three panels (Status / Profiles / Platforms), is keyboard-driven, and exits
cleanly with q. Use Tab to move between panels, ↑/↓ or j/k to navigate within a panel,
r to reload live data.
junction
junction | cat
junction --json
junction status --json
junction profile list --json
Headless contract (load-bearing):
bare + both TTYs + no --json → Ink TUI dashboard
bare + no TTY (pipe/CI/agent) → headless status (no hang)
any subcommand or meta flag → citty as before
Security: the TUI never renders credential secret values — only metadata
(displayName, kind, credentialCount per platform). secretRef is intentionally absent
from every DashboardSnapshot type.
Source management — inspect, disable, remove (increment 13)
Complete the create→inspect→disable→remove lifecycle for profile sources:
JUNCTION_HOME=/tmp/jt13 junction profile show --profile work --json
JUNCTION_HOME=/tmp/jt13 junction profile disable-source --profile work --namespace srv --json
JUNCTION_HOME=/tmp/jt13 junction profile enable-source --profile work --namespace srv --json
JUNCTION_HOME=/tmp/jt13 junction profile remove-source --profile work --namespace srv --json
JUNCTION_HOME=/tmp/jt13 junction profile delete --profile work --json
JUNCTION_HOME=/tmp/jt13 junction credential remove --id <credential-id> --json
JUNCTION_HOME=/tmp/jt13 junction platform remove --id <platform-id> --json
JUNCTION_HOME=/tmp/jt13 junction status
Security invariants (enforced by tests):
removeCredential deletes the secret ONLY after a successful DB delete (RESTRICT = secret never orphaned).
credential remove while source still references it → clean "in-use" error, exit≠0, secret untouched.
profile show / status / dashboard NEVER expose secretRef — only IDs, namespace, enabled flag.
- RESTRICT FK on
source_refs.credential_id and source_refs.platform_id — no cascade deletes into credentials/platforms.
TUI dashboard (increment 13):
The Profiles panel now shows per-source rows beneath each profile, with ✓/✗ enabled/disabled glyphs.
sourceCount removed from DashboardProfile; replaced with sources: DashboardSource[].
Optional credentials — public/no-auth sources (increment 16)
--credential is now optional in add-source and debug probe/debug call. Omit it to
create a public/no-auth source that connects with secret = null.
JUNCTION_HOME=/tmp/jt16 junction profile add-source \
--profile p --platform pub --namespace pub --json
JUNCTION_HOME=/tmp/jt16 junction profile show --name p --json
JUNCTION_HOME=/tmp/jt16 junction debug probe --platform pub
JUNCTION_HOME=/tmp/jt16 junction debug probe --platform github --credential <id>
Auth-declared-but-no-credential warning (informative, not blocking):
When --credential is omitted but the platform declares auth (MCP connection.auth or
OpenAPI openapi.auth), a warning is written to stderr only (stdout is the MCP channel):
junction mcp serve: source "pub": platform "github" declares auth but no credential is attached — calls may be unauthorized
Security invariants:
secret = null → injectAuth short-circuits; no auth header sent (already verified in openapi-client tests).
- No
store.get call is made when credentialId is absent — the credential store is untouched.
- RESTRICT FK on
credential_id still blocks deleting a credential referenced by a credentialed source.
- NULL
credential_id in DB is FK-exempt — it does NOT reference any credential row.
Web dashboard (increment 22)
junction web launches the local read-only dashboard on http://127.0.0.1:4321. The web package must be built first.
pnpm build
JUNCTION_HOME=/tmp/jt22 junction web
JUNCTION_HOME=/tmp/jt22 junction web --port 8080 --no-open
pnpm --filter @junction/web dev
Architecture:
junction web resolves the built server entry via import.meta.resolve("@junction/web/server") (artifact dep — not a code import) and spawns it as a subprocess bound to 127.0.0.1.
- All core data access goes through
createServerFn in packages/web/src/server/data.functions.ts; core is imported only in *.server.ts.
- Client bundle must never contain
better-sqlite3/@napi-rs/keyring/core DB code — verified by grep -rl "better-sqlite3\|napi-rs/keyring\|CREATE TABLE\|drizzle" packages/web/.output/public.
- Read-only (no mutations); no auth; loopback-only (
HOST=127.0.0.1); Host header guard in every server function.
Typecheck + verify:
pnpm verify
Large-spec selection + platform refresh (increment 19)
OpenAPI specs with more than maxTools (default 75) operations can be added as a slice using
--tag and/or --path (both repeatable). The selection is persisted in the descriptor and
re-applied at serve/debug time so agents see exactly the chosen slice.
JUNCTION_HOME=/tmp/jt19 junction platform add --id big --kind openapi \
--display-name Big --spec-url <large-spec-url>
JUNCTION_HOME=/tmp/jt19 junction platform add --id big --kind openapi \
--display-name Big --spec-url <large-spec-url> --tag pet --json
JUNCTION_HOME=/tmp/jt19 junction platform add --id big --kind openapi \
--display-name Big --spec-url <large-spec-url> --path /pet --json
JUNCTION_HOME=/tmp/jt19 junction platform add --id big --kind openapi \
--display-name Big --spec-url <large-spec-url> --tag store --path /user --json
JUNCTION_HOME=/tmp/jt19 junction debug probe --platform big
JUNCTION_HOME=/tmp/jt19 junction platform refresh --id big --json
Selection invariants (load-bearing):
- Selection is persisted in
OpenApiConnection.select (stored in the openapi JSON column) and
re-applied at runtime so listTools (serve/probe) returns exactly the persisted slice.
--tag uses tag membership; --path uses path-boundary prefix match; together = union.
- The cap (
maxTools) applies to the selected count, not the full spec count.
platform refresh invariants:
- Only openapi platforms whose
spec.from === "url" can be refreshed.
- If the refreshed spec would exceed the cap (after selection), refresh REFUSES and leaves the
DB descriptor + cached spec file completely unchanged (no-clobber).
- A fetch failure also leaves everything unchanged.
- Base URL is re-resolved from the refreshed spec's
servers; falls back to the existing stored
baseUrl if the new spec drops or templates its servers (so a working platform stays working).