From e35c04d339e1563b1edac748fa32df6f1e617508 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 20 Jul 2026 12:45:22 -0700 Subject: [PATCH 1/3] Turn automatic sync on once a sync target is configured Configuring a target is the statement of intent to keep it current, so automatic sync now resolves to on from that point instead of waiting for a separate `sync auto on` that most people never found. A stated preference always wins. `auto on` and `auto off` record that the user chose, and adding more targets never overrides that choice, so turning it off once keeps it off. The policy lives in one function, `automatic_sync_enabled`, read by the background gate, the `sync auto` display, and `sync target add`. Those three previously each read `auto.enabled` directly and would have drifted into reporting different answers for the same config. Nothing is written to reach the answer, so adding a target cannot overwrite a preference. `explicitly_set` is a plain bool rather than a nullable `enabled` tri-state because `load_sync_config` validates straight into the model: a null there would raise on any older CLI that still types the field as bool, breaking every command after a downgrade. A config written before the field existed carries no preference and so follows the default. Automatic sync shipped opt-in, so `enabled: false` in such a config almost always means untouched rather than declined. User-facing wording moves from "automatic pull" to "automatic sync". Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- src/goodeye_cli/background.py | 4 +- src/goodeye_cli/commands/workflows_sync.py | 80 +++++++--- src/goodeye_cli/sync.py | 63 +++++++- tests/test_background.py | 20 ++- tests/test_commands_skills_sync.py | 161 ++++++++++++++++++++- 6 files changed, 293 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 39f54df..207da26 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A skill is a markdown runbook your agent follows. A verifier is a check its outp **Private by default.** Nothing is public until you publish a template, which is a separate step you take on purpose. To share without going public, grant a named user or team access, and the verifiers the skill references go with it at the same version. Revoking works the same way. -**Always in sync.** `goodeye skills sync` mirrors your hosted skills into the directories your tools already read, so Claude Code, Codex, Cursor, and anything else reading skill files from disk run the same current version. Edit a skill once and every machine picks it up on the next pull. +**Always in sync.** `goodeye skills sync` mirrors your hosted skills into the directories your tools already read, so Claude Code, Codex, Cursor, and anything else reading skill files from disk run the same current version. Configuring a target is all it takes: automatic sync is on from then on, so edit a skill once and every machine picks it up on its own. **Verifiers, hosted.** A verifier can be deterministic (format, schema, tests, numeric bounds) or an LLM judge for the calls no test can make, like tone or image quality. Deploy a semantic verifier once, and every skill that references it runs that exact version, on your laptop, in CI, or on the machine of someone you granted it to. @@ -59,7 +59,7 @@ goodeye skills sync target add --preset cursor # ~/.cursor/skills goodeye skills sync ``` -Run the same commands on your other machines and they all read the current version. `goodeye skills sync auto on` keeps that up to date on its own; it only pulls new and updated skills, never overwrites local edits, and reports a conflict rather than clobbering it. +Run the same commands on your other machines and they all read the current version. Once you have a target, automatic sync is on, so they stay that way without a second command: it only pulls new and updated skills, never overwrites local edits, and reports a conflict rather than clobbering it. Turn it off with `goodeye skills sync auto off` and it stays off. The skill you just published came from a directory that is now a target, so a copy of it is already there. Goodeye did not write that copy, so the first pull reports it as modified and leaves it alone. The hosted version is what you published, so adopt it once with `goodeye skills sync pull --force `; from then on it is tracked like everything else. diff --git a/src/goodeye_cli/background.py b/src/goodeye_cli/background.py index 545d00e..a83668b 100644 --- a/src/goodeye_cli/background.py +++ b/src/goodeye_cli/background.py @@ -53,14 +53,14 @@ def should_run_auto_pull( ``--version``, the bare/``help``/``update`` forms, or an explicit ``skills sync ...`` command), - credentials exist (the caller is authenticated), - - automatic pull is enabled and at least one target is configured, + - automatic sync resolves to on and at least one target is configured, - the throttle interval has elapsed since the last automatic pull. """ if update_checks.should_suppress_auto_pull(args, env): return False if not authenticated: return False - if not config.auto.enabled: + if not sync.automatic_sync_enabled(config): return False if not config.targets: return False diff --git a/src/goodeye_cli/commands/workflows_sync.py b/src/goodeye_cli/commands/workflows_sync.py index a7a3299..4905341 100644 --- a/src/goodeye_cli/commands/workflows_sync.py +++ b/src/goodeye_cli/commands/workflows_sync.py @@ -257,11 +257,31 @@ def target_add( ) sync.save_sync_config(config, paths) + # Having a target is what turns automatic sync on, so there is nothing to + # set here: the new target changes the answer on its own. This only reports + # it, because a user configuring their first target has no other way to know + # the mirror will now keep itself current. + automatic = sync.automatic_sync_enabled(config) + if mode == "json": - echo_json(target) + # Additive: the target's own fields stay at the top level so existing + # readers of this payload keep working. + echo_json({**target.model_dump(), "automatic_sync_enabled": automatic}) return console = Console() console.print(f"[green]Added[/green] sync target {target.path} (scope={target.scope})") + if automatic: + console.print( + f"Automatic sync is on (every {config.auto.interval_seconds} seconds). " + "Turn it off with `goodeye skills sync auto off`." + ) + else: + # Reached only when the user turned it off themselves. Say so, so a + # target that never refreshes is not a mystery later. + console.print( + "[yellow]Automatic sync is off[/yellow], so this target updates only when " + "you run `goodeye skills sync`. Turn it on with `goodeye skills sync auto on`." + ) @target_app.command("list") @@ -361,17 +381,22 @@ def target_remove( auto_app = typer.Typer( - help="Turn automatic background pulls on or off, or show the current setting.", + help="Turn automatic sync on or off, or show the current setting.", invoke_without_command=True, ) app.add_typer(auto_app, name="auto") def _auto_status_payload(config: sync.SyncConfig, state: sync.SyncState) -> dict[str, object]: - """Build the reportable view of the automatic-pull setting and last run.""" + """Build the reportable view of the automatic-sync setting and last run. + + ``enabled`` reports the resolved answer, not the stored preference, so the + payload keeps its existing always-boolean shape and says what will actually + happen. + """ last = state.last_auto_pull_at return { - "enabled": config.auto.enabled, + "enabled": sync.automatic_sync_enabled(config), "interval_seconds": config.auto.interval_seconds, "last_auto_pull_at": last.isoformat() if last is not None else None, } @@ -383,13 +408,14 @@ def _auto_root( json_output: bool = typer.Option(False, "--json", help="Print the setting as JSON."), table_output: bool = typer.Option(False, "--table", help="Print the setting as a table."), ) -> None: - """Show whether automatic background pulls are on, with the interval and last run. - - Automatic pull is off by default. When on, the CLI refreshes the safe set of - your configured targets (new and behind-registry skills) in the - background after a command finishes, no more often than the interval. It - never overwrites local edits, never deletes a local copy, and never blocks - your command. Run with `on` or `off` to change the setting. + """Show whether automatic sync is on, with the interval and last run. + + Automatic sync is on once you have a sync target, unless you set it yourself + with `on` or `off`, which always wins and is never overridden by adding more + targets. When on, the CLI refreshes the safe set of your configured targets + (new and behind-registry skills) in the background after a command finishes, + no more often than the interval. It never overwrites local edits, never + deletes a local copy, and never blocks your command. """ if ctx.invoked_subcommand is not None: return @@ -405,12 +431,13 @@ def _auto_root( return console = Console() - status_word = "on" if config.auto.enabled else "off" - color = "green" if config.auto.enabled else "yellow" - console.print(f"Automatic pull is [{color}]{status_word}[/{color}].") + automatic = sync.automatic_sync_enabled(config) + status_word = "on" if automatic else "off" + color = "green" if automatic else "yellow" + console.print(f"Automatic sync is [{color}]{status_word}[/{color}].") console.print(f"Interval: {config.auto.interval_seconds} seconds.") last = payload["last_auto_pull_at"] - console.print(f"Last automatic pull: {last if last is not None else 'never'}.") + console.print(f"Last automatic sync: {last if last is not None else 'never'}.") @auto_app.command("on") @@ -418,16 +445,17 @@ def auto_on( interval: int | None = typer.Option( None, "--interval", - help="Minimum seconds between automatic pulls (defaults to the current setting).", + help="Minimum seconds between automatic syncs (defaults to the current setting).", ), json_output: bool = typer.Option(False, "--json", help="Print the setting as JSON."), table_output: bool = typer.Option(False, "--table", help="Print the setting as a table."), ) -> None: - """Turn automatic background pulls on, optionally setting the interval. + """Turn automatic sync on, optionally setting the interval. - Once on, the CLI keeps the safe set of your configured targets fresh in the - background. Local edits are always preserved and nothing is ever deleted - automatically. Requires at least one configured sync target to do anything. + Only needed to set an interval, or to turn it back on after `off`: it is + already on once you have a sync target. Local edits are always preserved and + nothing is ever deleted automatically. Requires at least one configured sync + target to do anything. """ if interval is not None and interval <= 0: raise ValidationFailed( @@ -438,6 +466,8 @@ def auto_on( paths = get_config_paths() config = sync.load_sync_config(paths) config.auto.enabled = True + # Stating a preference is what makes it stick across later target adds. + config.auto.explicitly_set = True if interval is not None: config.auto.interval_seconds = interval sync.save_sync_config(config, paths) @@ -449,7 +479,7 @@ def auto_on( console = Console() console.print( - f"[green]Automatic pull is on[/green] (interval: {config.auto.interval_seconds} seconds)." + f"[green]Automatic sync is on[/green] (interval: {config.auto.interval_seconds} seconds)." ) if not config.targets: console.print( @@ -463,14 +493,18 @@ def auto_off( json_output: bool = typer.Option(False, "--json", help="Print the setting as JSON."), table_output: bool = typer.Option(False, "--table", help="Print the setting as a table."), ) -> None: - """Turn automatic background pulls off. + """Turn automatic sync off. The interval setting is kept so turning it back on resumes the same cadence. + This is remembered: adding sync targets later will not turn it back on. """ mode = resolve_output_mode(json_output=json_output, table_output=table_output) paths = get_config_paths() config = sync.load_sync_config(paths) config.auto.enabled = False + # Stating a preference is what makes it stick: adding a target later will + # not turn automatic sync back on. + config.auto.explicitly_set = True sync.save_sync_config(config, paths) state = sync.load_sync_state(paths) @@ -479,7 +513,7 @@ def auto_off( return console = Console() - console.print("[yellow]Automatic pull is off.[/yellow]") + console.print("[yellow]Automatic sync is off.[/yellow]") _SKIPPED_ACTIONS = frozenset({"skipped-modified", "skipped-conflict"}) diff --git a/src/goodeye_cli/sync.py b/src/goodeye_cli/sync.py index 3ddbad6..b57e4f7 100644 --- a/src/goodeye_cli/sync.py +++ b/src/goodeye_cli/sync.py @@ -124,16 +124,40 @@ class SyncTarget(_SyncBase): class AutoConfig(_SyncBase): - """Opt-in automatic-pull settings for the local mirror. - - ``enabled`` is off by default, so a config written by an older CLI (which - has no ``auto`` block at all) loads with automatic pulls disabled and sees - no behavior change until the user opts in. ``interval_seconds`` is the - minimum gap between automatic pulls: a floor on how often the background - tail refreshes the mirror, not a freshness guarantee at any instant. + """Automatic-pull settings for the local mirror. + + ``enabled`` is off in the field default, so a config written by an older CLI + (which has no ``auto`` block at all) loads with automatic pulls disabled. + Adding a sync target turns it on, so in practice a configured mirror is an + automatic one: the field default only governs a config with no targets to + refresh. + + ``explicitly_set`` records that the user stated a preference by running + ``auto on`` or ``auto off``, and ``enabled`` is meaningful only then. + ``enabled`` alone cannot carry the distinction, because ``False`` is both + "never touched" and "deliberately turned off", and the default must respect + the second without being blocked by the first. It is deliberately not + inferred from the last-run stamp: the automatic tail is suppressed for + ``skills sync ...`` invocations, so an ``auto on`` immediately followed by + ``auto off`` never stamps and would be misread as untouched. + + The pair is two plain bools rather than a nullable ``enabled`` tri-state on + purpose. ``load_sync_config`` validates straight into this model, so a + ``null`` written here would raise on any older CLI that still types the + field as ``bool``, breaking every command after a downgrade. An unknown + extra key is ignored instead (see ``_SyncBase``), so an older CLI reads a + config written by this one and simply falls back to ``enabled``. + + Nothing writes these except ``auto on`` and ``auto off``. Read the resolved + answer through ``automatic_sync_enabled`` rather than either field. + + ``interval_seconds`` is the minimum gap between automatic pulls: a floor on + how often the background tail refreshes the mirror, not a freshness + guarantee at any instant. """ enabled: bool = False + explicitly_set: bool = False interval_seconds: int = 3600 @@ -513,6 +537,31 @@ class SyncState(_SyncBase): last_auto_pull_at: datetime | None = None +def automatic_sync_enabled(config: SyncConfig) -> bool: + """Return whether automatic sync is on, resolving preference against default. + + The single answer to "is automatic sync on?", used by the background gate, + the ``sync auto`` display, and ``sync target add``. Keeping it in one place + is what stops those three from drifting apart and reporting different + answers for the same config. + + A stated preference always wins. Absent one, having a sync target is taken + as wanting it kept current, so configuring a target is all it takes: the + user never has to find a second command. Nothing is written to reach that + conclusion, so adding a target cannot quietly overwrite a preference, and a + user who has said no stays at no however many targets they add later. + + A config written before ``explicitly_set`` existed carries no preference and + so follows the default. That is deliberate: automatic sync shipped as + opt-in, meaning nearly every such config reads ``enabled: false`` only + because it was never touched, and those users should get the default rather + than be stranded opted out. + """ + if config.auto.explicitly_set: + return config.auto.enabled + return bool(config.targets) + + def auto_is_due(state: SyncState, config: SyncConfig, now: datetime) -> bool: """Return whether enough time has elapsed for another automatic pull. diff --git a/tests/test_background.py b/tests/test_background.py index 9b8cdd8..adb6bd5 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -75,9 +75,12 @@ def test_gate_skips_when_unauthenticated() -> None: def test_gate_skips_when_auto_disabled() -> None: + """An explicit `auto off` stops the tail even with targets configured.""" now = datetime(2026, 6, 14, 12, 0, tzinfo=UTC) - config = sync.SyncConfig(targets=[sync.SyncTarget(path="~/skills", scope="owned")]) - assert config.auto.enabled is False + config = sync.SyncConfig( + targets=[sync.SyncTarget(path="~/skills", scope="owned")], + auto=sync.AutoConfig(enabled=False, explicitly_set=True), + ) assert ( background_sync.should_run_auto_pull( ["logout"], {}, config, sync.SyncState(), authenticated=True, now=now @@ -86,6 +89,19 @@ def test_gate_skips_when_auto_disabled() -> None: ) +def test_gate_runs_for_a_configured_target_with_no_stated_preference() -> None: + """Having a target is enough; the user never has to opt in separately.""" + now = datetime(2026, 6, 14, 12, 0, tzinfo=UTC) + config = sync.SyncConfig(targets=[sync.SyncTarget(path="~/skills", scope="owned")]) + assert config.auto.explicitly_set is False + assert ( + background_sync.should_run_auto_pull( + ["logout"], {}, config, sync.SyncState(), authenticated=True, now=now + ) + is True + ) + + def test_gate_skips_when_no_targets() -> None: now = datetime(2026, 6, 14, 12, 0, tzinfo=UTC) config = sync.SyncConfig(auto=sync.AutoConfig(enabled=True)) diff --git a/tests/test_commands_skills_sync.py b/tests/test_commands_skills_sync.py index 0694150..cb2d3a3 100644 --- a/tests/test_commands_skills_sync.py +++ b/tests/test_commands_skills_sync.py @@ -75,9 +75,12 @@ def test_target_add_by_path_defaults_to_compact_json( runner = CliRunner() result = runner.invoke(app, ["skills", "sync", "target", "add", "~/work/skills"]) assert result.exit_code == 0, result.output - # CliRunner stdout is not a TTY, so the default mode is compact JSON. + # CliRunner stdout is not a TTY, so the default mode is compact JSON. The + # target's own fields stay at the top level; automatic_sync_enabled is + # additive alongside them. assert result.output == ( - '{"path":"~/work/skills","scope":"owned","selected":[],"link":false}\n' + '{"path":"~/work/skills","scope":"owned","selected":[],"link":false,' + '"automatic_sync_enabled":true}\n' ) # The target landed in sync.json on disk. with tmp_config_paths.sync_file.open(encoding="utf-8") as fh: @@ -1637,6 +1640,158 @@ def test_auto_off_disables_but_keeps_interval(tmp_config_paths: ConfigPaths, mon assert _load_auto(tmp_config_paths).interval_seconds == 900 +def test_target_add_turns_automatic_sync_on(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + """Configuring a target is the statement of intent to keep it current.""" + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + result = runner.invoke(app, ["skills", "sync", "target", "add", "--preset", "claude"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["automatic_sync_enabled"] is True + # Reported, not stored: the target itself is what changed the answer, so no + # preference was written and there is nothing to overwrite later. + auto = _load_auto(tmp_config_paths) + assert auto.explicitly_set is False + assert auto.enabled is False + + +def test_target_add_keeps_the_configured_interval( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Enabling on add must not stomp an interval the user chose.""" + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + runner.invoke(app, ["skills", "sync", "auto", "on", "--interval", "900"]) + result = runner.invoke(app, ["skills", "sync", "target", "add", "~/work/skills"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["automatic_sync_enabled"] is True + assert _load_auto(tmp_config_paths).interval_seconds == 900 + + +def test_target_add_respects_an_explicit_auto_off( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """A deliberate `auto off` survives every later target add. + + Having a target is a default, not an override: it turns automatic sync on + for someone who never expressed a preference, and leaves a stated one alone. + """ + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + runner.invoke(app, ["skills", "sync", "target", "add", "~/first"]) + runner.invoke(app, ["skills", "sync", "auto", "off"]) + + result = runner.invoke(app, ["skills", "sync", "target", "add", "~/second"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["automatic_sync_enabled"] is False + + # Still off after a third add, so this is a durable preference and not a + # one-time reprieve. + runner.invoke(app, ["skills", "sync", "target", "add", "~/third"]) + status = runner.invoke(app, ["skills", "sync", "auto"]) + assert json.loads(status.output)["enabled"] is False + + +def test_target_add_says_so_when_automatic_sync_is_off( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """A target that will not refresh on its own should not be a mystery.""" + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + runner.invoke(app, ["skills", "sync", "auto", "off"]) + result = runner.invoke(app, ["skills", "sync", "target", "add", "~/work/skills", "--table"]) + assert result.exit_code == 0, result.output + # Rich wraps to the console width, so compare on normalized whitespace + # rather than letting the assertion depend on where the line breaks. + flattened = " ".join(result.output.split()) + assert "Automatic sync is off" in flattened + assert "goodeye skills sync auto on" in flattened + + +def test_target_add_announces_automatic_sync_when_it_enables_it( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + result = runner.invoke(app, ["skills", "sync", "target", "add", "~/work/skills", "--table"]) + assert result.exit_code == 0, result.output + flattened = " ".join(result.output.split()) + assert "Automatic sync is on" in flattened + assert "goodeye skills sync auto off" in flattened + + +def test_auto_on_then_off_is_remembered_as_explicit( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Both commands state a preference, so both must be recorded as one.""" + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + assert _load_auto(tmp_config_paths).explicitly_set is False + runner.invoke(app, ["skills", "sync", "auto", "on"]) + assert _load_auto(tmp_config_paths).explicitly_set is True + runner.invoke(app, ["skills", "sync", "auto", "off"]) + assert _load_auto(tmp_config_paths).explicitly_set is True + + +def test_automatic_sync_enabled_resolves_preference_against_default() -> None: + """The whole policy, read directly, without going through a command.""" + from goodeye_cli import sync + + target = [sync.SyncTarget(path="~/skills")] + + # No preference: having a target is what decides. + assert sync.automatic_sync_enabled(sync.SyncConfig()) is False + assert sync.automatic_sync_enabled(sync.SyncConfig(targets=target)) is True + + # A stated preference wins in both directions, targets or not. + off = sync.AutoConfig(enabled=False, explicitly_set=True) + on = sync.AutoConfig(enabled=True, explicitly_set=True) + assert sync.automatic_sync_enabled(sync.SyncConfig(targets=target, auto=off)) is False + assert sync.automatic_sync_enabled(sync.SyncConfig(auto=on)) is True + + +def test_automatic_sync_enabled_treats_a_pre_upgrade_config_as_unset() -> None: + """A config written before the preference field existed carries no choice. + + Automatic sync shipped opt-in, so `enabled: false` in such a config almost + always means untouched rather than declined. Those users get the default + instead of being stranded opted out. + """ + from goodeye_cli import sync + + legacy = sync.SyncConfig.model_validate( + { + "version": 1, + "targets": [{"path": "~/.claude/skills", "scope": "owned"}], + "auto": {"enabled": False, "interval_seconds": 3600}, + } + ) + assert legacy.auto.explicitly_set is False + assert sync.automatic_sync_enabled(legacy) is True + + +def test_target_add_only_append_leaves_automatic_sync_alone( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Appending to an existing target's allowlist is not adding a target. + + The enable-on-add rule is scoped to the branch that creates a target, so the + append path leaves the setting exactly as the user left it. + """ + _redirect_config(monkeypatch, tmp_config_paths) + runner = CliRunner() + runner.invoke( + app, + ["skills", "sync", "target", "add", "~/skills", "--scope", "selected", "--only", "alpha"], + ) + runner.invoke(app, ["skills", "sync", "auto", "off"]) + result = runner.invoke( + app, + ["skills", "sync", "target", "add", "~/skills", "--scope", "selected", "--only", "beta"], + ) + assert result.exit_code == 0, result.output + assert _load_auto(tmp_config_paths).enabled is False + + def test_auto_status_reports_current_setting(tmp_config_paths: ConfigPaths, monkeypatch) -> None: _redirect_config(monkeypatch, tmp_config_paths) runner = CliRunner() @@ -1658,7 +1813,7 @@ def test_auto_status_human_mode_on_tty(tmp_config_paths: ConfigPaths, monkeypatc # --table forces the human-readable view regardless of TTY detection. result = runner.invoke(app, ["skills", "sync", "auto", "--table"]) assert result.exit_code == 0, result.output - assert "Automatic pull is" in result.output + assert "Automatic sync is" in result.output assert "on" in result.output assert "never" in result.output From 826a04a36479fda7038f9be2cd622b755f25ca90 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 20 Jul 2026 13:05:12 -0700 Subject: [PATCH 2/3] docs: align auto-pull module docstring with target-driven gate The module docstring still described automatic pull as something the user opts into with `skills sync auto on`, which contradicts the gate below it: having a sync target is what turns automatic sync on, and an explicit preference is what overrides that. Point the docstring at automatic_sync_enabled as the single resolved answer. --- src/goodeye_cli/background.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/goodeye_cli/background.py b/src/goodeye_cli/background.py index a83668b..869c620 100644 --- a/src/goodeye_cli/background.py +++ b/src/goodeye_cli/background.py @@ -1,7 +1,9 @@ """Best-effort automatic-pull tail for the local skill mirror. -When the user opts in (`skills sync auto on`), the CLI keeps the safe set of -its configured sync targets fresh in the background. The work runs as a tail +Once the user has a sync target, the CLI keeps the safe set of its configured +targets fresh in the background; `skills sync auto on` and `off` state a +preference that always wins, and ``sync.automatic_sync_enabled`` resolves the +two into the single answer this gate reads. The work runs as a tail after the user's command finishes (registered through ``atexit`` in ``app.py``), so it never delays or alters the exit status of the command the user actually ran. Everything here is best-effort: the gate is a handful of local file reads, From efe55f6b9456813e38c6601be89f94e8f27b59d4 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 20 Jul 2026 13:15:35 -0700 Subject: [PATCH 3/3] Report automatic_sync_enabled on the target add append branch The --json output of skills sync target add carried automatic_sync_enabled only when it created a new target, not when it appended --only entries to an existing one, so the same command returned two payload shapes. Add the field to the append branch so machine readers see it on every target add. --- src/goodeye_cli/commands/workflows_sync.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/goodeye_cli/commands/workflows_sync.py b/src/goodeye_cli/commands/workflows_sync.py index 4905341..704668e 100644 --- a/src/goodeye_cli/commands/workflows_sync.py +++ b/src/goodeye_cli/commands/workflows_sync.py @@ -216,12 +216,15 @@ def target_add( stored_path = sync.normalize_target_path(raw_path) # type: ignore[arg-type] if mode == "json": + # Carry automatic_sync_enabled here too, so `target add --json` + # reports it whether it created a target or appended to one. echo_json( { "path": existing_target.path, "scope": existing_target.scope, "added": added, "already_present": already_present, + "automatic_sync_enabled": sync.automatic_sync_enabled(config), } ) return