Commit
eeco
feat: cross-project settings — global config layer + project import
Add two ways to carry eeco settings between projects. Global layer (live, git --global model): config now resolves in three layers — built-in defaults → user-global config.local → workspace config.local — each overriding the previous. The global file lives under EECO_CONFIG_HOME, else $XDG_CONFIG_HOME/eeco, else ~/.config/eeco. New `eeco config list|get|set [--global]` is the first CLI for editing config.local (previously only the TUI). `eeco cockpit target --global` manages a cross-project target set with a live fallback in LoadSelection. One-shot copy: `eeco init --from <path>` and `eeco config import [--force] <path>` copy a source project's config.local, cockpit.json, and scaffolded workflows. config.local is copied verbatim into a fresh workspace, key-merged into an existing one. Project-specific knowledge, state, and bug reports never travel; existing files/keys are preserved unless --force. The three-layer resolution order and the global directory locations are frozen behaviour (docs/PUBLIC_API.md). EECO_CONFIG_HOME is also the hermetic test seam, pinned to a temp dir in every test package that loads config or a cockpit selection.
modified CHANGELOG.md
@@ -33,6 +33,35 @@ eeco follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
over the surface in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md), under the
pre-stability caveat of the [versioning policy](VERSIONING.md) §2.1.
## [v0.2.0] - 2026-06-07
### Added
- **Cross-project settings — a user-global config layer.** Configuration
now resolves in three layers, each overriding the previous: built-in
defaults → a user-global `config.local` → the per-workspace
`config.local`. The global file lives under `EECO_CONFIG_HOME`, else
`$XDG_CONFIG_HOME/eeco`, else `~/.config/eeco`. Set a key once with
`--global` and every project inherits it unless it overrides the key
locally (the git `--global` model). See
[`docs/USAGE.md`](docs/USAGE.md) §4a.
- **`eeco config` command group.** `eeco config list` shows every key
with its effective value and origin (default | global | local);
`eeco config get <key>` prints one value; `eeco config set [--global]
<key> <value>` writes the workspace (or global) layer, validating the
key and value before writing. This is the first CLI surface for editing
`config.local` (previously only the TUI `/settings` and hand-editing).
- **Global cockpit target set.** `eeco cockpit target --global
list|add|rm` manages a cross-project target set; a project with no
`cockpit.json` of its own inherits it, with the workspace selection
always winning.
- **Import settings from another project.** `eeco init --from <path>` and
`eeco config import [--force] <path>` copy a source project's
`config.local`, cockpit selection, and scaffolded `workflows/` into this
one — a one-shot alternative to the live global layer. Project-specific
knowledge, state, and bug reports never travel; existing files and keys
are preserved unless `--force` is given.
## [v0.1.1] - 2026-06-06
### Fixed
@@ -141,6 +170,7 @@ configures; eeco is its author and mechanic. eeco is pre-stability on the
§2.1); the cockpit surface is documented but not yet frozen
([`docs/COCKPIT.md`](docs/COCKPIT.md)).
[v0.2.0]: https://github.com/ajhahnde/eeco/releases/tag/v0.2.0
[v0.1.1]: https://github.com/ajhahnde/eeco/releases/tag/v0.1.1
[v0.1.0]: https://github.com/ajhahnde/eeco/releases/tag/v0.1.0
modified README.md
@@ -9,7 +9,7 @@
<p>
<a href="https://github.com/ajhahnde/eeco/actions/workflows/ci.yml"><img src="https://github.com/ajhahnde/eeco/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://codecov.io/gh/ajhahnde/eeco"><img src="https://codecov.io/gh/ajhahnde/eeco/branch/main/graph/badge.svg" alt="Coverage"></a>
<a href="https://github.com/ajhahnde/eeco/releases/latest"><img src="https://img.shields.io/badge/version-v0.1.1-blue" alt="Version"></a>
<a href="https://github.com/ajhahnde/eeco/releases/latest"><img src="https://img.shields.io/badge/version-v0.2.0-blue" alt="Version"></a>
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
<img src="https://img.shields.io/badge/go-1.24-orange" alt="Go 1.24">
<img src="https://img.shields.io/badge/binary-single--static-lightgrey" alt="single-static binary">
@@ -100,6 +100,11 @@ proposing larger changes for review rather than acting unilaterally.
archive sha256, and the GitHub build-provenance attestation before
atomically replacing the running binary. Bare `eeco update` is
read-only.
- **Cross-project settings** (`eeco config`). `eeco config list|get|set`
edits the per-project config; `eeco config set --global <key> <value>`
writes a user-global layer every project inherits, with per-repo
overrides — the git `--global` model. See
[`docs/USAGE.md`](docs/USAGE.md) §4a.
- **Diagnostics** (`eeco doctor`), **clean removal** (`eeco uninstall`), and **friction capture** (`eeco report-bug`) for every
step before, during, and after.
modified cmd/eeco/cockpit.go
@@ -18,7 +18,7 @@ const cockpitUsage = `usage:
eeco cockpit off [--target T] [--playbook P] remove eeco's emitted artifacts (sha-gated, reversible)
eeco cockpit status show emitted-cockpit state, one line per artifact
eeco cockpit show [--playbook P] print the neutral playbook source (JSON)
eeco cockpit target list|add <t>|rm <t> manage the active harness target set
eeco cockpit target [--global] list|add <t>|rm <t> manage the active (or user-global) harness target set
eeco cockpit machinery on|off|status|refresh emit the auto-firing git-write guard as harness config
Targets: claude (enforced) · cursor, agents, gemini (advisory — not harness-enforced).
With no --target, generate/verify/off act on the active set (eeco cockpit target list).
@@ -391,23 +391,36 @@ func runCockpitShow(args []string, stdout, stderr io.Writer) int {
return 0
}
// runCockpitTarget manages the active harness target set in the selection
// store (<username>/.eeco/cockpit.json): list the active + available targets,
// or add/remove one. `rm` deselects only — it never deletes emitted files (it
// hints `eeco cockpit off`), keeping removal an explicit, reversible step.
// runCockpitTarget manages the active harness target set: list the active +
// available targets, or add/remove one. Without --global it edits the
// per-workspace selection store (<username>/.eeco/cockpit.json); with --global
// it edits the user-global selection (~/.config/eeco/cockpit.json) that
// projects with no workspace selection inherit. `rm` deselects only — it never
// deletes emitted files (it hints `eeco cockpit off`), keeping removal an
// explicit, reversible step.
func runCockpitTarget(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
global, rest := popBoolFlag(args, "global")
if len(rest) == 0 {
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
sub := rest[0]
rest = rest[1:]
// Resolve the selection from the chosen layer; saveSel writes it back.
var sel cockpit.Selection
saveSel := cockpit.SaveGlobalSelection
if global {
sel = cockpit.LoadGlobalSelection()
} else {
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
sel = cockpit.LoadSelection(cfg)
saveSel = func(s cockpit.Selection) error { return cockpit.SaveSelection(cfg, s) }
}
sel := cockpit.LoadSelection(cfg)
sub := args[0]
rest := args[1:]
switch sub {
case "list":
printTargetList(sel, stdout)
@@ -427,13 +440,18 @@ func runCockpitTarget(args []string, stdout, stderr io.Writer) int {
} else {
sel.Targets = removeTarget(sel.Targets, t)
}
if err := cockpit.SaveSelection(cfg, sel); err != nil {
if err := saveSel(sel); err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
if sub == "add" {
switch {
case global && sub == "add":
fmt.Fprintf(stdout, "eeco cockpit: target %s added to the global set — new projects inherit it\n", t)
case global:
fmt.Fprintf(stdout, "eeco cockpit: target %s removed from the global set\n", t)
case sub == "add":
fmt.Fprintf(stdout, "eeco cockpit: target %s added — run `eeco cockpit generate` to emit it\n", t)
} else {
default:
fmt.Fprintf(stdout, "eeco cockpit: target %s deselected — emitted files remain; run `eeco cockpit off --target %s` to remove them\n", t, t)
}
return 0
added cmd/eeco/config.go
@@ -0,0 +1,178 @@
package main
import (
"fmt"
"io"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
const configUsage = `usage:
eeco config list show every config key, its effective value, and origin
eeco config get <key> print the effective value of one key
eeco config set <key> <value> set a key in this workspace (<repo>/<user>/.eeco/config.local)
eeco config set --global <key> <value> set a key in the user-global layer (~/.config/eeco/config.local)
eeco config import [--force] <path> copy config.local, cockpit.json, and workflows from another eeco project
Resolution order: built-in defaults → user-global → workspace (later wins).
The global layer lets every project inherit your settings; set it once with --global.
Exit 0 clean, 1 on a failure, 2 on usage error.`
// runConfig dispatches `eeco config` subcommands. Config has three
// layers — defaults, the user-global file, and the per-workspace
// config.local — and this group inspects and edits the two file layers.
func runConfig(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, configUsage)
return 2
}
switch args[0] {
case "list":
return runConfigList(args[1:], stdout, stderr)
case "get":
return runConfigGet(args[1:], stdout, stderr)
case "set":
return runConfigSet(args[1:], stdout, stderr)
case "import":
return runConfigImport(args[1:], stdout, stderr)
default:
fmt.Fprintln(stderr, configUsage)
return 2
}
}
// runConfigImport copies portable settings from another eeco project's
// workspace into this one. Without --force, existing local files and keys are
// preserved; with --force, the source wins.
func runConfigImport(args []string, stdout, stderr io.Writer) int {
force, rest := popBoolFlag(args, "force")
if len(rest) != 1 {
fmt.Fprintln(stderr, "eeco config: import needs exactly one <path>")
return 2
}
dst, code := loadInitedConfig(stderr, "eeco config")
if code != 0 {
return code
}
src, err := resolveSourceWorkspace(rest[0])
if err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
if src.Workspace == dst.Workspace {
fmt.Fprintln(stderr, "eeco config: source and destination are the same workspace")
return 1
}
rep, err := importSettings(src, dst, force)
if err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
printImportReport(stdout, rest[0], rep)
return 0
}
// runConfigList prints every known key with its effective value and the
// layer that set it (default | global | local), git-config-style.
func runConfigList(args []string, stdout, stderr io.Writer) int {
if len(args) != 0 {
fmt.Fprintln(stderr, configUsage)
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco config")
if code != 0 {
return code
}
globalKeys, err := config.DeclaredKeys(config.GlobalConfigLocalPath())
if err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
localKeys, err := config.DeclaredKeys(filepath.Join(cfg.Workspace, config.LocalFilename))
if err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
keys := config.KnownKeys()
width := 0
for _, k := range keys {
if len(k) > width {
width = len(k)
}
}
for _, k := range keys {
val, _ := config.EffectiveValue(cfg, k)
origin := "default"
switch {
case localKeys[k]:
origin = "local"
case globalKeys[k]:
origin = "global"
}
fmt.Fprintf(stdout, "%-*s = %s (%s)\n", width, k, val, origin)
}
return 0
}
// runConfigGet prints the effective value of one key (bare, for scripts).
func runConfigGet(args []string, stdout, stderr io.Writer) int {
if len(args) != 1 {
fmt.Fprintln(stderr, "eeco config: get needs exactly one <key>")
return 2
}
key := args[0]
cfg, code := loadRepoConfig(stderr, "eeco config")
if code != 0 {
return code
}
val, ok := config.EffectiveValue(cfg, key)
if !ok {
fmt.Fprintf(stderr, "eeco config: unknown config key %q (run `eeco config list`)\n", key)
return 1
}
fmt.Fprintln(stdout, val)
return 0
}
// runConfigSet writes one key into the workspace config.local, or into
// the user-global config.local with --global. The value is validated
// against the same rules Load applies before anything is written.
func runConfigSet(args []string, stdout, stderr io.Writer) int {
global, rest := popBoolFlag(args, "global")
var key, val string
switch {
case len(rest) == 2:
key, val = rest[0], rest[1]
case len(rest) == 1 && strings.Contains(rest[0], "="):
key, val, _ = strings.Cut(rest[0], "=")
default:
fmt.Fprintln(stderr, "eeco config: set needs <key> <value> (or key=value)")
return 2
}
if err := config.ValidateSetValue(key, val); err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
if global {
if err := config.WriteGlobalKeys(map[string]string{key: val}); err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
fmt.Fprintf(stdout, "eeco config: set %s=%s (global: %s)\n", key, val, config.GlobalConfigLocalPath())
return 0
}
cfg, code := loadInitedConfig(stderr, "eeco config")
if code != 0 {
return code
}
if err := config.WriteLocalKeys(cfg, map[string]string{key: val}); err != nil {
fmt.Fprintln(stderr, "eeco config:", err)
return 1
}
fmt.Fprintf(stdout, "eeco config: set %s=%s (workspace)\n", key, val)
return 0
}
added cmd/eeco/config_test.go
@@ -0,0 +1,135 @@
package main
import (
"bytes"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
func TestRunConfig_SetGetList_Workspace(t *testing.T) {
setupInited(t)
if code := runConfig([]string{"set", "automation=manual"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("config set exit=%d", code)
}
var out bytes.Buffer
if code := runConfig([]string{"get", "automation"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("config get exit=%d", code)
}
if got := strings.TrimSpace(out.String()); got != "manual" {
t.Errorf("get automation = %q, want manual", got)
}
out.Reset()
if code := runConfig([]string{"list"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("config list exit=%d", code)
}
if !strings.Contains(out.String(), "automation") || !strings.Contains(out.String(), "(local)") {
t.Errorf("list missing automation/(local):\n%s", out.String())
}
}
func TestRunConfig_GlobalSetInheritedThenOverridden(t *testing.T) {
// Pin a per-test global dir so this write can't leak into other tests.
t.Setenv(config.GlobalConfigEnv, t.TempDir())
if code := runConfig([]string{"set", "--global", "automation=auto"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("config set --global exit=%d", code)
}
setupInited(t)
var out bytes.Buffer
if code := runConfig([]string{"list"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("config list exit=%d", code)
}
if !strings.Contains(out.String(), "automation") || !strings.Contains(out.String(), "auto (global)") {
t.Errorf("list should show automation inherited from global:\n%s", out.String())
}
// Workspace override wins.
if code := runConfig([]string{"set", "automation=manual"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("config set exit=%d", code)
}
out.Reset()
if code := runConfig([]string{"list"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("config list exit=%d", code)
}
if !strings.Contains(out.String(), "manual (local)") {
t.Errorf("workspace override not reflected:\n%s", out.String())
}
}
func TestRunConfig_GlobalSetNeedsNoWorkspace(t *testing.T) {
t.Setenv(config.GlobalConfigEnv, t.TempDir())
// No setupInited: a global set must work outside an initialised workspace,
// but still inside a repo (loadRepoConfig is not even needed for --global).
root := newGitRepo(t)
chdir(t, root)
if code := runConfig([]string{"set", "--global", "ai_budget=2"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("config set --global without init should succeed, exit=%d", code)
}
}
func TestRunConfig_GlobalFlagPositionIndependent(t *testing.T) {
// --global must work whether it precedes or trails the key=value, so a
// trailing flag can't silently write the wrong layer.
t.Setenv(config.GlobalConfigEnv, t.TempDir())
root := newGitRepo(t)
chdir(t, root)
if code := runConfig([]string{"set", "ai_budget=7", "--global"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("trailing --global exit=%d", code)
}
var out bytes.Buffer
if code := runConfig([]string{"get", "ai_budget"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("get exit=%d", code)
}
if got := strings.TrimSpace(out.String()); got != "7" {
t.Errorf("ai_budget = %q, want 7 (trailing --global should have written global)", got)
}
}
func TestRunCockpitTarget_GlobalFlagPositionIndependent(t *testing.T) {
t.Setenv(config.GlobalConfigEnv, t.TempDir())
// Trailing --global must add to the GLOBAL set, not the workspace.
if code := runCockpit([]string{"target", "add", "cursor", "--global"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("cockpit target add --global (trailing) exit=%d", code)
}
var out bytes.Buffer
if code := runCockpit([]string{"target", "--global", "list"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("cockpit target --global list exit=%d", code)
}
if !strings.Contains(out.String(), "cursor") {
t.Errorf("global set missing cursor:\n%s", out.String())
}
}
func TestRunConfig_UnknownKeyRejected(t *testing.T) {
t.Setenv(config.GlobalConfigEnv, t.TempDir())
var errOut bytes.Buffer
if code := runConfig([]string{"set", "--global", "no_such_key=x"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("unknown key exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "unknown config key") {
t.Errorf("missing unknown-key diagnostic: %q", errOut.String())
}
}
func TestRunConfig_UsageErrors(t *testing.T) {
cases := [][]string{
nil, // no subcommand
{"bogus"}, // unknown subcommand
{"get"}, // get needs a key
{"get", "a", "b"}, // too many
{"set"}, // set needs key+value
{"set", "onlyonewordkey"}, // single arg without '='
}
for _, args := range cases {
if code := runConfig(args, &bytes.Buffer{}, &bytes.Buffer{}); code != 2 {
t.Errorf("runConfig(%v) exit=%d, want 2", args, code)
}
}
}
modified cmd/eeco/helpers.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
@@ -51,6 +52,29 @@ func loadRepoConfig(stderr io.Writer, prefix string) (*config.Config, int) {
return cfg, 0
}
// popBoolFlag removes a boolean flag (`--name`, `-name`, or `--name=true|false`)
// from args wherever it appears and reports whether it was set, returning the
// remaining positional args. A subcommand with a single bool flag uses this
// instead of flag.FlagSet so the flag works in ANY position — flag.Parse stops
// at the first positional, which would silently drop a trailing `--global` and
// write the wrong layer.
func popBoolFlag(args []string, name string) (bool, []string) {
present := false
rest := make([]string, 0, len(args))
for _, a := range args {
switch {
case a == "--"+name || a == "-"+name:
present = true
case strings.HasPrefix(a, "--"+name+"=") || strings.HasPrefix(a, "-"+name+"="):
v := a[strings.IndexByte(a, '=')+1:]
present = v == "" || v == "true" || v == "1"
default:
rest = append(rest, a)
}
}
return present, rest
}
// newFlagSet builds a ContinueOnError flag set whose Usage prints usage to
// stderr. ContinueOnError lets the caller map a parse failure to exit 2
// rather than letting flag call os.Exit, and routing output to stderr keeps
added cmd/eeco/import.go
@@ -0,0 +1,255 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
)
// importReport counts what an import carried over, for a one-line summary.
type importReport struct {
ConfigKeys int // config.local keys merged (or -1 when the file was copied verbatim)
Cockpit bool // cockpit.json copied
Workflows int // workflow directories copied
}
// resolveSourceWorkspace loads the eeco config for an external project path and
// confirms it has an initialised workspace to import from. path may be the repo
// root or any directory inside it; config.Load walks up to the root.
func resolveSourceWorkspace(path string) (*config.Config, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("source path %s: %w", path, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("source path %s is not a directory", path)
}
// Canonicalize so the source workspace path matches a canonical cwd
// (os.Getwd resolves symlinks; filepath.Abs does not) — otherwise the
// same repo reached via a symlinked path would slip the self-import guard.
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
src, err := config.Load(abs, "")
if err != nil {
return nil, fmt.Errorf("source %s: %w", path, err)
}
if !config.IsInitialized(src) {
return nil, fmt.Errorf("source %s has no eeco workspace to import from", path)
}
return src, nil
}
// importSettings copies portable settings from an initialised source workspace
// into dst's workspace: config.local (verbatim into a fresh workspace, else a
// key-merge), cockpit.json, and the workflows/ directory. Without force,
// existing dst files/keys are preserved; with force, source values win.
// Project-specific knowledge, state, and bug reports are never copied.
func importSettings(src, dst *config.Config, force bool) (importReport, error) {
var rep importReport
n, err := importConfigLocal(src, dst, force)
if err != nil {
return rep, fmt.Errorf("config.local: %w", err)
}
rep.ConfigKeys = n
copied, err := importCockpit(src, dst, force)
if err != nil {
return rep, fmt.Errorf("cockpit.json: %w", err)
}
rep.Cockpit = copied
w, err := importWorkflows(src, dst, force)
if err != nil {
return rep, fmt.Errorf("workflows: %w", err)
}
rep.Workflows = w
return rep, nil
}
// importConfigLocal carries the source config.local over. Into a fresh
// workspace (no dst file) it copies the file verbatim for full fidelity
// (repeatable keys included) and returns -1; into an existing workspace it
// merges keys (source wins only with force) and returns the count merged.
func importConfigLocal(src, dst *config.Config, force bool) (int, error) {
srcPath := filepath.Join(src.Workspace, config.LocalFilename)
if _, err := os.Stat(srcPath); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
dstPath := filepath.Join(dst.Workspace, config.LocalFilename)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := copyFile(srcPath, dstPath); err != nil {
return 0, err
}
return -1, nil
}
srcKeys, err := config.ParseLocalFile(srcPath)
if err != nil {
return 0, err
}
if !force {
existing, err := config.DeclaredKeys(dstPath)
if err != nil {
return 0, err
}
for k := range srcKeys {
if existing[k] {
delete(srcKeys, k)
}
}
}
if len(srcKeys) == 0 {
return 0, nil
}
if err := config.WriteLocalKeys(dst, srcKeys); err != nil {
return 0, err
}
return len(srcKeys), nil
}
// importCockpit copies the source cockpit.json selection into dst when dst has
// none (or force). It never deletes dst's own selection without force.
func importCockpit(src, dst *config.Config, force bool) (bool, error) {
srcPath := cockpit.SelectionPath(src)
if _, err := os.Stat(srcPath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
dstPath := cockpit.SelectionPath(dst)
if _, err := os.Stat(dstPath); err == nil && !force {
return false, nil
}
if err := copyFile(srcPath, dstPath); err != nil {
return false, err
}
return true, nil
}
// importWorkflows copies each source workflow directory that dst lacks (or all,
// with force). Existing dst workflows are preserved without force.
func importWorkflows(src, dst *config.Config, force bool) (int, error) {
srcDir := filepath.Join(src.Workspace, "workflows")
entries, err := os.ReadDir(srcDir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
dstDir := filepath.Join(dst.Workspace, "workflows")
count := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
from := filepath.Join(srcDir, e.Name())
to := filepath.Join(dstDir, e.Name())
_, statErr := os.Stat(to)
existed := statErr == nil
if existed && !force {
continue
}
if err := copyTree(from, to); err != nil {
// Don't leave a freshly-created, half-copied directory: a
// re-import would skip it (the existing-dir check) and never
// finish. A pre-existing dir is left as-is.
if !existed {
os.RemoveAll(to)
}
return count, err
}
count++
}
return count, nil
}
// printImportReport writes a one-line-per-item summary of an import.
func printImportReport(w io.Writer, srcPath string, rep importReport) {
fmt.Fprintf(w, "eeco: imported settings from %s\n", srcPath)
switch {
case rep.ConfigKeys < 0:
fmt.Fprintln(w, " config.local: copied")
case rep.ConfigKeys > 0:
fmt.Fprintf(w, " config.local: %d key(s) merged\n", rep.ConfigKeys)
default:
fmt.Fprintln(w, " config.local: nothing to add")
}
if rep.Cockpit {
fmt.Fprintln(w, " cockpit.json: copied")
}
if rep.Workflows > 0 {
fmt.Fprintf(w, " workflows: %d copied\n", rep.Workflows)
}
}
// copyFile copies a regular file's contents and mode from src to dst,
// truncating dst if it exists. The parent directory must already exist.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// copyTree recursively copies the directory at src to dst, preserving file
// modes (so an executable workflow `run` stays executable).
func copyTree(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, info.Mode().Perm()|0o700); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
from := filepath.Join(src, e.Name())
to := filepath.Join(dst, e.Name())
if e.IsDir() {
if err := copyTree(from, to); err != nil {
return err
}
continue
}
if err := copyFile(from, to); err != nil {
return err
}
}
return nil
}
added cmd/eeco/import_test.go
@@ -0,0 +1,160 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// buildSourceProject stands up an initialised eeco project with a couple of
// tuned config keys, a non-default cockpit target, and one scaffolded
// workflow, then returns its repo root. It leaves the cwd inside the project.
func buildSourceProject(t *testing.T) string {
t.Helper()
root := setupInited(t)
if code := runConfig([]string{"set", "automation=auto"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("src set automation exit=%d", code)
}
if code := runConfig([]string{"set", "ai_budget=9"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("src set ai_budget exit=%d", code)
}
if code := runCockpit([]string{"target", "add", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("src cockpit add exit=%d", code)
}
if code := runNew([]string{"myflow"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("src new workflow exit=%d", code)
}
return root
}
func wsFile(root, name string) string {
return filepath.Join(root, "tester", ".eeco", name)
}
func TestConfigImport_IntoExistingWorkspace(t *testing.T) {
src := buildSourceProject(t)
dst := setupInited(t) // cwd now dst
var out bytes.Buffer
if code := runConfig([]string{"import", src}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("config import exit=%d", code)
}
// Keys carried over.
var got bytes.Buffer
runConfig([]string{"get", "ai_budget"}, &got, &bytes.Buffer{})
if v := strings.TrimSpace(got.String()); v != "9" {
t.Errorf("ai_budget = %q, want 9 after import", v)
}
// Workflow copied.
if _, err := os.Stat(filepath.Join(dst, "tester", ".eeco", "workflows", "myflow")); err != nil {
t.Errorf("workflow myflow not imported: %v", err)
}
// dst kept its own cockpit.json (no --force) — cursor not pulled in.
b, _ := os.ReadFile(wsFile(dst, "cockpit.json"))
if strings.Contains(string(b), "cursor") {
t.Errorf("import without --force overwrote dst cockpit.json:\n%s", b)
}
}
func TestConfigImport_NoForcePreservesExistingKey(t *testing.T) {
src := buildSourceProject(t) // automation=auto
setupInited(t)
if code := runConfig([]string{"set", "automation=manual"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("dst set exit=%d", code)
}
if code := runConfig([]string{"import", src}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("import exit=%d", code)
}
var got bytes.Buffer
runConfig([]string{"get", "automation"}, &got, &bytes.Buffer{})
if v := strings.TrimSpace(got.String()); v != "manual" {
t.Errorf("automation = %q, want manual (no --force must preserve dst)", v)
}
}
func TestConfigImport_ForceOverwrites(t *testing.T) {
src := buildSourceProject(t) // automation=auto, cockpit cursor
dst := setupInited(t)
if code := runConfig([]string{"set", "automation=manual"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("dst set exit=%d", code)
}
if code := runConfig([]string{"import", "--force", src}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("import --force exit=%d", code)
}
var got bytes.Buffer
runConfig([]string{"get", "automation"}, &got, &bytes.Buffer{})
if v := strings.TrimSpace(got.String()); v != "auto" {
t.Errorf("automation = %q, want auto (--force lets source win)", v)
}
if b, _ := os.ReadFile(wsFile(dst, "cockpit.json")); !strings.Contains(string(b), "cursor") {
t.Errorf("--force should have overwritten dst cockpit.json with source:\n%s", b)
}
}
func TestConfigImport_SelfGuard(t *testing.T) {
src := buildSourceProject(t) // cwd inside src
var errOut bytes.Buffer
if code := runConfig([]string{"import", src}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("self-import exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "same workspace") {
t.Errorf("missing self-import diagnostic: %q", errOut.String())
}
}
func TestConfigImport_BadAndUninitedSource(t *testing.T) {
setupInited(t)
// Nonexistent path.
if code := runConfig([]string{"import", filepath.Join(t.TempDir(), "nope")}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Errorf("import of nonexistent path exit=%d, want 1", code)
}
// A real repo with no eeco workspace.
bare := newGitRepo(t)
if code := runConfig([]string{"import", bare}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Errorf("import of uninited repo exit=%d, want 1", code)
}
}
func TestInitFrom_CopiesSettings(t *testing.T) {
src := buildSourceProject(t)
dst := newGitRepo(t)
chdir(t, dst)
writeFile(t, dst, "go.mod", "module dst\n\ngo 1.24\n")
var out bytes.Buffer
if code := runInit([]string{"--from", src}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("init --from exit=%d", code)
}
// config.local copied verbatim (fresh workspace).
b, err := os.ReadFile(wsFile(dst, "config.local"))
if err != nil || !strings.Contains(string(b), "ai_budget=9") {
t.Errorf("config.local not imported verbatim: %q err=%v", b, err)
}
// cockpit.json inherited (cursor) and the prompt was suppressed.
if cb, _ := os.ReadFile(wsFile(dst, "cockpit.json")); !strings.Contains(string(cb), "cursor") {
t.Errorf("cockpit.json not imported: %s", cb)
}
// workflow copied.
if _, err := os.Stat(filepath.Join(dst, "tester", ".eeco", "workflows", "myflow")); err != nil {
t.Errorf("workflow not imported: %v", err)
}
}
func TestInitFrom_BadSourceFailsBeforeScaffold(t *testing.T) {
dst := newGitRepo(t)
chdir(t, dst)
writeFile(t, dst, "go.mod", "module dst\n\ngo 1.24\n")
if code := runInit([]string{"--from", filepath.Join(t.TempDir(), "nope")}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Fatalf("init --from bad path exit=%d, want 1", code)
}
// Nothing should have been scaffolded.
if _, err := os.Stat(filepath.Join(dst, "tester", ".eeco")); err == nil {
t.Error("init --from with a bad source scaffolded a workspace anyway")
}
}
modified cmd/eeco/init.go
@@ -39,12 +39,13 @@ func runInit(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: eeco init [--workspace NAME] [--type CATEGORY] [--username NAME] [--ai] [--no-commit] [--no-push] [--no-track]")
fmt.Fprintln(stderr, "usage: eeco init [--workspace NAME] [--type CATEGORY] [--username NAME] [--from PATH] [--ai] [--no-commit] [--no-push] [--no-track]")
fs.PrintDefaults()
}
workspace := fs.String("workspace", config.DefaultWorkspace, "engine workspace directory name")
typeFlag := fs.String("type", "", "force the project-type category (skips detection)")
username := fs.String("username", "", "workspace owner directory name (defaults to git user.name)")
fromFlag := fs.String("from", "", "import settings (config.local, cockpit.json, workflows) from another eeco project at PATH")
aiFlag := fs.Bool("ai", false, "allow a gated AI pass to classify an ambiguous project")
noCommit := fs.Bool("no-commit", false, "do not auto-commit the .gitignore change")
noPush := fs.Bool("no-push", false, "do not auto-push after the init commit")
@@ -91,6 +92,22 @@ func runInit(args []string, stdout, stderr io.Writer) int {
return 1
}
// Validate --from early (read-only) so a bad source path fails before any
// workspace is scaffolded; the copy itself runs after Init below.
var importSrc *config.Config
if *fromFlag != "" {
src, err := resolveSourceWorkspace(*fromFlag)
if err != nil {
fmt.Fprintln(stderr, "eeco init: --from:", err)
return 1
}
if src.Workspace == cfg.Workspace {
fmt.Fprintln(stderr, "eeco init: --from points at this same project")
return 1
}
importSrc = src
}
// Migration awareness: a workspace at the legacy repo-root location
// (<repo>/.eeco) is the old layout. eeco homes the workspace under
// <repo>/<username>/. Scaffolding a second workspace alongside the old
@@ -136,6 +153,18 @@ func runInit(args []string, stdout, stderr io.Writer) int {
return 1
}
// --from: copy portable settings from the source project. Run before the
// cockpit prompt (an imported cockpit.json suppresses it) and before the
// history snapshot (imported files land in it). Additive — a copy failure
// warns but never fails an init that already succeeded.
if importSrc != nil {
if irep, err := importSettings(importSrc, cfg, false); err != nil {
fmt.Fprintln(stderr, "eeco init: --from:", err)
} else {
printImportReport(stdout, *fromFlag, irep)
}
}
// First-run cockpit target selection (C2): record which harness(es) the
// operator uses so `eeco cockpit generate` knows what to emit. Done before
// the private workspace-history repo is initialised below, so the
modified cmd/eeco/init_test.go
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// TestMain pins the workspace owner so config.Load resolves a
@@ -32,7 +34,17 @@ func TestMain(m *testing.M) {
// nested private git repo. The dedicated workspace-history tests flip
// this back on via setTrackHistory.
initTrackHistory = false
os.Exit(m.Run())
// Pin the user-global config dir to an empty temp dir so the global
// config layer is a hermetic no-op and command tests never read the
// dev box's ~/.config/eeco.
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(config.GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
// setTrackHistory flips the private-repo init seam on/off and returns a
modified cmd/eeco/main.go
@@ -32,6 +32,7 @@ usage:
eeco queue print the workspace queue (resolve by editing it)
eeco stats print cumulative AI usage from the call ledger
eeco hooks show or toggle opt-in reversible hooks
eeco config show/set config; set --global to share across projects (list|get|set)
eeco cockpit generate emit the active AI cockpit as harness config (reversible)
eeco cockpit verify check the emitted cockpit artifacts match + are safe
eeco cockpit off remove eeco's emitted cockpit artifacts (reversible)
@@ -111,6 +112,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return runStats(args[1:], stdout, stderr)
case "hooks":
return runHooks(args[1:], stdout, stderr)
case "config":
return runConfig(args[1:], stdout, stderr)
case "authorize":
return runAuthorize(args[1:], stdout, stderr)
case "gates":
modified docs/ARCHITECTURE.md
@@ -130,8 +130,11 @@ guards, so the host tree is never touched and the kept tree is unchanged.
1. `cmd/eeco/main.go` parses the subcommand and flags (stdlib `flag`).
2. `internal/config` walks upward to the repo root, detects the profile
(`go`, `python`, etc.), and loads `<workspace>/config.local` if the
workspace exists.
(`go`, `python`, etc.), then applies configuration in three layers,
each overriding the previous: built-in defaults, the user-global
`config.local` (under `EECO_CONFIG_HOME` / `$XDG_CONFIG_HOME/eeco` /
`~/.config/eeco`), and the workspace `<workspace>/config.local` if the
workspace exists. A single `applyConfigFile` parses each file layer.
3. `cmd/eeco/run.go` constructs an `Env` (repo root, workspace path,
profile, automation level, queue handle, memory handle, optional
`Gate`) and looks up the named workflow in the registry.
modified docs/COCKPIT.md
@@ -91,6 +91,22 @@ eeco cockpit target rm <target> # deselect a target (does NOT delete emitted
`eeco cockpit off --target <t>` to remove them (reversibility stays an explicit,
consented step).
### A cross-project default set
Add `--global` to any `eeco cockpit target` verb to manage a user-global target
set, stored as `cockpit.json` under the user-global config directory
(`$EECO_CONFIG_HOME`, else `$XDG_CONFIG_HOME/eeco`, else `~/.config/eeco`):
```
eeco cockpit target --global add cursor # every project inherits this set
eeco cockpit target --global list # show the global set
```
A project with no `cockpit.json` of its own inherits the global set; a workspace
selection always wins over the global one, which in turn wins over the built-in
default (Claude alone). This mirrors the three-layer `config.local` resolution
(see [`USAGE.md`](USAGE.md) §4a).
## Generating, verifying, removing
```
modified docs/PUBLIC_API.md
@@ -62,6 +62,16 @@ subcommands are additive and may land in any minor release. Additional
`eeco history …` subcommands (e.g. `compact`) are likewise additive and
may land in any minor release.
The `eeco config` verb group (`list`, `get`, `set`, `import`, the
`--global` flag on `set`, and the `--force` flag on `import`) is covered
by the [`USAGE.md`](USAGE.md) §4 command list. Its existence is frozen;
its exact output wording is a human-readable, evolving readout and is not
frozen. The `--global` flag on `eeco cockpit target` and the `--from
<path>` flag on `eeco init` are likewise additive and covered by §4. What
`--from`/`import` copies (config.local, cockpit.json, workflows — never
knowledge, state, or bug reports) is the frozen behaviour; the merge
wording is not.
The `eeco go --json` output is a JSON object whose **top-level keys**
are frozen: `project`, `profile`, `gate`, `top_level`, `initialized`,
`workflows`, `where_to_look`, `knowledge`, `open_decisions`. Their
@@ -117,6 +127,18 @@ Keys recognised in `<workspace>/config.local`: `profile`, `gate`,
`post_merge_workflows`, `workspace_history`.
Unknown keys are tolerated and preserved for forward compatibility.
Config resolves in three layers, each overriding the previous: built-in
defaults → the **user-global** file → the **workspace** `config.local`.
The global file is `config.local` under the user-global config directory,
resolved as `$EECO_CONFIG_HOME`, else `$XDG_CONFIG_HOME/eeco`, else
`$HOME/.config/eeco`. The global layer holds the same keys as a workspace
`config.local`; a project inherits it unless its own `config.local`
overrides the key. `eeco config set --global …` and
`eeco cockpit target --global …` are the only commands that write outside
a repository, and they write only into that global directory — never the
tracked tree. The three-layer resolution order is a frozen behaviour; the
global directory locations above are part of the contract.
The `workspace_history` key selects whether `eeco init` stands up a
private, local git repository inside the gitignored workspace directory
to version eeco's own knowledge layer, and how often it commits:
modified docs/USAGE.md
@@ -250,7 +250,7 @@ does not advance on a parked turn). Commands never require AI.
```
eeco open the control center (digest if non-TTY)
eeco init [--no-track] bootstrap the ecosystem in this repo (--no-track skips the private workspace-history repo)
eeco init [--no-track] [--from <path>] bootstrap the ecosystem in this repo (--from imports settings from another eeco project; --no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
@@ -261,6 +261,11 @@ eeco stats print cumulative AI usage from the call ledger (real to
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco config list show every config key, its effective value, and origin (default | global | local)
eeco config get <key> print the effective value of one config key
eeco config set [--global] <key> <value> set a key in this workspace, or (--global) in the cross-project layer
eeco config import [--force] <path> copy config.local, cockpit.json, and workflows from another eeco project
eeco cockpit target [--global] list|add <t>|rm <t> manage the active (or cross-project) harness target set
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
@@ -292,6 +297,74 @@ eeco help command reference
`1` finding/failure · `2` blocked (a required tool is missing) ·
`3` AI pass deferred (no `--ai`).
## 4a. Configuration — `eeco config`
Configuration resolves in **three layers**, each overriding the one
before it:
```
built-in defaults
→ user-global ~/.config/eeco/config.local (shared by every project)
→ workspace <repo>/<user>/.eeco/config.local (this project only)
```
Inspect and edit the two file layers with `eeco config`:
```
eeco config list # every key, effective value, and origin
eeco config get automation # one effective value, bare (for scripts)
eeco config set automation=manual # write the workspace layer (this project)
eeco config set --global automation=auto # write the cross-project layer
```
`set` accepts `key=value` or `key value`. A value is validated against the
same rules `config.local` uses, so a typo'd key or a malformed number is
rejected before anything is written. (Floor-invariant keys such as
`automation` tolerate any value and normalize at load, exactly as they do
in a hand-edited `config.local`.)
### Sharing settings across projects — the global layer
Set a key **once** with `--global` and every project inherits it, unless
that project overrides it in its own workspace `config.local`. This is the
git `--global` model: machine-wide defaults with per-repo overrides. The
global file lives at `~/.config/eeco/config.local` — or
`$XDG_CONFIG_HOME/eeco/config.local`, or wherever `EECO_CONFIG_HOME`
points (the override also makes the layer hermetic under tests).
`eeco config set --global …` is the one eeco command that writes outside a
repository, by design; everything else stays inside the project. It does
not need an initialised workspace, or even to be inside a repo.
Cockpit targets share the same model:
```
eeco cockpit target --global add cursor # new projects inherit this target set
eeco cockpit target list # falls back to the global set when this
# project has no cockpit.json of its own
```
### Copying settings from one specific project — `--from` / `import`
Where the global layer is a *live* shared default, `--from` is a *one-shot
copy* from one named project into another. It carries three things —
`config.local`, the cockpit selection (`cockpit.json`), and the scaffolded
`workflows/` — and nothing else (project-specific knowledge, state, and bug
reports never travel).
```
eeco init --from ~/other-project # bootstrap a new repo, then copy from another
eeco config import ~/other-project # copy into an already-initialised project
eeco config import --force ~/other-project # let the source win over existing files/keys
```
The source path may be the other repo's root or any directory inside it. Into a
**fresh** workspace the `config.local` is copied verbatim (full fidelity); into
an **existing** one it is key-merged. Without `--force`, files and keys the
destination already has are preserved; `--force` lets the source win. Importing
from a project of a different type may pull in type-specific keys (`profile`,
`gate`) — `eeco config set` fixes any you don't want.
## 5. Builtin workflows
| Workflow | Inspects | Writes | AI |
modified internal/ask/ask_test.go
@@ -12,6 +12,20 @@ import (
"github.com/ajhahnde/eeco/internal/memory"
)
// TestMain pins the user-global config dir to an empty temp dir so the
// global config layer is a hermetic no-op and these tests never read the
// dev box's ~/.config/eeco.
func TestMain(m *testing.M) {
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(config.GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
// fixture builds a throwaway repo (a bare .git so the directory-walk
// fallback engages) with a few source files carrying known terms, and
// returns a loaded config rooted at it.
modified internal/brief/brief_test.go
@@ -25,7 +25,14 @@ var updateGolden = flag.Bool("update", false, "rewrite golden files under testda
// <root>/tester/.eeco, which the golden fixtures encode.
func TestMain(m *testing.M) {
os.Setenv("EECO_USERNAME", "tester")
os.Exit(m.Run())
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(config.GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
func TestRender_Golden(t *testing.T) {
added internal/cockpit/global_selection_test.go
@@ -0,0 +1,85 @@
package cockpit
import (
"os"
"reflect"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// TestMain pins the user-global config dir to an empty temp dir so the global
// cockpit-selection fallback is a hermetic no-op and these tests never read the
// dev box's ~/.config/eeco. Tests that exercise the fallback override via
// t.Setenv(config.GlobalConfigEnv, ...).
func TestMain(m *testing.M) {
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(config.GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
func TestLoadSelection_GlobalFallback(t *testing.T) {
cfg := testConfig(t) // fresh workspace, no cockpit.json
gdir := t.TempDir()
t.Setenv(config.GlobalConfigEnv, gdir)
if err := SaveGlobalSelection(Selection{Targets: []string{"claude", "cursor"}}); err != nil {
t.Fatal(err)
}
got := LoadSelection(cfg)
if !reflect.DeepEqual(got.Targets, []string{"claude", "cursor"}) {
t.Errorf("LoadSelection targets = %v, want inherited [claude cursor]", got.Targets)
}
}
func TestLoadSelection_WorkspaceWinsOverGlobal(t *testing.T) {
cfg := testConfig(t)
gdir := t.TempDir()
t.Setenv(config.GlobalConfigEnv, gdir)
if err := SaveGlobalSelection(Selection{Targets: []string{"cursor", "gemini"}}); err != nil {
t.Fatal(err)
}
if err := SaveSelection(cfg, Selection{Targets: []string{"claude", "agents"}}); err != nil {
t.Fatal(err)
}
got := LoadSelection(cfg)
if !reflect.DeepEqual(got.Targets, []string{"claude", "agents"}) {
t.Errorf("LoadSelection targets = %v, want workspace [claude agents]", got.Targets)
}
}
func TestLoadSelection_DefaultWhenNeitherSet(t *testing.T) {
cfg := testConfig(t)
gdir := t.TempDir() // empty: no global cockpit.json
t.Setenv(config.GlobalConfigEnv, gdir)
got := LoadSelection(cfg)
if !reflect.DeepEqual(got.Targets, []string{"claude"}) {
t.Errorf("LoadSelection targets = %v, want default [claude]", got.Targets)
}
}
func TestGlobalSelection_SaveLoadRoundTripAndSanitize(t *testing.T) {
gdir := t.TempDir()
t.Setenv(config.GlobalConfigEnv, gdir)
// Duplicates, unknowns, and blanks dropped; order preserved.
if err := SaveGlobalSelection(Selection{Targets: []string{"cursor", "cursor", "bogus", "", "claude"}}); err != nil {
t.Fatal(err)
}
got := LoadGlobalSelection()
if !reflect.DeepEqual(got.Targets, []string{"cursor", "claude"}) {
t.Errorf("global round-trip targets = %v, want [cursor claude]", got.Targets)
}
}
func TestLoadGlobalSelection_DefaultWhenMissing(t *testing.T) {
gdir := t.TempDir()
t.Setenv(config.GlobalConfigEnv, gdir)
got := LoadGlobalSelection()
if !reflect.DeepEqual(got.Targets, []string{"claude"}) {
t.Errorf("LoadGlobalSelection targets = %v, want default [claude]", got.Targets)
}
}
modified internal/cockpit/selection.go
@@ -52,24 +52,87 @@ func DefaultSelection() Selection {
return Selection{Targets: []string{"claude"}}
}
// LoadSelection reads the active target set. A missing, empty, or corrupt
// file — or one whose targets are all unknown — degrades to DefaultSelection
// globalSelectionPath is the user-global cockpit selection, parallel to the
// global config.local layer: a project with no workspace selection inherits
// these targets. Empty when no global config dir resolves.
func globalSelectionPath() string {
dir := config.GlobalConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, selectionName)
}
// GlobalSelectionPath is the exported user-global cockpit selection path
// (or "" when no global config dir resolves), for command messaging.
func GlobalSelectionPath() string {
return globalSelectionPath()
}
// LoadGlobalSelection reads the user-global cockpit selection, degrading to
// DefaultSelection when it is absent, empty, corrupt, or all-unknown.
func LoadGlobalSelection() Selection {
if s, ok := loadSelectionFile(globalSelectionPath()); ok {
return s
}
return DefaultSelection()
}
// SaveGlobalSelection writes the user-global cockpit selection, creating the
// global config dir if absent. Same sanitize semantics as SaveSelection.
func SaveGlobalSelection(s Selection) error {
path := globalSelectionPath()
if path == "" {
return fmt.Errorf("cannot resolve a global config directory (set EECO_CONFIG_HOME or HOME)")
}
s.Targets = sanitizeTargets(s.Targets)
if len(s.Targets) == 0 {
s.Targets = DefaultSelection().Targets
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("selection dir: %w", err)
}
out, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(out, '\n'), 0o644)
}
// LoadSelection reads the active target set, resolving in layers: workspace
// cockpit.json → user-global cockpit.json → DefaultSelection. A missing,
// empty, corrupt, or all-unknown file at one layer falls through to the next
// rather than wedging the tool (mirrors loadLedger). Unknown target names are
// dropped so a stale entry from a newer binary can't break an older one.
func LoadSelection(cfg *config.Config) Selection {
b, err := os.ReadFile(selectionPath(cfg))
if s, ok := loadSelectionFile(selectionPath(cfg)); ok {
return s
}
if s, ok := loadSelectionFile(globalSelectionPath()); ok {
return s
}
return DefaultSelection()
}
// loadSelectionFile parses a cockpit.json at path. ok is false when the file
// is absent, empty, unparseable, or declares no known targets.
func loadSelectionFile(path string) (Selection, bool) {
if path == "" {
return Selection{}, false
}
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 {
return DefaultSelection()
return Selection{}, false
}
var s Selection
if err := json.Unmarshal(b, &s); err != nil {
return DefaultSelection()
return Selection{}, false
}
s.Targets = sanitizeTargets(s.Targets)
if len(s.Targets) == 0 {
return DefaultSelection()
return Selection{}, false
}
return s
return s, true
}
// SaveSelection writes the active target set under the workspace dir, creating
modified internal/config/config.go
@@ -692,12 +692,51 @@ func Load(cwd, workspaceName string) (*Config, error) {
SessionSettingsPath: os.Getenv("EECO_SESSION_SETTINGS"),
}
cfg.Gate = GateFor(cfg.Profile)
// Three-layer resolution: defaults (set above) → user-global
// config → workspace config.local. Each later layer overrides the
// earlier ones.
if err := applyConfigFile(cfg, globalConfigLocalPath()); err != nil {
return nil, fmt.Errorf("read global config: %w", err)
}
if err := applyLocal(cfg); err != nil {
return nil, fmt.Errorf("read config.local: %w", err)
}
return cfg, nil
}
// GlobalConfigEnv overrides the directory eeco reads user-global
// settings from. It takes precedence over XDG_CONFIG_HOME and the
// ~/.config default, and is the hermetic test seam (mirrors
// UsernameEnv) plus a power-user escape hatch.
const GlobalConfigEnv = "EECO_CONFIG_HOME"
// GlobalConfigDir resolves the user-global eeco config directory:
// $EECO_CONFIG_HOME, else $XDG_CONFIG_HOME/eeco, else $HOME/.config/eeco.
// It returns "" only when none can be resolved (no env, no HOME), in
// which case the global layer is simply skipped.
func GlobalConfigDir() string {
if dir := os.Getenv(GlobalConfigEnv); dir != "" {
return dir
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "eeco")
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".config", "eeco")
}
return ""
}
// globalConfigLocalPath is the user-global config.local file, or "" when
// no global config dir can be resolved.
func globalConfigLocalPath() string {
dir := GlobalConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, LocalFilename)
}
// validateWorkspaceName rejects names that would escape the repo root
// or otherwise misbehave as a relative path component.
func validateWorkspaceName(name string) error {
@@ -716,13 +755,10 @@ func validateWorkspaceName(name string) error {
return nil
}
// applyLocal reads <workspace>/config.local and overrides Profile and
// Gate when matching keys are present. The file is optional. Format is
// a flat KEY=VALUE list, one entry per line; blank lines and lines
// starting with `#` are ignored. Values may be wrapped in matching
// single or double quotes. Multi-word `gate` is split on whitespace
// into one chain step; the `gate` key is repeatable, each occurrence
// adding a step.
// applyLocal applies <workspace>/config.local over cfg when the
// workspace exists. It is the workspace (last-wins) layer of the
// three-layer resolution defaults → global → workspace; see Load and
// applyConfigFile.
func applyLocal(cfg *Config) error {
info, err := os.Stat(cfg.Workspace)
if err != nil {
@@ -736,7 +772,24 @@ func applyLocal(cfg *Config) error {
// surface that; don't fail config loading here.
return nil
}
path := filepath.Join(cfg.Workspace, "config.local")
return applyConfigFile(cfg, filepath.Join(cfg.Workspace, "config.local"))
}
// applyConfigFile reads a single config.local-format file at path and
// overrides cfg with the keys it declares. The file is optional — a
// missing file (or an empty path) is a no-op. Format is a flat
// KEY=VALUE list, one entry per line; blank lines and lines starting
// with `#` are ignored. Values may be wrapped in matching single or
// double quotes. Multi-word `gate` is split on whitespace into one
// chain step; the `gate` key is repeatable, each occurrence adding a
// step. Repeatable keys (gate, pre_commit_workflows,
// post_merge_workflows) reset their inherited value on their first
// occurrence in THIS file, so a later layer fully replaces an earlier
// one for that key.
func applyConfigFile(cfg *Config, path string) error {
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
modified internal/config/config_test.go
@@ -14,7 +14,18 @@ import (
// workspace under <root>/tester/.eeco.
func TestMain(m *testing.M) {
os.Setenv("EECO_USERNAME", "tester")
os.Exit(m.Run())
// Pin the user-global config dir to an empty temp dir so the global
// layer is a hermetic no-op and tests never read the dev box's
// ~/.config/eeco. Tests that exercise the global layer override via
// t.Setenv(GlobalConfigEnv, ...).
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
func TestFindRepoRoot_WalksUpToDotGit(t *testing.T) {
added internal/config/global_test.go
@@ -0,0 +1,202 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGlobalConfigDir_EnvPrecedence(t *testing.T) {
// EECO_CONFIG_HOME wins outright.
t.Setenv(GlobalConfigEnv, "/explicit/dir")
t.Setenv("XDG_CONFIG_HOME", "/xdg")
if got := GlobalConfigDir(); got != "/explicit/dir" {
t.Fatalf("GlobalConfigDir() = %q, want /explicit/dir", got)
}
// With EECO_CONFIG_HOME empty, XDG_CONFIG_HOME/eeco is used.
t.Setenv(GlobalConfigEnv, "")
t.Setenv("XDG_CONFIG_HOME", "/xdg")
if got, want := GlobalConfigDir(), filepath.Join("/xdg", "eeco"); got != want {
t.Fatalf("GlobalConfigDir() = %q, want %q", got, want)
}
// With neither, fall back to $HOME/.config/eeco.
t.Setenv(GlobalConfigEnv, "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", "/home/tester")
if got, want := GlobalConfigDir(), filepath.Join("/home/tester", ".config", "eeco"); got != want {
t.Fatalf("GlobalConfigDir() = %q, want %q", got, want)
}
}
func TestLoad_ThreeLayerPrecedence(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
gdir := t.TempDir()
t.Setenv(GlobalConfigEnv, gdir)
write(t, gdir, LocalFilename, strings.Join([]string{
"automation=auto",
"ai_budget=5",
"stale_days=99",
}, "\n"))
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
// Local overrides one global key; the others fall through from global.
write(t, wsDir, LocalFilename, strings.Join([]string{
"automation=manual",
"context_path=ctx.md",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Automation != AutomationManual {
t.Errorf("automation = %q, want manual (local wins)", cfg.Automation)
}
if cfg.AIBudget != 5 {
t.Errorf("ai_budget = %d, want 5 (from global)", cfg.AIBudget)
}
if cfg.StaleDays != 99 {
t.Errorf("stale_days = %d, want 99 (from global)", cfg.StaleDays)
}
if cfg.ContextPath != "ctx.md" {
t.Errorf("context_path = %q, want ctx.md (local only)", cfg.ContextPath)
}
if cfg.BugReportDir != DefaultBugReportDir {
t.Errorf("bug_report_dir = %q, want default %q", cfg.BugReportDir, DefaultBugReportDir)
}
}
func TestLoad_GlobalAppliesWithoutWorkspace(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
gdir := t.TempDir()
t.Setenv(GlobalConfigEnv, gdir)
write(t, gdir, LocalFilename, "automation=scaffold\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Automation != AutomationScaffold {
t.Errorf("automation = %q, want scaffold (global applies pre-init)", cfg.Automation)
}
}
func TestKnownKeysAndEffectiveValue(t *testing.T) {
if !KnownKey("automation") || KnownKey("definitely_not_a_key") {
t.Fatal("KnownKey mis-classified a key")
}
if len(KnownKeys()) < 20 {
t.Fatalf("KnownKeys() returned %d keys, expected the full set", len(KnownKeys()))
}
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if v, ok := EffectiveValue(cfg, "automation"); !ok || v != string(DefaultAutomation) {
t.Errorf("EffectiveValue(automation) = %q,%v, want %q,true", v, ok, DefaultAutomation)
}
if _, ok := EffectiveValue(cfg, "nope"); ok {
t.Error("EffectiveValue returned ok for an unknown key")
}
}
func TestValidateSetValue(t *testing.T) {
cases := []struct {
key, val string
wantErr bool
}{
{"automation", "auto", false},
{"ai_budget", "3", false},
{"context_path", "x.md", false},
// Floor-invariant enum keys tolerate any value (normalized at load),
// so set mirrors load and does NOT reject them.
{"automation", "bogus", false},
// Strictly-typed keys reject malformed values at parse time.
{"ai_budget", "notanint", true},
{"stale_days", "notanint", true},
{"init_detection_threshold", "2", true}, // out of [0,1]
{"no_such_key", "x", true}, // unknown key
}
for _, c := range cases {
err := ValidateSetValue(c.key, c.val)
if (err != nil) != c.wantErr {
t.Errorf("ValidateSetValue(%q,%q) err=%v, wantErr=%v", c.key, c.val, err, c.wantErr)
}
}
}
func TestWriteGlobalKeys_UpsertsAndCreatesDir(t *testing.T) {
gdir := filepath.Join(t.TempDir(), "nested", "eeco") // does not exist yet
t.Setenv(GlobalConfigEnv, gdir)
if err := WriteGlobalKeys(map[string]string{"automation": "auto"}); err != nil {
t.Fatal(err)
}
if err := WriteGlobalKeys(map[string]string{"ai_budget": "3"}); err != nil {
t.Fatal(err)
}
// Overwrite an existing key in place.
if err := WriteGlobalKeys(map[string]string{"automation": "manual"}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(gdir, LocalFilename))
if err != nil {
t.Fatal(err)
}
got := string(b)
if !strings.Contains(got, "automation=manual") {
t.Errorf("global file missing automation=manual:\n%s", got)
}
if !strings.Contains(got, "ai_budget=3") {
t.Errorf("global file missing ai_budget=3:\n%s", got)
}
if strings.Contains(got, "automation=auto") {
t.Errorf("global file kept stale automation=auto:\n%s", got)
}
// And the value resolves through Load.
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Automation != AutomationManual {
t.Errorf("automation = %q, want manual after WriteGlobalKeys", cfg.Automation)
}
}
func TestDeclaredKeys(t *testing.T) {
if keys, err := DeclaredKeys(""); err != nil || len(keys) != 0 {
t.Fatalf("DeclaredKeys(empty) = %v,%v, want empty,nil", keys, err)
}
dir := t.TempDir()
path := filepath.Join(dir, LocalFilename)
if err := os.WriteFile(path, []byte("# c\n\nautomation=auto\nai_budget=2\nunknown_key=x\n"), 0o644); err != nil {
t.Fatal(err)
}
keys, err := DeclaredKeys(path)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"automation", "ai_budget", "unknown_key"} {
if !keys[want] {
t.Errorf("DeclaredKeys missing %q", want)
}
}
if keys["nonexistent"] {
t.Error("DeclaredKeys reported a key not in the file")
}
}
added internal/config/keys.go
@@ -0,0 +1,165 @@
package config
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
)
// keySpecs maps every settable config.local key to a getter that renders
// its effective value off a resolved *Config. It is the canonical list
// of operator-settable keys, powering `eeco config list|get|set`.
//
// Keep this map in sync with the parse switch in applyConfigFile
// (config.go): a key parsed there should appear here so it is settable
// and inspectable, and vice versa.
var keySpecs = map[string]func(*Config) string{
"profile": func(c *Config) string { return string(c.Profile) },
"gate": func(c *Config) string { return strings.Join(GateSteps(c.Gate), " && ") },
"stale_days": func(c *Config) string { return strconv.Itoa(c.StaleDays) },
"automation": func(c *Config) string { return string(c.Automation) },
"workspace_history": func(c *Config) string { return string(c.WorkspaceHistory) },
"ai_command": func(c *Config) string { return strings.Join(c.AICommand, " ") },
"ai_budget": func(c *Config) string { return strconv.Itoa(c.AIBudget) },
"ai_provider": func(c *Config) string { return c.AIProvider },
"ai_model": func(c *Config) string { return c.AIModel },
"ai_api_key_env": func(c *Config) string { return c.AIAPIKeyEnv },
"session_settings_path": func(c *Config) string { return c.SessionSettingsPath },
"session_start_docs": func(c *Config) string { return strings.Join(c.SessionStartDocs, " ") },
"session_files": func(c *Config) string { return strings.Join(c.SessionFiles, " ") },
"session_start_mailbox": func(c *Config) string { return c.SessionStartMailbox },
"session_start_roadmap_glob": func(c *Config) string { return c.SessionStartRoadmapGlob },
"session_start_pinned_bodies": func(c *Config) string { return strconv.FormatBool(c.SessionStartPinnedBodies) },
"handover_glob": func(c *Config) string { return c.HandoverGlob },
"bug_report_dir": func(c *Config) string { return c.BugReportDir },
"context_path": func(c *Config) string { return c.ContextPath },
"context_budget": func(c *Config) string { return strconv.Itoa(c.ContextBudget) },
"brief_include_notes": func(c *Config) string { return strconv.FormatBool(c.BriefIncludeNotes) },
"pre_commit_workflows": func(c *Config) string { return strings.Join(c.PreCommitWorkflows, " ") },
"post_merge_workflows": func(c *Config) string { return strings.Join(c.PostMergeWorkflows, " ") },
"version_locations": func(c *Config) string { return strings.Join(c.VersionLocations, " ") },
"version_anchor": func(c *Config) string { return c.VersionAnchor },
"attribution_pattern": func(c *Config) string { return strings.Join(c.AttributionPatterns, " ") },
"init_detection_threshold": func(c *Config) string { return strconv.FormatFloat(c.InitDetectionThreshold, 'g', -1, 64) },
}
// KnownKeys returns the sorted list of operator-settable config keys.
func KnownKeys() []string {
keys := make([]string, 0, len(keySpecs))
for k := range keySpecs {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// KnownKey reports whether key is a recognised config.local key.
func KnownKey(key string) bool {
_, ok := keySpecs[key]
return ok
}
// EffectiveValue renders the effective value of key off cfg. The second
// result is false when key is not a known config key.
func EffectiveValue(cfg *Config, key string) (string, bool) {
get, ok := keySpecs[key]
if !ok {
return "", false
}
return get(cfg), true
}
// ValidateSetValue checks that key is a known config key and that val
// parses cleanly under the same rules Load applies. It is the guard for
// `eeco config set` so a typo'd key or malformed value is rejected
// before anything is written. It never mutates caller state.
func ValidateSetValue(key, val string) error {
if !KnownKey(key) {
return fmt.Errorf("unknown config key %q (run `eeco config list` for valid keys)", key)
}
// Validate the value format with the real parser by probing a
// throwaway file against a throwaway config.
tmp, err := os.CreateTemp("", "eeco-cfgval-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(key + "=" + val + "\n"); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return applyConfigFile(&Config{}, tmp.Name())
}
// DeclaredKeys returns the set of config keys explicitly declared in the
// config.local-format file at path (unknown keys included). A missing
// file or empty path yields an empty set, not an error.
func DeclaredKeys(path string) (map[string]bool, error) {
out := map[string]bool{}
if path == "" {
return out, nil
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return nil, err
}
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, _, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(k)] = true
}
return out, nil
}
// ParseLocalFile reads a config.local-format file and returns its declared
// key→value pairs (unknown keys included, quotes stripped). For a key that
// appears more than once (a repeatable key such as gate) the last value wins —
// callers that need full multi-occurrence fidelity should copy the file
// verbatim instead. A missing file or empty path yields an empty map, not an
// error.
func ParseLocalFile(path string) (map[string]string, error) {
out := map[string]string{}
if path == "" {
return out, nil
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return nil, err
}
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(k)] = unquote(strings.TrimSpace(v))
}
return out, nil
}
// GlobalConfigLocalPath is the exported path of the user-global
// config.local file (or "" when no global dir resolves). It lets command
// code attribute a key's origin to the global layer.
func GlobalConfigLocalPath() string {
return globalConfigLocalPath()
}
modified internal/config/local.go
@@ -32,8 +32,34 @@ func WriteLocalKeys(cfg *Config, kv map[string]string) error {
if err != nil || !info.IsDir() {
return fmt.Errorf("workspace %s is not initialised", cfg.Workspace)
}
path := filepath.Join(cfg.Workspace, LocalFilename)
return upsertKeys(filepath.Join(cfg.Workspace, LocalFilename), kv)
}
// WriteGlobalKeys upserts key=value pairs into the user-global
// config.local (GlobalConfigDir()/config.local), creating the global
// directory if absent. Same upsert semantics as WriteLocalKeys. This is
// the one writer that touches a file outside any repo, by design — it
// backs `eeco config set --global`, the cross-project settings layer.
func WriteGlobalKeys(kv map[string]string) error {
if len(kv) == 0 {
return nil
}
dir := GlobalConfigDir()
if dir == "" {
return errors.New("cannot resolve a global config directory (set EECO_CONFIG_HOME or HOME)")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create global config dir: %w", err)
}
return upsertKeys(filepath.Join(dir, LocalFilename), kv)
}
// upsertKeys writes key=value pairs into the config.local-format file at
// path, preserving comments, blank lines, unknown keys, and line order.
// An existing non-comment line whose key matches is replaced in place; a
// key not yet present is appended (appended keys in sorted order for a
// deterministic file).
func upsertKeys(path string, kv map[string]string) error {
existing, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read %s: %w", LocalFilename, err)
modified internal/guide/testdata/usage.rendered.golden
@@ -215,7 +215,7 @@ does not advance on a parked turn). Commands never require AI.
───────────
eeco open the control center (digest if non-TTY)
eeco init [--no-track] bootstrap the ecosystem in this repo (--no-track skips the private workspace-history repo)
eeco init [--no-track] [--from <path>] bootstrap the ecosystem in this repo (--from imports settings from another eeco project; --no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
@@ -226,6 +226,11 @@ does not advance on a parked turn). Commands never require AI.
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco config list show every config key, its effective value, and origin (default | global | local)
eeco config get <key> print the effective value of one config key
eeco config set [--global] <key> <value> set a key in this workspace, or (--global) in the cross-project layer
eeco config import [--force] <path> copy config.local, cockpit.json, and workflows from another eeco project
eeco cockpit target [--global] list|add <t>|rm <t> manage the active (or cross-project) harness target set
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
@@ -256,6 +261,67 @@ Exit codes (also the workflow contract): 0 clean ·
1 finding/failure · 2 blocked (a required tool is missing) ·
3 AI pass deferred (no --ai).
4a. Configuration — eeco config
───────────────────────────────
Configuration resolves in three layers, each overriding the one
before it:
built-in defaults
→ user-global ~/.config/eeco/config.local (shared by every project)
→ workspace <repo>/<user>/.eeco/config.local (this project only)
Inspect and edit the two file layers with eeco config:
eeco config list # every key, effective value, and origin
eeco config get automation # one effective value, bare (for scripts)
eeco config set automation=manual # write the workspace layer (this project)
eeco config set --global automation=auto # write the cross-project layer
set accepts key=value or key value. A value is validated against the
same rules config.local uses, so a typo'd key or a malformed number is
rejected before anything is written. (Floor-invariant keys such as
automation tolerate any value and normalize at load, exactly as they do
in a hand-edited config.local.)
Sharing settings across projects — the global layer
Set a key once with --global and every project inherits it, unless
that project overrides it in its own workspace config.local. This is the
git --global model: machine-wide defaults with per-repo overrides. The
global file lives at ~/.config/eeco/config.local — or
$XDG_CONFIG_HOME/eeco/config.local, or wherever EECO_CONFIG_HOME
points (the override also makes the layer hermetic under tests).
eeco config set --global … is the one eeco command that writes outside a
repository, by design; everything else stays inside the project. It does
not need an initialised workspace, or even to be inside a repo.
Cockpit targets share the same model:
eeco cockpit target --global add cursor # new projects inherit this target set
eeco cockpit target list # falls back to the global set when this
# project has no cockpit.json of its own
Copying settings from one specific project — --from / import
Where the global layer is a *live* shared default, --from is a *one-shot
copy* from one named project into another. It carries three things —
config.local, the cockpit selection (cockpit.json), and the scaffolded
workflows/ — and nothing else (project-specific knowledge, state, and bug
reports never travel).
eeco init --from ~/other-project # bootstrap a new repo, then copy from another
eeco config import ~/other-project # copy into an already-initialised project
eeco config import --force ~/other-project # let the source win over existing files/keys
The source path may be the other repo's root or any directory inside it. Into a
fresh workspace the config.local is copied verbatim (full fidelity); into
an existing one it is key-merged. Without --force, files and keys the
destination already has are preserved; --force lets the source win. Importing
from a project of a different type may pull in type-specific keys (profile,
gate) — eeco config set fixes any you don't want.
5. Builtin workflows
────────────────────
modified internal/guide/usage.md
@@ -250,7 +250,7 @@ does not advance on a parked turn). Commands never require AI.
```
eeco open the control center (digest if non-TTY)
eeco init [--no-track] bootstrap the ecosystem in this repo (--no-track skips the private workspace-history repo)
eeco init [--no-track] [--from <path>] bootstrap the ecosystem in this repo (--from imports settings from another eeco project; --no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
@@ -261,6 +261,11 @@ eeco stats print cumulative AI usage from the call ledger (real to
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco config list show every config key, its effective value, and origin (default | global | local)
eeco config get <key> print the effective value of one config key
eeco config set [--global] <key> <value> set a key in this workspace, or (--global) in the cross-project layer
eeco config import [--force] <path> copy config.local, cockpit.json, and workflows from another eeco project
eeco cockpit target [--global] list|add <t>|rm <t> manage the active (or cross-project) harness target set
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
@@ -292,6 +297,74 @@ eeco help command reference
`1` finding/failure · `2` blocked (a required tool is missing) ·
`3` AI pass deferred (no `--ai`).
## 4a. Configuration — `eeco config`
Configuration resolves in **three layers**, each overriding the one
before it:
```
built-in defaults
→ user-global ~/.config/eeco/config.local (shared by every project)
→ workspace <repo>/<user>/.eeco/config.local (this project only)
```
Inspect and edit the two file layers with `eeco config`:
```
eeco config list # every key, effective value, and origin
eeco config get automation # one effective value, bare (for scripts)
eeco config set automation=manual # write the workspace layer (this project)
eeco config set --global automation=auto # write the cross-project layer
```
`set` accepts `key=value` or `key value`. A value is validated against the
same rules `config.local` uses, so a typo'd key or a malformed number is
rejected before anything is written. (Floor-invariant keys such as
`automation` tolerate any value and normalize at load, exactly as they do
in a hand-edited `config.local`.)
### Sharing settings across projects — the global layer
Set a key **once** with `--global` and every project inherits it, unless
that project overrides it in its own workspace `config.local`. This is the
git `--global` model: machine-wide defaults with per-repo overrides. The
global file lives at `~/.config/eeco/config.local` — or
`$XDG_CONFIG_HOME/eeco/config.local`, or wherever `EECO_CONFIG_HOME`
points (the override also makes the layer hermetic under tests).
`eeco config set --global …` is the one eeco command that writes outside a
repository, by design; everything else stays inside the project. It does
not need an initialised workspace, or even to be inside a repo.
Cockpit targets share the same model:
```
eeco cockpit target --global add cursor # new projects inherit this target set
eeco cockpit target list # falls back to the global set when this
# project has no cockpit.json of its own
```
### Copying settings from one specific project — `--from` / `import`
Where the global layer is a *live* shared default, `--from` is a *one-shot
copy* from one named project into another. It carries three things —
`config.local`, the cockpit selection (`cockpit.json`), and the scaffolded
`workflows/` — and nothing else (project-specific knowledge, state, and bug
reports never travel).
```
eeco init --from ~/other-project # bootstrap a new repo, then copy from another
eeco config import ~/other-project # copy into an already-initialised project
eeco config import --force ~/other-project # let the source win over existing files/keys
```
The source path may be the other repo's root or any directory inside it. Into a
**fresh** workspace the `config.local` is copied verbatim (full fidelity); into
an **existing** one it is key-merged. Without `--force`, files and keys the
destination already has are preserved; `--force` lets the source win. Importing
from a project of a different type may pull in type-specific keys (`profile`,
`gate`) — `eeco config set` fixes any you don't want.
## 5. Builtin workflows
| Workflow | Inspects | Writes | AI |
modified internal/tui/tui_test.go
@@ -19,6 +19,20 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// TestMain pins the user-global config dir to an empty temp dir so the
// global config layer is a hermetic no-op and these tests never read the
// dev box's ~/.config/eeco.
func TestMain(m *testing.M) {
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(config.GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
// miniDotFrames lists the MiniDot spinner glyphs; a running footer renders
// one of them and an idle footer none.
const miniDotFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
modified internal/workflow/bench_test.go
@@ -15,6 +15,19 @@ import (
"github.com/ajhahnde/eeco/internal/config"
)
// TestMain pins the user-global config dir to an empty temp dir so the
// global config layer is a hermetic no-op under the bench fixture.
func TestMain(m *testing.M) {
gdir, err := os.MkdirTemp("", "eeco-global-")
if err != nil {
panic(err)
}
os.Setenv(config.GlobalConfigEnv, gdir)
code := m.Run()
os.RemoveAll(gdir)
os.Exit(code)
}
const (
benchFileCount = 50_000
// benchSeed is fixed so the fixture is byte-identical every run; only