Compare commits

..
38 Commits
Author SHA1 Message Date
lukaszraczylo 12e448ca39 fix(deps): require Go 1.26.8
Go 1.26.0 carries 17 standard library vulnerabilities that govulncheck reports. The security workflow takes its toolchain from go.mod, so the scan failed. Go 1.26.8 clears them.
2026-10-02 16:18:05 +01:00
lukaszraczylo 0865c405fb feat(pi): emit model context and output limits when set
The pi adapter writes contextWindow and maxTokens to models.json only
when the profile sets them. Pi keeps its own defaults for every other
model. Tests cover both cases.
2026-10-02 05:11:46 +01:00
lukaszraczylo b6bd5303e0 ci(security): resolve latest Go patch in govulncheck job
The govulncheck job fails on 17 standard library findings. The runner
uses an outdated Go toolchain. Set check-latest so setup-go installs
the newest patch release.
2026-10-02 05:10:31 +01:00
lukaszraczyloandGitHub f81492eb46 Update go.mod and go.sum (#28) 2026-09-19 04:18:26 +01:00
lukaszraczyloandGitHub 76ac377b5d Update go.mod and go.sum (#27) 2026-09-14 04:22:09 +01:00
lukaszraczyloandGitHub 2e661fb526 Update go.mod and go.sum (#26) 2026-09-11 04:19:54 +01:00
lukaszraczyloandGitHub c4f1b0fa1d Update go.mod and go.sum (#25) 2026-09-09 04:19:47 +01:00
lukaszraczyloandGitHub bf7be9d9c5 Update go.mod and go.sum (#24) 2026-09-02 04:19:26 +01:00
lukaszraczyloandGitHub c5707acfd3 Update go.mod and go.sum (#23) 2026-08-18 04:20:33 +01:00
lukaszraczyloandGitHub 3e3c78725e Update go.mod and go.sum (#22) 2026-08-12 04:47:48 +01:00
lukaszraczyloandGitHub 82e1de9773 Update go.mod and go.sum (#21) 2026-08-11 04:36:13 +01:00
lukaszraczyloandGitHub 565d72768b Update go.mod and go.sum (#20) 2026-08-03 07:28:07 +01:00
lukaszraczyloandGitHub 4701b45a12 Update go.mod and go.sum (#19) 2026-07-24 06:53:40 +01:00
lukaszraczyloandGitHub 8e3ec78e2b Update go.mod and go.sum (#18) 2026-07-23 07:05:08 +01:00
lukaszraczylo 62debbfcda fixup! Add pi support 2026-07-22 11:00:04 +01:00
lukaszraczylo b5e3bcd301 Add pi support 2026-06-28 00:51:34 +01:00
lukaszraczyloandGitHub fd6519eed1 Update go.mod and go.sum (#13) 2026-06-24 05:30:56 +01:00
lukaszraczyloandGitHub bca4a4be1a Update go.mod and go.sum (#12) 2026-06-23 05:28:57 +01:00
lukaszraczyloandGitHub fa17e9a289 Update go.mod and go.sum (#11) 2026-06-17 05:37:11 +01:00
lukaszraczylo f30c0f65a6 feat: sync rules across all harnesses + agents to opencode/kilo
Add Rule as a first-class canonical kind (rules/<name>.md, loaded like
agents) and propagate it the way each harness actually loads it:

- claude-code: native ~/.claude/rules directory symlink (preserves rule
  path-scoping frontmatter); rules NOT folded into CLAUDE.md.
- opencode/kilo/zed/crush: fold rule bodies into the global AGENTS.md
  always-on instructions file (frontmatter stripped). crush has no global
  instructions file, so register the absolute AGENTS.md path in
  options.context_paths (defaults are re-prepended at load, so nothing is
  clobbered).
- goose: fold into ~/.config/goose/.goosehints.
- cagent: fold into the per-agent instruction.

Also:
- Wire markdown agents to opencode (~/.config/opencode/agents) and fix the
  kilo agents symlink: plural ~/.config/kilo/agents (singular "agent" is a
  silent no-op on current Kilo CLI).
- Add ManagesAgents/ManagesRules to HarnessCapabilities + ImportResult.Rules;
  claude-code/opencode import rules/agents round-trip.
- Centralize instruction selection in Bundle.InstructionText /
  InstructionTextWithRules (replaces per-adapter override duplication).
- init scaffolds + imports rules; README capability matrix gains
  Agents/Rules columns with native-vs-folded footnotes.

Grounded each harness's sink against official docs + config source this
session. Gate: gofmt, go vet, go test -race ./..., golangci-lint (0),
govulncheck (clean).
2026-06-16 21:32:12 +01:00
lukaszraczyloandGitHub 0892cb317e Update go.mod and go.sum (#10) 2026-06-12 05:36:13 +01:00
lukaszraczylo 1053d223e5 fixup! fix(apply): propagate read errors, auto-open repo, dedupe adapter base (#6) 2026-06-12 01:32:21 +01:00
lukaszraczyloandGitHub e01f3419ea fix(apply): propagate read errors, auto-open repo, dedupe adapter base (#6)
Three robustness fixes for the apply pipeline:

1. Silent read errors on state/target files treated 'file absent' as
   'file empty' and fast-forward-overwrote user edits. Add
   common.ReadExistingFile that returns (nil,nil) only on
   os.IsNotExist; other I/O errors propagate. Use it from apply.Run
   and from every adapter's Render (claudecode, crush, kilo,
   opencode, goose, zed).

2. apply.Run required callers to pre-construct *gitx.Repo; passing
   nil silently skipped the commit. Run now auto-opens a repo from
   Bundle.Root, gracefully no-ops when the root is not a git repo,
   and uses a new gitx.HasStagedChanges() check to skip the no-op
   'nothing to commit' that triggered the original silent-skip.

3. Each of 7 adapter packages re-implemented the same 25-line
   Option/WithHome/New constructor. Hoist into adapter.Base +
   adapter.BaseOption + NewBase. Each adapter embeds *Base and
   reduces to a 9-line constructor. Public New(WithHome(h)) API
   preserved; a.home becomes a.Home.

Tests:
  - apply: 5 new (propagates-read-error, auto-opens-repo,
    no-repo-skips-commit, skips-no-op-commit, commit-message-counts)
  - gitx: 2 new (HasStagedChanges empty/after-add)
  - adapter/common: 3 new (ReadExistingFile missing/content/perm)
  - adapter: 4 new (NewBase default/WithHome/order/typed)
  - existing adapter tests unchanged — verify a.Home still honoured.
2026-06-01 13:11:07 +01:00
lukaszraczyloandGitHub 5704ede5c7 Update go.mod and go.sum (#5) 2026-05-31 05:37:10 +01:00
lukaszraczyloandGitHub a7338e91dc Update go.mod and go.sum (#4) 2026-05-30 05:27:23 +01:00
lukaszraczyloandGitHub ae44e31abd Update go.mod and go.sum (#3) 2026-05-29 05:34:40 +01:00
lukaszraczylo 2eaa4ff825 fixup! fixup! refactor: remove dead code, deduplicate defaultHome, simplify mcpEntry + kilo render 2026-05-29 00:29:23 +01:00
lukaszraczylo 0e35af67b6 fixup! refactor: remove dead code, deduplicate defaultHome, simplify mcpEntry + kilo render 2026-05-28 20:11:12 +01:00
lukaszraczylo 6483eb8044 refactor: remove dead code, deduplicate defaultHome, simplify mcpEntry + kilo render
Dead code removed:
- BuildProviders, buildModels, ProviderEntry (no production callers)
- ZedLanguageModels (shadowed by adapter-local version, wrong schema)
- GatewayProviderID deprecated constant (zero references)
- Corresponding dead tests for BuildProviders

Deduplication:
- Extract common.DefaultHome() to internal/adapter/common/home.go
- All 7 adapters call common.DefaultHome() instead of local copies (-49 lines)

Simplification:
- mcp.go: merge MCPClaudeStyle/MCPCrushStyle branches (identical URL path,
  one-line difference in stdio path) — clearer intent, fewer lines
- kilo.go: extract opencode.jsonc merge to mergeOpenCodeJSONC() helper,
  Render() drops from 92 to ~55 lines
- goose.go: strings.IndexByte → strings.Cut in splitProviderModel
2026-05-23 21:22:49 +01:00
lukaszraczylo 21a86c084f feat: profile rename subcommand + expand all model display names 2026-05-23 21:13:27 +01:00
lukaszraczylo fb02b165d7 feat: URL-derived provider keys (hs-<hostname>) + dedup providers sharing same gateway URL
- GatewayProviderKey() derives stable key from gateway URL hostname
  e.g. https://llmgw.h.raczylo.com/v1 → hs-llmgw-h-raczylo-com
- AbsorbDuplicateProviders() merges providers sharing the same baseURL
  into our canonical entry; user-crafted model metadata wins for overlaps
- kilo: renders to opencode.jsonc (primary config), deep-merges providers,
  deletes enabled_providers filter that was hiding our gateway
- opencode: absorbs duplicate providers from existing config
- All adapters use GatewayProviderKey() instead of hardcoded string
- Fix goose URL doubling (was /v1/v1/chat/completions → /chat/completions)
- Fix token propagation: api_key written directly (not env var ref)
- Zed: adds prompt_cache_key capability, deletes context_servers key
  to avoid ContextServerSettingsContent serde untagged-enum parse error
2026-05-23 21:13:16 +01:00
lukaszraczylo 77158d9f2b fix: propagate gateway token; kilo targets opencode.jsonc; goose URL + api_key fix 2026-05-23 21:12:47 +01:00
lukaszraczylo 7716c180e7 zed: add prompt_cache_key capability field to model entries 2026-05-23 21:12:47 +01:00
lukaszraczylo 00ba950cd9 zed: delete context_servers key to fix serde parse error; update tests 2026-05-23 21:12:47 +01:00
lukaszraczylo bde3128dba fix(zed): switch to language_models.openai_compatible for Agent panel visibility
The Zed Agent panel (new ACP-based UI) only discovers providers registered
under language_models.openai_compatible.<name>, not language_models.openai.
Each named entry appears as its own provider in the LLM Providers list.

- language_models.openai_compatible.harness-sync-gateway holds gateway URL + models
- agent.default_model.provider = 'harness-sync-gateway' (was 'openai')
- Model entries include capabilities {tools,images,parallel_tool_calls}
- Export common.GatewayProviderID so zed.go can reference it
- Fix staticcheck QF1001 (De Morgan's law) in io.go
- Fix struct field alignment in File (80→72 bytes)
- gofmt remaining files
2026-05-23 21:12:47 +01:00
lukaszraczylo fd97c60df2 fix(opencode/kilo): add top-level name + models Record to ProvidersAsMap
opencode/kilo require top-level 'name' and a 'models' Record<modelID,Model>
in each provider entry for the harness UI to enumerate and display models.
Without 'name', the gateway appears unlabelled; without 'models', the provider
shows no model list in the selector.
2026-05-23 21:12:46 +01:00
lukaszraczyloandGitHub ce7a217231 Create CNAME 2026-05-23 20:42:35 +01:00
lukaszraczyloandGitHub da5eabb180 Create CNAME 2026-05-23 20:42:14 +01:00
70 changed files with 2816 additions and 588 deletions
+4
View File
@@ -0,0 +1,4 @@
{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-05-23T19:08:40.613Z"}
{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-05-23T19:55:51.755Z"}
{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-06-27T23:46:20.411Z"}
{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-06-27T23:48:27.473Z"}
+3
View File
@@ -0,0 +1,3 @@
{"exportedAt":"2026-06-27T23:47:20.367Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-27T23:48:20.367Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-27T23:49:20.368Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
+1 -1
View File
@@ -15,6 +15,6 @@ jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.24"
go-version: ">=1.25"
release-workflow: "release.yaml"
secrets: inherit
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: ">=1.24"
go-version: ">=1.25"
rolling-release-tag: "v1"
secrets: inherit
+1
View File
@@ -31,6 +31,7 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
cache: true
- name: Install govulncheck
+3
View File
@@ -0,0 +1,3 @@
{
"history": []
}
+1
View File
@@ -0,0 +1 @@
harness-sync.raczylo.com
+44 -12
View File
@@ -1,9 +1,9 @@
<p align="center">
<img src="docs/assets/banner.svg" alt="harness-sync — one config, every harness" width="900">
<a href="https://harness-sync.raczylo.com"><img src="docs/assets/banner.svg" alt="harness-sync — one config, every harness" width="900"></a>
</p>
<p align="center">
<strong>One canonical source of truth</strong> for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine.
<strong>One canonical source of truth</strong> for skills, agents, rules, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine.
</p>
<p align="center">
@@ -40,6 +40,7 @@ Edit once. Forget the others. They drift. Things stop working in odd places.
│ • profiles/<name>.yaml │
│ • skills/<name>/SKILL.md │
│ • agents/<name>.md │
│ • rules/<name>.md │
│ • mcp.yaml │
│ • instructions/global.md │
└──────────────┬───────────────────┘
@@ -131,15 +132,46 @@ harness's official docs before the adapter was written. Wrong key names
silently produce configs the harness can't parse — that's a bug class
harness-sync goes out of its way to avoid.
| Harness | Native config | MCP key | Model selector | Instructions | Symlinked |
|---|---|---|---|---|---|
| **claude-code** | `~/.claude.json` (merged, primary) + `~/.claude/mcp_servers.json` | `mcpServers` (`type: stdio`/`http`) | `env.ANTHROPIC_DEFAULT_MODEL` | `~/.claude/CLAUDE.md` | `skills/`, `agents/` |
| **crush** | `~/.config/crush/crush.json` (merged) | `mcp` (type: stdio\|http\|sse) | `default_model` | auto-read `AGENTS.md` | `skills/` |
| **kilo** | `~/.config/kilo/kilo.json` (merged) | `mcp` (type: local\|remote) | `model` + `small_model` | `instructions` array | `agent/` |
| **opencode** | `~/.config/opencode/opencode.jsonc` (merged) | `mcp` (type: local\|remote) | `model` | `AGENTS.md` | — |
| **goose** | `~/.config/goose/config.yaml` (merged) | n/a — `extensions` map (type: stdio) | `GOOSE_PROVIDER` + `GOOSE_MODEL` | env via `extensions.tom` | — |
| **cagent** | `~/.config/cagent/default.yaml` (starter) | `mcps` | per-agent `model:` | inline `instruction:` | — |
| **zed** | `~/.config/zed/settings.json` (merged) | `context_servers` (source: custom) | `agent.default_model.{provider,model}` | `~/.config/zed/rules/` | — |
### Native config format
| Harness | Native config | MCP key | Provider / model config | Skills path |
|---|---|---|---|---|
| **claude-code** | `~/.claude.json` (merged) + `~/.claude/mcp_servers.json` | `mcpServers` (`type: stdio`/`http`) | own subscription — not managed | `~/.claude/skills/` |
| **crush** | `~/.config/crush/crush.json` (merged) | `mcp` (`type: stdio`/`http`/`sse`) | `providers` map + `default_model` | `~/.config/crush/skills/` |
| **kilo** | `~/.config/kilo/kilo.json` (merged) | `mcp` (`type: local`/`remote`) | `provider` map + `model` + `small_model` | `~/.kilo/skills/` |
| **opencode** | `~/.config/opencode/opencode.jsonc` (merged) | `mcp` (`type: local`/`remote`) | `provider` map + `model` | `~/.config/opencode/skills/` |
| **pi** | `~/.pi/agent/settings.json` (merged) + `~/.pi/agent/models.json` | — | `defaultProvider` + `defaultModel` + `models.json` provider | `~/.pi/agent/skills/` |
| **goose** | `~/.config/goose/config.yaml` (merged) + `custom_providers/<name>.json` | `extensions` map (`type: stdio`) | `GOOSE_PROVIDER` + `GOOSE_MODEL` | `~/.agents/skills/` |
| **cagent** | `~/.config/cagent/default.yaml` (starter) | `mcps` | `providers` map + per-agent `model:` | — |
| **zed** | `~/.config/zed/settings.json` (merged) | `context_servers` | `language_models.openai.{api_url, available_models}` + `agent.default_model` | — |
### Capability matrix
What harness-sync writes per harness. Harnesses with a **built-in subscription** receive MCP + skills + agents + rules + instructions only — provider/model/endpoint config is skipped to avoid conflicting with the harness's own auth.
Skills paths are grounded in each harness's official documentation (kilo.ai, opencode.ai, goose-docs.ai). kilo, opencode, and goose also scan `~/.claude/skills/` as a compatibility path, so the claude-code symlink passively reaches them already.
| Harness | Built-in sub | Providers | Models | MCP | Skills | Agents | Rules | Instructions |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **claude-code** | ✓ | — | — | ✓ | ✓ | ✓ | ✓ⁿ | ✓ |
| **crush** | — | ✓ | ✓ | ✓ | ✓ | — | ✓ᶠ | ✓ᶠ |
| **kilo** | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ᶠ | ✓ᶠ |
| **opencode** | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ᶠ | ✓ |
| **pi** | — | ✓ | ✓ | — | ✓ | — | ✓ᶠ | ✓ᶠ |
| **goose** | — | ✓ | ✓ | ✓ | ✓ | — | ✓ᶠ | ✓ᶠ |
| **cagent** | — | ✓ | ✓ | ✓ | — | — | ✓ᶠ | ✓ |
| **zed** | — | ✓ | ✓ | — | — | — | ✓ᶠ | ✓ᶠ |
The matrix is machine-readable via `adapter.Capabilities()` — every adapter implements `HarnessCapabilities` (`ManagesProviders/Models/MCP/Skills/Agents/Rules/Instructions`) so tooling can inspect what will change before running `apply`.
### Agents & rules — native where possible, folded where necessary
Each kind is delivered the way the target harness actually loads it (grounded in each harness's docs and config source — a wrong path is a silent no-op):
- **Agents** are markdown subagents. They map cleanly to **claude-code** (`~/.claude/agents/`), **kilo** (`~/.config/kilo/agents/` — plural; the older singular `agent/` is a no-op on current Kilo CLI), and **opencode** (`~/.config/opencode/agents/`), all as directory symlinks. **goose** (YAML recipes) and **cagent** (inline YAML `agents:`) use incompatible agent formats, and **crush**/**zed** have no markdown-subagent concept — so agents are not propagated there.
- **Rules** (`rules/<name>.md`) are topic-scoped instruction fragments. **claude-code** auto-loads a rules *directory* natively (ⁿ), so they're delivered as a `~/.claude/rules` symlink — preserving each rule's optional path-scoping frontmatter. No other harness has a rules directory, so their bodies are **folded** (ᶠ) into that harness's global always-on instructions file — `AGENTS.md` (opencode/kilo/zed/crush), `.goosehints` (goose), or the per-agent `instruction:` (cagent). crush has no global instructions file, so harness-sync writes `~/.config/crush/AGENTS.md` and registers its absolute path in `options.context_paths` (crush always re-adds its built-in defaults at load, so nothing is clobbered).
ⁿ delivered as a native rules directory ᶠ folded into the harness's global instructions file
> **Merged, not replaced.** For harnesses with user-managed config keys (every one
> except cagent), harness-sync reads the existing file, overlays only the keys
@@ -259,7 +291,7 @@ end-to-end test that runs the real binary against a fake `$HOME`.
```
cmd/harness-sync/main.go # cobra entrypoint + adapter registration
internal/canonical/ # Bundle, Profile, MCPRegistry, Skill, Agent types + loader
internal/canonical/ # Bundle, Profile, MCPRegistry, Skill, Agent, Rule types + loader
internal/adapter/ # Adapter interface, Registry, FileSet
internal/adapter/common/ # shared building blocks (BuildProviders, BuildMCPMapStyled, MergeJSONKeys, MergeYAMLKeys, …)
internal/adapters/<harness>/ # one package per harness, ~50-100 LOC of harness-specific glue
+7
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"time"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapters/cagent"
@@ -11,18 +12,24 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapters/goose"
"github.com/lukaszraczylo/harness-sync/internal/adapters/kilo"
"github.com/lukaszraczylo/harness-sync/internal/adapters/opencode"
"github.com/lukaszraczylo/harness-sync/internal/adapters/pi"
"github.com/lukaszraczylo/harness-sync/internal/adapters/zed"
"github.com/lukaszraczylo/harness-sync/internal/cli"
"github.com/lukaszraczylo/oss-telemetry"
)
var version = "dev"
func main() {
telemetry.SendForModule("harness-sync", "github.com/lukaszraczylo/harness-sync", version)
defer telemetry.Wait(2 * time.Second)
reg := adapter.NewRegistry()
reg.Register(claudecode.New())
reg.Register(crush.New())
reg.Register(kilo.New())
reg.Register(opencode.New())
reg.Register(pi.New())
reg.Register(goose.New())
reg.Register(cagent.New())
reg.Register(zed.New())
+1
View File
@@ -0,0 +1 @@
harness-sync.raczylo.com
+320
View File
@@ -0,0 +1,320 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>harness-sync · one config, every harness</title>
<meta name="description" content="One canonical source of truth for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine."/>
<link rel="canonical" href="https://harness-sync.raczylo.com"/>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://harness-sync.raczylo.com"/>
<meta property="og:title" content="harness-sync · one config, every harness"/>
<meta property="og:description" content="One canonical source of truth for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine."/>
<meta property="og:image" content="https://harness-sync.raczylo.com/assets/banner.svg"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="harness-sync · one config, every harness"/>
<meta name="twitter:description" content="One canonical source of truth for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine."/>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config={darkMode:'class'}</script>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
body{font-family:system-ui,sans-serif}
code,pre,.mono{font-family:"JetBrains Mono",ui-monospace,monospace}
.terminal{background:#0d1117;color:#c9d1d9;border:1px solid #21262d;border-radius:8px;overflow:hidden}
.terminal-bar{background:#161b22;border-bottom:1px solid #21262d;padding:10px 14px;display:flex;gap:6px}
.terminal-dot{width:11px;height:11px;border-radius:50%}
.terminal pre{padding:18px 20px;margin:0;white-space:pre-wrap;font-size:13px;line-height:1.7;overflow-x:auto}
.terminal .g{color:#7ee787}.terminal .c{color:#79c0ff}.terminal .y{color:#d29922}.terminal .r{color:#ff7b72}.terminal .w{color:#c9d1d9}.terminal .d{color:#6e7681}
section + section{border-top:1px solid theme('colors.neutral.200')}
.dark section + section{border-top:1px solid theme('colors.neutral.800')}
@media(max-width:768px){.terminal pre{font-size:12px;padding:14px}}
</style>
<script>
if(localStorage.theme==="dark"||(!("theme" in localStorage)&&window.matchMedia("(prefers-color-scheme:dark)").matches))document.documentElement.classList.add("dark");else document.documentElement.classList.remove("dark");
</script>
</head>
<body class="bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
<!-- Nav -->
<nav class="sticky top-0 z-50 bg-white/90 dark:bg-neutral-950/90 backdrop-blur border-b border-neutral-200 dark:border-neutral-800">
<div class="max-w-3xl mx-auto px-5 flex items-center justify-between h-14">
<a href="#" class="mono text-sm font-medium tracking-tight"><span class="text-emerald-600 dark:text-emerald-400">$</span> harness-sync</a>
<div class="hidden md:flex items-center gap-6 text-sm text-neutral-500 dark:text-neutral-400">
<a href="#problem" class="hover:text-neutral-900 dark:hover:text-neutral-100">problem</a>
<a href="#features" class="hover:text-neutral-900 dark:hover:text-neutral-100">features</a>
<a href="#harnesses" class="hover:text-neutral-900 dark:hover:text-neutral-100">harnesses</a>
<a href="#install" class="hover:text-neutral-900 dark:hover:text-neutral-100">install</a>
<a href="#usage" class="hover:text-neutral-900 dark:hover:text-neutral-100">usage</a>
<a href="#architecture" class="hover:text-neutral-900 dark:hover:text-neutral-100">architecture</a>
</div>
<div class="flex items-center gap-3">
<button id="theme-toggle" class="text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 p-1" aria-label="Toggle theme">
<svg id="theme-moon" class="w-4 h-4 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
<svg id="theme-sun" class="w-4 h-4 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button>
<a href="https://github.com/lukaszraczylo/harness-sync" class="text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 p-1" aria-label="GitHub">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
</button>
<button id="mobile-toggle" class="md:hidden text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 p-1" aria-label="Menu">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-950">
<div class="px-5 py-3 flex flex-col gap-2 text-sm">
<a href="#problem" class="text-neutral-500 dark:text-neutral-400 py-1">problem</a>
<a href="#features" class="text-neutral-500 dark:text-neutral-400 py-1">features</a>
<a href="#harnesses" class="text-neutral-500 dark:text-neutral-400 py-1">harnesses</a>
<a href="#install" class="text-neutral-500 dark:text-neutral-400 py-1">install</a>
<a href="#usage" class="text-neutral-500 dark:text-neutral-400 py-1">usage</a>
<a href="#architecture" class="text-neutral-500 dark:text-neutral-400 py-1">architecture</a>
</div>
</div>
</nav>
<!-- Hero -->
<header class="max-w-3xl mx-auto px-5 pt-16 pb-12 md:pt-24 md:pb-16">
<p class="mono text-sm text-emerald-600 dark:text-emerald-400 mb-4">~ $ harness-sync</p>
<h1 class="text-3xl md:text-4xl font-semibold tracking-tight leading-tight mb-4">One config, every <span class="text-neutral-400 dark:text-neutral-500">LLM</span> harness.</h1>
<p class="text-lg text-neutral-600 dark:text-neutral-400 mb-8 max-w-xl leading-relaxed">A CLI tool that syncs skills, agents, MCP servers, endpoints, and instructions from a single canonical tree into each harness's native format.</p>
<div class="terminal mb-8">
<div class="terminal-bar">
<span class="terminal-dot" style="background:#ff5f57"></span>
<span class="terminal-dot" style="background:#febc2e"></span>
<span class="terminal-dot" style="background:#28c840"></span>
</div>
<pre><span class="d"># import from whatever you already use</span>
<span class="g">$</span> <span class="c">harness-sync</span> detect
<span class="w"> ✓ claude-code → ~/.claude/</span>
<span class="w"> ✓ kilo → ~/.config/kilo/</span>
<span class="w"> ✓ crush → ~/.config/crush/</span>
<span class="d"> × goose not found</span>
<span class="d"> × opencode not found</span>
<span class="g">$</span> <span class="c">harness-sync</span> init
<span class="w"> imported 12 skills, 4 agents, 6 mcp servers from claude-code</span>
<span class="g">$</span> <span class="c">harness-sync</span> apply --dry-run
<span class="w"> plan: write 14 files to claude-code, 11 to kilo, 9 to crush</span>
<span class="y"> (dry-run, nothing written)</span>
</pre>
</div>
<div class="flex items-center gap-3">
<a href="#install" class="inline-block bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 px-5 py-2.5 text-sm font-medium rounded">install</a>
<a href="https://github.com/lukaszraczylo/harness-sync" class="inline-block border border-neutral-300 dark:border-neutral-700 px-5 py-2.5 text-sm font-medium rounded hover:bg-neutral-50 dark:hover:bg-neutral-900">github</a>
</div>
</header>
<!-- Problem -->
<section id="problem" class="max-w-3xl mx-auto px-5 py-12 md:py-16">
<h2 class="text-2xl font-semibold tracking-tight mb-3">The problem</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-8 leading-relaxed">You run <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">claude-code</code>, <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">kilo</code>, <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">crush</code>, <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">opencode</code>, <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">goose</code>, <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">cagent</code>, <code class="mono text-sm bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">zed</code> — each keeps its own copy of the same config.</p>
<div class="grid md:grid-cols-2 gap-6">
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3">without harness-sync</h3>
<ul class="space-y-2 text-sm text-neutral-700 dark:text-neutral-300">
<li class="flex gap-2"><span class="text-red-500 shrink-0">✕</span><span>same skill markdown, duplicated seven ways</span></li>
<li class="flex gap-2"><span class="text-red-500 shrink-0">✕</span><span>same agent definitions, rewritten per harness</span></li>
<li class="flex gap-2"><span class="text-red-500 shrink-0">✕</span><span>same MCP registry, seven different key names (<code class="mono text-xs">mcpServers</code>, <code class="mono text-xs">mcp</code>, <code class="mono text-xs">context_servers</code>…)</span></li>
<li class="flex gap-2"><span class="text-red-500 shrink-0">✕</span><span>same gateway URL, hand-copied into seven files</span></li>
<li class="flex gap-2"><span class="text-red-500 shrink-0">✕</span><span>edit once, forget the others, things break</span></li>
</ul>
</div>
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3">with harness-sync</h3>
<ul class="space-y-2 text-sm text-neutral-700 dark:text-neutral-300">
<li class="flex gap-2"><span class="text-emerald-600 dark:text-emerald-400 shrink-0">→</span><span>one canonical tree under git version control</span></li>
<li class="flex gap-2"><span class="text-emerald-600 dark:text-emerald-400 shrink-0">→</span><span>per-harness adapters render native config formats</span></li>
<li class="flex gap-2"><span class="text-emerald-600 dark:text-emerald-400 shrink-0">→</span><span>3-way merge via <code class="mono text-xs">git merge-file</code></span></li>
<li class="flex gap-2"><span class="text-emerald-600 dark:text-emerald-400 shrink-0">→</span><span>secrets via <code class="mono text-xs">${VAR}</code> env-var substitution, never stored</span></li>
<li class="flex gap-2"><span class="text-emerald-600 dark:text-emerald-400 shrink-0">→</span><span>git rollback on every apply</span></li>
</ul>
</div>
</div>
</section>
<!-- Features -->
<section id="features" class="max-w-3xl mx-auto px-5 py-12 md:py-16">
<h2 class="text-2xl font-semibold tracking-tight mb-6">What it does</h2>
<ul class="space-y-4 text-sm text-neutral-700 dark:text-neutral-300">
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Canonical tree.</strong> Profiles, skills, agents, mcp servers, and instructions in a single git-managed directory.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Native adapters.</strong> Each harness gets its correct keys and file types — no lowest-common-denominator config.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">3-way merge.</strong> <code class="mono text-xs">git merge-file</code> semantics — disjoint changes merge cleanly, conflicts produce <code class="mono text-xs">.rej</code> files.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Profiles.</strong> Bundle gateway URL, model allowlist, and upstream keys — switch with one command.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Secret safety.</strong> Env-var substitution at render time. Secrets never written to disk or canonical tree.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Auto-detection.</strong> Detects installed harnesses. Only touches what's relevant on your machine.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Dry-run preview.</strong> Diff every target file before writing anything to disk.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Git rollback.</strong> Revert the last <em>n</em> applies. Full version history in local git.</div></li>
<li class="flex gap-3"><span class="text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5">→</span><div><strong class="font-medium text-neutral-900 dark:text-neutral-100">Non-destructive overlay.</strong> Only managed keys are written. Your hooks, permissions, and editor settings survive.</div></li>
</ul>
</section>
<!-- Harnesses -->
<section id="harnesses" class="max-w-3xl mx-auto px-5 py-12 md:py-16">
<h2 class="text-2xl font-semibold tracking-tight mb-3">Supported harnesses</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">Each adapter grounded against real config files and upstream docs.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 dark:border-neutral-800 text-left">
<tr class="text-neutral-500 dark:text-neutral-400 mono text-xs uppercase tracking-wide">
<th class="pb-2 pr-4 font-medium">harness</th>
<th class="pb-2 pr-4 font-medium">manages</th>
<th class="pb-2 font-medium">note</th>
</tr>
</thead>
<tbody class="text-neutral-700 dark:text-neutral-300">
<tr class="border-b border-neutral-100 dark:border-neutral-800/60">
<td class="py-2.5 pr-4 mono font-medium">claude-code</td>
<td class="py-2.5 pr-4">mcp · skills · instructions</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">provider config managed by Anthropic directly</td>
</tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60">
<td class="py-2.5 pr-4 mono font-medium">crush</td>
<td class="py-2.5 pr-4">mcp · skills · providers · models</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">full gateway rendering with provider map</td>
</tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60">
<td class="py-2.5 pr-4 mono font-medium">kilo</td>
<td class="py-2.5 pr-4">mcp · skills · providers · models</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">small model + provider map config</td>
</tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60">
<td class="py-2.5 pr-4 mono font-medium">opencode</td>
<td class="py-2.5 pr-4">mcp · skills · providers · models · instructions</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">JSONC config with instructions merging</td>
</tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60">
<td class="py-2.5 pr-4 mono font-medium">goose</td>
<td class="py-2.5 pr-4">mcp · skills · providers · models</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">YAML with extensions map</td>
</tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60">
<td class="py-2.5 pr-4 mono font-medium">cagent</td>
<td class="py-2.5 pr-4">mcp · providers · models · instructions</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">starter YAML with per-agent model</td>
</tr>
<tr>
<td class="py-2.5 pr-4 mono font-medium">zed</td>
<td class="py-2.5 pr-4">mcp · providers · models</td>
<td class="py-2.5 text-neutral-500 dark:text-neutral-500">context servers in settings.json</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Install -->
<section id="install" class="max-w-3xl mx-auto px-5 py-12 md:py-16">
<h2 class="text-2xl font-semibold tracking-tight mb-6">Install</h2>
<div class="space-y-6">
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-2">one-liner (recommended)</h3>
<div class="terminal"><pre><span class="g">$</span> curl -fsSL https://raw.githubusercontent.com/<span class="w">\</span>
lukaszraczylo/harness-sync/main/install.sh | bash</pre></div>
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">Picks the right release for OS/arch, verifies SHA-256, drops binary in <code class="mono">~/.local/bin</code>.</p>
</div>
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-2">homebrew</h3>
<div class="terminal"><pre><span class="g">$</span> brew install lukaszraczylo/taps/harness-sync</pre></div>
</div>
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-2">from source</h3>
<div class="terminal"><pre><span class="g">$</span> git clone https://github.com/lukaszraczylo/harness-sync
<span class="g">$</span> make build && make install</pre></div>
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">Requires Go 1.24+ and <code class="mono">git</code> on <code class="mono">$PATH</code>.</p>
</div>
</div>
</section>
<!-- Usage -->
<section id="usage" class="max-w-3xl mx-auto px-5 py-12 md:py-16">
<h2 class="text-2xl font-semibold tracking-tight mb-6">Usage</h2>
<div class="terminal mb-8"><pre><span class="d"># 1. see what's installed</span>
<span class="g">$</span> <span class="c">harness-sync</span> detect
<span class="d"># 2. import from detected harnesses into canonical tree</span>
<span class="g">$</span> <span class="c">harness-sync</span> init
<span class="d"># 3. render canonical tree to all harness configs</span>
<span class="g">$</span> <span class="c">harness-sync</span> apply
<span class="g">$</span> <span class="c">harness-sync</span> apply --dry-run <span class="d"># preview without writing</span>
<span class="g">$</span> <span class="c">harness-sync</span> diff <span class="d"># same as apply --dry-run</span>
<span class="g">$</span> <span class="c">harness-sync</span> profile list <span class="d"># list profiles</span>
<span class="g">$</span> <span class="c">harness-sync</span> profile use work <span class="d"># switch active profile</span>
<span class="g">$</span> <span class="c">harness-sync</span> rollback 2 <span class="d"># git revert last 2 applies</span>
<span class="g">$</span> <span class="c">harness-sync</span> update <span class="d"># pull latest release</span>
</pre></div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3">flags</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 dark:border-neutral-800 text-left">
<tr class="text-neutral-500 dark:text-neutral-400 mono text-xs uppercase tracking-wide"><th class="pb-2 pr-4 font-medium">flag</th><th class="pb-2 font-medium">effect</th></tr>
</thead>
<tbody class="text-neutral-700 dark:text-neutral-300">
<tr class="border-b border-neutral-100 dark:border-neutral-800/60"><td class="py-2 pr-4 mono">--dry-run</td><td class="py-2">show plan, write nothing</td></tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60"><td class="py-2 pr-4 mono">--force</td><td class="py-2">overwrite without merge</td></tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60"><td class="py-2 pr-4 mono">--yes</td><td class="py-2">skip confirmation prompt</td></tr>
<tr class="border-b border-neutral-100 dark:border-neutral-800/60"><td class="py-2 pr-4 mono">--from a,b</td><td class="py-2">pick source adapters for init</td></tr>
<tr><td class="py-2 pr-4 mono">--no-prompt</td><td class="py-2">take all detected sources</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Architecture -->
<section id="architecture" class="max-w-3xl mx-auto px-5 py-12 md:py-16">
<h2 class="text-2xl font-semibold tracking-tight mb-3">Architecture</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">Single Go binary. ~7,400 LOC. 174 passing tests. 18 packages.</p>
<div class="flex justify-center mb-8">
<img src="assets/architecture.svg" alt="harness-sync architecture" class="w-full max-w-2xl"/>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3">canonical tree</h3>
<div class="terminal"><pre><span class="c">~/.config/harness-sync/</span> <span class="d">(git)</span>
├── profiles/<span class="y">&lt;name&gt;.yaml</span>
├── skills/<span class="y">&lt;name&gt;/SKILL.md</span>
├── agents/<span class="y">&lt;name&gt;.md</span>
├── mcp.yaml
└── instructions/<span class="y">global.md</span></pre></div>
</div>
<div>
<h3 class="mono text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-3">merge model</h3>
<div class="terminal"><pre><span class="d">base </span> ← state/ <span class="d">(last render)</span>
<span class="g">ours </span> ← rendered <span class="d">(what we'd write now)</span>
<span class="c">theirs</span> ← on disk <span class="d">(what's actually there)</span>
<span class="d">disjoint</span> → clean merge, write file
<span class="y">overlap </span> → .rej file, exit non-zero</pre></div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="border-t border-neutral-200 dark:border-neutral-800">
<div class="max-w-3xl mx-auto px-5 py-8 flex flex-col md:flex-row justify-between items-center gap-3 text-sm text-neutral-500 dark:text-neutral-500">
<div class="mono">
<span class="text-emerald-600 dark:text-emerald-400">$</span> harness-sync — one config, every harness
</div>
<div class="flex items-center gap-5">
<a href="https://github.com/lukaszraczylo/harness-sync" class="hover:text-neutral-900 dark:hover:text-neutral-100">github</a>
<a href="https://github.com/sponsors/lukaszraczylo" class="hover:text-neutral-900 dark:hover:text-neutral-100">sponsor</a>
<span>MIT</span>
</div>
</div>
</footer>
<script>
document.getElementById('theme-toggle').addEventListener('click',()=>{
const d=document.documentElement.classList;
if(d.contains('dark')){d.remove('dark');localStorage.theme='light'}
else{d.add('dark');localStorage.theme='dark'}
});
const mt=document.getElementById('mobile-toggle'),mm=document.getElementById('mobile-menu');
mt.addEventListener('click',()=>mm.classList.toggle('hidden'));
mm.querySelectorAll('a').forEach(l=>l.addEventListener('click',()=>mm.classList.add('hidden')));
</script>
</body>
</html>
+21 -19
View File
@@ -1,12 +1,13 @@
module github.com/lukaszraczylo/harness-sync
go 1.25
go 1.26.8
require (
github.com/charmbracelet/huh v1.0.0
github.com/pelletier/go-toml/v2 v2.2.3
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/pelletier/go-toml/v2 v2.4.3
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.8.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.9.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -15,31 +16,32 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.28.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v1.2.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
+43 -41
View File
@@ -8,53 +8,59 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -63,34 +69,30 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+16
View File
@@ -23,9 +23,24 @@ func (k Kind) String() string {
return "unknown"
}
// HarnessCapabilities declares what an adapter manages.
// HasBuiltInSub means the harness has its own AI subscription (e.g. Claude Max)
// and harness-sync should not write provider/model/endpoint config for it.
type HarnessCapabilities struct {
ManagesProviders bool // writes provider/gateway endpoint config
ManagesModels bool // writes model selection config
ManagesMCP bool // writes MCP server config
ManagesSkills bool // writes skills symlink or equivalent
ManagesAgents bool // writes agents symlink or equivalent
ManagesRules bool // delivers rules (native rules dir, or folded into instructions)
ManagesInstructions bool // writes instructions file (CLAUDE.md, AGENTS.md, etc.)
HasBuiltInSub bool // has its own subscription; provider/model config is skipped
}
type Adapter interface {
Name() string
Detect() bool
Capabilities() HarnessCapabilities
Render(b *canonical.Bundle) (*FileSet, error)
Import(home string) (*ImportResult, error)
}
@@ -34,6 +49,7 @@ type ImportResult struct {
Instructions string
Skills []canonical.Skill
Agents []canonical.Agent
Rules []canonical.Rule
MCP []canonical.MCPServer
Profiles []canonical.Profile
}
+1
View File
@@ -34,6 +34,7 @@ func (f *fakeAdapter) Name() string { return f.n
func (f *fakeAdapter) Detect() bool { return f.detect }
func (f *fakeAdapter) Render(_ *canonical.Bundle) (*FileSet, error) { return NewFileSet(), nil }
func (f *fakeAdapter) Import(_ string) (*ImportResult, error) { return &ImportResult{}, nil }
func (f *fakeAdapter) Capabilities() HarnessCapabilities { return HarnessCapabilities{} }
func TestRegistry(t *testing.T) {
r := NewRegistry()
+29
View File
@@ -0,0 +1,29 @@
package adapter
import "github.com/lukaszraczylo/harness-sync/internal/adapter/common"
// Base holds the cross-adapter defaults (currently just the home directory
// used to compute target paths). Embed it in each adapter struct to inherit
// the WithHome/New constructor pattern without re-implementing it.
type Base struct {
Home string
}
// BaseOption configures a Base. Returned by WithHome and consumed by NewBase.
type BaseOption func(*Base)
// WithHome overrides the home directory used for target paths. Defaults to
// common.DefaultHome() (current $HOME) when no option is supplied.
func WithHome(h string) BaseOption {
return func(b *Base) { b.Home = h }
}
// NewBase returns a Base populated from opts, with Home defaulting to the
// current user's home directory.
func NewBase(opts ...BaseOption) *Base {
b := &Base{Home: common.DefaultHome()}
for _, o := range opts {
o(b)
}
return b
}
+46
View File
@@ -0,0 +1,46 @@
package adapter_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
)
func TestNewBaseDefaultsHomeFromEnv(t *testing.T) {
b := adapter.NewBase()
// DefaultHome returns either $HOME or "". We only assert that the
// helper does not panic and that Home is a string (Go zero value is "").
// Environment-dependence: skip if HOME is unset (CI minimal envs).
if b.Home == "" {
t.Skip("HOME unset in test env; cannot verify default")
}
assert.NotEmpty(t, b.Home)
}
func TestNewBaseWithHomeOverrides(t *testing.T) {
b := adapter.NewBase(adapter.WithHome("/custom/home/path"))
assert.Equal(t, "/custom/home/path", b.Home)
}
func TestNewBaseOptionsApplyInOrder(t *testing.T) {
// Last write wins — covers the basic Apply loop in NewBase.
b := adapter.NewBase(
adapter.WithHome("/first"),
adapter.WithHome("/second"),
)
assert.Equal(t, "/second", b.Home)
}
func TestBaseOptionTypedCorrectly(t *testing.T) {
// adapter.BaseOption is the public type; WithHome must return it.
opt := adapter.WithHome("/x")
require.NotNil(t, opt)
// Apply it manually to confirm signature.
var b adapter.Base
opt(&b)
assert.Equal(t, "/x", b.Home)
}
+62 -105
View File
@@ -3,69 +3,44 @@ package common
import (
"encoding/json"
"net/url"
"strings"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
const gatewayProviderID = "harness-sync-gateway"
const fallbackGatewayProviderID = "harness-sync-gateway"
// ProviderEntry is a generic provider record. Adapters may massage further.
type ProviderEntry = map[string]any
// BuildProviders returns the canonical-to-generic provider list: gateway first
// (if configured), then any upstreams. Each entry has id, name, base_url,
// api_key, and (for the gateway) a models list.
func BuildProviders(p *canonical.Profile) []ProviderEntry {
out := make([]ProviderEntry, 0, len(p.Upstreams)+1)
if p.Gateway.URL != "" {
gw := ProviderEntry{
"id": gatewayProviderID,
"name": "harness-sync gateway",
"base_url": p.Gateway.URL,
"api_key": p.Gateway.Token,
}
if models := buildModels(p.Models); len(models) > 0 {
gw["models"] = models
}
out = append(out, gw)
// GatewayProviderKey returns a stable provider key derived from the gateway URL.
// Format: "hs-<hostname>" with dots and special chars normalised to dashes.
// Falls back to "harness-sync-gateway" when the URL is empty or unparseable.
//
// https://llmgw.h.raczylo.com/v1 → hs-llmgw-h-raczylo-com
func GatewayProviderKey(gatewayURL string) string {
if gatewayURL == "" {
return fallbackGatewayProviderID
}
for _, up := range p.Upstreams {
e := ProviderEntry{"id": up.Name, "name": up.Name}
if up.BaseURL != "" {
e["base_url"] = up.BaseURL
}
if up.APIKey != "" {
e["api_key"] = up.APIKey
}
out = append(out, e)
u, err := url.Parse(gatewayURL)
if err != nil || u.Hostname() == "" {
return fallbackGatewayProviderID
}
return out
}
func buildModels(models []canonical.Model) []map[string]any {
out := make([]map[string]any, 0, len(models))
for _, m := range models {
e := map[string]any{"id": m.ID}
if m.Alias != "" {
e["alias"] = m.Alias
}
out = append(out, e)
}
return out
host := u.Hostname()
// Replace dots, underscores, and colons (port separators) with dashes.
r := strings.NewReplacer(".", "-", "_", "-", ":", "-")
return "hs-" + r.Replace(host)
}
// crushModel builds a 10-field model entry required by the crush schema.
func crushModel(id string) map[string]any {
func crushModel(m canonical.Model) map[string]any {
return map[string]any{
"id": id,
"name": id,
"id": m.ID,
"name": m.ID,
"cost_per_1m_in": 0,
"cost_per_1m_out": 0,
"cost_per_1m_in_cached": 0,
"cost_per_1m_out_cached": 0,
"context_window": 200000,
"default_max_tokens": 8192,
"context_window": m.ContextLimit(),
"default_max_tokens": m.OutputLimit(),
"can_reason": false,
"supports_attachments": true,
}
@@ -81,12 +56,12 @@ func ProvidersAsCrushMap(p *canonical.Profile) map[string]any {
}
models := make([]map[string]any, 0)
for _, m := range p.Models {
models = append(models, crushModel(m.ID))
models = append(models, crushModel(m))
}
if len(models) == 0 && p.Gateway.DefaultModel != "" {
models = append(models, crushModel(p.Gateway.DefaultModel))
models = append(models, crushModel(canonical.Model{ID: p.Gateway.DefaultModel}))
}
out[gatewayProviderID] = map[string]any{
out[GatewayProviderKey(p.Gateway.URL)] = map[string]any{
"type": "openai-compat",
"base_url": p.Gateway.URL,
"api_key": p.Gateway.Token,
@@ -103,7 +78,7 @@ func CrushRoleModels(p *canonical.Profile) map[string]any {
}
sel := map[string]any{
"model": p.Gateway.DefaultModel,
"provider": gatewayProviderID,
"provider": GatewayProviderKey(p.Gateway.URL),
}
return map[string]any{
"large": sel,
@@ -113,18 +88,34 @@ func CrushRoleModels(p *canonical.Profile) map[string]any {
}
// ProvidersAsMap returns a name-keyed map of provider objects for harnesses
// like opencode/kilo that use `provider` (singular, map-shaped) instead of a
// slice. Each entry has npm + options.{baseURL,apiKey} per the opencode schema.
// like opencode/kilo that use `provider` (singular, map-shaped). Includes
// top-level "name" and "models" Record so the harness UI can enumerate models.
func ProvidersAsMap(p *canonical.Profile) map[string]any {
out := map[string]any{}
if p.Gateway.URL != "" {
out[gatewayProviderID] = map[string]any{
"npm": "@ai-sdk/openai-compatible",
models := map[string]any{}
for _, m := range p.Models {
displayName := m.ID
if m.Alias != "" {
displayName = m.Alias
}
models[m.ID] = map[string]any{
"name": displayName,
"limit": map[string]any{"context": m.ContextLimit(), "output": m.OutputLimit()},
}
}
entry := map[string]any{
"name": "harness-sync gateway",
"npm": "@ai-sdk/openai-compatible",
"options": map[string]any{
"baseURL": p.Gateway.URL,
"apiKey": p.Gateway.Token,
},
}
if len(models) > 0 {
entry["models"] = models
}
out[GatewayProviderKey(p.Gateway.URL)] = entry
}
for _, up := range p.Upstreams {
entry := map[string]any{
@@ -150,7 +141,7 @@ func KiloModelString(p *canonical.Profile) string {
if p.Gateway.DefaultModel == "" {
return ""
}
return gatewayProviderID + "/" + p.Gateway.DefaultModel
return GatewayProviderKey(p.Gateway.URL) + "/" + p.Gateway.DefaultModel
}
// ProvidersAsCagentMap returns the providers block for cagent:
@@ -160,62 +151,28 @@ func ProvidersAsCagentMap(p *canonical.Profile) map[string]any {
if p.Gateway.URL == "" {
return out
}
out[gatewayProviderID] = map[string]any{
"base_url": p.Gateway.URL,
"token_key": "HARNESS_SYNC_GATEWAY_TOKEN",
"provider": "openai",
out[GatewayProviderKey(p.Gateway.URL)] = map[string]any{
"base_url": p.Gateway.URL,
"api_key": p.Gateway.Token,
"provider": "openai",
}
return out
}
// ZedLanguageModels returns the language_models.openai block for Zed when a
// gateway URL is configured.
func ZedLanguageModels(p *canonical.Profile) map[string]any {
if p.Gateway.URL == "" {
return map[string]any{}
}
models := make([]map[string]any, 0)
for _, m := range p.Models {
models = append(models, map[string]any{
"name": m.ID,
"display_name": func() string {
if m.Alias != "" {
return m.Alias
}
return m.ID
}(),
"max_tokens": 8192,
})
}
if len(models) == 0 && p.Gateway.DefaultModel != "" {
models = append(models, map[string]any{
"name": p.Gateway.DefaultModel,
"display_name": p.Gateway.DefaultModel,
"max_tokens": 8192,
})
}
return map[string]any{
"openai": map[string]any{
"api_url": p.Gateway.URL,
"available_models": models,
},
}
}
// GooseCustomProviderFile returns the JSON body and provider ID for the goose
// custom_providers/<id>.json file. Returns nil body when Gateway.URL is empty.
func GooseCustomProviderFile(p *canonical.Profile) ([]byte, string) {
if p.Gateway.URL == "" {
return nil, ""
}
providerID := gatewayProviderID
providerID := GatewayProviderKey(p.Gateway.URL)
providerName := "custom_" + providerID
models := make([]map[string]any, 0)
for _, m := range p.Models {
models = append(models, map[string]any{
"name": m.ID,
"context_limit": 200000,
"context_limit": m.ContextLimit(),
})
}
if len(models) == 0 && p.Gateway.DefaultModel != "" {
@@ -226,7 +183,7 @@ func GooseCustomProviderFile(p *canonical.Profile) ([]byte, string) {
}
models = append(models, map[string]any{
"name": modelID,
"context_limit": 200000,
"context_limit": canonical.DefaultContextLimit,
})
}
@@ -242,16 +199,16 @@ func GooseCustomProviderFile(p *canonical.Profile) ([]byte, string) {
}
}
chatURL := strings.TrimRight(p.Gateway.URL, "/") + "/v1/chat/completions"
// Append only /chat/completions — the profile URL already contains /v1.
chatURL := strings.TrimRight(p.Gateway.URL, "/") + "/chat/completions"
entry := map[string]any{
"name": providerName,
"engine": "openai",
"display_name": displayName,
"base_url": chatURL,
"models": models,
"requires_auth": true,
"api_key_env": "CUSTOM_HARNESS_SYNC_GATEWAY_API_KEY",
"name": providerName,
"engine": "openai",
"display_name": displayName,
"base_url": chatURL,
"models": models,
"api_key": p.Gateway.Token,
}
body, err := json.MarshalIndent(entry, "", " ")
if err != nil {
+50 -26
View File
@@ -1,6 +1,7 @@
package common
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
@@ -9,32 +10,6 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestBuildProvidersGatewayFirst(t *testing.T) {
p := &canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw", Token: "t", DefaultModel: "x"},
Models: []canonical.Model{{ID: "m1", Alias: "a1"}},
Upstreams: []canonical.Upstream{
{Name: "anthropic", APIKey: "k", BaseURL: "https://api.anthropic.com"},
},
}
out := BuildProviders(p)
require.Len(t, out, 2)
assert.Equal(t, "harness-sync-gateway", out[0]["id"])
assert.Equal(t, "anthropic", out[1]["id"])
assert.NotNil(t, out[0]["models"])
}
func TestBuildProvidersNoGateway(t *testing.T) {
p := &canonical.Profile{
Upstreams: []canonical.Upstream{
{Name: "anthropic", APIKey: "k"},
},
}
out := BuildProviders(p)
require.Len(t, out, 1)
assert.Equal(t, "anthropic", out[0]["id"])
}
func TestBuildMCPMap(t *testing.T) {
reg := &canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "filepuff", Command: "/bin/x", Transport: "stdio"},
@@ -90,3 +65,52 @@ func TestParseMCPFromJSONMissingKey(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, servers)
}
func TestProvidersAsMapHonorsPerModelLimits(t *testing.T) {
p := &canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw.example.com/v1", Token: "t"},
Models: []canonical.Model{
{ID: "prov/big", Context: 262144, Output: 65536},
{ID: "prov/default"},
},
}
models := ProvidersAsMap(p)[GatewayProviderKey(p.Gateway.URL)].(map[string]any)["models"].(map[string]any)
big := models["prov/big"].(map[string]any)["limit"].(map[string]any)
assert.Equal(t, 262144, big["context"], "explicit context override should win")
assert.Equal(t, 65536, big["output"], "explicit output override should win")
def := models["prov/default"].(map[string]any)["limit"].(map[string]any)
assert.Equal(t, 200000, def["context"], "unset model falls back to default context")
assert.Equal(t, 8192, def["output"], "unset model falls back to default output")
}
func TestProvidersAsCrushMapHonorsPerModelLimits(t *testing.T) {
p := &canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw.example.com/v1", Token: "t"},
Models: []canonical.Model{
{ID: "prov/big", Context: 262144, Output: 65536},
{ID: "prov/default"},
},
}
models := ProvidersAsCrushMap(p)[GatewayProviderKey(p.Gateway.URL)].(map[string]any)["models"].([]map[string]any)
assert.Equal(t, 262144, models[0]["context_window"], "explicit context override should win")
assert.Equal(t, 65536, models[0]["default_max_tokens"], "explicit output override should win")
assert.Equal(t, 200000, models[1]["context_window"], "unset model falls back to default context")
assert.Equal(t, 8192, models[1]["default_max_tokens"], "unset model falls back to default output")
}
func TestGooseCustomProviderFileHonorsPerModelContext(t *testing.T) {
p := &canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw.example.com/v1", Token: "t"},
Models: []canonical.Model{{ID: "big", Context: 262144}, {ID: "default"}},
}
body, _ := GooseCustomProviderFile(p)
var entry map[string]any
require.NoError(t, json.Unmarshal(body, &entry))
models := entry["models"].([]any)
assert.Equal(t, float64(262144), models[0].(map[string]any)["context_limit"], "explicit context override should win")
assert.Equal(t, float64(200000), models[1].(map[string]any)["context_limit"], "unset model falls back to default context")
}
+69
View File
@@ -0,0 +1,69 @@
package common
import "maps"
// AbsorbDuplicateProviders scans provMap for entries (other than ourKey) whose
// options.baseURL matches gatewayURL and absorbs their models into our entry.
//
// Merge strategy:
// - Models only in the duplicate: added to our provider.
// - Models only in our canonical entry: kept as-is.
// - Models in both: the DUPLICATE's entry wins (preserves user-crafted display
// names and custom limits that are often more descriptive than the profile
// alias).
//
// Absorbed entries are deleted from the returned map. Returns provMap unchanged
// when gatewayURL is empty or our entry is absent.
func AbsorbDuplicateProviders(provMap map[string]any, ourKey, gatewayURL string) map[string]any {
if gatewayURL == "" || provMap == nil {
return provMap
}
ourEntry, ok := provMap[ourKey].(map[string]any)
if !ok {
return provMap
}
// Clone top-level so we don't mutate the caller's map.
result := make(map[string]any, len(provMap))
maps.Copy(result, provMap)
ourModels, _ := ourEntry["models"].(map[string]any)
if ourModels == nil {
ourModels = map[string]any{}
}
// merged starts with our canonical models; duplicates may override per-model.
merged := make(map[string]any, len(ourModels))
maps.Copy(merged, ourModels)
for k, v := range result {
if k == ourKey {
continue
}
entry, ok := v.(map[string]any)
if !ok {
continue
}
opts, _ := entry["options"].(map[string]any)
if opts == nil {
continue
}
if opts["baseURL"] != gatewayURL {
continue
}
// Same gateway URL — absorb and delete.
if dupModels, ok := entry["models"].(map[string]any); ok {
// Duplicate (user-crafted) wins for overlapping IDs.
maps.Copy(merged, dupModels)
}
delete(result, k)
}
updated := make(map[string]any, len(ourEntry))
maps.Copy(updated, ourEntry)
if len(merged) > 0 {
updated["models"] = merged
}
result[ourKey] = updated
return result
}
+12
View File
@@ -0,0 +1,12 @@
package common
import "os"
// DefaultHome returns the current user's home directory, or "" on error.
func DefaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
}
+58 -10
View File
@@ -29,6 +29,19 @@ func ReadIfExists(path string) (string, error) {
return ReadIfExistsFS(fsx.OS(), path)
}
// ReadExistingFile returns the file body or (nil, nil) when missing.
// Other errors (permission denied, I/O failure) are returned verbatim so
// read-modify-write callers can distinguish a fresh first-run state from a
// broken read. Treating every error as "file is empty" silently overwrites
// the user's on-disk edits during apply.
func ReadExistingFile(path string) ([]byte, error) {
b, err := os.ReadFile(path) //nolint:gosec // caller-controlled path
if err != nil && os.IsNotExist(err) {
return nil, nil
}
return b, err
}
// DirExistsFS reports whether path is an existing directory on fs.
func DirExistsFS(fs fsx.FS, path string) bool {
info, err := fs.Stat(path)
@@ -40,18 +53,51 @@ func DirExists(path string) bool {
return DirExistsFS(fsx.OS(), path)
}
var (
blockComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
lineComment = regexp.MustCompile(`(?m)//[^\n]*`)
)
var trailingComma = regexp.MustCompile(`,(\s*[}\]])`)
// StripJSONComments is a naive JSONC -> JSON stripper. It does not understand
// strings containing // or /* and is therefore only safe for hand-edited
// config files that don't put comment markers inside string literals.
// StripJSONComments converts JSONC to standard JSON by:
// - removing // line comments (only outside string literals)
// - removing /* */ block comments (only outside string literals)
// - removing trailing commas before } or ]
//
// It is string-aware so it won't mangle // inside URL values like "https://...".
func StripJSONComments(s string) string {
s = blockComment.ReplaceAllString(s, "")
s = lineComment.ReplaceAllString(s, "")
return strings.TrimSpace(s)
result := make([]byte, 0, len(s))
i, n := 0, len(s)
for i < n {
switch {
case s[i] == '"': // string literal — copy verbatim including escape sequences
result = append(result, s[i])
i++
for i < n {
c := s[i]
result = append(result, c)
i++
if c == '\\' && i < n {
result = append(result, s[i])
i++
} else if c == '"' {
break
}
}
case i+1 < n && s[i] == '/' && s[i+1] == '*': // /* block comment */
i += 2
for i+1 < n && (s[i] != '*' || s[i+1] != '/') {
i++
}
i += 2
case i+1 < n && s[i] == '/' && s[i+1] == '/': // // line comment
i += 2
for i < n && s[i] != '\n' {
i++
}
default:
result = append(result, s[i])
i++
}
}
out := trailingComma.ReplaceAllString(string(result), "$1")
return strings.TrimSpace(out)
}
// ImportMCPFromJSONFileFS reads a JSON file from fs and extracts an MCP server
@@ -122,6 +168,7 @@ func parseMCPEntry(name string, raw json.RawMessage) (canonical.MCPServer, bool)
Cmd json.RawMessage `json:"cmd"`
Args []string `json:"args"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Env map[string]string `json:"env"`
Environment map[string]string `json:"environment"`
}
@@ -152,6 +199,7 @@ func parseMCPEntry(name string, raw json.RawMessage) (canonical.MCPServer, bool)
Args: args,
URL: e.URL,
Transport: transport,
Headers: e.Headers,
Env: env,
}, true
}
@@ -0,0 +1,64 @@
package common_test
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
)
// ReadExistingFile: missing file returns (nil, nil). Permission errors and
// other I/O failures must NOT be silently swallowed — those would let
// read-modify-write code clobber the user's on-disk state.
func TestReadExistingFileMissing(t *testing.T) {
got, err := common.ReadExistingFile(filepath.Join(t.TempDir(), "absent"))
require.NoError(t, err)
assert.Nil(t, got)
}
func TestReadExistingFileContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ok.txt")
require.NoError(t, os.WriteFile(path, []byte("body\n"), 0o600))
got, err := common.ReadExistingFile(path)
require.NoError(t, err)
assert.Equal(t, []byte("body\n"), got)
}
func TestReadExistingFilePropagatesError(t *testing.T) {
// Create a directory the OS will refuse us read access to.
if os.Geteuid() == 0 {
t.Skip("running as root; cannot test permission denial")
}
dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "locked"), 0o000))
t.Cleanup(func() { _ = os.Chmod(filepath.Join(dir, "locked"), 0o755) })
_, err := common.ReadExistingFile(filepath.Join(dir, "locked", "file"))
require.Error(t, err)
assert.False(t, os.IsNotExist(err), "permission denied must not be reported as not-exist")
assert.True(t, errors.Is(err, os.ErrPermission) || isPermission(err),
"expected permission error, got %v", err)
}
func isPermission(err error) bool {
// Some platforms wrap the error in *os.PathError. Check the string too
// for portability — it's a coarse signal but adequate here.
return err != nil && (contains(err.Error(), "permission") || contains(err.Error(), "access denied"))
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+2
View File
@@ -42,6 +42,7 @@ func TestImportMCPFromJSONFileFSExtractsServers(t *testing.T) {
"myserver": {
"command": "/usr/bin/mytool",
"args": ["--flag"],
"headers": {"Authorization": "Bearer ${MCP_TOKEN}"},
"type": "stdio"
}
}
@@ -55,6 +56,7 @@ func TestImportMCPFromJSONFileFSExtractsServers(t *testing.T) {
assert.Equal(t, "/usr/bin/mytool", servers[0].Command)
assert.Equal(t, []string{"--flag"}, servers[0].Args)
assert.Equal(t, "stdio", servers[0].Transport)
assert.Equal(t, map[string]string{"Authorization": "Bearer ${MCP_TOKEN}"}, servers[0].Headers)
}
func TestImportMCPFromJSONFileFSReadOnlyFS(t *testing.T) {
+20 -30
View File
@@ -12,7 +12,7 @@ const (
MCPCrushStyle
// MCPOpencodeStyle: kilo + opencode shape — type: local|remote, command as []string.
MCPOpencodeStyle
// MCPZedStyle: zed context_servers — {enabled, source, command, args} or {enabled, url}.
// MCPZedStyle: zed context_servers — {enabled, command, args} or {enabled, url}.
MCPZedStyle
)
@@ -31,32 +31,7 @@ func BuildMCPMapStyled(reg *canonical.MCPRegistry, style MCPStyle) map[string]an
func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
e := map[string]any{}
switch style {
case MCPClaudeStyle:
// Claude Code's actual entry shape uses "type" (not "transport").
// Default to stdio when no URL is set.
t := s.Transport
if s.URL != "" {
if t == "" {
t = "http"
}
e["type"] = t
e["url"] = s.URL
} else {
if t == "" {
t = "stdio"
}
e["type"] = t
if s.Command != "" {
e["command"] = s.Command
}
if len(s.Args) > 0 {
e["args"] = s.Args
}
if len(s.Env) > 0 {
e["env"] = s.Env
}
}
case MCPCrushStyle:
case MCPClaudeStyle, MCPCrushStyle:
if s.URL != "" {
t := s.Transport
if t == "" {
@@ -64,8 +39,16 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
}
e["type"] = t
e["url"] = s.URL
if len(s.Headers) > 0 {
e["headers"] = s.Headers
}
} else {
e["type"] = "stdio"
// Claude honours Transport field; Crush always uses "stdio".
t := "stdio"
if style == MCPClaudeStyle && s.Transport != "" {
t = s.Transport
}
e["type"] = t
if s.Command != "" {
e["command"] = s.Command
}
@@ -80,6 +63,9 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
if s.URL != "" {
e["type"] = "remote"
e["url"] = s.URL
if len(s.Headers) > 0 {
e["headers"] = s.Headers
}
} else {
e["type"] = "local"
cmd := []string{}
@@ -94,11 +80,15 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
}
e["enabled"] = true
case MCPZedStyle:
e["enabled"] = true
// Do NOT emit "enabled" — Zed 1.3.x has a serde flatten+deny_unknown_fields
// bug where outer-struct fields bleed into the untagged enum variants and
// fail all of them. Omitting "enabled" defaults to true.
if s.URL != "" {
e["url"] = s.URL
if len(s.Headers) > 0 {
e["headers"] = s.Headers
}
} else {
e["source"] = "custom"
if s.Command != "" {
e["command"] = s.Command
}
+17 -5
View File
@@ -26,11 +26,16 @@ func TestBuildMCPMapStyledClaudeStyle(t *testing.T) {
}
func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) {
r := reg(canonical.MCPServer{Name: "remote", URL: "https://example.com"})
r := reg(canonical.MCPServer{
Name: "remote",
URL: "https://example.com",
Headers: map[string]string{"Authorization": "Bearer ${MCP_TOKEN}"},
})
out := BuildMCPMapStyled(r, MCPClaudeStyle)
e := out["remote"].(map[string]any)
assert.Equal(t, "https://example.com", e["url"])
assert.Equal(t, "http", e["type"])
assert.Equal(t, map[string]string{"Authorization": "Bearer ${MCP_TOKEN}"}, e["headers"])
}
func TestBuildMCPMapStyledCrushStdio(t *testing.T) {
@@ -70,12 +75,17 @@ func TestBuildMCPMapStyledOpencodeLocal(t *testing.T) {
}
func TestBuildMCPMapStyledOpencodeRemote(t *testing.T) {
r := reg(canonical.MCPServer{Name: "r", URL: "https://example.com"})
r := reg(canonical.MCPServer{
Name: "r",
URL: "https://example.com",
Headers: map[string]string{"X-Tenant": "tenant-a"},
})
out := BuildMCPMapStyled(r, MCPOpencodeStyle)
e := out["r"].(map[string]any)
assert.Equal(t, "remote", e["type"])
assert.Equal(t, true, e["enabled"])
assert.Equal(t, "https://example.com", e["url"])
assert.Equal(t, map[string]string{"X-Tenant": "tenant-a"}, e["headers"])
}
func TestBuildMCPMapStyledOpencodeEnv(t *testing.T) {
@@ -89,8 +99,9 @@ func TestBuildMCPMapStyledZedStdio(t *testing.T) {
r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"-x"}})
out := BuildMCPMapStyled(r, MCPZedStyle)
e := out["fp"].(map[string]any)
assert.Equal(t, true, e["enabled"])
assert.Equal(t, "custom", e["source"])
// Zed 1.3.x serde bug: "enabled" and "source" must NOT be present.
assert.NotContains(t, e, "enabled")
assert.NotContains(t, e, "source")
assert.Equal(t, "/bin/fp", e["command"])
assert.Equal(t, []string{"-x"}, e["args"])
}
@@ -99,7 +110,8 @@ func TestBuildMCPMapStyledZedURL(t *testing.T) {
r := reg(canonical.MCPServer{Name: "r", URL: "https://example.com"})
out := BuildMCPMapStyled(r, MCPZedStyle)
e := out["r"].(map[string]any)
assert.Equal(t, true, e["enabled"])
// Zed 1.3.x serde bug: "enabled" must NOT be present.
assert.NotContains(t, e, "enabled")
assert.Equal(t, "https://example.com", e["url"])
assert.NotContains(t, e, "source")
}
+5 -1
View File
@@ -6,8 +6,12 @@ type File struct {
Dest string
SymlinkTarget string
Content []byte
Mode os.FileMode
Kind Kind
Mode os.FileMode
// NoMerge skips the 3-way git merge and always writes the rendered content.
// Use for files the adapter partially manages via JSON/YAML merge (e.g. live
// state files where the adapter already reconciles at the key level).
NoMerge bool
}
type FileSet struct {
+30 -33
View File
@@ -19,49 +19,48 @@ import (
const name = "cagent"
// Adapter implements adapter.Adapter for the cagent harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
type Adapter struct {
*adapter.Base
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what cagent harness-sync manages.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: true,
ManagesSkills: false,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.config/cagent/ exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "cagent"))
_, err := os.Stat(filepath.Join(a.Home, ".config", "cagent"))
return err == nil
}
// Render produces a starter default.yaml for cagent.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.home, ".config", "cagent", "default.yaml")
cfgPath := filepath.Join(a.Home, ".config", "cagent", "default.yaml")
instructions := b.Instructions.Global
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
instructions = override
}
// cagent has no rules concept; the per-agent instruction (system prompt) is
// the only sink, so fold rules into it.
instructions := b.InstructionTextWithRules(name)
mcps := map[string]any{}
for _, s := range b.MCP.Servers {
@@ -77,11 +76,9 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
mcps[s.Name] = e
}
provKey := common.GatewayProviderKey(b.Profile.Gateway.URL)
// inline model shorthand: "providerID/modelID"
modelRef := ""
if b.Profile.Gateway.DefaultModel != "" {
modelRef = "harness-sync-gateway/" + b.Profile.Gateway.DefaultModel
}
modelRef := common.KiloModelString(&b.Profile)
cfg := map[string]any{
"version": 8,
@@ -96,8 +93,8 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
},
"providers": common.ProvidersAsCagentMap(&b.Profile),
"models": map[string]any{
"harness-sync-gateway": map[string]any{
"provider": "harness-sync-gateway",
provKey: map[string]any{
"provider": provKey,
"model": b.Profile.Gateway.DefaultModel,
},
},
+9 -8
View File
@@ -48,20 +48,20 @@ func TestCagentRenderProducesExpectedTargets(t *testing.T) {
agents := parsed["agents"].(map[string]any)
def := agents["default"].(map[string]any)
assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", def["model"])
assert.Equal(t, "hs-gw/claude-sonnet-4-6", def["model"])
assert.Contains(t, def["instruction"], "# global instruction")
// providers map (new schema).
providers := parsed["providers"].(map[string]any)
gwProv := providers["harness-sync-gateway"].(map[string]any)
gwProv := providers["hs-gw"].(map[string]any)
assert.Equal(t, "https://gw", gwProv["base_url"])
assert.Equal(t, "HARNESS_SYNC_GATEWAY_TOKEN", gwProv["token_key"])
assert.Equal(t, "openai", gwProv["provider"])
assert.NotContains(t, gwProv, "token_key")
// models map.
models := parsed["models"].(map[string]any)
gw := models["harness-sync-gateway"].(map[string]any)
assert.Equal(t, "harness-sync-gateway", gw["provider"])
gw := models["hs-gw"].(map[string]any)
assert.Equal(t, "hs-gw", gw["provider"])
assert.Equal(t, "claude-sonnet-4-6", gw["model"])
mcps := parsed["mcps"].(map[string]any)
@@ -93,16 +93,17 @@ func TestCagentRenderProvidersAndModelsMaps(t *testing.T) {
// providers block present.
providers, ok := parsed["providers"].(map[string]any)
require.True(t, ok, "providers must be a map")
gw, ok := providers["harness-sync-gateway"].(map[string]any)
gw, ok := providers["hs-gw"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "https://gw", gw["base_url"])
assert.Equal(t, "HARNESS_SYNC_GATEWAY_TOKEN", gw["token_key"])
assert.Equal(t, "tok", gw["api_key"])
assert.Equal(t, "openai", gw["provider"])
assert.NotContains(t, gw, "token_key")
// agents.default.model uses inline shorthand.
agents := parsed["agents"].(map[string]any)
def := agents["default"].(map[string]any)
assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", def["model"])
assert.Equal(t, "hs-gw/claude-sonnet-4-6", def["model"])
}
func TestCagentRenderNoMCP(t *testing.T) {
+37
View File
@@ -0,0 +1,37 @@
package cagent
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"gopkg.in/yaml.v3"
)
func TestCagentFoldsRulesIntoInstruction(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "rust", Body: "# Rust\n\nno unwrap"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfg := seen[filepath.Join(home, ".config", "cagent", "default.yaml")]
require.Equal(t, adapter.RenderedFile, cfg.Kind)
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(cfg.Content, &parsed))
agents := parsed["agents"].(map[string]any)
def := agents["default"].(map[string]any)
instr := def["instruction"].(string)
assert.Contains(t, instr, "# global")
assert.Contains(t, instr, "no unwrap")
}
+47 -56
View File
@@ -2,6 +2,7 @@
package claudecode
import (
"fmt"
"os"
"path/filepath"
@@ -14,48 +15,47 @@ const name = "claude-code"
// Adapter implements adapter.Adapter for the claude-code harness.
type Adapter struct {
home string
*adapter.Base
}
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what claude-code harness-sync manages.
// Claude Code has its own Anthropic Max subscription — provider/model config is skipped.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: false,
ManagesModels: false,
ManagesMCP: true,
ManagesSkills: true,
ManagesAgents: true,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: true,
}
}
// Detect returns true when ~/.claude exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".claude"))
_, err := os.Stat(filepath.Join(a.Home, ".claude"))
return err == nil
}
// Render produces the FileSet that applies the canonical bundle to claude-code.
// settings.json is MERGED into any existing file so user-managed keys
// settings.json is MERGED into any existing file so user-managed keys
// (hooks, permissions, env, …) are preserved.
// Claude Code manages its own subscription and settings.json — harness-sync
// only manages skills/agents symlinks, CLAUDE.md, and MCP servers.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".claude")
base := filepath.Join(a.Home, ".claude")
fs.Add(adapter.File{
Dest: filepath.Join(base, "skills"),
@@ -67,40 +67,21 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"),
})
// Claude Code natively auto-loads ~/.claude/rules/*.md (memory feature), so
// rules are delivered as their own directory symlink — NOT folded into
// CLAUDE.md — preserving each rule's optional path-scoping frontmatter.
fs.Add(adapter.File{
Dest: filepath.Join(base, "rules"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "rules"),
})
instructions := b.Instructions.Global
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
instructions = override
}
fs.Add(adapter.File{
Dest: filepath.Join(base, "CLAUDE.md"),
Kind: adapter.RenderedFile,
Content: []byte(instructions),
Content: []byte(b.InstructionText(name)),
})
// settings.json holds the gateway env vars + model override.
// Only emit when a gateway URL is configured.
if b.Profile.Gateway.URL != "" {
settingsPath := filepath.Join(base, "settings.json")
settingsExisting, _ := os.ReadFile(settingsPath)
settingsOverlay := map[string]any{
"model": b.Profile.Gateway.DefaultModel,
"env": map[string]any{
"ANTHROPIC_BASE_URL": b.Profile.Gateway.URL,
"ANTHROPIC_AUTH_TOKEN": b.Profile.Gateway.Token,
},
}
settingsMerged, err := common.MergeJSONKeys(settingsExisting, settingsOverlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: settingsPath,
Kind: adapter.RenderedFile,
Content: settingsMerged,
})
}
// Claude Code reads MCP servers from two files on disk:
// * ~/.claude.json (live, written by `claude mcp add`)
// * ~/.claude/mcp_servers.json (older / fallback location)
@@ -111,7 +92,10 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
overlay := map[string]any{"mcpServers": mcpMap}
dedicatedPath := filepath.Join(base, "mcp_servers.json")
dedicatedExisting, _ := os.ReadFile(dedicatedPath)
dedicatedExisting, err := common.ReadExistingFile(dedicatedPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", dedicatedPath, err)
}
dedicatedMerged, err := common.MergeJSONKeys(dedicatedExisting, overlay)
if err != nil {
return nil, err
@@ -122,9 +106,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: dedicatedMerged,
})
livePath := filepath.Join(a.home, ".claude.json")
livePath := filepath.Join(a.Home, ".claude.json")
if _, err := os.Stat(livePath); err == nil {
liveExisting, _ := os.ReadFile(livePath)
liveExisting, err := common.ReadExistingFile(livePath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", livePath, err)
}
liveMerged, err := common.MergeJSONKeys(liveExisting, overlay)
if err != nil {
return nil, err
@@ -133,6 +120,10 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Dest: livePath,
Kind: adapter.RenderedFile,
Content: liveMerged,
// NoMerge: .claude.json is a live state file (Claude Code rewrites it
// constantly). The JSON merge above already reconciles at the key level;
// a git 3-way merge on top causes perpetual conflicts.
NoMerge: true,
})
}
@@ -94,7 +94,9 @@ func TestRenderMergesLiveClaudeJSON(t *testing.T) {
assert.Equal(t, "stdio", fp["type"])
}
func TestClaudeCodeRenderSetsAnthropicBaseURLInSettings(t *testing.T) {
func TestClaudeCodeRenderNeverWritesSettings(t *testing.T) {
// Claude Code uses its own Anthropic Max subscription.
// harness-sync must NEVER write settings.json even when a gateway is configured.
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
@@ -113,16 +115,7 @@ func TestClaudeCodeRenderSetsAnthropicBaseURLInSettings(t *testing.T) {
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
settingsDest := filepath.Join(home, ".claude", "settings.json")
require.Contains(t, seen, settingsDest)
assert.Equal(t, adapter.RenderedFile, seen[settingsDest].Kind)
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[settingsDest].Content, &parsed))
assert.Equal(t, "claude-sonnet-4-6", parsed["model"])
env, ok := parsed["env"].(map[string]any)
require.True(t, ok, "env must be a map")
assert.Equal(t, "https://gw", env["ANTHROPIC_BASE_URL"])
assert.Equal(t, "tok", env["ANTHROPIC_AUTH_TOKEN"])
assert.NotContains(t, seen, settingsDest, "settings.json must never be written — claude-code has its own subscription")
}
func TestClaudeCodeRenderNoGatewayNoSettings(t *testing.T) {
+10
View File
@@ -32,6 +32,16 @@ func importFrom(home string) (*adapter.ImportResult, error) {
})
}
rules, err := common.ImportMarkdownTree(filepath.Join(base, "rules"), "")
if err != nil {
return nil, err
}
for _, r := range rules {
res.Rules = append(res.Rules, canonical.Rule{
Name: r.Name, Description: r.Description, Body: r.Body, Path: r.Path,
})
}
body, err := common.ReadIfExists(filepath.Join(base, "CLAUDE.md"))
if err != nil {
return nil, err
@@ -0,0 +1,47 @@
package claudecode
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestClaudeCodeRendersRulesSymlink(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{Root: "/canon"}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
rulesDest := filepath.Join(home, ".claude", "rules")
assert.Equal(t, adapter.SymlinkDir, seen[rulesDest].Kind)
assert.Equal(t, "/canon/rules", seen[rulesDest].SymlinkTarget)
// Rules are delivered natively as a directory — never folded into CLAUDE.md.
claudemd := string(seen[filepath.Join(home, ".claude", "CLAUDE.md")].Content)
assert.NotContains(t, claudemd, "/canon/rules")
}
func TestClaudeCodeImportsRules(t *testing.T) {
home := t.TempDir()
rulesDir := filepath.Join(home, ".claude", "rules")
require.NoError(t, os.MkdirAll(rulesDir, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(rulesDir, "go.md"),
[]byte("# Go Rules\n\ngofmt always"), 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
require.Len(t, res.Rules, 1)
assert.Equal(t, "go", res.Rules[0].Name)
assert.Contains(t, res.Rules[0].Body, "gofmt always")
}
+71 -24
View File
@@ -2,6 +2,8 @@
package crush
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -13,37 +15,37 @@ import (
const name = "crush"
// Adapter implements adapter.Adapter for the crush harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
type Adapter struct {
*adapter.Base
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what crush harness-sync manages.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: true,
ManagesSkills: true,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.config/crush exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "crush"))
_, err := os.Stat(filepath.Join(a.Home, ".config", "crush"))
return err == nil
}
@@ -52,7 +54,7 @@ func (a *Adapter) Detect() bool {
// MCP key is "mcp" (not "mcpServers"); entries include a "type" field.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".config", "crush")
base := filepath.Join(a.Home, ".config", "crush")
fs.Add(adapter.File{
Dest: filepath.Join(base, "skills"),
@@ -60,15 +62,32 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
SymlinkTarget: filepath.Join(b.Root, "skills"),
})
// crush has no global instructions file and no rules directory: it loads
// context from the files listed in options.context_paths (defaults are always
// prepended; absolute paths load verbatim; the global config applies to every
// project). Write a global AGENTS.md with rules folded in, and register its
// absolute path so crush loads it in every session.
agentsMD := filepath.Join(base, "AGENTS.md")
fs.Add(adapter.File{
Dest: agentsMD,
Kind: adapter.RenderedFile,
Content: []byte(b.InstructionTextWithRules(name)),
})
cfgPath := filepath.Join(base, "crush.json")
existing, _ := os.ReadFile(cfgPath)
existing, err := common.ReadExistingFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err)
}
overlay := map[string]any{}
overlay["options"] = crushOptionsWithContextPath(existing, agentsMD)
if providers := common.ProvidersAsCrushMap(&b.Profile); len(providers) > 0 {
overlay["providers"] = providers
}
if roleModels := common.CrushRoleModels(&b.Profile); len(roleModels) > 0 {
overlay["models"] = roleModels
}
overlay["default_model"] = b.Profile.Gateway.DefaultModel
if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPCrushStyle); len(mcp) > 0 {
overlay["mcp"] = mcp
}
@@ -85,6 +104,34 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
return fs, nil
}
// crushOptionsWithContextPath returns the crush "options" object with contextFile
// added to options.context_paths, preserving every other existing option key
// (lsp, permissions, …). crush always prepends its built-in default context
// paths at load time, so only the extra path is set here. Idempotent.
func crushOptionsWithContextPath(existing []byte, contextFile string) map[string]any {
opts := map[string]any{}
if len(existing) > 0 {
var raw map[string]any
clean := common.StripJSONComments(string(existing))
if json.Unmarshal([]byte(clean), &raw) == nil {
if o, ok := raw["options"].(map[string]any); ok {
opts = o
}
}
}
var paths []any
if existingPaths, ok := opts["context_paths"].([]any); ok {
paths = existingPaths
}
for _, p := range paths {
if s, ok := p.(string); ok && s == contextFile {
return opts // already registered
}
}
opts["context_paths"] = append(paths, contextFile)
return opts
}
// Import reads crush config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
+6 -6
View File
@@ -44,8 +44,8 @@ func TestCrushRenderProducesExpectedTargets(t *testing.T) {
// providers must be a map (not array), keyed by providerID.
providers, ok := parsed["providers"].(map[string]any)
require.True(t, ok, "providers must be a map")
gw, ok := providers["harness-sync-gateway"].(map[string]any)
require.True(t, ok, "harness-sync-gateway provider missing")
gw, ok := providers["hs-gw"].(map[string]any)
require.True(t, ok, "hs-gw provider missing")
assert.Equal(t, "openai-compat", gw["type"])
// models must be role-map with large/small/title.
models, ok := parsed["models"].(map[string]any)
@@ -53,8 +53,8 @@ func TestCrushRenderProducesExpectedTargets(t *testing.T) {
assert.Contains(t, models, "large")
assert.Contains(t, models, "small")
assert.Contains(t, models, "title")
// Must NOT write "default_model" or "mcpServers".
assert.NotContains(t, parsed, "default_model")
// Must write "default_model" (prevents Bedrock fallback) but NOT "mcpServers".
assert.Contains(t, parsed, "default_model")
assert.NotContains(t, parsed, "mcpServers")
}
@@ -137,7 +137,7 @@ func TestCrushRenderProducesMapProvidersAndRoleModels(t *testing.T) {
// providers is a map, not an array.
providers, ok := parsed["providers"].(map[string]any)
require.True(t, ok, "providers must be a map")
gw, ok := providers["harness-sync-gateway"].(map[string]any)
gw, ok := providers["hs-gw"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "openai-compat", gw["type"])
assert.Equal(t, "https://gw", gw["base_url"])
@@ -160,7 +160,7 @@ func TestCrushRenderProducesMapProvidersAndRoleModels(t *testing.T) {
sel, ok := roleModels[role].(map[string]any)
require.True(t, ok, "role %s missing", role)
assert.Equal(t, "claude-sonnet-4-6", sel["model"])
assert.Equal(t, "harness-sync-gateway", sel["provider"])
assert.Equal(t, "hs-gw", sel["provider"])
}
}
+83
View File
@@ -0,0 +1,83 @@
package crush
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func renderCrush(t *testing.T, home string, b *canonical.Bundle) map[string]adapter.File {
t.Helper()
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
return seen
}
func TestCrushWritesAGENTSAndRegistersContextPath(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Root: "/canon",
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "sec", Body: "# Security\n\nno secrets in code"}},
}
seen := renderCrush(t, home, b)
base := filepath.Join(home, ".config", "crush")
agentsMD := filepath.Join(base, "AGENTS.md")
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "no secrets in code")
var cfg map[string]any
require.NoError(t, json.Unmarshal(seen[filepath.Join(base, "crush.json")].Content, &cfg))
opts := cfg["options"].(map[string]any)
paths := opts["context_paths"].([]any)
assert.Contains(t, paths, agentsMD, "absolute AGENTS.md path must be registered for global load")
}
func TestCrushPreservesExistingOptionsAndIsIdempotent(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "crush")
require.NoError(t, os.MkdirAll(base, 0o750))
agentsMD := filepath.Join(base, "AGENTS.md")
// Existing crush.json with other options keys and a pre-existing context path
// (including our own — second apply must not duplicate it).
existing := map[string]any{
"options": map[string]any{
"context_paths": []any{"PROJECT.md", agentsMD},
"lsp": map[string]any{"go": true},
},
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "crush.json"), body, 0o600))
seen := renderCrush(t, home, &canonical.Bundle{Root: "/canon"})
var cfg map[string]any
require.NoError(t, json.Unmarshal(seen[filepath.Join(base, "crush.json")].Content, &cfg))
opts := cfg["options"].(map[string]any)
// Other option keys survive.
assert.Contains(t, opts, "lsp")
// Pre-existing user path survives; ours is present exactly once (idempotent).
paths := opts["context_paths"].([]any)
count := 0
for _, p := range paths {
if p == agentsMD {
count++
}
}
assert.Equal(t, 1, count, "our context path must not be duplicated on re-apply")
assert.Contains(t, paths, "PROJECT.md")
}
+48 -27
View File
@@ -10,6 +10,7 @@
package goose
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -22,37 +23,37 @@ import (
const name = "goose"
// Adapter implements adapter.Adapter for the Goose harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
type Adapter struct {
*adapter.Base
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what goose harness-sync manages.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: true,
ManagesSkills: true,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.config/goose/ exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "goose"))
_, err := os.Stat(filepath.Join(a.Home, ".config", "goose"))
return err == nil
}
@@ -63,16 +64,26 @@ func (a *Adapter) Detect() bool {
// also added to the FileSet so goose can load the provider on first run.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.home, ".config", "goose", "config.yaml")
cfgPath := filepath.Join(a.Home, ".config", "goose", "config.yaml")
existing, _ := os.ReadFile(cfgPath)
// goose reads skills from ~/.agents/skills/<name>/SKILL.md (open Agent Skills spec)
fs.Add(adapter.File{
Dest: filepath.Join(a.Home, ".agents", "skills"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "skills"),
})
existing, err := common.ReadExistingFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err)
}
provider, model := splitProviderModel(b.Profile.Gateway.DefaultModel)
if b.Profile.Gateway.URL != "" {
// Emit the custom provider JSON file.
body, providerName := common.GooseCustomProviderFile(&b.Profile)
if body != nil {
cpDir := filepath.Join(a.home, ".config", "goose", "custom_providers")
cpDir := filepath.Join(a.Home, ".config", "goose", "custom_providers")
fs.Add(adapter.File{
Dest: filepath.Join(cpDir, providerName+".json"),
Kind: adapter.RenderedFile,
@@ -103,6 +114,16 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: merged,
})
// goose has no native rules directory; ~/.config/goose/.goosehints is the
// global hints file applied to every session, so fold rules into it.
if hints := b.InstructionTextWithRules(name); hints != "" {
fs.Add(adapter.File{
Dest: filepath.Join(a.Home, ".config", "goose", ".goosehints"),
Kind: adapter.RenderedFile,
Content: []byte(hints),
})
}
return fs, nil
}
@@ -133,8 +154,8 @@ func buildExtensions(servers []canonical.MCPServer) map[string]any {
// splitProviderModel splits "provider/model" into its parts.
// If no slash, provider is "anthropic" and the whole string is the model.
func splitProviderModel(s string) (string, string) {
if idx := strings.IndexByte(s, '/'); idx >= 0 {
return s[:idx], s[idx+1:]
if p, m, ok := strings.Cut(s, "/"); ok {
return p, m
}
return "anthropic", s
}
+6 -5
View File
@@ -91,7 +91,7 @@ func TestGooseRenderGatewayURL(t *testing.T) {
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed))
// Custom gateway → GOOSE_PROVIDER is the custom provider name.
assert.Equal(t, "custom_harness-sync-gateway", parsed["GOOSE_PROVIDER"])
assert.Equal(t, "custom_hs-gw", parsed["GOOSE_PROVIDER"])
}
func TestGooseRenderProducesCustomProviderFile(t *testing.T) {
@@ -109,16 +109,17 @@ func TestGooseRenderProducesCustomProviderFile(t *testing.T) {
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cpDest := filepath.Join(home, ".config", "goose", "custom_providers", "custom_harness-sync-gateway.json")
cpDest := filepath.Join(home, ".config", "goose", "custom_providers", "custom_hs-gw.json")
require.Contains(t, seen, cpDest)
assert.Equal(t, adapter.RenderedFile, seen[cpDest].Kind)
var cp map[string]any
require.NoError(t, json.Unmarshal(seen[cpDest].Content, &cp))
assert.Equal(t, "custom_harness-sync-gateway", cp["name"])
assert.Equal(t, "custom_hs-gw", cp["name"])
assert.Equal(t, "openai", cp["engine"])
assert.Equal(t, true, cp["requires_auth"])
assert.Equal(t, "CUSTOM_HARNESS_SYNC_GATEWAY_API_KEY", cp["api_key_env"])
assert.Equal(t, "tok", cp["api_key"])
assert.NotContains(t, cp, "api_key_env")
assert.NotContains(t, cp, "requires_auth")
models, ok := cp["models"].([]any)
require.True(t, ok)
require.Len(t, models, 1)
+31
View File
@@ -0,0 +1,31 @@
package goose
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestGooseFoldsRulesIntoGoosehints(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "k8s", Body: "# Kube\n\nlabel everything"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
hints := filepath.Join(home, ".config", "goose", ".goosehints")
require.Contains(t, seen, hints)
assert.Equal(t, adapter.RenderedFile, seen[hints].Kind)
body := string(seen[hints].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "label everything")
}
+95 -24
View File
@@ -2,6 +2,9 @@
package kilo
import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
@@ -14,38 +17,37 @@ const name = "kilo"
// Adapter implements adapter.Adapter for the kilo harness.
type Adapter struct {
home string
*adapter.Base
}
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what kilo harness-sync manages.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: true,
ManagesSkills: true,
ManagesAgents: true,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.config/kilo exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "kilo"))
_, err := os.Stat(filepath.Join(a.Home, ".config", "kilo"))
return err == nil
}
@@ -56,16 +58,35 @@ func (a *Adapter) Detect() bool {
// No "providers" key — kilo has no such concept.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".config", "kilo")
base := filepath.Join(a.Home, ".config", "kilo")
// Kilo CLI reads global markdown subagents from ~/.config/kilo/agents/<name>.md
// (PLURAL — the singular "agent/" used by older OpenCode-fork builds is a
// silent no-op on current Kilo).
fs.Add(adapter.File{
Dest: filepath.Join(base, "agent"),
Dest: filepath.Join(base, "agents"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"),
})
// kilo reads skills from ~/.kilo/skills/<name>/SKILL.md
fs.Add(adapter.File{
Dest: filepath.Join(a.Home, ".kilo", "skills"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "skills"),
})
// kilo has no global rules directory; ~/.config/kilo/AGENTS.md is the
// always-on global instructions file, so fold rules into it.
fs.Add(adapter.File{
Dest: filepath.Join(base, "AGENTS.md"),
Kind: adapter.RenderedFile,
Content: []byte(b.InstructionTextWithRules(name)),
})
cfgPath := filepath.Join(base, "kilo.json")
existing, _ := os.ReadFile(cfgPath)
existing, err := common.ReadExistingFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err)
}
modelStr := common.KiloModelString(&b.Profile)
overlay := map[string]any{}
if providers := common.ProvidersAsMap(&b.Profile); len(providers) > 0 {
@@ -88,9 +109,59 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: merged,
})
// Also merge into opencode.jsonc when present — kilo reads it as its
// primary config. Delete the enabled_providers filter so our provider is
// visible alongside user-defined providers.
ocPath := filepath.Join(base, "opencode.jsonc")
if ocContent, mergeErr := mergeOpenCodeJSONC(ocPath, overlay, b.Profile.Gateway.URL); mergeErr != nil {
return nil, mergeErr
} else if ocContent != nil {
fs.Add(adapter.File{
Dest: ocPath,
Kind: adapter.RenderedFile,
Content: ocContent,
NoMerge: true,
})
}
return fs, nil
}
// mergeOpenCodeJSONC deep-merges our overlay into the existing opencode.jsonc
// file, deleting the enabled_providers filter and deduplicating gateway providers.
// Returns nil content (no error) when the file does not exist.
func mergeOpenCodeJSONC(path string, overlay map[string]any, gatewayURL string) ([]byte, error) {
ocRaw, err := os.ReadFile(path)
if err != nil {
return nil, nil //nolint:nilerr // file absent is not an error
}
var ocBase map[string]any
clean := common.StripJSONComments(string(ocRaw))
_ = json.Unmarshal([]byte(clean), &ocBase)
if ocBase == nil {
ocBase = map[string]any{}
}
ocOverlay := map[string]any{"enabled_providers": nil}
if newProv, ok := overlay["provider"].(map[string]any); ok && len(newProv) > 0 {
existingProv, _ := ocBase["provider"].(map[string]any)
if existingProv == nil {
existingProv = map[string]any{}
}
maps.Copy(existingProv, newProv)
ocOverlay["provider"] = common.AbsorbDuplicateProviders(existingProv, common.GatewayProviderKey(gatewayURL), gatewayURL)
}
for _, k := range []string{"model", "small_model", "mcp"} {
if v, ok := overlay[k]; ok {
ocOverlay[k] = v
}
}
return common.MergeJSONKeys([]byte(clean), ocOverlay)
}
// Import reads kilo config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
+51 -8
View File
@@ -33,7 +33,8 @@ func TestKiloRender(t *testing.T) {
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentDest := filepath.Join(home, ".config", "kilo", "agent")
// Global markdown subagents dir is PLURAL "agents" on current Kilo CLI.
agentDest := filepath.Join(home, ".config", "kilo", "agents")
assert.Equal(t, adapter.SymlinkDir, seen[agentDest].Kind)
assert.Equal(t, "/canon/agents", seen[agentDest].SymlinkTarget)
@@ -43,12 +44,12 @@ func TestKiloRender(t *testing.T) {
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// model is "providerID/modelID" string.
assert.Equal(t, "harness-sync-gateway/sonnet", parsed["model"])
assert.Equal(t, "harness-sync-gateway/sonnet", parsed["small_model"])
assert.Equal(t, "hs-gw/sonnet", parsed["model"])
assert.Equal(t, "hs-gw/sonnet", parsed["small_model"])
// provider map present with npm field.
provider, ok := parsed["provider"].(map[string]any)
require.True(t, ok, "provider must be a map")
gw, ok := provider["harness-sync-gateway"].(map[string]any)
gw, ok := provider["hs-gw"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "@ai-sdk/openai-compatible", gw["npm"])
assert.Contains(t, parsed, "mcp")
@@ -113,14 +114,14 @@ func TestKiloRenderEmitsModelString(t *testing.T) {
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// model must be "harness-sync-gateway/<modelID>".
assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", parsed["model"])
assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", parsed["small_model"])
// model must be "hs-gw/<modelID>".
assert.Equal(t, "hs-gw/claude-sonnet-4-6", parsed["model"])
assert.Equal(t, "hs-gw/claude-sonnet-4-6", parsed["small_model"])
// provider map has npm field.
provider, ok := parsed["provider"].(map[string]any)
require.True(t, ok)
gw, ok := provider["harness-sync-gateway"].(map[string]any)
gw, ok := provider["hs-gw"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "@ai-sdk/openai-compatible", gw["npm"])
opts, ok := gw["options"].(map[string]any)
@@ -128,6 +129,48 @@ func TestKiloRenderEmitsModelString(t *testing.T) {
assert.Equal(t, "https://gw", opts["baseURL"])
}
func TestKiloRenderMergesOpenCodeJSONC(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "kilo")
require.NoError(t, os.MkdirAll(base, 0o750))
// Existing opencode.jsonc with a filter and a different provider.
existing := `{
"enabled_providers": ["llmgw"],
"provider": {
"llmgw": {"npm": "@ai-sdk/openai-compatible", "options": {"baseURL": "https://old"}}
},
"model": "llmgw/old-model"
}`
require.NoError(t, os.WriteFile(filepath.Join(base, "opencode.jsonc"), []byte(existing), 0o600))
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw", Token: "dummy", DefaultModel: "sonnet"},
Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "sonnet"}},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
ocDest := filepath.Join(base, "opencode.jsonc")
require.Contains(t, seen, ocDest)
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[ocDest].Content, &parsed))
// enabled_providers deleted so our provider is visible.
assert.NotContains(t, parsed, "enabled_providers")
// llmgw preserved, harness-sync-gateway added.
provider := parsed["provider"].(map[string]any)
assert.Contains(t, provider, "llmgw")
assert.Contains(t, provider, "hs-gw")
// model updated.
assert.Equal(t, "hs-gw/sonnet", parsed["model"])
}
func TestKiloImport(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "kilo")
+32
View File
@@ -0,0 +1,32 @@
package kilo
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestKiloFoldsRulesIntoAGENTS(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Root: "/canon",
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "py", Body: "# Py\n\ntype hints"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentsMD := filepath.Join(home, ".config", "kilo", "AGENTS.md")
require.Contains(t, seen, agentsMD)
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "type hints")
}
+11
View File
@@ -5,12 +5,23 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func importFrom(home string) (*adapter.ImportResult, error) {
base := filepath.Join(home, ".config", "opencode")
res := &adapter.ImportResult{}
agents, err := common.ImportMarkdownTree(filepath.Join(base, "agents"), "")
if err != nil {
return nil, err
}
for _, a := range agents {
res.Agents = append(res.Agents, canonical.Agent{
Name: a.Name, Description: a.Description, Body: a.Body, Path: a.Path,
})
}
cfgBody, err := common.ReadIfExists(filepath.Join(base, "opencode.jsonc"))
if err != nil {
return nil, err
+66 -30
View File
@@ -2,6 +2,8 @@
package opencode
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -13,37 +15,38 @@ import (
const name = "opencode"
// Adapter implements adapter.Adapter for the opencode harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
type Adapter struct {
*adapter.Base
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what opencode harness-sync manages.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: true,
ManagesSkills: true,
ManagesAgents: true,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.config/opencode exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "opencode"))
_, err := os.Stat(filepath.Join(a.Home, ".config", "opencode"))
return err == nil
}
@@ -53,12 +56,47 @@ func (a *Adapter) Detect() bool {
// "mcp" (map with type: local|remote entries).
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".config", "opencode")
base := filepath.Join(a.Home, ".config", "opencode")
// opencode reads skills from ~/.config/opencode/skills/<name>/SKILL.md
fs.Add(adapter.File{
Dest: filepath.Join(base, "skills"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "skills"),
})
// opencode reads markdown subagents from ~/.config/opencode/agents/<name>.md
fs.Add(adapter.File{
Dest: filepath.Join(base, "agents"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"),
})
cfgPath := filepath.Join(base, "opencode.jsonc")
existing, _ := os.ReadFile(cfgPath)
existing, err := common.ReadExistingFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err)
}
// Build the provider map, then absorb any existing providers at the same
// gateway URL so duplicates don't accumulate across apply runs.
provMap := common.ProvidersAsMap(&b.Profile)
if len(existing) > 0 {
var base map[string]any
clean := common.StripJSONComments(string(existing))
if json.Unmarshal([]byte(clean), &base) == nil {
if existingProv, ok := base["provider"].(map[string]any); ok {
for k, v := range existingProv {
if _, ours := provMap[k]; !ours {
provMap[k] = v
}
}
provMap = common.AbsorbDuplicateProviders(provMap, common.GatewayProviderKey(b.Profile.Gateway.URL), b.Profile.Gateway.URL)
}
}
}
overlay := map[string]any{
"provider": common.ProvidersAsMap(&b.Profile),
"provider": provMap,
"model": common.KiloModelString(&b.Profile),
}
if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPOpencodeStyle); len(mcp) > 0 {
@@ -74,14 +112,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: merged,
})
instructions := b.Instructions.Global
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
instructions = override
}
// opencode has no native rules directory; rule content reaches it only via
// the global AGENTS.md instructions file, so fold the rules in here.
fs.Add(adapter.File{
Dest: filepath.Join(base, "AGENTS.md"),
Kind: adapter.RenderedFile,
Content: []byte(instructions),
Content: []byte(b.InstructionTextWithRules(name)),
})
return fs, nil
+3 -3
View File
@@ -129,15 +129,15 @@ func TestOpencodeRenderProviderHasNpm(t *testing.T) {
// provider map has npm field.
provider, ok := parsed["provider"].(map[string]any)
require.True(t, ok)
gw, ok := provider["harness-sync-gateway"].(map[string]any)
gw, ok := provider["hs-gw"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "@ai-sdk/openai-compatible", gw["npm"])
opts, ok := gw["options"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "https://gw", opts["baseURL"])
// model is "harness-sync-gateway/modelID".
assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", parsed["model"])
// model is "hs-gw/modelID".
assert.Equal(t, "hs-gw/claude-sonnet-4-6", parsed["model"])
}
func TestOpencodeImportStripsComments(t *testing.T) {
+52
View File
@@ -0,0 +1,52 @@
package opencode
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestOpencodeRendersAgentsSymlinkAndFoldsRules(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Root: "/canon",
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "go", Body: "# Go\n\ngofmt always"}},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentsDest := filepath.Join(home, ".config", "opencode", "agents")
assert.Equal(t, adapter.SymlinkDir, seen[agentsDest].Kind)
assert.Equal(t, "/canon/agents", seen[agentsDest].SymlinkTarget)
agentsMD := filepath.Join(home, ".config", "opencode", "AGENTS.md")
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "gofmt always")
}
func TestOpencodeImportsAgents(t *testing.T) {
home := t.TempDir()
agentsDir := filepath.Join(home, ".config", "opencode", "agents")
require.NoError(t, os.MkdirAll(agentsDir, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "review.md"),
[]byte("---\nname: review\ndescription: d\n---\nbody"), 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
require.Len(t, res.Agents, 1)
assert.Equal(t, "review", res.Agents[0].Name)
}
+21
View File
@@ -0,0 +1,21 @@
package pi
import (
"os"
"path/filepath"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
)
func importFrom(home string) (*adapter.ImportResult, error) {
base := filepath.Join(home, ".pi", "agent")
res := &adapter.ImportResult{}
if body, err := os.ReadFile(filepath.Join(base, "AGENTS.md")); err == nil {
res.Instructions = string(body)
} else if !os.IsNotExist(err) {
return nil, err
}
return res, nil
}
+159
View File
@@ -0,0 +1,159 @@
// Package pi is the Pi coding agent harness adapter.
package pi
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
const name = "pi"
// Adapter implements adapter.Adapter for the Pi coding agent harness.
type Adapter struct {
*adapter.Base
}
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what pi harness-sync manages.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: false,
ManagesSkills: true,
ManagesAgents: false,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.pi/agent exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.Home, ".pi", "agent"))
return err == nil
}
// Render produces the FileSet that applies the canonical bundle to Pi.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.Home, ".pi", "agent")
fs.Add(adapter.File{
Dest: filepath.Join(base, "skills"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "skills"),
})
if instr := b.InstructionTextWithRules(name); instr != "" {
fs.Add(adapter.File{
Dest: filepath.Join(base, "AGENTS.md"),
Kind: adapter.RenderedFile,
Content: []byte(instr),
})
}
cfgPath := filepath.Join(base, "settings.json")
existing, err := common.ReadExistingFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err)
}
overlay := map[string]any{}
if b.Profile.Gateway.DefaultModel != "" {
overlay["defaultProvider"] = common.GatewayProviderKey(b.Profile.Gateway.URL)
overlay["defaultModel"] = b.Profile.Gateway.DefaultModel
}
if len(overlay) > 0 {
merged, err := common.MergeJSONKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: merged,
})
}
if modelsContent, err := piModelsJSON(b); err != nil {
return nil, err
} else if len(modelsContent) > 0 {
fs.Add(adapter.File{
Dest: filepath.Join(base, "models.json"),
Kind: adapter.RenderedFile,
Content: modelsContent,
})
}
return fs, nil
}
func piModelsJSON(b *canonical.Bundle) ([]byte, error) {
if b.Profile.Gateway.URL == "" {
return nil, nil
}
modelEntries := make([]map[string]any, 0, len(b.Profile.Models))
for _, m := range b.Profile.Models {
entry := map[string]any{"id": m.ID}
if m.Alias != "" {
entry["name"] = m.Alias
}
// Emit limits only when the profile sets them explicitly. Pi applies
// its own defaults (contextWindow 128000, maxTokens 16384) to a model
// definition that omits them, so writing ContextLimit()/OutputLimit()
// unconditionally would push canonical's defaults onto every model
// that never asked for them.
if m.Context > 0 {
entry["contextWindow"] = m.Context
}
if m.Output > 0 {
entry["maxTokens"] = m.Output
}
modelEntries = append(modelEntries, entry)
}
if len(modelEntries) == 0 && b.Profile.Gateway.DefaultModel != "" {
modelEntries = append(modelEntries, map[string]any{"id": b.Profile.Gateway.DefaultModel})
}
provider := map[string]any{
"baseUrl": b.Profile.Gateway.URL,
"api": "openai-completions",
"apiKey": b.Profile.Gateway.Token,
}
if len(modelEntries) > 0 {
provider["models"] = modelEntries
}
body, err := json.MarshalIndent(map[string]any{
"providers": map[string]any{
common.GatewayProviderKey(b.Profile.Gateway.URL): provider,
},
}, "", " ")
if err != nil {
return nil, err
}
return append(body, '\n'), nil
}
// Import reads Pi config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
}
+149
View File
@@ -0,0 +1,149 @@
package pi
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestPiRender(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Root: "/canon",
Profile: canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw", Token: "dummy", DefaultModel: "sonnet"},
Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "Sonnet"}},
},
Instructions: canonical.Instructions{Global: "# global"},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
skillsDest := filepath.Join(home, ".pi", "agent", "skills")
assert.Equal(t, adapter.SymlinkDir, seen[skillsDest].Kind)
assert.Equal(t, "/canon/skills", seen[skillsDest].SymlinkTarget)
agentsDest := filepath.Join(home, ".pi", "agent", "AGENTS.md")
assert.Equal(t, adapter.RenderedFile, seen[agentsDest].Kind)
assert.Contains(t, string(seen[agentsDest].Content), "# global")
settingsDest := filepath.Join(home, ".pi", "agent", "settings.json")
var settings map[string]any
require.NoError(t, json.Unmarshal(seen[settingsDest].Content, &settings))
assert.Equal(t, "hs-gw", settings["defaultProvider"])
assert.Equal(t, "sonnet", settings["defaultModel"])
modelsDest := filepath.Join(home, ".pi", "agent", "models.json")
var models map[string]any
require.NoError(t, json.Unmarshal(seen[modelsDest].Content, &models))
providers := models["providers"].(map[string]any)
gw := providers["hs-gw"].(map[string]any)
assert.Equal(t, "https://gw", gw["baseUrl"])
assert.Equal(t, "openai-completions", gw["api"])
assert.Equal(t, "dummy", gw["apiKey"])
modelList := gw["models"].([]any)
first := modelList[0].(map[string]any)
assert.Equal(t, "claude-sonnet-4-6", first["id"])
assert.Equal(t, "Sonnet", first["name"])
// A model with no explicit limits must carry none, so pi keeps applying
// its own defaults rather than inheriting canonical's.
assert.NotContains(t, first, "contextWindow")
assert.NotContains(t, first, "maxTokens")
}
func TestPiRenderEmitsExplicitModelLimits(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw", Token: "dummy"},
Models: []canonical.Model{
{ID: "local/current-model", Alias: "Local", Context: 1048576, Output: 32768},
{ID: "bare/model"},
},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
var models map[string]any
require.NoError(t, json.Unmarshal(seen[filepath.Join(home, ".pi", "agent", "models.json")].Content, &models))
list := models["providers"].(map[string]any)["hs-gw"].(map[string]any)["models"].([]any)
withLimits := list[0].(map[string]any)
assert.InDelta(t, float64(1048576), withLimits["contextWindow"], 0)
assert.InDelta(t, float64(32768), withLimits["maxTokens"], 0)
withoutLimits := list[1].(map[string]any)
assert.NotContains(t, withoutLimits, "contextWindow")
assert.NotContains(t, withoutLimits, "maxTokens")
}
func TestPiRenderMergesExistingSettings(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".pi", "agent")
require.NoError(t, os.MkdirAll(base, 0o750))
existing := map[string]any{
"theme": "light",
"defaultThinkingLevel": "high",
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "settings.json"), body, 0o600))
ad := New(WithHome(home))
b := &canonical.Bundle{Profile: canonical.Profile{Gateway: canonical.Gateway{URL: "https://gw", DefaultModel: "sonnet"}}}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[filepath.Join(base, "settings.json")].Content, &parsed))
assert.Equal(t, "light", parsed["theme"])
assert.Equal(t, "high", parsed["defaultThinkingLevel"])
assert.Equal(t, "hs-gw", parsed["defaultProvider"])
assert.Equal(t, "sonnet", parsed["defaultModel"])
}
func TestPiRenderWithoutGatewaySkipsProviderFiles(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{Root: "/canon"}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
assert.Contains(t, seen, filepath.Join(home, ".pi", "agent", "skills"))
assert.NotContains(t, seen, filepath.Join(home, ".pi", "agent", "settings.json"))
assert.NotContains(t, seen, filepath.Join(home, ".pi", "agent", "models.json"))
}
func TestPiImport(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".pi", "agent")
require.NoError(t, os.MkdirAll(base, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(base, "AGENTS.md"), []byte("# global"), 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
assert.Equal(t, "# global", res.Instructions)
}
+31
View File
@@ -0,0 +1,31 @@
package zed
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestZedFoldsRulesIntoAGENTS(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "ts", Body: "# TS\n\nstrict mode"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentsMD := filepath.Join(home, ".config", "zed", "AGENTS.md")
require.Contains(t, seen, agentsMD)
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "strict mode")
}
+116 -50
View File
@@ -1,14 +1,15 @@
// Package zed is the Zed editor harness adapter.
//
// Zed uses ~/.config/zed/settings.json with many user-managed keys.
// We MERGE only "agent" and "context_servers"; all other keys are preserved.
// MCP servers become context_servers entries with source: "custom".
// harness-sync manages: context_servers (MCP), language_models.openai, agent.default_model.
// All other keys are preserved via JSON merge.
package zed
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
@@ -18,64 +19,69 @@ import (
const name = "zed"
// Adapter implements adapter.Adapter for the Zed harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
type Adapter struct {
*adapter.Base
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
// WithHome overrides the home directory used to resolve target paths.
func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...adapter.BaseOption) *Adapter {
return &Adapter{Base: adapter.NewBase(opts...)}
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Capabilities declares what zed harness-sync manages.
// Zed does not have a built-in subscription — it routes through the configured gateway.
func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{
ManagesProviders: true,
ManagesModels: true,
ManagesMCP: false, // Agent panel uses Extensions; context_servers key causes serde parse errors
ManagesSkills: false,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false,
}
}
// Detect returns true when ~/.config/zed/ exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "zed"))
_, err := os.Stat(filepath.Join(a.Home, ".config", "zed"))
return err == nil
}
// Render produces the FileSet that applies the canonical bundle to Zed.
// settings.json is MERGED to preserve all user-managed Zed configuration.
// Only "agent" (default_model) and "context_servers" (MCP) are overlaid.
// Manages: language_models.openai_compatible, agent.default_model.
//
// NOTE: context_servers is NOT written. Zed 1.3+ uses Extension-based MCP
// servers in the Agent panel; writing context_servers causes a serde parse
// error (ContextServerSettingsContent untagged enum bug) that aborts loading
// the entire settings.json. We clear any existing context_servers key to fix
// pre-existing parse errors from user-configured entries.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.home, ".config", "zed", "settings.json")
existing, _ := os.ReadFile(cfgPath)
cfgPath := filepath.Join(a.Home, ".config", "zed", "settings.json")
existing, err := common.ReadExistingFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err)
}
// Set context_servers to nil so MergeJSONKeys removes the key entirely,
// clearing any entries that trigger the serde ContextServerSettingsContent bug.
overlay := map[string]any{
"context_servers": common.BuildMCPMapStyled(&b.MCP, common.MCPZedStyle),
"context_servers": nil,
}
if dm := b.Profile.Gateway.DefaultModel; dm != "" {
provider, model := splitProviderModel(dm)
overlay["agent"] = map[string]any{
"default_model": map[string]any{
"provider": provider,
"model": model,
},
}
}
if lm := common.ZedLanguageModels(&b.Profile); len(lm) > 0 {
overlay["language_models"] = lm
if b.Profile.Gateway.URL != "" {
overlay["language_models"] = zedLanguageModels(&b.Profile)
overlay["agent"] = zedAgentBlock(existing, &b.Profile)
} else {
overlay["language_models"] = nil
}
merged, err := common.MergeJSONKeys(existing, overlay)
@@ -86,21 +92,81 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: merged,
// NoMerge: Zed settings.json is JSONC (trailing commas, comments). The
// JSON merge above already reconciles at the key level; git 3-way merge
// on JSONC causes spurious conflicts on whitespace/comment differences.
NoMerge: true,
})
// Zed reads personal/global instructions from ~/.config/zed/AGENTS.md
// (always-on for the Agent across all projects). Zed has no rules directory,
// so fold rules into it.
if instr := b.InstructionTextWithRules(name); instr != "" {
fs.Add(adapter.File{
Dest: filepath.Join(a.Home, ".config", "zed", "AGENTS.md"),
Kind: adapter.RenderedFile,
Content: []byte(instr),
})
}
return fs, nil
}
// zedLanguageModels builds language_models.openai_compatible from the profile
// gateway. Each named entry becomes its own provider in Zed's Agent panel.
// API key is read by Zed from env var HARNESS_SYNC_GATEWAY_API_KEY at runtime.
func zedLanguageModels(p *canonical.Profile) map[string]any {
entry := map[string]any{"api_url": p.Gateway.URL}
if len(p.Models) > 0 {
models := make([]map[string]any, 0, len(p.Models))
for _, m := range p.Models {
me := map[string]any{
"name": m.ID,
"max_tokens": m.ContextLimit(),
"capabilities": map[string]any{
"tools": true,
"images": false,
"parallel_tool_calls": false,
"prompt_cache_key": false,
},
}
if m.Alias != "" {
me["display_name"] = m.Alias
}
models = append(models, me)
}
entry["available_models"] = models
}
return map[string]any{
"openai_compatible": map[string]any{
common.GatewayProviderKey(p.Gateway.URL): entry,
},
}
}
// zedAgentBlock merges default_model into the existing agent block,
// preserving all other user-managed agent settings.
func zedAgentBlock(existing []byte, p *canonical.Profile) map[string]any {
base := map[string]any{}
if len(existing) > 0 {
var raw map[string]any
clean := common.StripJSONComments(string(existing))
if json.Unmarshal([]byte(clean), &raw) == nil {
if ag, ok := raw["agent"].(map[string]any); ok {
base = ag
}
}
}
if p.Gateway.DefaultModel != "" {
base["default_model"] = map[string]any{
"provider": common.GatewayProviderKey(p.Gateway.URL),
"model": p.Gateway.DefaultModel,
}
}
return base
}
// Import reads context_servers from Zed settings.json back to MCP servers.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
}
// splitProviderModel splits "provider/model" at the first slash.
// If no slash, provider is "anthropic".
func splitProviderModel(s string) (string, string) {
if idx := strings.IndexByte(s, '/'); idx >= 0 {
return s[:idx], s[idx+1:]
}
return "anthropic", s
}
+52 -33
View File
@@ -16,7 +16,13 @@ import (
func bundle() *canonical.Bundle {
return &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "anthropic/claude-sonnet-4-6"},
Gateway: canonical.Gateway{
URL: "https://llmgw.example.com/v1",
DefaultModel: "claude-sonnet-4-6",
},
Models: []canonical.Model{
{ID: "claude-sonnet-4-6", Alias: "sonnet"},
},
},
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "filepuff", Command: "/bin/filepuff", Args: []string{"--serve"}},
@@ -40,18 +46,40 @@ func TestZedRenderProducesExpectedTargets(t *testing.T) {
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// Agent default_model is an object.
// agent.default_model routes through hs-llmgw-example-com (openai_compatible provider).
agent := parsed["agent"].(map[string]any)
dm := agent["default_model"].(map[string]any)
assert.Equal(t, "anthropic", dm["provider"])
assert.Equal(t, "hs-llmgw-example-com", dm["provider"])
assert.Equal(t, "claude-sonnet-4-6", dm["model"])
// context_servers.
cs := parsed["context_servers"].(map[string]any)
fp := cs["filepuff"].(map[string]any)
assert.Equal(t, true, fp["enabled"])
assert.Equal(t, "custom", fp["source"])
assert.Equal(t, "/bin/filepuff", fp["command"])
// language_models.openai_compatible has our named gateway entry.
lm := parsed["language_models"].(map[string]any)
oc := lm["openai_compatible"].(map[string]any)
gw := oc["hs-llmgw-example-com"].(map[string]any)
assert.Equal(t, "https://llmgw.example.com/v1", gw["api_url"])
// context_servers must be absent — Zed serde untagged-enum bug aborts
// settings loading when this key is present; we delete it via nil overlay.
assert.NotContains(t, parsed, "context_servers")
}
func TestZedRenderHonorsPerModelContext(t *testing.T) {
home := t.TempDir()
b := bundle()
b.Profile.Models = []canonical.Model{{ID: "big", Context: 262144}}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "zed", "settings.json")
require.Contains(t, seen, cfgDest)
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
models := parsed["language_models"].(map[string]any)["openai_compatible"].(map[string]any)["hs-llmgw-example-com"].(map[string]any)["available_models"].([]any)
assert.Equal(t, float64(262144), models[0].(map[string]any)["max_tokens"], "explicit context override should win")
}
func TestZedRenderMergesExistingKeys(t *testing.T) {
@@ -83,9 +111,9 @@ func TestZedRenderMergesExistingKeys(t *testing.T) {
assert.Equal(t, "One Dark", parsed["theme"])
assert.Equal(t, float64(16), parsed["font_size"])
assert.Equal(t, true, parsed["vim_mode"])
// Managed keys present.
// Managed keys present; context_servers absent (serde bug — we delete it).
assert.Contains(t, parsed, "agent")
assert.Contains(t, parsed, "context_servers")
assert.NotContains(t, parsed, "context_servers")
}
func TestZedRenderNoMCP(t *testing.T) {
@@ -105,13 +133,11 @@ func TestZedRenderNoMCP(t *testing.T) {
cfgDest := filepath.Join(home, ".config", "zed", "settings.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// context_servers key present but empty map.
cs, ok := parsed["context_servers"]
assert.True(t, ok)
assert.Empty(t, cs)
// context_servers must be absent (nil overlay deletes the key).
assert.NotContains(t, parsed, "context_servers")
}
func TestZedRenderEmitsLanguageModelsOpenAI(t *testing.T) {
func TestZedRenderEmitsLanguageModelsOpenAICompatible(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
@@ -131,15 +157,21 @@ func TestZedRenderEmitsLanguageModelsOpenAI(t *testing.T) {
lm, ok := parsed["language_models"].(map[string]any)
require.True(t, ok, "language_models must be present")
oai, ok := lm["openai"].(map[string]any)
require.True(t, ok, "language_models.openai must be present")
assert.Equal(t, "https://gw", oai["api_url"])
avail, ok := oai["available_models"].([]any)
oc, ok := lm["openai_compatible"].(map[string]any)
require.True(t, ok, "language_models.openai_compatible must be present")
gw, ok := oc["hs-gw"].(map[string]any)
require.True(t, ok, "openai_compatible.hs-gw must be present")
assert.Equal(t, "https://gw", gw["api_url"])
avail, ok := gw["available_models"].([]any)
require.True(t, ok)
require.Len(t, avail, 1)
m0 := avail[0].(map[string]any)
assert.Equal(t, "claude-sonnet-4-6", m0["name"])
assert.Equal(t, "sonnet", m0["display_name"])
caps, ok := m0["capabilities"].(map[string]any)
require.True(t, ok, "model must have capabilities")
assert.Equal(t, true, caps["tools"])
assert.Equal(t, false, caps["prompt_cache_key"])
}
func TestZedImport(t *testing.T) {
@@ -181,16 +213,3 @@ func TestZedDetect(t *testing.T) {
ad := New(WithHome(home))
assert.False(t, ad.Detect())
}
func TestSplitProviderModel(t *testing.T) {
t.Run("with slash", func(t *testing.T) {
p, m := splitProviderModel("anthropic/claude-sonnet-4-6")
assert.Equal(t, "anthropic", p)
assert.Equal(t, "claude-sonnet-4-6", m)
})
t.Run("no slash", func(t *testing.T) {
p, m := splitProviderModel("claude-sonnet")
assert.Equal(t, "anthropic", p)
assert.Equal(t, "claude-sonnet", m)
})
}
+39 -10
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"github.com/lukaszraczylo/harness-sync/internal/gitx"
"github.com/lukaszraczylo/harness-sync/internal/merge"
@@ -42,6 +43,11 @@ type Action struct {
// Run renders all adapters and writes their FileSets to disk.
// Conflicts are recorded as <dest>.rej files; processing continues past conflicts.
//
// When Options.Repo is nil the canonical repo is auto-opened from Bundle.Root.
// If that directory is not a git repository the commit step is silently
// skipped — useful for one-off renders (CI smoke tests, dry-runs) where
// initialising a repo would be more surprising than useful.
//
// IMPORTANT: harness-sync intentionally does NOT resolve ${VAR} references in
// canonical configs. Every supported harness performs its own env-var
// expansion at MCP launch / config-load time, so resolving here would only
@@ -66,13 +72,30 @@ func Run(opt Options) (*Report, error) {
return rep, renderErr
}
}
if !opt.DryRun && rep.Written > 0 && opt.Repo != nil {
if err := opt.Repo.AddAll(); err != nil {
return rep, err
}
if err := opt.Repo.Commit(fmt.Sprintf("apply: %d files, %d conflicts", rep.Written, rep.Conflicts)); err != nil {
return rep, err
}
if opt.DryRun || rep.Written == 0 {
return rep, nil
}
repo := opt.Repo
if repo == nil {
repo = gitx.New(opt.Bundle.Root)
}
if !repo.IsRepo() {
return rep, nil
}
if err := repo.AddAll(); err != nil {
return rep, err
}
dirty, err := repo.HasStagedChanges()
if err != nil {
return rep, err
}
if !dirty {
// Nothing changed inside the canonical repo (writes were outside it,
// e.g. symlinks to temp dirs in tests). Skip the no-op commit.
return rep, nil
}
if err := repo.Commit(fmt.Sprintf("apply: %d files, %d conflicts", rep.Written, rep.Conflicts)); err != nil {
return rep, err
}
return rep, nil
}
@@ -93,8 +116,14 @@ func statePath(root, adapterName, dest string) string {
func handleRendered(opt Options, adapterName string, f adapter.File, rep *Report) error {
sp := statePath(opt.Bundle.Root, adapterName, f.Dest)
base, _ := os.ReadFile(sp)
current, _ := os.ReadFile(f.Dest)
base, readErr := common.ReadExistingFile(sp)
if readErr != nil {
return fmt.Errorf("read state %s: %w", sp, readErr)
}
current, readErr := common.ReadExistingFile(f.Dest)
if readErr != nil {
return fmt.Errorf("read target %s: %w", f.Dest, readErr)
}
if string(current) == string(f.Content) {
rep.Skipped++
@@ -102,7 +131,7 @@ func handleRendered(opt Options, adapterName string, f adapter.File, rep *Report
return writeState(opt, sp, f.Content)
}
if opt.Force || len(base) == 0 || string(current) == string(base) {
if opt.Force || f.NoMerge || len(base) == 0 || string(current) == string(base) {
if opt.DryRun {
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "would write"})
return nil
+160
View File
@@ -26,6 +26,9 @@ func (f *failingAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) {
func (f *failingAdapter) Import(_ string) (*adapter.ImportResult, error) {
return &adapter.ImportResult{}, nil
}
func (f *failingAdapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{}
}
type stubAdapter struct {
name string
@@ -44,6 +47,9 @@ func (s *stubAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) {
}
return fs, nil
}
func (s *stubAdapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{}
}
// ---------------------------------------------------------------------------
// Existing tests
@@ -578,6 +584,160 @@ func TestApplyUnknownFileKind(t *testing.T) {
assert.Contains(t, err.Error(), "unknown file kind")
}
// ---------------------------------------------------------------------------
// TestApplyPropagatesReadErrors — regression for the silent-read footgun.
// A target file in a directory we can no longer read must surface as an
// error from Run, not as a fast-forward overwrite.
// ---------------------------------------------------------------------------
func TestApplyPropagatesReadErrors(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root; cannot test permission denial")
}
root := t.TempDir()
targetDir := t.TempDir()
target := filepath.Join(targetDir, "out.txt")
require.NoError(t, os.WriteFile(target, []byte("user-content\n"), 0o600))
require.NoError(t, initCanonical(root))
// Strip read perms on the target's parent so os.ReadFile fails.
require.NoError(t, os.Chmod(targetDir, 0o000))
t.Cleanup(func() { _ = os.Chmod(targetDir, 0o755) })
ad := &stubAdapter{name: "stub", files: []adapter.File{
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("new-render\n")},
}}
_, err := Run(Options{
Bundle: &canonical.Bundle{Root: root},
Adapters: []adapter.Adapter{ad},
Repo: gitx.New(root),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "read target")
// User's on-disk file must be untouched.
_ = os.Chmod(targetDir, 0o755)
body, rerr := os.ReadFile(target)
require.NoError(t, rerr)
assert.Equal(t, "user-content\n", string(body),
"target must not be overwritten when read fails")
}
// ---------------------------------------------------------------------------
// TestApplyAutoOpensRepoFromBundleRoot — regression: callers no longer need
// to pre-construct a *gitx.Repo. Run opens one from Bundle.Root, skips
// commit gracefully when the root is not a git repo, and commits when it is.
// ---------------------------------------------------------------------------
func TestApplyAutoOpensRepoFromBundleRoot(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "out.txt")
require.NoError(t, initCanonical(root))
ad := &stubAdapter{name: "stub", files: []adapter.File{
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("auto\n")},
}}
// Intentionally pass Repo: nil — the new behaviour should open one itself.
rep, err := Run(Options{
Bundle: &canonical.Bundle{Root: root},
Adapters: []adapter.Adapter{ad},
})
require.NoError(t, err)
assert.Equal(t, 1, rep.Written)
// git log should have a new commit from apply, on top of the init one.
out, gitErr := runGit(root, "log", "--oneline")
require.NoError(t, gitErr)
assert.GreaterOrEqual(t, countLines(out), 2)
}
// ---------------------------------------------------------------------------
// TestApplyNoRepoSkipsCommit — when Bundle.Root isn't a git repo, Run must
// still succeed (no error, no commit attempt). One-off renders / dry-runs.
// ---------------------------------------------------------------------------
func TestApplyNoRepoSkipsCommit(t *testing.T) {
root := t.TempDir() // NOT initCanonical — no .git
target := filepath.Join(t.TempDir(), "out.txt")
ad := &stubAdapter{name: "stub", files: []adapter.File{
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("x\n")},
}}
rep, err := Run(Options{
Bundle: &canonical.Bundle{Root: root},
Adapters: []adapter.Adapter{ad},
})
require.NoError(t, err)
assert.Equal(t, 1, rep.Written)
body, rerr := os.ReadFile(target)
require.NoError(t, rerr)
assert.Equal(t, "x\n", string(body))
}
// ---------------------------------------------------------------------------
// TestApplySkipsNoOpCommit — symlinks at a path outside the canonical repo
// do not write a state snapshot inside the repo (handleSymlink has no
// state-tracking), so git has nothing to commit. apply.Run must detect
// "no staged changes" and skip the commit instead of failing with
// "nothing to commit" from git itself.
// ---------------------------------------------------------------------------
func TestApplySkipsNoOpCommit(t *testing.T) {
root := t.TempDir()
require.NoError(t, initCanonical(root))
targetDir := t.TempDir()
target := filepath.Join(targetDir, "outside-link")
real := filepath.Join(targetDir, "real.txt")
require.NoError(t, os.WriteFile(real, []byte("real\n"), 0o600))
ad := &stubAdapter{name: "stub", files: []adapter.File{
{Dest: target, Kind: adapter.SymlinkFile, SymlinkTarget: real},
}}
rep, err := Run(Options{
Bundle: &canonical.Bundle{Root: root},
Adapters: []adapter.Adapter{ad},
// Repo: nil — auto-open from Bundle.Root (which IS a git repo).
})
require.NoError(t, err)
assert.Equal(t, 1, rep.Written)
// Symlink exists on disk.
got, lerr := os.Readlink(target)
require.NoError(t, lerr)
assert.Equal(t, real, got)
// No extra commit — still just the init commit. A failed "nothing to
// commit" would surface here as an error from Run.
out, gitErr := runGit(root, "log", "--oneline")
require.NoError(t, gitErr)
assert.Equal(t, 1, countLines(out),
"expected only the init commit; symlink outside canonical root should not create one")
}
// ---------------------------------------------------------------------------
// TestApplyCommitMessageHasCounts — commit message includes the file and
// conflict counts so the rollback story is auditable from git log alone.
// ---------------------------------------------------------------------------
func TestApplyCommitMessageHasCounts(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "msg.txt")
require.NoError(t, initCanonical(root))
ad := &stubAdapter{name: "stub", files: []adapter.File{
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("m\n")},
}}
_, err := Run(Options{
Bundle: &canonical.Bundle{Root: root},
Adapters: []adapter.Adapter{ad},
})
require.NoError(t, err)
out, gitErr := runGit(root, "log", "-1", "--pretty=%s")
require.NoError(t, gitErr)
assert.Contains(t, out, "apply: 1 files, 0 conflicts")
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
+47
View File
@@ -0,0 +1,47 @@
package canonical
import "strings"
// InstructionText returns the instruction body a harness should receive: the
// per-harness override when one is set, otherwise the global instructions.
func (b *Bundle) InstructionText(harness string) string {
if override, ok := b.Instructions.PerHarness[harness]; ok && override != "" {
return override
}
return b.Instructions.Global
}
// InstructionTextWithRules returns InstructionText for the harness with every
// canonical rule body folded in. Harnesses without a native rules directory
// (everything except claude-code/kilo) only receive rule content this way —
// via their global instructions file. Rule frontmatter is stripped so the
// folded output stays clean; bodies are joined in load order (deterministic).
func (b *Bundle) InstructionTextWithRules(harness string) string {
base := b.InstructionText(harness)
var sb strings.Builder
sb.WriteString(base)
for _, r := range b.Rules {
body := strings.TrimSpace(stripFrontmatter(r.Body))
if body == "" {
continue
}
if sb.Len() > 0 {
sb.WriteString("\n\n")
}
sb.WriteString(body)
}
return sb.String()
}
// stripFrontmatter removes a leading YAML frontmatter block ("---\n…\n---") so
// folding a rule into a plain instructions file does not leak its metadata.
func stripFrontmatter(s string) string {
if !strings.HasPrefix(s, "---\n") {
return s
}
end := strings.Index(s[4:], "\n---")
if end == -1 {
return s
}
return strings.TrimPrefix(s[4+end+len("\n---"):], "\n")
}
+102
View File
@@ -0,0 +1,102 @@
package canonical
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestInstructionText(t *testing.T) {
t.Run("global when no override", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G"}}
assert.Equal(t, "G", b.InstructionText("zed"))
})
t.Run("per-harness override wins", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G", PerHarness: map[string]string{"zed": "Z"}}}
assert.Equal(t, "Z", b.InstructionText("zed"))
})
t.Run("empty override falls back to global", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G", PerHarness: map[string]string{"zed": ""}}}
assert.Equal(t, "G", b.InstructionText("zed"))
})
t.Run("empty everything", func(t *testing.T) {
t.Parallel()
b := Bundle{}
assert.Equal(t, "", b.InstructionText("zed"))
})
}
func TestInstructionTextWithRules(t *testing.T) {
t.Run("folds rules after global", func(t *testing.T) {
t.Parallel()
b := Bundle{
Instructions: Instructions{Global: "# Global"},
Rules: []Rule{
{Name: "a", Body: "# Rule A\n\nbe concise"},
{Name: "b", Body: "# Rule B\n\nbe safe"},
},
}
got := b.InstructionTextWithRules("opencode")
assert.Equal(t, "# Global\n\n# Rule A\n\nbe concise\n\n# Rule B\n\nbe safe", got)
})
t.Run("strips rule frontmatter", func(t *testing.T) {
t.Parallel()
b := Bundle{
Instructions: Instructions{Global: "G"},
Rules: []Rule{
{Name: "scoped", Body: "---\nname: scoped\npaths: \"**/*.go\"\n---\n# Go\n\ngofmt"},
},
}
got := b.InstructionTextWithRules("opencode")
assert.Equal(t, "G\n\n# Go\n\ngofmt", got)
assert.NotContains(t, got, "paths:")
assert.NotContains(t, got, "---")
})
t.Run("no rules returns plain instruction text", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G"}}
assert.Equal(t, "G", b.InstructionTextWithRules("opencode"))
})
t.Run("empty global still emits rules", func(t *testing.T) {
t.Parallel()
b := Bundle{Rules: []Rule{{Name: "a", Body: "rule body"}}}
assert.Equal(t, "rule body", b.InstructionTextWithRules("opencode"))
})
t.Run("blank rule bodies are skipped", func(t *testing.T) {
t.Parallel()
b := Bundle{
Instructions: Instructions{Global: "G"},
Rules: []Rule{
{Name: "empty", Body: " \n "},
{Name: "real", Body: "keep"},
},
}
assert.Equal(t, "G\n\nkeep", b.InstructionTextWithRules("opencode"))
})
}
func TestStripFrontmatter(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"no frontmatter", "# Title\nbody", "# Title\nbody"},
{"with frontmatter", "---\nname: x\n---\n# Title", "# Title"},
{"unterminated frontmatter left intact", "---\nname: x\nbody", "---\nname: x\nbody"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, stripFrontmatter(tt.in))
})
}
}
+8
View File
@@ -72,6 +72,14 @@ func LoadFS(fs fsx.FS, root string) (*Bundle, error) {
b.Agents = append(b.Agents, Agent(m))
}
rules, err := loadMarkdownDirFS(fs, filepath.Join(root, "rules"), "")
if err != nil {
return nil, err
}
for _, m := range rules {
b.Rules = append(b.Rules, Rule(m))
}
b.Instructions.PerHarness = map[string]string{}
body, err := readFileIfExistsFS(fs, filepath.Join(root, "instructions", "global.md"))
if err != nil {
+4
View File
@@ -28,6 +28,10 @@ func TestLoad(t *testing.T) {
require.Len(t, b.Agents, 1)
assert.Equal(t, "sample", b.Agents[0].Name)
require.Len(t, b.Rules, 1)
assert.Equal(t, "sample-rule", b.Rules[0].Name)
assert.Contains(t, b.Rules[0].Body, "Always be concise.")
assert.Contains(t, b.Instructions.Global, "Be helpful.")
}
@@ -0,0 +1,3 @@
# Sample Rule
Always be concise.
+39
View File
@@ -35,6 +35,33 @@ type Upstream struct {
type Model struct {
ID string `yaml:"id"`
Alias string `yaml:"alias,omitempty"`
// Context and Output are optional per-model limit overrides (tokens). When
// unset (0), harnesses fall back to DefaultContextLimit / DefaultOutputLimit.
Context int `yaml:"context,omitempty"`
Output int `yaml:"output,omitempty"`
}
// Default per-model limits, applied by harnesses that surface context/output
// limits when a Model doesn't specify its own.
const (
DefaultContextLimit = 200000
DefaultOutputLimit = 8192
)
// ContextLimit returns the model's context window, or DefaultContextLimit if unset.
func (m Model) ContextLimit() int {
if m.Context > 0 {
return m.Context
}
return DefaultContextLimit
}
// OutputLimit returns the model's max output tokens, or DefaultOutputLimit if unset.
func (m Model) OutputLimit() int {
if m.Output > 0 {
return m.Output
}
return DefaultOutputLimit
}
type MCPRegistry struct {
@@ -43,6 +70,7 @@ type MCPRegistry struct {
type MCPServer struct {
Env map[string]string `yaml:"env,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Name string `yaml:"name"`
Command string `yaml:"command,omitempty"`
URL string `yaml:"url,omitempty"`
@@ -64,6 +92,16 @@ type Agent struct {
Path string
}
// Rule is a topic-scoped instruction fragment (e.g. ~/.claude/rules/go.md).
// Claude Code auto-loads a directory of these; harnesses without a native rules
// directory receive the bodies folded into their global instructions file.
type Rule struct {
Name string
Description string
Body string
Path string
}
type Instructions struct {
PerHarness map[string]string
Global string
@@ -76,6 +114,7 @@ type Bundle struct {
Profile Profile
Skills []Skill
Agents []Agent
Rules []Rule
MCP MCPRegistry
}
+3
View File
@@ -24,6 +24,9 @@ func (d *detectableAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error
func (d *detectableAdapter) Import(_ string) (*adapter.ImportResult, error) {
return &adapter.ImportResult{}, nil
}
func (d *detectableAdapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{ManagesMCP: true}
}
func TestDetectCommandPrintsList(t *testing.T) {
reg := adapter.NewRegistry()
+25 -3
View File
@@ -93,6 +93,7 @@ func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, pic
}
seenSkills := map[string]bool{}
seenAgents := map[string]bool{}
seenRules := map[string]bool{}
seenMCP := map[string]bool{}
var instructions []string
@@ -121,6 +122,14 @@ func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, pic
seenAgents[key] = true
bundle.Agents = append(bundle.Agents, a)
}
for _, r := range res.Rules {
key := r.Name + "|" + r.Body
if seenRules[key] {
continue
}
seenRules[key] = true
bundle.Rules = append(bundle.Rules, r)
}
for _, m := range res.MCP {
if seenMCP[m.Name] {
continue
@@ -153,8 +162,8 @@ func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, pic
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
"imported from %v into %s\n skills: %d\n agents: %d\n mcp servers: %d\n instructions: %d source(s)\n",
picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.MCP.Servers), len(instructions),
"imported from %v into %s\n skills: %d\n agents: %d\n rules: %d\n mcp servers: %d\n instructions: %d source(s)\n",
picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.Rules), len(bundle.MCP.Servers), len(instructions),
)
return nil
}
@@ -172,7 +181,7 @@ const importedProfileBody = "# harness-sync profile \xe2\x80\x94 generated by in
"models: []\n"
func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
for _, sub := range []string{"profiles", "skills", "agents", "instructions"} {
for _, sub := range []string{"profiles", "skills", "agents", "rules", "instructions"} {
if err := os.MkdirAll(filepath.Join(root, sub), 0o750); err != nil {
return err
}
@@ -233,6 +242,19 @@ func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
return err
}
}
for _, r := range b.Rules {
rel := r.Path
if rel == "" {
rel = safeFilename(r.Name) + ".md"
}
path := filepath.Join(root, "rules", rel)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
if err := os.WriteFile(path, []byte(r.Body), 0o600); err != nil {
return err
}
}
if b.Instructions.Global != "" {
if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"),
[]byte(b.Instructions.Global), 0o600); err != nil {
+31
View File
@@ -23,6 +23,9 @@ func (i *importableAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error
return adapter.NewFileSet(), nil
}
func (i *importableAdapter) Import(_ string) (*adapter.ImportResult, error) { return i.res, nil }
func (i *importableAdapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{ManagesMCP: true}
}
func TestInitImportWritesCanonical(t *testing.T) {
root := t.TempDir()
@@ -50,6 +53,34 @@ func TestInitImportWritesCanonical(t *testing.T) {
require.NoError(t, err)
}
func TestInitImportsRules(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
reg := adapter.NewRegistry()
reg.Register(&importableAdapter{
name: "stub",
res: &adapter.ImportResult{
Rules: []canonical.Rule{
{Name: "go", Body: "# Go\n\ngofmt", Path: "go.md"},
{Name: "py", Body: "# Py\n\ntypes", Path: "py.md"},
},
},
})
cmd := NewInit(reg)
cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
require.NoError(t, cmd.Execute())
assert.FileExists(t, filepath.Join(root, "rules", "go.md"))
assert.FileExists(t, filepath.Join(root, "rules", "py.md"))
// Round-trips back through the loader.
b, err := canonical.Load(root)
require.NoError(t, err)
require.Len(t, b.Rules, 2)
}
func makeStubReg() *adapter.Registry {
reg := adapter.NewRegistry()
reg.Register(&importableAdapter{
+69 -3
View File
@@ -34,8 +34,8 @@ func NewProfile(reg *adapter.Registry) *cobra.Command {
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".yaml") {
names = append(names, strings.TrimSuffix(e.Name(), ".yaml"))
if n, ok := strings.CutSuffix(e.Name(), ".yaml"); ok {
names = append(names, n)
}
}
sort.Strings(names)
@@ -84,8 +84,54 @@ func NewProfile(reg *adapter.Registry) *cobra.Command {
rootFlag(use)
use.Flags().BoolVar(&applyAfter, "apply", false, "run apply automatically after switching profile")
rename := &cobra.Command{
Use: "rename <old> <new>",
Short: "Rename a profile (file + name field + active_profile if needed)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
r, err := resolveRoot(root)
if err != nil {
return err
}
oldName, newName := args[0], args[1]
oldPath := filepath.Join(r, "profiles", oldName+".yaml")
newPath := filepath.Join(r, "profiles", newName+".yaml")
if _, statErr := os.Stat(oldPath); statErr != nil {
return fmt.Errorf("profile %q not found", oldName)
}
if _, statErr := os.Stat(newPath); statErr == nil {
return fmt.Errorf("profile %q already exists", newName)
}
body, err := os.ReadFile(oldPath)
if err != nil {
return err
}
if err := os.WriteFile(newPath, []byte(setProfileName(string(body), newName)), 0o600); err != nil {
return err
}
if err := os.Remove(oldPath); err != nil {
_ = os.Remove(newPath)
return err
}
// Update active_profile in config.yaml if it points at the old name.
configPath := filepath.Join(r, "config.yaml")
if existing, readErr := os.ReadFile(configPath); readErr == nil {
if isActiveProfile(string(existing), oldName) {
_ = os.WriteFile(configPath, []byte(setActiveProfile(string(existing), newName)), 0o600)
}
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Renamed profile %q → %q.\n", oldName, newName)
return nil
},
}
rootFlag(rename)
cmd := &cobra.Command{Use: "profile", Short: "Manage profiles"}
cmd.AddCommand(list, use)
cmd.AddCommand(list, use, rename)
return cmd
}
@@ -99,3 +145,23 @@ func setActiveProfile(existing, name string) string {
}
return strings.TrimRight(existing, "\n") + "\nactive_profile: " + name + "\n"
}
func isActiveProfile(configYAML, name string) bool {
for l := range strings.SplitSeq(configYAML, "\n") {
if rest, ok := strings.CutPrefix(l, "active_profile:"); ok {
return strings.TrimSpace(rest) == name
}
}
return false
}
func setProfileName(body, name string) string {
lines := strings.Split(body, "\n")
for i, l := range lines {
if strings.HasPrefix(l, "name:") {
lines[i] = "name: " + name
return strings.Join(lines, "\n")
}
}
return "name: " + name + "\n" + strings.TrimLeft(body, "\n")
}
+63
View File
@@ -62,6 +62,69 @@ func TestProfileUseHintMessage(t *testing.T) {
assert.Contains(t, buf.String(), "harness-sync apply")
}
func TestProfileRename(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "old.yaml"), []byte("name: old\ngateway:\n url: https://gw\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: other\n"), 0o600))
var buf bytes.Buffer
cmd := NewProfile(nil)
cmd.SetOut(&buf)
cmd.SetArgs([]string{"rename", "old", "new", "--root", root})
require.NoError(t, cmd.Execute())
// old file gone, new file present with updated name field.
_, err := os.Stat(filepath.Join(root, "profiles", "old.yaml"))
assert.True(t, os.IsNotExist(err), "old.yaml should be removed")
body, err := os.ReadFile(filepath.Join(root, "profiles", "new.yaml"))
require.NoError(t, err)
assert.Contains(t, string(body), "name: new")
assert.NotContains(t, string(body), "name: old")
// config.yaml not modified — old was not active.
cfg, err := os.ReadFile(filepath.Join(root, "config.yaml"))
require.NoError(t, err)
assert.Contains(t, string(cfg), "active_profile: other")
assert.Contains(t, buf.String(), `"old" → "new"`)
}
func TestProfileRenameUpdatesActiveProfile(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "old.yaml"), []byte("name: old\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: old\n"), 0o600))
cmd := NewProfile(nil)
cmd.SetArgs([]string{"rename", "old", "new", "--root", root})
require.NoError(t, cmd.Execute())
cfg, err := os.ReadFile(filepath.Join(root, "config.yaml"))
require.NoError(t, err)
assert.Contains(t, string(cfg), "active_profile: new")
}
func TestProfileRenameErrorNotFound(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
cmd := NewProfile(nil)
cmd.SetArgs([]string{"rename", "missing", "new", "--root", root})
assert.Error(t, cmd.Execute())
}
func TestProfileRenameErrorAlreadyExists(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "old.yaml"), []byte("name: old\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "new.yaml"), []byte("name: new\n"), 0o600))
cmd := NewProfile(nil)
cmd.SetArgs([]string{"rename", "old", "new", "--root", root})
assert.Error(t, cmd.Execute())
}
// TestProfileUseWithApply verifies --apply triggers apply after switching.
func TestProfileUseWithApply(t *testing.T) {
root := t.TempDir()
+3
View File
@@ -31,6 +31,9 @@ func (r *renderingAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error)
}
return fs, nil
}
func (r *renderingAdapter) Capabilities() adapter.HarnessCapabilities {
return adapter.HarnessCapabilities{}
}
func TestShowPrintsTargets(t *testing.T) {
root := t.TempDir()
+15 -1
View File
@@ -50,7 +50,10 @@ func (r *Repo) Add(paths ...string) error {
}
func (r *Repo) Commit(msg string) error {
_, err := r.run("commit", "-q", "-m", msg)
// --no-verify: harness-sync makes internal state-tracking commits in the
// canonical repo; user-configured hooks (secret scanners, linters) must not
// block them — those hooks belong to the harness-sync source repo, not here.
_, err := r.run("commit", "-q", "--no-verify", "-m", msg)
return err
}
@@ -68,6 +71,17 @@ func (r *Repo) IsRepo() bool {
return err == nil
}
// HasStagedChanges reports whether the index currently differs from HEAD.
// Used by callers that want to skip a no-op `git commit` (commit exits 1
// when there is nothing to commit, which is the wrong signal to propagate).
func (r *Repo) HasStagedChanges() (bool, error) {
b, err := r.run("status", "--porcelain")
if err != nil {
return false, err
}
return len(bytes.TrimSpace(b)) > 0, nil
}
// Revert undoes the last N commits with `git revert --no-edit HEAD~N..HEAD`.
// n must be >= 1.
func (r *Repo) Revert(n int) error {
+30
View File
@@ -41,3 +41,33 @@ func TestShowFileAtHead(t *testing.T) {
_, err = r.ShowFileAtHead("nope.txt")
assert.Error(t, err)
}
func TestHasStagedChangesEmptyAfterInit(t *testing.T) {
dir := t.TempDir()
r := New(dir)
require.NoError(t, r.Init())
require.NoError(t, r.Configure("nobody", "[email protected]"))
// No commits yet, working tree clean.
dirty, err := r.HasStagedChanges()
require.NoError(t, err)
assert.False(t, dirty)
}
func TestHasStagedChangesAfterAdd(t *testing.T) {
dir := t.TempDir()
r := New(dir)
require.NoError(t, r.Init())
require.NoError(t, r.Configure("nobody", "[email protected]"))
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o600))
require.NoError(t, r.AddAll())
dirty, err := r.HasStagedChanges()
require.NoError(t, err)
assert.True(t, dirty)
// After commit, index matches HEAD again → no staged changes.
require.NoError(t, r.Commit("c"))
dirty, err = r.HasStagedChanges()
require.NoError(t, err)
assert.False(t, dirty)
}
+7 -2
View File
@@ -17,11 +17,15 @@ func TestInitApplyDiffCycle(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, ".config", "harness-sync")
// Fake a claude-code install with one skill
// Fake a claude-code install with one skill and one rule
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills", "hi"), 0o750))
require.NoError(t, os.WriteFile(
filepath.Join(home, ".claude", "skills", "hi", "SKILL.md"),
[]byte("---\nname: hi\n---\nbody"), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "rules"), 0o750))
require.NoError(t, os.WriteFile(
filepath.Join(home, ".claude", "rules", "go.md"),
[]byte("# Go Rules\n\ngofmt always"), 0o600))
run := func(args ...string) ([]byte, error) {
cmd := exec.Command(bin, args...)
@@ -33,6 +37,7 @@ func TestInitApplyDiffCycle(t *testing.T) {
require.NoError(t, err, string(out))
assert.FileExists(t, filepath.Join(root, "config.yaml"))
assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md"))
assert.FileExists(t, filepath.Join(root, "rules", "go.md"))
// apply may produce conflicts on a freshly imported tree (because the
// claude-code adapter merges into existing settings.json which differs
@@ -85,7 +90,7 @@ func TestDetectListsAllAdapters(t *testing.T) {
// None should be detected in an empty HOME
s := string(out)
for _, name := range []string{"claude-code", "crush", "kilo", "opencode", "goose", "cagent", "zed"} {
for _, name := range []string{"claude-code", "crush", "kilo", "opencode", "pi", "goose", "cagent", "zed"} {
assert.Contains(t, s, name)
}
assert.Contains(t, s, "not detected")