From 21a86c084fe67ac3f5ecdbb4a5f75384f11b0814 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 23 May 2026 21:10:16 +0100 Subject: [PATCH] feat: profile rename subcommand + expand all model display names --- .crew/audit/prune.jsonl | 1 + docs/index.html | 957 +++++++++++------------------------ internal/cli/profile.go | 72 ++- internal/cli/profile_test.go | 63 +++ 4 files changed, 440 insertions(+), 653 deletions(-) diff --git a/.crew/audit/prune.jsonl b/.crew/audit/prune.jsonl index c854baa..628ab43 100644 --- a/.crew/audit/prune.jsonl +++ b/.crew/audit/prune.jsonl @@ -1 +1,2 @@ {"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"} diff --git a/docs/index.html b/docs/index.html index f827088..71ea386 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,663 +1,320 @@ - - - - harness-sync — One Config, Every Harness - - - - - - - - - - - - - - - - - - - - - - + + + +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
-
-
-
-
-
+ +
+

~ $ harness-sync

+

One config, every LLM harness.

+

A CLI tool that syncs skills, agents, MCP servers, endpoints, and instructions from a single canonical tree into each harness's native format.

+
+
+ + + +
+
# import from whatever you already use
+$ harness-sync detect
+  ✓ claude-code  → ~/.claude/
+  ✓ kilo         → ~/.config/kilo/
+  ✓ crush        → ~/.config/crush/
+  × goose          not found
+  × opencode       not found
 
-        
-        
-
-
-

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/

-
-
-
-
-
-
+$ harness-sync init + imported 12 skills, 4 agents, 6 mcp servers from claude-code - -
-
-
-

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
+$ harness-sync apply --dry-run
+  plan: write 14 files to claude-code, 11 to kilo, 9 to crush
+  (dry-run, nothing written)
 
-
-
-
-
+
+ +
- -
-
-
-

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
+
+
+

The problem

+

You run claude-code, kilo, crush, opencode, goose, cagent, zed — each keeps its own copy of the same config.

+
+
+

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 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 merge-file
  • +
  • →secrets via ${VAR} env-var substitution, never stored
  • +
  • →git rollback on every apply
  • +
+
+
+
+ + +
+

What it does

+
    +
  • →
    Canonical tree. Profiles, skills, agents, mcp servers, and instructions in a single git-managed directory.
  • +
  • →
    Native adapters. Each harness gets its correct keys and file types — no lowest-common-denominator config.
  • +
  • →
    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 written to disk or canonical tree.
  • +
  • →
    Auto-detection. Detects installed harnesses. Only touches what's relevant on your machine.
  • +
  • →
    Dry-run preview. Diff every target file before writing anything to disk.
  • +
  • →
    Git rollback. Revert the last n applies. Full version history in local git.
  • +
  • →
    Non-destructive overlay. Only managed keys are written. Your hooks, permissions, and editor settings survive.
  • +
+
+ + +
+

Supported harnesses

+

Each adapter grounded against real config files and upstream docs.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
harnessmanagesnote
claude-codemcp · skills · instructionsprovider config managed by Anthropic directly
crushmcp · skills · providers · modelsfull gateway rendering with provider map
kilomcp · skills · providers · modelssmall model + provider map config
opencodemcp · skills · providers · models · instructionsJSONC config with instructions merging
goosemcp · skills · providers · modelsYAML with extensions map
cagentmcp · providers · models · instructionsstarter YAML with per-agent model
zedmcp · providers · modelscontext servers in settings.json
+
+
+ + +
+

Install

+
+
+

one-liner (recommended)

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

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

+
+
+

homebrew

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

from source

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

Requires Go 1.24+ and git on $PATH.

+
+
+
+ + +
+

Usage

+
# 1. see what's installed
+$ harness-sync detect
+
+# 2. import from detected harnesses into canonical tree
+$ harness-sync init
+
+# 3. render canonical tree to all harness configs
+$ harness-sync apply
+$ harness-sync apply --dry-run        # preview without writing
+$ harness-sync diff                  # same as apply --dry-run
+$ harness-sync profile list           # list profiles
+$ harness-sync profile use work       # switch active profile
+$ harness-sync rollback 2             # git revert last 2 applies
+$ harness-sync update                 # pull latest release
+
+

flags

+
+ + + + + + + + + + + +
flageffect
--dry-runshow plan, write nothing
--forceoverwrite without merge
--yesskip confirmation prompt
--from a,bpick source adapters for init
--no-prompttake all detected sources
+
+
+ + +
+

Architecture

+

Single Go binary. ~7,400 LOC. 174 passing tests. 18 packages.

+
+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)
+└── instructions/global.md
+
+
+

merge model

+
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
-
-
-
-
-
-
+disjoint → clean merge, write file +overlap → .rej file, exit non-zero + + + - - + + - - + + diff --git a/internal/cli/profile.go b/internal/cli/profile.go index 47e5193..ba71a69 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -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 ", + 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") +} diff --git a/internal/cli/profile_test.go b/internal/cli/profile_test.go index 79ca72e..8c12596 100644 --- a/internal/cli/profile_test.go +++ b/internal/cli/profile_test.go @@ -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()