From 77158d9f2b36deca9ab5accf96c8b67dae26b792 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 23 May 2026 20:33:03 +0100 Subject: [PATCH] fix: propagate gateway token; kilo targets opencode.jsonc; goose URL + api_key fix --- docs/index.html | 653 ++++++++++++++++++++++++ internal/adapter/common/blocks.go | 22 +- internal/adapters/cagent/cagent_test.go | 5 +- internal/adapters/goose/goose_test.go | 5 +- internal/adapters/kilo/kilo.go | 48 ++ internal/adapters/kilo/kilo_test.go | 42 ++ 6 files changed, 760 insertions(+), 15 deletions(-) create mode 100644 docs/index.html diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..d4b6223 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,653 @@ + + + + + + harness-sync — One Config, Every Harness + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+
+ +
+
+

+ One Config,
Every Harness +

+

+ One canonical source of truth for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine. +

+ +
+ Version + License + Go Report +
+
+
+
+ +
+
+
+

The Problem

+

Seven harnesses. Seven copies of the same config. They drift.

+
+
+
+

+ Without harness-sync +

+
+
Same skill markdown, duplicated seven ways
+
Same agent definitions, rewritten per harness
+
Same MCP registry, seven different key names (mcpServers, mcp, context_servers...)
+
Same gateway URL, hand-copied into seven config files
+
Edit once. Forget the others. Things break.
+
+
+
+

+ With harness-sync +

+
+
One canonical tree under git version control
+
Per-harness adapters render native config formats
+
3-way merge via git for conflict resolution
+
Secrets via ${VAR} env-var substitution, never stored
+
Git rollback on every apply
+
+
+
+
+
+ + +
+
+
+

Features

+

Everything you need to manage LLM harness config at scale

+
+
+
+
+
+ +
+
+

Canonical Tree

+

Profiles, skills, agents, MCP servers, and instructions — one git-managed directory

+
+
+
+
+
+
+ +
+
+

Native Adapters

+

Each harness gets its correct native format — right keys, right types, right paths

+
+
+
+
+
+
+ +
+
+

3-Way Merge

+

Git merge-file semantics — disjoint changes merge cleanly, conflicts produce .rej files

+
+
+
+
+
+
+ +
+
+

Profiles

+

Bundle gateway URL, model allowlist, and upstream keys — switch with one command

+
+
+
+
+
+
+ +
+
+

Secret Safety

+

Env-var substitution at render time — secrets never stored in canonical tree

+
+
+
+
+
+
+ +
+
+

Auto-Detection

+

Detects which harnesses are installed and only touches what's relevant

+
+
+
+
+
+
+ +
+
+

Dry-Run Preview

+

See exactly what will change before writing — diff every file without touching disk

+
+
+
+
+
+
+ +
+
+

Git Rollback

+

Revert the last N applies — full version history preserved in local git

+
+
+
+
+
+
+ +
+
+

Self-Update

+

Pulls latest release in place with SHA-256 verification

+
+
+
+
+
+
+ +
+
+

Non-Destructive

+

Only overlays managed keys — your hooks, permissions, and editor settings survive

+
+
+
+
+
+
+ +
+
+

Import from Any

+

harness-sync init picks the best source harness and imports everything

+
+
+
+
+
+
+ +
+
+

Extensible

+

Add a harness: drop a Go package under internal/adapters/

+
+
+
+
+
+
+ + +
+
+
+

Supported Harnesses

+

Each adapter grounded against real config files and official docs

+
+
+
+
+
+ +
+
+

claude-code

+

MCP · Skills · Instructions

+
+
+

Built-in subscription — provider config managed by Anthropic directly.

+
+
+
+
+ +
+
+

crush

+

MCP · Skills · Providers · Models

+
+
+

Full gateway rendering with provider map and default model support.

+
+
+
+
+ +
+
+

kilo

+

MCP · Skills · Providers · Models

+
+
+

Kilo CLI with provider map, model, and small model configuration.

+
+
+
+
+ +
+
+

opencode

+

MCP · Skills · Providers · Models · Instructions

+
+
+

Full rendering with JSONC config support and instructions merging.

+
+
+
+
+ +
+
+

goose

+

MCP · Skills · Providers · Models

+
+
+

YAML config with extensions map and custom provider JSON files.

+
+
+
+
+ +
+
+

cagent

+

MCP · Providers · Models · Instructions

+
+
+

Starter YAML config with per-agent model selection.

+
+
+
+
+ +
+
+

zed

+

MCP · Providers · Models

+
+
+

Context servers and language model configuration in settings.json.

+
+
+
+
+ + +
+
+
+

Install

+

One binary, zero dependencies, every platform

+
+
+
+
+
+ +
+

One-liner

+ Recommended +
+
+ $ curl -fsSL https://raw.githubusercontent.com/
  lukaszraczylo/harness-sync/main/install.sh | bash +
+

Picks the right artefact for OS/arch, verifies SHA-256, drops binary in ~/.local/bin.

+
+
+
+
+ +
+

Homebrew

+
+
+ $ brew install lukaszraczylo/taps/harness-sync +
+

macOS and Linux via Homebrew tap.

+
+
+
+
+ +
+

From Source

+
+
+ $ git clone https://github.com/
  lukaszraczylo/harness-sync
+ $ make build && make install +
+

Requires Go 1.24+ and git on $PATH.

+
+
+
+
+ + +
+
+
+

Usage

+

Three commands to get going

+
+
+
+
+ 01 +

Detect

+
+
+ $ harness-sync detect +
+

Lists which harnesses are installed on your machine.

+
+
+
+ 02 +

Init

+
+
+ $ harness-sync init +
+

Imports config from detected harnesses into the canonical tree.

+
+
+
+ 03 +

Apply

+
+
+ $ harness-sync apply +
+

Renders and writes native config to every detected harness.

+
+
+ + +
+

+ Full Command Reference +

+
+
+$ harness-sync detect                       # list adapters + detection status
+$ harness-sync show [harness...]            # print files each adapter manages
+$ harness-sync init                         # import from detected harnesses
+                --from a,b              # pick adapters, skip prompt
+                --no-prompt             # take all detected
+                --force                 # allow re-init
+$ harness-sync apply [harness...]           # render + write
+                --dry-run               # show plan, write nothing
+                --force                 # overwrite without merge
+                --yes                   # skip confirmation prompt
+$ harness-sync diff [harness...]            # apply --dry-run shorthand
+$ harness-sync profile list                 # list canonical profiles
+$ harness-sync profile use <name>           # switch active profile
+                --apply                 # reapply after switching
+$ harness-sync rollback [n]                 # git revert last N applies
+$ harness-sync update                       # reinstall latest release
+
+
+
+
+
+ + +
+
+
+

Architecture

+

Single Go binary, ~5000 LOC, 18 packages, 93 tests

+
+
+
+ harness-sync architecture +
+
+
+
+

+ Canonical Tree +

+
+
+~/.config/harness-sync/  (git)
+├── profiles/<name>.yaml
+├── skills/<name>/SKILL.md
+├── agents/<name>.md
+├── mcp.yaml
+└── instructions/global.md
+
+
+
+
+

+ 3-Way Merge +

+
+
+base   ← state/  (last render)
+ours   ← rendered (what we'd write now)
+theirs ← on disk  (what's actually there)
+
+disjoint → clean merge, write
+overlap  → .rej file, exit non-zero
+
+
+
+
+
+
+ + + + + + + diff --git a/internal/adapter/common/blocks.go b/internal/adapter/common/blocks.go index 3305a12..ae5043b 100644 --- a/internal/adapter/common/blocks.go +++ b/internal/adapter/common/blocks.go @@ -180,9 +180,9 @@ func ProvidersAsCagentMap(p *canonical.Profile) map[string]any { return out } out[gatewayProviderID] = map[string]any{ - "base_url": p.Gateway.URL, - "token_key": "HARNESS_SYNC_GATEWAY_TOKEN", - "provider": "openai", + "base_url": p.Gateway.URL, + "api_key": p.Gateway.Token, + "provider": "openai", } return out } @@ -261,16 +261,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 { diff --git a/internal/adapters/cagent/cagent_test.go b/internal/adapters/cagent/cagent_test.go index 0fa267f..3c8c215 100644 --- a/internal/adapters/cagent/cagent_test.go +++ b/internal/adapters/cagent/cagent_test.go @@ -55,8 +55,8 @@ func TestCagentRenderProducesExpectedTargets(t *testing.T) { providers := parsed["providers"].(map[string]any) gwProv := providers["harness-sync-gateway"].(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) @@ -96,8 +96,9 @@ func TestCagentRenderProvidersAndModelsMaps(t *testing.T) { gw, ok := providers["harness-sync-gateway"].(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) diff --git a/internal/adapters/goose/goose_test.go b/internal/adapters/goose/goose_test.go index a633b68..ef6e829 100644 --- a/internal/adapters/goose/goose_test.go +++ b/internal/adapters/goose/goose_test.go @@ -117,8 +117,9 @@ func TestGooseRenderProducesCustomProviderFile(t *testing.T) { require.NoError(t, json.Unmarshal(seen[cpDest].Content, &cp)) assert.Equal(t, "custom_harness-sync-gateway", 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) diff --git a/internal/adapters/kilo/kilo.go b/internal/adapters/kilo/kilo.go index 136e0e8..10e3a9c 100644 --- a/internal/adapters/kilo/kilo.go +++ b/internal/adapters/kilo/kilo.go @@ -2,6 +2,8 @@ package kilo import ( + "encoding/json" + "maps" "os" "path/filepath" @@ -106,6 +108,52 @@ 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. Deep-merge the provider map + // so existing providers (e.g. "llmgw") are preserved. + ocPath := filepath.Join(base, "opencode.jsonc") + if ocRaw, readErr := os.ReadFile(ocPath); readErr == nil { + // Parse existing content (supports JSONC comments internally). + 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, // delete filter + } + + // Deep-merge provider: add our entries INTO the existing provider map. + if newProv, ok := overlay["provider"].(map[string]any); ok && len(newProv) > 0 { + existing, _ := ocBase["provider"].(map[string]any) + if existing == nil { + existing = map[string]any{} + } + maps.Copy(existing, newProv) + ocOverlay["provider"] = existing + } + // Copy model/small_model/mcp from the kilo.json overlay. + for _, k := range []string{"model", "small_model", "mcp"} { + if v, ok := overlay[k]; ok { + ocOverlay[k] = v + } + } + + ocMerged, mergeErr := common.MergeJSONKeys([]byte(clean), ocOverlay) + if mergeErr != nil { + return nil, mergeErr + } + fs.Add(adapter.File{ + Dest: ocPath, + Kind: adapter.RenderedFile, + Content: ocMerged, + NoMerge: true, + }) + } + return fs, nil } diff --git a/internal/adapters/kilo/kilo_test.go b/internal/adapters/kilo/kilo_test.go index ba59d72..cc60f4e 100644 --- a/internal/adapters/kilo/kilo_test.go +++ b/internal/adapters/kilo/kilo_test.go @@ -128,6 +128,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, "harness-sync-gateway") + // model updated. + assert.Equal(t, "harness-sync-gateway/sonnet", parsed["model"]) +} + func TestKiloImport(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".config", "kilo")