Zakira.Conduit
0.5.0
dotnet tool install --global Zakira.Conduit --version 0.5.0
dotnet new tool-manifest
dotnet tool install --local Zakira.Conduit --version 0.5.0
#tool dotnet:?package=Zakira.Conduit&version=0.5.0
nuke :add-package Zakira.Conduit --version 0.5.0
Zakira.Conduit
A .NET 10 global tool that mirrors agent skills from remote (or local) sources into one or more local target directories — driven by a single declarative manifest.
conduit is the missing piece between agent skills you've collected (on GitHub, or just on disk) and the many different folders that your local agents read them from. You describe what to sync and where it should land; conduit sync does the rest.
+-------------------+ +---------+ +-------------------------+
| github.com/... | ===> | | ===> | ~/.config/claude/... |
| /vendor/skills | ===> | conduit | ===> | ~/projects/foo/.agents |
| ...future... | ===> | sync | ===> | ... |
+-------------------+ +---------+ +-------------------------+
Highlights
- One manifest, many targets, many sub-paths. Each entry has a single source (which may produce one or many content units) and a list of destinations.
- Multiple source kinds, same model. Ships with GitHub (zipball snapshot), Azure DevOps (REST Items API, with PAT and
azCLI auth) and local directory sources today; new kinds (GitLab, plain HTTP archive, ...) are a single record + fetcher away. type: urishorthand. Skip the discriminator entirely — paste a URL and Conduit picks the right source kind:{ "source": "https://github.com/..." }(bare-string) or{ "source": { "type": "uri", "uri": "..." } }(object form, when you need overrides). See URI inference below.- Optional
name, source-derived defaults. Dropnamefrom an entry and Conduit derives the destination folder from the source itself (GitHub/AzDO repo name, local dir basename). Override per-entry or per-array-element with"... -> Name"shorthand or{ "source": ..., "as": "Name" }wrapper. - Cross-entry destination guard. Two entries that would write into the same
<target>/<destName>/are caught at validation time with a clear error pointing at both offenders. - Array
source. One entry can declare several remotes at once:{ "source": ["https://github.com/foo/bar/skills", "./vendor/skill"] }. Each element becomes its own independent sub-entry sharing the same targets. See Array sources below. - Browser-paste friendly. GitHub sources accept slug (
owner/repo), browser URL (https://github.com/owner/repo), or SSH form (git@github.com:owner/repo.git) in the same field. - Multi-path fetches. A single entry can pull N sub-paths out of one source in one go (one zipball, one extraction, N destinations).
- Per-path and per-target renames. Both
pathsandtargetsaccept either a bare string or{ "path": ..., "as": ... }to override the destination directory name. - Smart caching. A
.conduit-state.jsonfile next to the manifest tracks each entry's last resolved commit SHA, ETag and target list. Re-runs skip commit-pinned entries (no network), and branch-tracked entries use HTTP ETags (If-None-Match+ 304) to skip work when GitHub hasn't changed.--forcebypasses everything. - Parallel by default. Entries sync concurrently (
--parallel N, default 4). Sequential mode is opt-in (--parallel 1). - JSON output for scripting. Every command supports
--output json; logs are routed to stderr so stdout stays pure forjq. - TTY-aware colours. ANSI colours when stdout is a terminal; plain text in pipes or when
NO_COLORis set. - Pin / unpin / update for reproducibility.
conduit pinlocks every GitHub / AzDO entry to a specific commit SHA by rewriting the source URL (or object) to the URL-native pinned form — GitHub becomestree/<sha>/<path>, AzDO becomes?version=GC<sha>.conduit unpinreverses it, restoring branch tracking (--to <branch>to pick; otherwise queries the repo's default branch and falls back tomain).conduit updateis an alias ofpinfor the refresh-flavoured verb. Already-pinned entries are skipped with a pointer atunpin; pin is intentionally one-way so the user's branch choice is never silently lost. - Atomic mirroring. Each target is written via a sibling staging directory and swapped in place, so a failure cannot leave a half-updated target.
- Stale files are removed. When a source changes, files that disappeared upstream disappear locally too — without nuking unrelated content in the same target directory.
conduit watch. Re-runs the sync whenever the manifest changes on disk, with debounced burst-write coalescing.conduit init --interactive. Walks first-time users through one starter entry instead of dropping a placeholder template.- XDG-friendly discovery. Drops
conduit.jsonat$XDG_CONFIG_HOME/Zakira.Conduit/, with sensible fallbacks on every platform. - Safety rails. Refuses to run when a source path overlaps with one of its targets. Duplicate destination basenames in a multi-path entry are caught at validation time.
- Extensible.
ISource+ISourceFetcherare first-class abstractions; adding a new source kind is a single record + fetcher away. - Tested. 180+ tests across unit, integration and end-to-end suites; the GitHub fetcher and ref resolver are exercised against an in-process HTTP mock. CI runs the suite on Linux, macOS and Windows.
Install
conduit is distributed as a .NET global tool. With the .NET 10 SDK installed:
dotnet tool install --global Zakira.Conduit
Update later with:
dotnet tool update --global Zakira.Conduit
Until the first NuGet release, you can
dotnet packthe repo locally (dotnet pack -c Release) and install from the produced.nupkg:dotnet tool install --global --add-source ./artifacts Zakira.Conduit
Quickstart
# 1. Create a starter manifest at $XDG_CONFIG_HOME/Zakira.Conduit/conduit.json
conduit init
# 2. Edit it (or point at your own with --manifest <path>) and validate
conduit validate
# 3. Preview what a sync would do
conduit sync --dry-run
# 4. Run it for real
conduit sync
A complete sample manifest \u2014 mixing GitHub and local sources, a pinned
commit, and a disabled entry \u2014 lives in example/conduit.json.
You can drive the CLI against it without copying:
conduit validate --manifest example/conduit.json
conduit list --manifest example/conduit.json
conduit sync --manifest example/conduit.json --dry-run
The manifest (conduit.json)
Each entry has a source mirrored to one-or-more targets.
{
"$schema": "https://raw.githubusercontent.com/MoaidHathot/Zakira.Conduit/main/schemas/conduit.schema.json",
"version": 1,
"entries": [
{
"name": "code-review",
"description": "One skill from a GitHub repo, tracked on main.",
"source": {
"type": "github",
"repo": "anthropics/skills", // also accepts https://github.com/anthropics/skills
"path": "code-review",
"branch": "main"
},
"targets": [
"~/.config/claude/skills",
"~/projects/foo/.agents/skills"
]
// → ~/.config/claude/skills/code-review/
// → ~/projects/foo/.agents/skills/code-review/
},
{
"name": "anthropic-bundle",
"description": "Mirror MANY skills from one repo with a single fetch.",
"source": {
"type": "github",
"repo": "https://github.com/anthropics/skills",
"paths": ["code-review", "test-writer", "refactor"],
"branch": "main"
},
"targets": ["~/.config/claude/skills"]
// → ~/.config/claude/skills/code-review/
// → ~/.config/claude/skills/test-writer/
// → ~/.config/claude/skills/refactor/
// (entry name 'anthropic-bundle' is metadata only when paths.Count > 1)
},
{
"name": "internal-runbooks",
"description": "Private repo pinned to a commit SHA. Uses $CONDUIT_GITHUB_TOKEN.",
"source": {
"type": "github",
"repo": "my-org/agent-runbooks",
"commit": "1f2e3d4c5b6a"
},
"targets": ["$XDG_CONFIG_HOME/agents/skills"]
},
{
"name": "house-style",
"description": "An in-house skill kept on disk under version control.",
"source": {
"type": "local",
"path": "./vendor/skills/house-style"
},
"targets": [
"~/.config/claude/skills",
"~/projects/bar/.agents/skills"
]
},
{
"name": "local-bundle",
"description": "Mirror several local directories at once.",
"source": {
"type": "local",
"paths": ["./vendor/skills/a", "./vendor/skills/b"]
},
"targets": ["~/projects/bar/.agents/skills"]
// → ~/projects/bar/.agents/skills/a/
// → ~/projects/bar/.agents/skills/b/
}
]
}
Source kinds
github
Snapshot of a GitHub repository, fetched as a zipball over HTTPS.
| Field | Required | Notes |
|---|---|---|
repo |
yes | Repository identifier. Accepts the owner/repo slug, a browser URL (https://github.com/owner/repo, with or without .git), github.com/owner/repo, or the SSH form (git@github.com:owner/repo.git). |
path |
no | Single repo-relative sub-path. No .., no leading /. Mutually exclusive with paths. |
paths |
no | List of repo-relative sub-paths. Each entry may be a bare string or { "path": ..., "as": ... } to override the destination directory name. Mutually exclusive with path. When two or more are listed, each one mirrors to <target>/<basename-or-alias>/ and the entry's name becomes metadata only (used for filtering and logs). Duplicate destination names are a validation error. |
branch |
no | Branch or tag name. May coexist with commit: in that case branch is the tracking intent that conduit pin / conduit update resolve, and commit is the snapshot the synchronizer fetches. |
commit |
no | Pin to an immutable commit SHA. Wins over branch for fetching. |
If neither branch nor commit is given, the repository's default branch is used. If neither path nor paths is given, the whole repository is mirrored.
azdo
Snapshot of an Azure DevOps (cloud or Server) repository, fetched as a zip via
the Git Items REST API. No git clone, no external CLI required for the
actual download — auth is purely an HTTP header.
The repository can be addressed in either of two equivalent ways:
// Browser-paste / git remote form:
"source": {
"type": "azdo",
"url": "https://dev.azure.com/contoso/Conduit/_git/agent-skills",
"branch": "main",
"path": "skills/code-review"
}
// Explicit triplet (required for self-hosted AzDO Server with a non-default base URL):
"source": {
"type": "azdo",
"organization": "contoso",
"project": "Conduit",
"repo": "agent-skills",
"baseUrl": "https://devops.contoso.internal/", // optional; defaults to https://dev.azure.com/
"branch": "main",
"paths": ["skills/code-review", "skills/test-writer"]
}
| Field | Required | Notes |
|---|---|---|
url |
one of url / triplet |
Browser URL, git remote (HTTPS or SSH form). Mutually exclusive with the explicit triplet. |
organization |
one of url / triplet |
Org (cloud) or collection (Server) name. Required as part of the triplet. |
project |
one of url / triplet |
Project name. |
repo |
one of url / triplet |
Repository name or GUID. |
baseUrl |
no | Override the REST base URL for AzDO Server. Defaults to https://dev.azure.com/ for the triplet form; derived from the URL for the url form. |
branch |
no | Branch name. May coexist with commit (branch = intent, commit = snapshot). |
tag |
no | Tag name. Mutually exclusive with branch. |
commit |
no | Commit SHA pin. Wins over branch / tag for fetching. |
path |
no | Single repo-relative sub-path. Mutually exclusive with paths. |
paths |
no | List of repo-relative sub-paths. Same shape as the GitHub source. |
auth |
no | Auth chain (string or array). See below. Default: ["env", "az"]. |
patEnv |
no | For the pat mode: the env var name to read a PAT from. Defaults to CONDUIT_AZDO_TOKEN. |
Each entry in paths triggers one HTTP request to the Items endpoint with the
corresponding scopePath, so transfers stay small even when a single entry
mirrors several sub-trees out of a large monorepo. If neither path nor
paths is given the whole repository is mirrored.
conduit pin and conduit update resolve a tracked branch or tag into a
commit SHA and rewrite the manifest's commit field, exactly like the GitHub
flow.
Authentication (auth)
auth accepts a single mode name or an ordered array of mode names. The
first provider that yields a credential wins. The default chain when auth
is unset is ["env", "az"].
| Mode | Source of credential | HTTP header |
|---|---|---|
env |
CONDUIT_AZDO_TOKEN → AZURE_DEVOPS_EXT_PAT → SYSTEM_ACCESSTOKEN (so it just works in AzDO Pipelines). |
Authorization: Basic base64(":"+PAT) |
az |
Calls az account get-access-token --resource <azdo-aad-guid> -o tsv. Tokens are cached in-memory for ~50 minutes; never persisted. |
Authorization: Bearer <token> |
pat |
Reads from the env var named by patEnv (defaults to CONDUIT_AZDO_TOKEN). |
Authorization: Basic |
anonymous |
Skips authentication entirely; useful for public repos. | none |
The az CLI is only required when az appears in the active chain. When it
isn't (or when it isn't installed and is reached as a fallback), conduit moves
on to the next link without surfacing an error.
If the source is a private repo and every provider in the chain declines, the
request is sent with no Authorization header and AzDO responds with HTTP
401, which is surfaced as a clear failure for that entry.
local
One or more directories on the local filesystem. Useful for in-repo skills, in-house skills you check in next to other code, or anything you'd otherwise cp -R manually.
| Field | Required | Notes |
|---|---|---|
path |
one of path/paths |
Single source directory. Absolute, or relative to the manifest's directory. Mutually exclusive with paths. |
paths |
one of path/paths |
Multiple source directories. Each entry may be a bare string or { "path": ..., "as": ... }. Mutually exclusive with path. When two or more are listed, each mirrors to <target>/<basename-or-alias>/ and the entry's name becomes metadata only. |
Paths support ~ and environment-variable expansion ($VAR, ${VAR}, and %VAR% on Windows). No copy is made on the way in: directories are read directly and mirrored into each target. conduit refuses to run when a source path overlaps with one of its targets, so you can't accidentally recurse into your own output.
URI inference (type: uri)
type: uri lets you skip the per-kind discriminator and let Conduit pick
the right source kind from the URI shape. This is purely an ergonomic shortcut
— at load time the entry is rewritten into the equivalent concrete source
(github, azdo, local) and the rest of the pipeline (validation, sync,
state cache, pin, update) never sees the uri form.
Two equivalent shapes:
// Bare-string shorthand. Use this when no overrides are needed.
"source": "https://github.com/anthropics/skills"
// Bare-string shorthand with an in-line destination alias.
// (Equivalent to the object wrapper { "source": "...", "as": "review" }.)
"source": "https://github.com/anthropics/skills/code-review -> review"
// Object form. Use this when you need to set path / branch / commit / etc.
"source": { "type": "uri", "uri": "https://github.com/anthropics/skills", "path": "code-review", "branch": "main" }
// And both are equivalent to the fully-explicit form:
"source": { "type": "github", "repo": "https://github.com/anthropics/skills", "path": "code-review", "branch": "main" }
Detection rules:
| URI shape | Inferred kind |
|---|---|
https://github.com/..., git@github.com:..., github.com/... |
github |
https://dev.azure.com/.../_git/..., *.visualstudio.com/.../_git/..., git@ssh.dev.azure.com:v3/..., any URL containing /_git/ (AzDO Server) |
azdo |
./foo, ../foo, /abs, ~/foo, $VAR/..., %VAR%\..., C:\... (drive letters) |
local |
Optional fields on the uri source (path, paths, branch, tag,
commit, baseUrl, auth, patEnv) are forwarded only to source kinds
that accept them. Setting branch on an inferred local source, for example,
is a load-time error with a clear message.
What the inferrer intentionally does not handle:
- Bare slugs (
anthropics/skills). They're ambiguous in a URI-driven manifest. Use"type": "github"+"repo": "anthropics/skills"when you want the slug ergonomics. - Refs embedded in the URL (
/tree/<branch>,/commit/<sha>, AzDO's?version=GB<branch>). Use the dedicatedbranch/tag/commitfields — they're consistent across every source kind. - Self-hosted GitLab / Gitea / etc. until they have their own inferrer.
When in doubt, keep using the explicit "type": ... form. It is and will remain the canonical way to declare a source.
URL sub-paths and refs in inferred sources
When a URI is itself a browse URL with extra path segments, the inferrer
harvests them into the equivalent path (and, for GitHub tree/blob/raw
URLs, branch) fields automatically. The short form — just append the
sub-path to the repo URL — is the recommended default; the longer
/tree/<branch>/... form is supported for when you want to lock the entry
to a specific branch at the same time:
| Pasted URI | Inferred source |
|---|---|
https://github.com/anthropics/skills |
github repo anthropics/skills, whole repo, default branch |
https://github.com/anthropics/skills/code-review |
github repo, path code-review, default branch |
https://github.com/anthropics/skills/tree/main/code-review/sub |
github repo, branch main, path code-review/sub |
https://dev.azure.com/contoso/Conduit/_git/agent-skills/skills/x |
azdo repo, path skills/x |
https://dev.azure.com/contoso/Conduit/_git/agent-skills?version=GBmain&path=/skills |
azdo repo, branch main, path skills (from ?path=) |
Tip: omit /tree/<branch>/ whenever you're happy tracking the default
branch — Conduit resolves it at fetch time. conduit pin / update
also work fine on these: they discover the repo's default branch via the
GitHub API (one extra call, cached per run) and then write both branch
and commit back into the manifest, converting bare-string sources to
the explicit object form along the way. Add /tree/<branch>/ (or set
branch explicitly on the object form) up-front when you want a specific
non-default branch.
Setting an explicit path/paths/branch on the same source while the URL
also carries one is rejected as a contradiction.
Array sources
A single entry can declare several remotes at once by giving its source an
array value. Each element — bare-URI string (optionally with an
in-string -> Name alias suffix), wrapper object ({ source, as }), or
explicit source object — is expanded at load time into its own
independent sub-entry that shares the parent's targets, description, and
disabled flag:
// Common case: drop 'name' entirely; each element's destination folder is
// the repo (or local-dir) name. Bare-repo URLs grab the default branch;
// append '/<sub-path>' to mirror just a sub-tree (still default branch).
{
"source": [
"https://github.com/MoaidHathot/ActionView/skills",
"https://github.com/MoaidHathot/PowerReview/skills",
"./local-skill-sample"
],
"targets": ["~/.config/claude/skills"]
}
Override an individual element's destination name with either shorthand:
{
"source": [
"https://github.com/anthropics/skills/code-review -> CodeReview", // arrow
{ "source": "https://github.com/anthropics/skills/test-writer", "as": "Tests" } // wrapper
],
"targets": ["~/.config/claude/skills"]
}
When the parent entry does set name, it becomes a grouping prefix on
every expanded element — useful when you want one bundle's children to
show up grouped in conduit list:
{
"name": "agent-bundle",
"source": [
"https://github.com/anthropics/skills/code-review",
"https://github.com/acme/skills/test-writer"
],
"targets": ["~/.config/claude/skills"]
}
// names: agent-bundle-skills (or agent-bundle-CodeReview when aliased),
// agent-bundle-test-writer (or agent-bundle-Tests)
Each expanded sub-entry appears as a separate row in conduit list, each
gets its own state record (so cache hits, pin/update, and failures are
per-element), and each can carry a different source kind without forcing
the user to repeat boilerplate.
Per-target as aliases are rejected on array-source entries (they would not
apply cleanly to N destinations — same constraint that already applies
to multi-paths entries).
Destination naming
| Source produces | Destination per target |
|---|---|
1 content unit (no paths, or 1-element paths) |
<target>/<target.as ?? entry.name ?? alias ?? sourceDefault>/ |
N >= 2 content units (paths with multiple elements) |
<target>/<path.as ?? basename(path)>/ per unit. The entry's name is metadata only. Per-target aliases are rejected for multi-unit entries (they wouldn't apply cleanly to N destinations). |
sourceDefault is the source's natural identity: the GitHub repo name, the
AzDO repo name, or the local directory basename (for single-path local
sources). It lets you drop name entirely on the common single-unit case:
{ "source": "https://github.com/MoaidHathot/ActionView/skills",
"targets": ["~/.config/claude/skills"] }
// -> ~/.config/claude/skills/ActionView/
// (repo name 'ActionView', not the sub-path basename 'skills'; tracks the default branch)
This keeps the simple case ergonomic ("name the entry after the skill, target gets one folder by that name") while letting one entry mirror N skills out of one source with a single fetch.
A cross-entry destination collision check runs at validation time: two
entries (or two array-expanded sub-entries) writing into the same
<target>/<destName>/ directory are rejected with a clear error pointing at
both offenders. Rename one (set name, supply as) to disambiguate.
Aliases (giving a source a name without an outer name field)
When you want the destination folder to be something other than the source-derived default — especially inside an array source — Conduit accepts two equivalent shorthand forms:
// In-string arrow suffix. The portion after ' -> ' must match [A-Za-z0-9._-]+.
"source": "https://github.com/anthropics/skills/code-review -> CodeReview"
// Object wrapper. Works around any inner source shape (string, full object).
"source": { "source": "https://github.com/anthropics/skills/code-review", "as": "CodeReview" }
Both set the entry's name to CodeReview. The wrapper form is more
flexible (the inner source can be a concrete {type: "github", ...} object);
the arrow form is terser for single-line array elements.
Targets with explicit aliases
"targets": [
"~/.config/claude/skills", // landing dir = entry.name
{ "path": "~/.config/zed/skills", "as": "review" } // landing dir = "review"
]
Source paths with explicit aliases
"paths": [
"skills/code-review", // landing dir = "code-review"
{ "path": "skills/test-writer", "as": "tests" } // landing dir = "tests"
]
Field reference
| Field | Required | Notes |
|---|---|---|
version |
yes | Schema version. Currently 1. |
entries[] |
yes | At least one. |
entries[].name |
no | [A-Za-z0-9._-]+. Optional: when omitted Conduit derives a default from the source (GitHub/AzDO repo name, single-path local directory basename) or from an explicit alias ("... -> Name" shorthand or { "source": ..., "as": "Name" } wrapper). Used as the destination subdirectory when the source produces exactly one content unit; metadata only otherwise. Must be unique within the manifest. |
entries[].description |
no | Free-form documentation; ignored at runtime. |
entries[].disabled |
no | true skips the entry during sync. |
entries[].source.type |
yes | "github", "azdo", "local", or "uri" (auto-detect). The discriminator for future source kinds. |
entries[].source.* |
varies | See the per-kind tables above. |
entries[].targets[] |
yes | List of directory paths. ~, $VAR, ${VAR} and (on Windows) %VAR% are expanded. Relative paths are rooted against the manifest's directory. |
Manifest discovery
When --manifest / -m is not provided, conduit probes each of the
following locations in order, preferring conduit.json over conduit.jsonc
at each location:
$XDG_CONFIG_HOME/Zakira.Conduit/conduit.{json,jsonc}$HOME/.config/Zakira.Conduit/conduit.{json,jsonc}(XDG-style fallback)./conduit.{json,jsonc}(current working directory)
The first existing file wins. The same XDG-style resolution is used on every operating system — including Windows — so the rules are identical for everyone collaborating on the same manifest.
JSONC support (comments and trailing commas)
The manifest is parsed as JSONC: line comments (// ...), block comments
(/* ... */), and trailing commas are all accepted regardless of file
extension. The .jsonc extension is recognised purely as an editor hint
(VS Code and many other editors switch to a JSONC mode when the file ends
in .jsonc); functionally conduit.json and conduit.jsonc are
interchangeable.
// agents/skills mirror — last reviewed 2025-Q2.
{
"version": 1,
"entries": [
{
// Bundle pinned at the Q2 cut.
"source": [
"https://github.com/MoaidHathot/ActionView/skills",
"https://github.com/MoaidHathot/PowerReview/skills",
],
"targets": [
"$XDG_CONFIG_HOME/Orchestra/workspace/skills",
]
},
]
}
conduit pin / conduit unpin / conduit update preserve comments and
trailing commas when every entry they touch has a string-shaped source
on disk (URL rewrite, leaf-level edit only). When an entry has an
object-shaped source that requires inserting or removing a key
(e.g. dropping branch on pin of {type:"github", repo, branch}), the
write falls back to a full reformat which currently loses trivia —
this is a known limitation of System.Text.Json and is tracked in code.
Commands
conduit [--manifest <path>] [--verbosity <level>|--quiet|--verbose] [--output text|json] <command>
| Command | Description |
|---|---|
conduit init [--force] [--interactive] |
Write a starter conduit.json. --interactive walks you through prompts. Refuses to overwrite an existing file unless --force is set. |
conduit validate |
Parse and validate the manifest. Does not touch the network. |
conduit list |
Print a one-line summary of every entry. |
conduit sync [options] |
Fetch sources and mirror them into each target. |
conduit pin [options] |
Lock every GitHub / AzDO entry to a specific commit SHA by rewriting its source to the URL-native pinned form (GitHub: tree/<sha>/<path>; AzDO: ?version=GC<sha>). For entries without an explicit branch, pin discovers the repo's default branch via the API. Already-pinned entries are skipped with a pointer at conduit unpin. |
conduit update [options] |
Alias of pin. Lock each unpinned entry to the latest commit on its tracked branch (or the repo's default branch when none is set). |
conduit unpin [options] |
Restore branch tracking on pinned entries by rewriting tree/<sha>/<path> back to tree/<branch>/<path> (GitHub) or ?version=GC<sha> back to ?version=GB<branch> (AzDO). --to <branch> picks the branch literally; without it, Conduit queries the repo's default branch and falls back to main if discovery fails. |
conduit watch [options] |
Run an initial sync, then re-sync whenever the manifest changes on disk. Ctrl+C to stop. |
conduit sync options
| Option | Description |
|---|---|
--entry <name> |
Restrict the run to specific entries. Repeatable: --entry a --entry b. |
--dry-run |
Fetch sources and report what would change, without writing to targets or updating the state file. |
--stop-on-first-error |
Abort on the first failing entry instead of attempting the rest. |
--force, -f |
Ignore the cached state and re-fetch / re-mirror every entry. Useful after manual edits to a target. |
--parallel <N>, -p <N> |
Maximum number of entries synced in parallel. Default: 4. --parallel 1 forces sequential execution. |
-m, --manifest <path> |
Override manifest discovery (global). |
-o, --output text\|json |
Output format (global). json keeps stdout clean for jq. |
-v, --verbose / --verbosity detailed |
Verbose output (wire-level debug). |
-q, --quiet |
Errors only. |
conduit pin / conduit update options
| Option | Description |
|---|---|
--entry <name> |
Limit the operation to specific entries. Repeatable. |
--dry-run |
Report what would change, without rewriting the manifest. |
-o, --output |
Text or JSON, same as elsewhere. |
Note:
pinandupdatereformat the manifest viaSystem.Text.Json. Any comments or trailing commas in the source file are lost on write. Tag the manifest in git before running if you care.
conduit watch options
| Option | Description |
|---|---|
--debounce <ms> |
Time to coalesce burst writes from editors that save via temp + rename. Default: 250. |
--parallel <N> |
Forwarded to each re-run. Default: 4. |
Exit codes
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
One or more entries failed during sync, or one or more refs failed to resolve during pin/update. |
2 |
Manifest could not be located, parsed or validated. |
Caching & state file
After each successful sync, conduit writes a .conduit-state.json file next to the manifest. It records, per entry:
- the resolved commit SHA (or short SHA, for branch-tracked entries),
- the HTTP
ETagreturned by GitHub, - the timestamp of the last successful sync,
- the absolute target directories.
On subsequent runs:
- Commit-pinned GitHub entries (
commit: ...) skip the network entirely when the state's SHA matches and every recorded target directory still exists. - Branch-tracked GitHub entries still hit the API but send
If-None-Match; GitHub typically replies with HTTP 304 Not Modified andconduitskips the mirror. - Local sources always re-mirror (it's cheap and source content can change at any time without a signal).
You should add .conduit-state.json to .gitignore — absolute paths inside make it machine-specific.
Use conduit sync --force to bypass the cache for one run.
Output formats and colours
--output json switches every command to a stable JSON envelope on stdout. Logs are always written to stderr, so:
conduit sync --output json | jq .succeeded
works without ever mixing log lines into the data.
In text mode, output uses ANSI colours when stdout is a terminal. Colours are disabled automatically when stdout is redirected, and you can opt out explicitly with the NO_COLOR environment variable.
Verbosity
The default verbosity is minimal: only warnings and errors are printed to stderr. The per-entry operational narration (Fetching ..., Syncing entry ..., Using manifest: ..., [dry-run] Would mirror ..., etc.) is opt-in via --verbosity normal (-vn) or --verbose / -v. The summary table and --output json payload are part of the primary stdout output and are never affected by the verbosity flag.
| Flag | Floor |
|---|---|
| (none) | minimal — warnings + errors |
-q, --quiet, --verbosity quiet |
errors only |
--verbosity normal |
adds operational logs |
-v, --verbose, --verbosity detailed |
adds wire-level debug |
--verbosity diagnostic |
adds trace |
Reproducibility: pin + update
branch and commit may now coexist in a GitHub source. When both are present, branch is the tracking intent and commit is the locked-in snapshot that the synchronizer actually fetches.
# Lock every branch-tracked entry to its current SHA.
conduit pin
# Same operation, refresh-flavoured verb.
conduit update
# Pin one entry. Use --dry-run to preview without rewriting.
conduit pin --entry code-review --dry-run
After pinning, the manifest entry might look like:
"source": {
"type": "github",
"repo": "anthropics/skills",
"path": "code-review",
"branch": "main", // intent
"commit": "1a2b3c4d5e6f7890abcdef1234567890abcdef12" // snapshot
}
A subsequent conduit update will bump the commit to the new tip of main.
Authentication & rate limits
For private repositories or to lift the anonymous GitHub rate-limit, set a token before running conduit:
export CONDUIT_GITHUB_TOKEN=ghp_********************************
# or, if you already export it for other tools:
export GITHUB_TOKEN=ghp_********************************
conduit sends the token as Authorization: Bearer ... only against the configured GitHub API base address. It is never persisted.
For testing or to point at a different host, set CONDUIT_GITHUB_API_BASE (e.g. http://127.0.0.1:5050/).
Bundled agent skills
This repository ships its own agent skill under skills/zakira-conduit/. Drop it into any Agent Skills-compatible client and the agent will know how to drive conduit — recognise the relevant requests, edit the manifest safely, and explain sync / pin / status output to you.
You can — recursively — mirror it via conduit itself:
{
"name": "zakira-conduit",
"source": {
"type": "github",
"repo": "MoaidHathot/Zakira.Conduit",
"path": "skills/zakira-conduit",
"branch": "main"
},
"targets": ["~/.config/agents/skills"]
}
See skills/README.md for details on the format and how to add more skills here.
How it works
+---------+ 1. resolve manifest path +-----------+
| CLI args| -----------------------------> | Manifest |
+---------+ (--manifest > XDG > ...) +-----------+
|
2. load + validate v
+----+----+
| Entries |
+----+----+
|
for each entry: |
v
+------------------+
| SourceFetcher|
+--------+----------+
| (GitHub zipball ->
| stream -> temp dir,
| optionally sub-pathed)
v
+-----------+
| Local copy |
+-----+------+
|
v (per target)
+-----------------------------------+
| AtomicDirectoryMirror |
| - write into .staging-<guid> |
| - move existing aside |
| - rename staging into place |
| - delete the aside copy |
+-----------------------------------+
Key properties of the mirror step:
- Per-entry sub-directory: the entry
nameis appended to every configured target, so the target dir itself is not destroyed. - Atomic-ish swap: the new content is fully materialized in a sibling directory, then renamed into place. On the same filesystem this rename is atomic.
- No leaked files: when content shrinks (a file is deleted upstream), the target shrinks too, because the swap replaces the whole sub-directory.
Extending: add a new source kind
Implement two small types and register them with DI.
// 1. The manifest-shape: implement ISource and register the discriminator.
public sealed record GitLabSource : ISource
{
public const string TypeDiscriminator = "gitlab";
[JsonPropertyName("project")] public required string Project { get; init; }
[JsonPropertyName("ref")] public string? Ref { get; init; }
[JsonIgnore] public string Kind => TypeDiscriminator;
}
Add [JsonDerivedType(typeof(GitLabSource), GitLabSource.TypeDiscriminator)] on ISource.
// 2. The fetcher: turn a source into a local content directory.
public sealed class GitLabSourceFetcher : ISourceFetcher
{
public string SourceKind => GitLabSource.TypeDiscriminator;
public Task<FetchedSource> FetchAsync(ISource source, FetchContext context, CancellationToken ct = default)
{
// `context.ManifestDirectory` lets you resolve any path-shaped fields
// in the source relative to the manifest, mirroring how the local
// and target paths are handled.
// Download a zip, extract to a temp dir, return a FetchedSource pointing
// at that dir with a cleanup callback.
}
}
Register it:
services.AddConduitCore();
services.AddSingleton<ISourceFetcher, GitLabSourceFetcher>();
The synchronizer and mirror are source-agnostic, so that's all that's needed.
Repository layout
src/
Zakira.Conduit.Core/ # Manifest model, source abstractions, mirror, sync engine.
Zakira.Conduit/ # CLI (packaged as a global tool, command name: conduit).
tests/
Zakira.Conduit.Core.UnitTests/ # Pure unit tests, no network, no real fs except tmp.
Zakira.Conduit.IntegrationTests/ # GitHub fetcher + synchronizer against an in-process HTTP mock.
Zakira.Conduit.E2ETests/ # Spawns the built conduit.dll as a subprocess.
skills/
zakira-conduit/ # An agent skill that teaches an agent how to drive conduit.
example/
conduit.json # Runnable sample manifest covering every supported feature.
local-skill-sample/ # On-disk content referenced by the sample's `local` entry.
schemas/
conduit.schema.json # JSON-Schema for editor IntelliSense.
Tooling: global.json pins the SDK to .NET 10; Directory.Build.props and Directory.Packages.props set shared properties and central package versions; NuGet.config pins the package source to nuget.org.
Building & testing
# Restore + build everything.
dotnet build
# Run every test suite.
dotnet test
# Run a single suite.
dotnet test tests/Zakira.Conduit.Core.UnitTests/Zakira.Conduit.Core.UnitTests.csproj
dotnet test tests/Zakira.Conduit.IntegrationTests/Zakira.Conduit.IntegrationTests.csproj
dotnet test tests/Zakira.Conduit.E2ETests/Zakira.Conduit.E2ETests.csproj
# Pack the global tool.
dotnet pack src/Zakira.Conduit/Zakira.Conduit.csproj -c Release -o ./artifacts
# Install locally and try it.
dotnet tool install --global --add-source ./artifacts Zakira.Conduit
conduit --help
A convenience script wraps pack + push for releases:
# Pack only (writes Zakira.Conduit.*.nupkg + Zakira.Conduit.Core.*.nupkg into ./artifacts).
./pack.ps1
# Pack + push to nuget.org. Reads the key from $env:NUGET_API_KEY when -ApiKey is omitted.
$env:NUGET_API_KEY = 'oy2abc...'
./pack.ps1 -Push
Versioning
Every package, assembly, and file in this repository ships with the same version, stamped from a single line in Directory.Build.props:
<VersionPrefix>0.1.0</VersionPrefix>
To cut a release:
- Edit that number.
- Commit and push.
- Run
./pack.ps1 -Push.
pack.ps1 intentionally does not expose a -Version override. The only way to change what gets shipped is to change the file. That keeps git history, NuGet, and the embedded assembly version in lock-step, and prevents accidental publishes whose version isn't recorded anywhere.
If a new packable project is added later it inherits the same VersionPrefix automatically — no per-project version is ever declared.
The integration & E2E tests use System.Net.HttpListener to stand up an in-process server that emulates GitHub's zipball endpoint, so they run offline and are deterministic.
License
MIT — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.