@codixus/mt
v0.16.0
Published
Cross-platform skill manager + dev shortcut CLI
Maintainers
Readme
@codixus/mt
Cross-platform skill manager + dev shortcut CLI for Claude Code, Codex, Gemini CLI, Cursor and other AI coding agents.
Install
npm i -g @codixus/mt
# or
bun add -g @codixus/mtRequires Bun ≥1.0 (the CLI ships as .ts and runs through bun).
Skill manager
Subscribe to skill repos (public or private), install only what you need, sync across machines.
mt login # save GitHub PAT to ~/.mt/auth.json (chmod 600)
mt add anthropics/skills --path "skills/frontend-design" # cherry-pick a single skill
mt add me/private-skills --path "skills/*" --exclude "cxs-*" # subscribe to a whole repo with filters
mt sync # fetch + reconcile skills + hooks (idempotent)
mt sync --global --force # also wire the shortcut rc files (zsh + bash) in one shot
mt list # show configured + installed
mt remove <name> # remove + reconcile orphans
mt skills disable <name> # exclude one skill from a repo subscription, delete its files now
mt skills enable <name> # clear the disable flag and sync it back inTwo scopes, auto-detected by cwd:
- Global (
~/.mt/): installs to~/.claude/,~/.codex/,~/.gemini/, etc. - Local (
<project>/.mt/): installs only to that project's.claude/etc.
Use --global or --local to override detection.
Per-name overrides live in skills.json's skills map, keyed by skill/command/agent leaf name:
{
"entries": [{ "name": "codixus", "repo": "codixus/skills", "path": "skills/*" }],
"skills": {
"cxs-app-audit": { "enabled": false },
"mtc": { "alias": "mt-commit" }
}
}enabled: false excludes that name from every future mt sync (whether it comes from a glob subscription or not) and deletes any previously-synced copy immediately; alias materialises a new short name as a copy of an existing leaf. mt skills disable/mt skills enable manage the enabled flag for you; alias entries are still hand-edited directly in skills.json.
Shell shortcuts
Cross-platform alias manager (bash, zsh, fish, PowerShell 7+).
mt shortcuts add b "bun install"
mt shortcuts add bt "bun test"
mt shortcuts add mkcd "mkdir -p {1} && cd {1}"
mt shortcuts sync # one-time: wires up your rc file
exec zsh
b foo # → bun install foo
mkcd test/dir # → mkdir -p test/dir && cd test/dirTemplated args use {N} / {@} placeholders; mt translates per shell.
Built-in utilities
mt kp 3000 5173 # kill processes on ports (cross-platform)
mt sv ./public -p 8080 # serve directory as static web server
mt open . # cross-platform Finder/Explorer/xdg-open
mt awake # block system/display sleep + screensaver until Ctrl+C
mt rimraf node_modules dist
mt compress *.png # lossless image compress in-place (with .bak)
mt convert webp *.png # batch format conversion (lossless quality 100)
mt paste shot.png # save the clipboard image as a PNG
mt paste # same, named clipboard-YYYYMMDD-HHMMSS.pngSupported image formats: PNG, JPG/JPEG, WebP, AVIF.
mt paste reads the image on the system clipboard and writes it as a PNG. It uses osascript on
macOS, PowerShell on Windows, and wl-paste (Wayland) or xclip (X11) on Linux - one of those two
has to be installed for the Linux path. An existing file at the target is overwritten.
Linter
mt lint is a generic project linter - works in any project, not skill-specific. ~35 rules covering things ESLint doesn't: secrets (AWS/JWT/Stripe/GitHub PATs), AI-slop phrases, Turkish diacritics, typography (smart quotes, em-dash, mojibake), design tokens (off-scale px/rem, raw hex, magic z-index), markdown structure, cross-platform footguns, and i18n locale sync.
mt lint # lint staged files
mt lint --all # lint all tracked files
mt lint --fix # apply safe auto-fixes
mt lint init # write .mt/lint.json (auto-detects project type)
mt lint list-rules # show all rules + severity
mt lint explain <ruleId> # show rule detail
mt lint install-hook # install pre-commit hookWatching (mt watch)
Watch something from the background and have its developments delivered into your agent chat, so you do not poll in a loop. Three targets: a pull request (mt watch pr), an issue (mt watch issue), and a repository (mt watch repo).
PR watching
Watch a pull request and have its developments (CI results, new commits, reviews, comments) delivered into your agent chat, so you do not poll gh pr checks in a loop.
mt watch pr subscribe --pr "owner/repo#123" --thread <session-id> [--events checks,commit,review,comment] [--interval 30]
mt watch pr unsubscribe (--pr "owner/repo#123" | --all) --thread <session-id>
mt watch pr status [--thread <session-id>] [--json]
mt watch pr wait --thread <session-id> [--timeout 600]--pracceptsowner/repo#Nor agithub.comPR URL.--threadis the agent's session id.subscribeis idempotent per PR (re-subscribing updates events/interval and keeps the cursor) and starts a detached watcher process. The watcher polls with conditional (ETag) requests where GitHub supports them (the PR object and check-runs), so an unchanged poll of those returns304and does not count against the rate limit; review and comment polls are still counted. Against the 5000/hour authenticated budget this is comfortable for a handful of watches. It needs no cron, launchd, or root.- Updates land in chat through the
pr-watchhook (Stop + UserPromptSubmit), the same state-gated pattern as the autopilot hooks: silent for any thread with no watches. An idle session wakes automatically when events land: anasyncRewakeStop waiter (Claude Code >= 2.1.x) sleeps until the watcher queues a batch, then delivers it as a system reminder and wakes the session. Codex has no rewake mechanism, so delivery there stays on turn boundaries.mt watch pr waitremains for an explicit blocking wait - run it withrun_in_background; it exits0with the events as JSON, or8on timeout. - A merged/closed PR, or the 24h TTL, auto-removes the subscription with a final
stateevent. When the last watch on a thread goes, the watcher stops itself. - Auth defaults to the
ghCLI token (gh auth token), then falls back to~/.mt/auth.json,$MT_GITHUB_TOKEN,$GITHUB_TOKEN.--key <gh|config|env>pins exactly one source. gh-first is deliberate: on a machine whose~/.mt/auth.jsonpoints at an account that cannot see the org's private repos, config-first silently resolves a token that 404s.
Issue watching
Watch one issue and get told when someone comments on it, closes it, or changes its labels or assignees.
mt watch issue subscribe --issue "owner/repo#12" [--thread <session-id>] [--events comment,state,label,assign] [--interval 30] [--key gh|config|env]
mt watch issue unsubscribe (--issue "owner/repo#12" | --all) [--thread <session-id>]
mt watch issue status [--thread <session-id>] [--json]
mt watch issue logs [--thread <session-id>] [--lines 50]--issueacceptsowner/repo#Nor agithub.comissue URL. A/pull/NURL is rejected: that is amt watch prinput.- Default events are
comment,state,assign;labelis opt-in, since label churn is triage traffic most watches never act on.stateis always implicit. - The first poll seeds from the issue's current state and emits nothing, so subscribing to a three-year-old issue never replays its comment history into your chat.
- A close is terminal: it arrives as a final
stateevent and drops the subscription, the way a merge drops a PR watch. There is no reopen event, because a surviving watch is always on an open issue. subscriberuns a one-shot access preflight. It also tells you when the number is actually a pull request - GitHub numbers issues and PRs from one sequence, so#12may well be a PR, and an issue watch on it would silently carry no checks or reviews. The poller stays quiet about this on purpose: a permanent error note there would back the watch off to its 300s ceiling for the whole TTL.- An issue watch and a PR watch on the same number coexist. Subscription ids are kind-prefixed (
issue:owner/repo#12) precisely so the two cannot overwrite each other. - Defaults: interval 30s (15s floor), TTL 24 hours - the same as a PR watch, since an issue watch resolves when the issue closes.
Repo watching
Watch a repository and get told when a new pull request or issue is opened in it, so an agent can react (review it, subscribe it) instead of noticing by accident.
mt watch repo subscribe --repo "owner/repo" [--thread <session-id>] [--events pr,issue] [--interval 60] [--key gh|config|env]
mt watch repo unsubscribe (--repo "owner/repo" | --all) [--thread <session-id>]
mt watch repo status [--thread <session-id>] [--json]
mt watch repo logs [--thread <session-id>] [--lines 50]--repoacceptsowner/repoor agithub.comrepo URL. Subscribe to several repos by callingsubscribeonce per repo.- It reports one thing: something was opened. A repo watch does not subscribe what it finds and starts nothing on its own. You decide, per item, whether to act and whether to track it with
mt watch pr subscribe --pr owner/repo#Normt watch issue subscribe --issue owner/repo#N. --eventspicks the kinds:pr(the default, and what every subscription made before issue support keeps meaning),issue, or both. Each kind carries its own cursor and its own ETag, so turning issues on later never replays the ones already open. Changing the set restarts the watcher, so a process from an older build cannot keep polling only the kinds it knew about.- The first poll of each kind seeds from what is currently open and emits nothing, so items already open when you subscribed never arrive as "new". After that, everything numbered above that kind's cursor produces one
openedevent, oldest first. GET /issuesserves pull requests too, so the issue half drops entries carryingpull_request: a PR is never announced twice.- No filtering otherwise: drafts and bot PRs are reported too. Decide which ones deserve action in the instructions you give the agent.
- Defaults: interval 60s (15s floor), TTL 7 days. Errors are soft - a 404/403/network failure keeps the watch alive and surfaces in
status, since a repo can become visible later. - One watcher process per thread serves every watch - PR, issue and repo alike;
unsubscribe --alldrops all of them and stops it.
Claiming a working tree (mt claim)
Several agents run on one machine at once. Two of them landing in the same working tree produce failures that look like nothing from either side: a poisoned git index, an overwritten uncommitted change, two branches racing one remote ref, a test run whose output belongs to somebody else's edit. Every command succeeded, so nothing reports it.
A claim is an agent saying "I am driving this working tree", in a place every other agent on the machine can check.
mt claim tree acquire [slug] [--path <dir>] [--actor <name>] [--note <text>] [--takeover] [--session <id>] [--json]
mt claim tree check [slug] [--path <dir>] [--session <id>] [--json]
mt claim tree renew [slug] [--path <dir>] [--actor <name>] [--session <id>] [--json]
mt claim tree release ([slug] | --path <dir> | --all) [--actor <name>] [--session <id>] [--json]
mt claim tree list [--repo <org/repo>] [--session <id>] [--stale] [--json]
mt claim tree sweep [--json]
mt claim tree doctor [--json]- The identity is a slug:
org/repo@branch, plus a#worktreediscriminator when the tree is a linked worktree. You may declare it, butmtmeasures the real one from git and refuses a declaration that disagrees, printing what it measured. A claim on a slug that does not describe reality protects nothing while reading as protection, which is worse than no claim at all. With no argument it measures one and prints it, so the ordinary call ismt claim tree acquire. - A directory that is not a git repository cannot be claimed. That is deliberate: it keeps a session started in a multi-repo workspace root from holding every repo under it. Such a session takes one claim per repo it actually writes to.
- Two grades of conflict. The same working tree held by another session is hard: exit
3, and the message names the holder and suggests a free worktree path, because "work somewhere else" is only useful advice when it comes with a path. The sameorg/repo@branchin a different tree is soft: the claim is written, exit0, and a warning goes to stderr. Those two do not clobber each other's files; they argue over the remote ref. - Owner is a session plus an actor.
$CLAUDE_CODE_SESSION_IDis inherited by tool shells, so a session's main agent and every subagent it dispatches see the same id.--actordistinguishes them, so parallel subagents of one session are visible to each other (soft, not hard) instead of invisible. - Liveness, not bookkeeping. A claim counts only while its agent process is alive AND it was
renewed inside two hours. A crashed agent's claim is reclaimable at once; a forgotten one expires
on its own. Editing through the file tools renews its claim, so an agent editing files never loses one; a
session that writes only through Bash (
sed -i, a heredoc,git) renews nothing and shouldmt claim tree renew, because the guard matches the edit tools and not arbitrary shell. listis the board, not your claims. It never narrows to the caller's own session, even when the environment names one: an agent running it to find out whether anyone else is in a tree would otherwise see only its own rows, which is the opposite of the question it asked.--sessionnarrows only when you type it.--takeoveracquires through a hard conflict and records who was displaced.sweepclears dead claims by hand;doctorreports whether the hooks below are actually installed, across both~/.claude/settings.jsonand~/.codex/hooks.json.
Exit codes: 0 free, soft or re-entrant; 1 from doctor when something is broken; 2 usage or
verification failure; 3 hard conflict, or releasing another session's claim; 4 the registry lock
could not be taken.
The registry is one file, ~/.mt/claims.json, machine-local and behind a cross-process lock. Two
agents on two laptops pushing one branch is a different problem with a different answer.
The hooks that make it speak
The CLI is the gate; the hooks are what make the mechanism work when no gate was called.
- PostToolUse touches a per-session heartbeat, so a session busy inside one long turn stops
reading as idle (
mt sessionsuses the same signal). - PreToolUse watches file edits. Writing into a tree another live session holds gets you a note naming the holder. Writing into a tree you never claimed gets you the exact command to claim it, at most three times, fifteen minutes apart, then silence. It never blocks a call and never touches a permission decision: a guard that bricks the session that merely forgot to claim gets turned off within a day.
- SessionStart lists whatever is live on the machine, and prints nothing when nothing is.
- SessionEnd releases the session's claims, best effort. It is an optimisation, not the guarantee: SessionEnd does not run on a crash, which is what the two-hour ceiling is for.
Agent hooks (Claude Code + Codex)
Built-in hooks ship with mt and are merged into both ~/.claude/settings.json (Claude Code) and ~/.codex/hooks.json (Codex) on mt sync. Pick which ones you want via ~/.mt/hooks.json (include / exclude over predefined ids).
mt setup --profile mt --global # writes ~/.mt/hooks.json with include: ["*"]
mt hooks list # show built-in hooks + enabled state
mt hooks enable <id> # turn a hook on
mt hooks disable <id> # turn a hook off
mt hooks sync # reconcile both hook files nowShipped hooks:
| id | what it does |
|---|---|
| block-claude-coauthor | Blocks a Claude/Anthropic co-author trailer, a Generated with/by/using Claude Code trailer, or an @anthropic.com email - in git Bash calls and in file content (Write/Edit/MultiEdit on Claude Code, apply_patch added lines on Codex), closing the bypass of writing the trailer into a file and git commit -F-ing it. |
| block-host-destructive | Blocks Bash commands that write HOST-scope state shared by every agent, session and user on the machine: Wi-Fi radio, network interfaces, routing, DNS, packet filter, power/shutdown, launchd, system config, disks, keychain, and kills of the agent session or the runtime it runs on. Reads are never blocked, so diagnosis stays possible (pmset -g log, networksetup -getairportpower, diskutil list), and turning a radio back on is recovery and passes. A heredoc fed to a shell or written into a script is scanned; one fed to a known non-shell interpreter (python3, jq, psql, ...) is that interpreter's data and is not. Deny messages name an in-scope alternative. There is no per-call override: for a deliberate, approved change to this machine, run mt hooks disable block-host-destructive first. |
| block-foreign-pr-merge | Blocks any Bash command that merges a pull request whose author is not the account gh is authenticated as in that directory - gh pr merge, mt gh pr merge, a REST PUT .../pulls/N/merge, a GraphQL mergePullRequest. Reading, reviewing, commenting on and checking out other people's PRs are untouched; only the irreversible act is denied. A merge the guard cannot pin to one concrete PR is denied rather than waved through, so gh pr merge $n inside a loop is refused and merges name their PR. So is a merge whose author lookup fails. The identity is resolved the same way the merge would resolve it (mt gh where a git profile governs the directory, ambient gh otherwise). Unlike every other hook here it has no agent-reachable escape hatch: mt hooks disable block-foreign-pr-merge is itself denied, so lifting it is something the human does in their own shell. Origin: on 2026-08-06 an agent told to merge four named PRs swept the workspace for APPROVED + CLEAN and merged three colleagues' PRs into dev/devx under the user's account in one 13-second loop; the first author opened a revert PR two hours later. Known false positive, same class as block-host-destructive and left in the safe direction: a multi-line git commit -m (or any Bash-borne prose) that quotes a merge command is read as running one, because the tokenizer resets quote state at each newline. Write that text through a file (git commit -F, Write) or reword it; the guard is not the thing to bend. |
| block-em-en-dash | Blocks new content containing an em-dash or en-dash (AI-typography tell) - Write/Edit/MultiEdit on Claude Code, apply_patch added lines on Codex. The hook self-exempts edits to its own source under hooks/pretooluse/block-em-en-dash.{ts,partial.json,partial.codex.json}; for other files that legitimately contain those characters, run mt hooks disable block-em-en-dash first. |
| protect-hooks | Blocks Write/Edit/MultiEdit (Claude Code) and apply_patch (Codex) edits whose target is a managed hook source (hooks/pretooluse/*.{ts,partial.json,partial.codex.json}, hooks/lib/*.ts, hooks/registry.json, and anything under ~/.agents/hooks/), so the agent can't weaken or disable the guards by editing their source. To change a hook, run mt hooks disable <id> first, edit, then mt hooks sync. Covers file-tool edits only - not Bash sed/redirection, nor the merged settings.json/hooks.json. |
| inject-mt-start | SessionStart hook (fires on startup/clear/compact): injects the mt-start development-workflow skill into context, plus the workspace docs/MEMORY.md when present. Reads the installed skill at ~/.agents/skills/mt-start/SKILL.md (single source of truth, no duplication) and stays silent when it is absent. MEMORY.md path resolves from $MT_DOCS, then $WS/docs, then ~/Documents/Coding/docs. |
| notify-ask-question | PreToolUse on AskUserQuestion and ExitPlanMode: fires an OS notification (osascript on macOS, notify-send on Linux) whenever the agent hands control back to you, so neither a question nor a plan awaiting approval goes unnoticed in a background or unfocused session. Titled with the current project directory; bodied with the first question's text, or with the plan's first meaningful line (markdown heading/bullet markers stripped). Bodies are capped at 120 chars, since ExitPlanMode carries the whole plan document. Never blocks the call - a missing/failing notifier is silent. Codex has no ExitPlanMode tool today, so that matcher alternative is inert there. |
| agent-background | PreToolUse on Agent: forces every subagent call to run_in_background: true via updatedInput, no matter what the model asked for, and attaches a short additionalContext note telling it to use the wait for independent next-step work instead of idling. Codex has no Agent/subagent tool today, so the matcher never fires there. |
| claim | PostToolUse + PreToolUse + SessionStart + SessionEnd: makes mt claim speak when nobody called it. The PostToolUse half touches a per-session heartbeat on every tool call, which is what stops a session busy inside one long turn from reading as idle. The PreToolUse half watches file edits: writing into a working tree another live session holds gets a note naming the holder; writing into a tree this session never claimed gets the exact command to claim it, at most three times, fifteen minutes apart, then silence. It observes and annotates, it never gates a call and never touches a permission decision - a guard that bricks the session that merely forgot to claim gets turned off within a day. SessionStart lists what is live on the machine and prints nothing when nothing is; SessionEnd releases the session's claims best-effort, waiting at most 300ms for the lock rather than eating the 1.5s budget every SessionEnd hook shares. Liveness is the agent's real pid plus a two-hour renewal ceiling, because SessionEnd does not run on a crash. |
| resume-without-user-input | PermissionRequest + PreToolUse on AskUserQuestion/ExitPlanMode: resumes a call that has been waiting on a human for too long, judged per interaction rather than by machine idle time. Every call that hands control back is registered in ~/.mt/pending-tool-calls/<uuid>.json the moment it starts waiting; once it has been pending for the window (default 90s, MT_RESUME_SECS) the hook answers with the safe and recommended option and tells the model a hook answered, not a person. This is the gap afk-auto-approve cannot see: while you type in another window, macOS idle time never crosses its threshold, so a dialog in a pane you are not looking at stalls indefinitely. The question and plan surfaces gate on presence before holding anything, because there a wait hides the call instead of delaying it (MT_RESUME_PRESENT_SECS, see below). Pushing or merging to main is never auto-approved, deny/ask rules still win, and mt pending observes or overrides any waiting call. Silent during autopilot runs and with MT_RESUME=0. |
The merge is idempotent: managed handlers are matched by absolute script path under ~/.agents/hooks/, so user-added entries in either file are preserved untouched. Hooks are global only.
Codex note: Codex requires reviewing and trusting non-managed hooks before they run, keyed to the exact hook definition - after a sync adds or changes hooks, run /hooks inside Codex to trust them. Per-hook Codex variants live next to the Claude partials as *.partial.codex.json (the Claude partial is reused when no variant exists).
Calls waiting on you (mt pending)
The state behind the resume-without-user-input hook: one JSON file per call that is currently blocked on a human, under ~/.mt/pending-tool-calls/.
mt pending list [--json] # what is waiting, and how long until it resumes
mt pending sweep [--watch] # stamp calls past their window, drop dead-session orphans
mt pending resume <id> # continue one now instead of at the window
mt pending hold <id> # keep it for yourself; the dialog waits however long you needOne policy, two readers. The hook holding a call polls its own entry, so the window is enforced with nothing scheduled; mt pending sweep applies the same rule from outside, which is what makes a call resumable early (resume), cancellable (hold), observable while it waits, and collectable when the session that was waiting is killed (a SIGKILL runs no cleanup, so the entry outlives its waiter until a sweep drops it). Ids accept any unambiguous prefix, so the 8 characters list prints are enough.
Cron is optional. Add it if you want orphan collection and stamps without opening a terminal:
* * * * * $(which mt) pending sweep >/dev/null 2>&1 # crontab
mt pending sweep --watch --interval-secs 5 # or a loop in a terminalQuestions and plans are gated on presence first. A PreToolUse hook returns before its tool runs, and for AskUserQuestion and ExitPlanMode the tool's own run is the render: while the hook waits there is no dialog on screen at all. Holding one of those for a user who is sitting right there does not delay the question, it hides it and then answers it for them. So those two surfaces use machine idle, not the interaction clock: they are held only for a user who has not touched this machine for the whole window, real keyboard or mouse input during a wait hands the call straight back within a second, and an unreadable idle signal (non-darwin, remote session, broken ioreg) always renders the question. The permission dialog keeps the interaction clock, because it can be held without being hidden.
Knobs: MT_RESUME_SECS (window, default 90), MT_RESUME_PRESENT_SECS (how recently you must have touched the machine to count as present, default: the window itself) and MT_RESUME=0 (off). A window far above the hook's 150s timeout in settings.json cannot land: the hook is killed first and the dialog simply waits for you, which is the fail-open direction everywhere in this feature.
Your task list (mt task)
Arbitrary work you do not want to forget, kept outside any one agent session: one markdown file per task under ~/.mt/tasks/<id>.md.
mt task add "Fix the ads tx-history bug" --repo backend --cwd /path/to/backend --tag p2
mt task add "Rewrite the paywall copy" --body-stdin < notes.md
mt task list [--json] # grouped by status
mt task show <id> # the whole thing, body included
mt task set <id> --status in_progress # todo | in_progress | blocked | done
mt task link <id> --session <sid> # record an agent session started for this task
mt task sweep # drop links to sessions the registry lost
mt task doctor # is anything failing to parse?This is not mt autopilot task, and the difference is the point. An autopilot task belongs to one run: it is filed to keep a thread going and it dies with the goal. These outlive every session, have no goal above them, and are the thing you start a session from - which is why each carries a markdown body (the context a new session gets handed) and the list of sessions that have worked on it.
Markdown with YAML frontmatter rather than JSON, because the body is written for a reader: links, constraints, what "done" means. Writes are atomic (tmp + rename) with no lock, so a concurrent reader never sees half a task, and a file that stops parsing is skipped rather than guessed at - which is what doctor exists to surface, since a quietly short list looks exactly like a genuinely short one.
link is how a task and an agent session find each other. Cockpit writes it the moment it starts a session (with an id it minted itself, so the link exists before the session's own hooks fire), and mt sessions list turns those ids back into live state. sweep drops ids the registry no longer knows - the links only; the task itself is never swept, since outliving its sessions is the whole reason it exists.
Git profiles + command policies
Declarative git/GitHub identities and per-repo command policies, reconciled by mt sync into git's own native config so git works automatically for every caller (human, IDE, agent, script, local CI) - no shell wrapping required for git itself.
Source of truth: ~/.mt/git.json (chmod 600), two top-level arrays:
{
"profiles": [
{
"id": "work", // stable slug [a-z0-9-]
"name": "Metehan Kurucu", // git user.name
"email": "[email protected]", // git user.email
"ghToken": "ghp_...", // optional - omit for a passthrough profile
"repos": ["/Users/mt/Documents/Coding/work"],
"isDefault": false
}
],
"policies": [
{
"repos": ["/Users/mt/Documents/Coding/work"],
"default": "allow",
"rules": [
{ "deny": "git push", "when": { "branch": "main" }, "message": "no direct push to main" },
{ "deny": "gh pr merge" }
]
}
]
}- Profiles (
profiles):id/name/email/ghToken/repos/isDefault. Exactly one profile must haveisDefault: true(it catches every path not matched by another profile'srepos); the longest matchingreposprefix wins for overlapping paths. A profile with noghTokenis a passthrough: identity is set but auth is left to your existing git/gh setup. - Policies (
policies): repo-scopedrulesofdeny/allowcommand patterns ("git push","gh pr merge", ...), each optionally gated bywhen.branch(glob),when.args(token match, e.g.--force),when.remote. First matching rule wins; unmatched commands fall back todefault("allow"= denylist,"deny"= allowlist).
mt git-profile add --id work --name "..." --email "..." --repos <p1,p2> [--token-stdin] [--default]
mt git-profile list [--json] # never prints tokens; shows hasToken
mt git-profile set <id> ... # same flags as add
mt git-profile remove <id>
mt git-profile bind <id> <path> # add a path to a profile's repos
mt git-profile unbind <path>
mt git-profile which [--path <dir>] # which profile + policy govern cwd/path + why
mt git-profile doctor # verify gitconfig block, hooks, gh
mt git-policy list [--path <dir>] [--json] # effective rules for a path
mt git-policy check "git push origin main" # dry-run a command (ALLOW/DENY + why)
mt git <args...> # policy-checked git wrapper
mt gh <args...> # policy-checked gh wrapperTokens are entered via --token-stdin only, never as a plain argv (shell history / ps leak).
mt sync reconciles git.json into ~/.gitconfig (a marker-delimited block: an unconditional include for the default profile, includeIf "gitdir/i:<path>/" for the rest, ordered by specificity) plus a per-profile gitconfig at ~/.mt/git-profiles/<id>.gitconfig that sets [user] identity, a credential.helper pointing at mt git-credential --profile <id>, and core.hooksPath = ~/.mt/git-hooks. That is what makes it automatic: any git invocation under a governed path, from anyone, picks up the right identity/auth/hooks with zero per-call wrapping.
Command policies are enforced two different ways depending on which tool is being policed:
- git - natively, via the
core.hooksPathhooks (pre-push,pre-commit) thatmt syncscaffolds. They shell out tomt git-hook, which resolves the repo's policy and refuses the operation (e.g. blocking a push tomain) before git completes it. This fires for every caller, not just interactive shells. - gh - has no native per-directory config surface, so there is no equivalent hook. Coverage is
mt gh(and theghalias below) checking the policy before spawning the realghbinary; a script or CI job invoking the realghbinary directly bypasses it.
mt git/mt gh (and the git/gh shortcuts the mt profile ships) exec the real git/gh binaries directly - no recursion. Native git enforcement (identity, credential helper, core.hooksPath) does not depend on the alias at all; it applies even if you never touch git/gh through mt. The alias is a convenience layer that adds an earlier, friendlier policy refusal at the interactive prompt for both tools, and is presently the only local coverage gh gets outside of a future agent hook.
Honest limits:
- The
pre-push/pre-commitpolicy hooks are a guardrail against mistakes, not an adversarial security boundary:git push --no-verifyor a locally overriddencore.hooksPath(e.g. husky) bypasses them. For unbypassable protection of a branch likemain, use GitHub branch protection / rulesets (server-side) - mt's policy is local convenience on top of that, not a replacement for it. ghhas no native per-directory config, so its coverage is inherently narrower than git's: themt ghwrapper and theghalias, not a standalone script or CI job calling the realghbinary directly.
Tokens live only in ~/.mt/git.json (chmod 600); the generated per-profile gitconfig never embeds a token, it references mt git-credential --profile <id> (and mt gh-token), which read the token from git.json at call time.
Skill authoring
If you maintain a skills repo, these helpers operate on the repo's skills/, commands/, and agents/ folders:
mt skills validate # check SKILL.md frontmatter + structure
mt skills new <name> # scaffold a new skill (SKILL.md template)
mt skills list # list skills + commands in this repoSelf-update
mt upgrade # re-install @codixus/mt@latest via your package managerConfiguration
mt uses a .mt/ config folder (npm-style global vs local).
| File | Purpose | Scope |
|---|---|---|
| skills.json | skill subscriptions + cherry-picks | global + local |
| shortcuts.json | shell aliases | global only |
| hooks.json | Claude Code hook include/exclude | global only |
| lint.json | lint rules + ignore patterns | global + local |
| manifest.json | mt's record of installed files (don't edit) | global + local |
| auth.json | GitHub token (chmod 600) | global only |
| tasks/<id>.md | the global task list (mt task) | global only |
Auth chain (in order): ~/.mt/auth.json → $MT_GITHUB_TOKEN → $GITHUB_TOKEN → gh auth token. mt never embeds tokens; it delegates to whatever auth you already have.
License
MIT.
