semctl is the command-line client for the semctx code-intelligence server —
semantic + exact code search and a precise symbol/call/value-flow graph over your
repositories, exposed both as a CLI and as an MCP
stdio server for AI coding tools (Claude Code, Codex).
It's a thin HTTP+JSON client: all indexing and retrieval happen server-side, so
semctl links no engine code and has an entirely public dependency graph.
The one-liner downloads the prebuilt binary from the latest GitHub release,
verifies its checksum, and hands off to semctl install — which puts the binary
on your PATH and wires it into the AI tools it finds. No Rust toolchain needed.
Linux / macOS (shell):
curl -fsSL https://raw.githubusercontent.com/napbat/semctl/main/install-cli.sh | shWindows (PowerShell):
irm https://raw.githubusercontent.com/napbat/semctl/main/install-cli.ps1 | iexFor a platform without a prebuilt binary — or if you'd rather build it — install with cargo (needs a Rust toolchain):
cargo install --git https://github.com/napbat/semctl --locked semctlThen sign in and index a repo:
semctl install # interactive: integrates Claude Code, Codex CLI, and Oh My Pi
semctl auth login # OIDC device-code sign-in
semctl index # register + sync the current repo for indexing| Command | What it does |
|---|---|
semctl search <query> |
Cross-domain semantic search. |
semctl index |
Register the current Git worktree (or selected non-Git folder) and sync its files. |
semctl graph … |
Exact code intelligence plus symbol search, type hierarchy, call graphs, cycles, unused, duplicates, and rich outlines. |
semctl edit … |
Grammar-native rename/delete/body/insertion planning and verified local apply/undo. |
semctl files … |
File catalog (tree / filtered list) and revision-pinned line/byte source reads. |
semctl inspect … |
Projects/domains plus visible codebases and the effective server/tenant/index/graph context. |
semctl mcp |
Run as an MCP stdio server (launched by the host, not by hand). |
semctl daemon status / stop |
Report or end the shared local daemon that serves the MCP sessions. |
semctl install |
Add/remove the editor/agent integrations. |
semctl uninstall |
Reverse install: unwire the tools, remove from PATH, delete the binary (--purge also drops config + credentials). |
semctl upgrade / update |
Update the binary and refresh each installed editor/agent integration. |
semctl auth login / logout / whoami / tenants |
Account & session. |
semctl index uploads the requested files in batches. A failed batch does not
stop the other batches. A batch that failed because of a connection, gateway, or
server error is sent once more, alone, after the other batches. Then the command
completes the sync for the files that the server received. It exits with an
error that names the files that the server did not receive. Run semctl index
again to upload them. The MCP auto-index logs the same files, and
semctl daemon status shows them for each checkout.
The server's edit operations are planners: they resolve one qualified or
positional symbol through semctx's grammar graph, reparse and re-resolve the
proposed postimages, then return a JSON WorkspaceEditPlan. They never write a
repository. For example:
semctl edit rename --path src/lib.rs --line 42 --column 8 NewName > plan.json
semctl edit apply plan.json
semctl edit undo <plan-id>Apply and undo use one local transaction engine. Apply checks the checkout identity and current server graph generation. It verifies every planned path, preimage, edit range, and expected postimage before staging the complete plan. Open directory handles keep mutations bound to the approved checkout. Reapplying an unchanged completed plan is idempotent. Undo requires all current files to match the retained postimage hashes.
Each replacement verifies the bytes it displaced. Installation and rollback
never replace a target created during the swap. Edits require filesystem
hard-link support; semctl checks this before changing source files. A conflict
retains private .semctl-*.edit recovery directories next to affected files
and reports their paths. Inspect retained files before retrying. Directory
renames can change those reported locations. Multiple file replacements are
not globally atomic. Semctl detects writes observed before cleanup, but a
writer can still change a displaced inode through an old open descriptor after
the final check. Stop such writers when full isolation is required.
Undo history stays in the local config directory's edit-history/ folder.
A plan-supplied formatter runs only with explicit --run-formatter approval.
Formatter commands, arguments, and paths are bounded to the approved plan.
Each formatter receives only a planned
postimage through standard input. Semctl captures the formatted output before
it writes any file. Prettier runs without project configuration, EditorConfig
settings, or configured plugins. These restrictions keep formatter writes
within the approved plan.
The MCP surface exposes edits as immediate actions. rename_symbol,
safe_delete_symbol, replace_symbol_body, insert_before_symbol, and
insert_after_symbol ask the server for its internal plan and consume it inside
the same call; callers never shuttle plan JSON through MCP. Each action is
write/destructive in the host approval UI, returns an editId, and can be
reversed with the hash-guarded undo_edit action. Read-only additions include
list_codebases, current_context, read_source, search_symbols,
type_hierarchy, call_graph, cycles, unused, and duplicates; existing
reference/search/outline tools expose the richer occurrence, scope, and nesting
options instead of gaining aliases.
Every read-only MCP tool call ends within one deadline. The default is 25
seconds, below the 30 seconds after which Oh My Pi ends an MCP request. Set
SEMCTX_TOOL_DEADLINE_SECS to change it. A value from 5 to 600 applies as
given, a smaller or larger value is held to that range, and 0 or an
unreadable value means the default. Inside the deadline, a request that meets a
gateway failure or a refused connection is sent once more after one second, and
only when it only reads. A call that has no answer in time returns an error
result that names the cause and the next step. A retrieval call also waits at
most 5 seconds for a first index before it reports that the index is still
running. The edit actions have no deadline: a limit that fired after the files
changed would report a failed edit that was applied.
The index_codebase tool does not wait for the first index. It registers the
codebase, starts the scan and upload in the background, and returns at once with
the codebase id and the path. The sync_status tool then reports the first-index
phase: registering, syncing, embedding, ready, or failed. While a sync runs, it
also reports a current sync: line with the latest step, such as the number of
uploaded files. semctl daemon status reports the same phase and step for each
checkout.
The first semctl install automatically retires a previous semctx install: it
migrates your config + credentials to ~/.config/semctl, removes the old binary
and its PATH entry, and re-points the Claude Code / Codex plugin at semctl's
source (the plugin itself stays the same). A cargo install'd semctx is left
for you to remove with cargo uninstall semctx-cli.
semctl mcp can serve its session from one shared local process instead of
serving it in the process the host started. One daemon serves every MCP session
of one operating-system user and one configuration directory. It keeps one
filesystem watcher, one reconcile queue, one scan cache, and one HTTP connection
pool per checkout, however many sessions use that checkout. Nothing changes at
the MCP tool boundary: the host still launches semctl mcp, and that process
becomes a byte pump between the host and the daemon.
SEMCTX_MCP_DAEMON selects the role of a semctl mcp invocation:
| Value | What it does |
|---|---|
auto (default) |
Attach to the daemon, start one when none is running, and serve the session in this process when that fails. |
off |
Serve the session in this process, as every earlier version did. |
require |
Attach to the daemon, start one when none is running, and exit with status 1 when that fails. |
On Windows, a daemon that semctl mcp starts must belong to no job object
before it opens the shared endpoint. The client asks Windows to release the
daemon from the client's jobs. A job that forbids breakaway blocks that
request, and an ancestor job can keep the daemon even when the request
succeeds. The daemon rejects membership in any job, including a job that would
not end the daemon, because Windows offers no simple way to read an ancestor
job's limits. When the daemon still belongs to a job, auto serves this
session in its own process and require reports the startup failure. Clients
in a job can still attach to an existing daemon. Closing one client's job must
not end other clients' sessions.
Some hosts run every process in a job that forbids breakaway. To share one
daemon there, run semctl daemon run yourself as the same user and with the
same configuration as the clients, from a process whose lifetime you control.
A daemon started by hand skips the job check, and clients attach to it.
Two commands inspect and end a running daemon:
semctl daemon status # version, pid, uptime, sessions, permits, checkouts, their first-index phase, and the progress of a running sync
semctl daemon status --json # the same facts as one JSON object
semctl daemon stop # end every session and exitBoth exit with status 1 and report no daemon is running for this configuration when no daemon answers. A daemon also exits by itself after ten
minutes with no session.
A running daemon keeps its loaded executable while it serves a session. It
checks its executable file every 30 seconds. After an on-disk update, such as
semctl upgrade, it exits as soon as its last session ends, and the next
semctl mcp starts a daemon from the new executable. Existing sessions keep the
old version until the host reconnects them. To switch at once, run
semctl daemon stop and reconnect the host's MCP sessions.
On Windows, the daemon and its Git probes use CREATE_NO_WINDOW: redirecting
standard streams alone does not prevent Windows from allocating a console for
each probe. Git source-policy checks skip the separate exclusion-path query when
the complete resolved configuration proves core.excludesFile is absent.
Explicit values still use Git's path expansion and participate in policy
fingerprints; ignore-source identity and byte checks remain in place.
| Env | What it sets |
|---|---|
SEMCTX_DAEMON_IDLE_SECS |
Seconds with no session before the daemon exits. Default 600; 0 exits with the last session. |
SEMCTX_DAEMON_AUTO_UPDATE |
0 keeps an idle daemon running after its executable is updated on disk. Default on. |
SEMCTX_DAEMON_SCAN_PERMITS |
Concurrent checkout scans. Default: half the available CPUs, at least 2 and at most 8. |
SEMCTX_DAEMON_UPLOAD_PERMITS |
Concurrent upload requests across every checkout. Default 8. |
SEMCTX_DAEMON_REMOTE_PERMITS |
Concurrent remote requests from tool calls. Default 64. |
Each permit override is clamped to the range 1 to 1024.
A daemon inherits the environment of the client that started it, minus the
seven per-session variables (SEMCTX_TOKEN, SEMCTX_SERVER, SEMCTX_TENANT,
SEMCTX_CODEBASE, SEMCTX_MCP_RESYNC_SECS, SEMCTX_TOOL_DEADLINE_SECS,
SEMCTX_MCP_UPDATE_CHECK). Every
later session of that daemon therefore runs with the first client's proxy
settings, its PATH, which decides which formatter an edit runs, and its Git
configuration variables, which decide which rules a source policy reads. Run
semctl daemon stop to change any of them: the next semctl mcp client starts
a daemon with its own environment. The daemon's working directory is not
inherited; each session carries its own in its attach request.
The endpoint and the daemon log live in the same place on each platform:
| Platform | Endpoint | Log |
|---|---|---|
| Linux | $XDG_RUNTIME_DIR/semctl/<id>.sock, else /tmp/semctl-<uid>/<id>.sock |
<same directory>/<id>.log |
| macOS | $TMPDIR/semctl/<id>.sock, else /tmp/semctl-<uid>/<id>.sock |
<same directory>/<id>.log |
| Windows | \\.\pipe\semctl-<id> |
%LOCALAPPDATA%\semctl\daemon-<id>.log |
<id> is a hash of the configuration directory, the build version, and the
user. A new version therefore starts its own daemon and never attaches to a
daemon of another version.
On Unix the runtime directory and the socket are owner-only, semctl refuses a runtime directory it does not own or one that carries group or other permission bits, and the daemon checks each peer's user id before it reads a handshake. On Windows the pipe carries a security descriptor that grants the current user's SID alone, the first instance of the pipe name is the election, and the client opens the pipe at identification level so the server cannot impersonate it. Secrets travel only inside the handshake body: no token appears in an endpoint name, a command argument, a log line, or status output.
The full shared-daemon lifecycle suite is Unix-only. Native Windows regressions
cover rejected startup in breakaway-denied and nested jobs, standalone
fallback, attachment to an existing daemon, console-free Git probes, and
source-policy syncing. Startup that survives its client's job needs a process
outside Cargo's job, so that case runs only from the test binary directly (see
tests/daemon_windows_jobs.rs). Windows named-pipe startup, MCP
initialization, tool listing/calls, and client EOF have also been
smoke-tested. The macOS daemon path remains compile-checked without runtime
measurements.
semctl upgradeFor a binary installed the standard way (the install script), upgrade downloads
the latest release and self-replaces in place. If semctl was installed with
cargo install, it's cargo-managed — upgrade detects that and tells you to
re-run cargo install … --force instead of clobbering it. In both cases,
upgrade refreshes each installed integration through the same update path as
semctl install. This refresh can update hooks, skills, and MCP configuration.
The refresh also runs when the binary is already current. It does not install or
remove an integration.
Release maintainers must run an asset rebuild from the existing version tag. The workflow refuses a rebuild when the selected commit differs from that tag. Publication verifies the tag again before it uploads assets.
Config lives at ~/.config/semctl/config.toml (credentials in a sibling
0600 credentials.json); XDG_CONFIG_HOME overrides the base. A non-secret
installation-id in the same directory lets the server distinguish concurrent
local checkouts without receiving a host name or local path. Local codebases are
mapped by canonical checkout path, not guessed from Git remote or folder name:
servers without project-key support keep separate checkout slugs. Servers with
project-key support decide project membership from complete remote identities.
Ordinary checkout paths retain their existing source IDs. Native paths with
invalid UTF-8, the Unicode replacement character, or a literal Unix backslash
receive lossless source IDs and cache keys. Old ambiguous cache entries do not
authorize a checkout or reuse its single-manifest codebase. These paths require
indexing again because the old encoding could identify another checkout. An older
~/.config/semctx/ install remains readable.
Legacy credentials migrate when their JWT issuer matches the configured server's
advertised authority. Credentials without that evidence require a new login.
Each stored login records its resource server, authentication authority, and
generation. Requests refuse credentials from another server. A server override
therefore needs a login for that server, or an explicit SEMCTX_TOKEN for the
invocation. Refresh tokens remain bound to the authority that issued them.
Login, logout, refresh, migration, and purge share one state lock. The non-secret
.semctl.state.lock and .semctl.state.generation files remain in the config
base after purge. They prevent pending operations from restoring cleared login
state. Blank server flags and environment values fall through to the next
configured source.
sync-cache/ stores non-secret per-checkout hashes and content-filter decisions.
Every scan reads and hashes current file bytes. Matching hashes permit reuse of
filter decisions. Equal file sizes and timestamps never prove unchanged content.
Source contents are never written to this cache, and semctl uninstall --purge
removes it with the rest of the semctl config directory.
Use .semctlignore to exclude repository files from indexing. The legacy
.semctxignore name remains supported. Both files use Git ignore syntax. A sync aborts when any effective exclusion
source cannot be read or changes during the scan. This includes Git global
exclusions, included configuration, and worktree info/exclude files.
| Setting | Flag | Env |
|---|---|---|
| Server base URL | --server |
SEMCTX_SERVER |
| Active tenant | --tenant |
SEMCTX_TENANT |
| Active codebase | --codebase |
SEMCTX_CODEBASE |
Login clears the previous account's tenant and automatically selects a sole
membership. Tenant selection applies only while that login remains current.
If a saved tenant later becomes
invalid, an authenticated request repairs it the same way and retries once.
Explicit --tenant / SEMCTX_TENANT overrides are never replaced; with
multiple memberships, run semctl auth tenants in an interactive terminal to
pick by number, or choose non-interactively with
semctl auth tenants --switch <slug>.
With nothing configured, semctl talks to the hosted server at
https://semctx.napbat.ca.
The MCP server sends retrieval queries and opted-in repository content to the
configured semctx server. When trusted Claude/Codex hooks or the native Oh My Pi
extension are enabled, retrieval-shaped user prompts are also sent to semctl hook for a bounded, best-effort candidate search. The OMP extension runs
in-process but delegates retrieval, authentication, and nudge state to the
semctl subprocess; it does not make network requests itself. Set
SEMCTX_HOOK_DISABLE=1 to disable all hook behavior or
SEMCTX_NUDGE_DISABLE=1 to disable only shell-search reminders.
Observed semctx use cools further reminders for the active prompt. A new prompt
or context reset re-arms guidance immediately; within one prompt, three
consecutive broad built-in searches re-arm it by default. Tune that streak with
SEMCTX_NUDGE_REARM_BROAD, and tune the existing escalation ladder with
SEMCTX_NUDGE_GRACE, SEMCTX_NUDGE_COOLDOWN, and SEMCTX_NUDGE_MAX (default
cap: four successful nudges per clear/compact segment).
Run mise install to install the repository's Rust and Bun toolchains. Install
Python 3, a POSIX shell, and PowerShell 7 for the bootstrap tests.
Run prek install once to install the pre-commit hook. The complete local and CI
gate is:
prek run --all-filesThe configured hooks run:
cargo fmt --check
cargo clippy --workspace --all-targets --locked -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items --locked
cargo test --workspace --locked
mise exec -c "bun test plugins/semctx/adapters/omp/index.test.ts"
python3 tests/bootstrap_installers.pyThe installer test runs the sh and pwsh installer cases for each shell that
is installed. It skips a missing shell and reports the skip. CI runs sh on
Linux and pwsh on Windows. Use --shell all to require both shells.
plugins/semctx/ is the shared plugin root for every supported coding agent.
Its skills/codebase-retrieval/ has one physical source. Claude/Codex consume
the shared hook manifest; OMP loads adapters/omp/index.ts, which maps native
lifecycle events onto the same semctl hook wire contract. See
plugins/semctx/README.md before adding another
agent integration. Do not copy or symlink shared skills.
The deterministic hook and OMP-extension cases run under the normal Rust and Bun test commands.
tests/hosts/ runs each supported coding agent (OMP, Claude Code, and Codex)
with the plugin from this checkout and the semctl that Cargo builds from it.
Each host talks to a local scripted model, so a session is deterministic and
offline. semctl runs logged out against an unreachable server. The tests read
its decisions from the SEMCTX_HOOK_TRACE file. One scenario list runs on every
host. Each host adapter in tests/hosts/hosts.ts maps the scenarios to that
host's native tool calls. These tests are slow, so CI does not run them. Run
them locally:
SEMCTX_HOST_INTEGRATION=1 mise exec -c "bun test tests/hosts"Without SEMCTX_HOSTS, the suite skips a host whose CLI is not on PATH.
SEMCTX_HOSTS=omp,claude selects hosts, and a selected host whose CLI is
missing fails. SEMCTX_HOST_COMMAND_<ID> replaces a host's launch command, for
example SEMCTX_HOST_COMMAND_CODEX="bun x @openai/codex".
To add a host, add an adapter to HOST_ADAPTERS in tests/hosts/hosts.ts. If
the host speaks a new model API, also add a wire format to
tests/hosts/model_server.ts.
SEMCTX_HOOK_TRACE=<file> also helps with host debugging. Each semctl hook
run appends one JSON line with the event, host, tool name, PreToolUse
decision, and whether the hook emitted context. A trace line never contains
prompt text, tool input, or context text.
To record fresh Codex, Claude, and OMP tool-event traces for the model-level golden prompts:
python3 scripts/run_skill_evals.py --host allCI (.github/workflows/ci.yml) installs the current stable Rust toolchain and
runs these checks. Use the same Rust version for local checks. New stable
releases can add Clippy lints. Clippy uses pedantic. Warnings, including broken
doc links, fail the build.
Releases are cut by bumping the version: set a new version in Cargo.toml
(e.g. 0.1.0 → 0.1.1) and push to main. .github/workflows/release.yml
detects the new version, builds the per-platform binaries, and publishes them as
release v<version> after the shared CI gate passes for the same commit. The
release tag points to that commit. semctl upgrade then picks it up. Pushes that
do not change the version are a no-op.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.