Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <slug>`; from then on it is tracked like everything else.

Expand Down
10 changes: 6 additions & 4 deletions src/goodeye_cli/background.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -53,14 +55,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
Expand Down
83 changes: 60 additions & 23 deletions src/goodeye_cli/commands/workflows_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -257,11 +260,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")
Expand Down Expand Up @@ -361,17 +384,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,
}
Expand All @@ -383,13 +411,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
Expand All @@ -405,29 +434,31 @@ 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")
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(
Expand All @@ -438,6 +469,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)
Expand All @@ -449,7 +482,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(
Expand All @@ -463,14 +496,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)

Expand All @@ -479,7 +516,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"})
Expand Down
63 changes: 56 additions & 7 deletions src/goodeye_cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.

Expand Down
20 changes: 18 additions & 2 deletions tests/test_background.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
Loading