Zakira.Conduit 0.5.0

dotnet tool install --global Zakira.Conduit --version 0.5.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local Zakira.Conduit --version 0.5.0
                    
This package contains a .NET tool you can call from the shell/command line.
#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 az CLI auth) and local directory sources today; new kinds (GitLab, plain HTTP archive, ...) are a single record + fetcher away.
  • type: uri shorthand. 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. Drop name from 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 paths and targets accept either a bare string or { "path": ..., "as": ... } to override the destination directory name.
  • Smart caching. A .conduit-state.json file 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. --force bypasses 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 for jq.
  • TTY-aware colours. ANSI colours when stdout is a terminal; plain text in pipes or when NO_COLOR is set.
  • Pin / unpin / update for reproducibility. conduit pin locks every GitHub / AzDO entry to a specific commit SHA by rewriting the source URL (or object) to the URL-native pinned form — GitHub becomes tree/<sha>/<path>, AzDO becomes ?version=GC<sha>. conduit unpin reverses it, restoring branch tracking (--to <branch> to pick; otherwise queries the repo's default branch and falls back to main). conduit update is an alias of pin for the refresh-flavoured verb. Already-pinned entries are skipped with a pointer at unpin; 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.json at $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 + ISourceFetcher are 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 pack the 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_TOKENAZURE_DEVOPS_EXT_PATSYSTEM_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 dedicated branch / tag / commit fields — 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:

  1. $XDG_CONFIG_HOME/Zakira.Conduit/conduit.{json,jsonc}
  2. $HOME/.config/Zakira.Conduit/conduit.{json,jsonc} (XDG-style fallback)
  3. ./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: pin and update reformat the manifest via System.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 ETag returned 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 and conduit skips 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 name is 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:

  1. Edit that number.
  2. Commit and push.
  3. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.5.0 104 6/6/2026
0.4.0 104 6/4/2026
0.3.0 101 6/4/2026
0.2.0 113 5/20/2026
0.1.0 114 5/20/2026