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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "goodeye"
version = "0.25.1"
version = "0.25.2"
description = "Goodeye CLI: a private home for the skills your AI follows and the verifiers its work must pass, from the terminal."
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
34 changes: 34 additions & 0 deletions src/goodeye_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
WorkflowDeleteResult,
WorkflowDeleteVersionResult,
WorkflowDetail,
WorkflowFilePatchResult,
WorkflowGrantList,
WorkflowGrantResult,
WorkflowGrantRevokeResult,
Expand Down Expand Up @@ -529,6 +530,39 @@ def save_workflow(
response = self._request("POST", "/v1/skills", json_body=payload)
return WorkflowSaveResult.model_validate(_alias_skill_id(response.json()))

def patch_workflow_files(
self,
id_or_slug: str,
*,
expected_version_token: str,
files: list[dict[str, Any]] | None = None,
delete_paths: list[str] | None = None,
source: str | None = None,
) -> WorkflowFilePatchResult:
"""PATCH /v1/skills/{id_or_slug}/files: change named paths, keep the rest.

Only the paths named in ``files`` and ``delete_paths`` are touched;
every other path in the skill rides forward unchanged. This is the
opposite of ``save_workflow``, whose ``files`` is a whole-tree snapshot
that deletes any path left out.

Each entry in ``files`` carries ``path`` plus exactly one of
``content`` (verbatim UTF-8 text), ``content_base64`` (base64-encoded
bytes), or ``sha256`` (a blob the registry can already read). Omitting
``executable`` or ``purpose`` on an entry keeps that file's current
value, so a flag the caller did not set must be left off entirely
rather than sent as a default.
"""
payload: dict[str, Any] = {
"expected_version_token": expected_version_token,
"files": list(files or []),
"delete_paths": list(delete_paths or []),
}
if source is not None:
payload["source"] = source
response = self._request("PATCH", f"/v1/skills/{id_or_slug}/files", json_body=payload)
return WorkflowFilePatchResult.model_validate(_alias_skill_id(response.json()))

def archive_workflow(self, workflow_id: str) -> WorkflowArchiveResult:
response = self._request("POST", f"/v1/skills/{workflow_id}/archive", json_body={})
return WorkflowArchiveResult.model_validate(_alias_skill_id(response.json()))
Expand Down
278 changes: 276 additions & 2 deletions src/goodeye_cli/commands/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from goodeye_cli.client import GoodeyeClient
from goodeye_cli.commands import workflows_sync
from goodeye_cli.commands.prompts import confirm_destructive
from goodeye_cli.config import get_api_key, get_server
from goodeye_cli.config import get_api_key, get_config_paths, get_server
from goodeye_cli.errors import AuthRequired, ValidationFailed
from goodeye_cli.frontmatter import (
coerce_outcome,
Expand All @@ -32,7 +32,7 @@
next_page_hint,
resolve_output_mode,
)
from goodeye_cli.wire import SafetyCheckResult, WorkflowDetail
from goodeye_cli.wire import SafetyCheckResult, WorkflowDetail, WorkflowFilePatchResult

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -603,6 +603,278 @@ def publish(
_print_authoring_notes([*result.authoring_notes, *extra_notes])


def _read_file_bytes(source: Path) -> bytes:
"""Read the raw bytes of a local file named as file content."""
if not source.exists():
raise ValidationFailed(
slug="validation_error",
message=f"File not found: {source}",
)
if not source.is_file():
raise ValidationFailed(
slug="validation_error",
message=f"Not a file: {source}",
)
try:
return source.read_bytes()
except OSError as exc:
raise ValidationFailed(
slug="validation_error",
message=f"Could not read file: {source}",
) from exc


def _read_stdin_bytes() -> bytes:
"""Read standard input as raw bytes so binary content survives the pipe."""
buffer = getattr(sys.stdin, "buffer", None)
if buffer is None: # pragma: no cover - only on a text-only stdin stub
return sys.stdin.read().encode("utf-8")
return buffer.read()


def _resolve_expected_version_token(
client: GoodeyeClient, skill_id: str, supplied: str | None
) -> str:
"""Return the token to write against, reading the current one when unset.

Resolving it here is not a weaker guard than demanding the flag: the server
still rejects the write if another writer landed between this read and it,
which is the only race a single command invocation can have. The flag stays
for scripts that already hold a token and want to skip the round-trip.
"""
if supplied:
return supplied
detail = client.get_workflow(skill_id)
assert isinstance(detail, WorkflowDetail)
if not detail.version_token:
raise ValidationFailed(
slug="validation_error",
message=f"Could not read the current version token for {skill_id}.",
hint="Pass --expected-version-token with the token from `goodeye skills get --json`.",
)
return detail.version_token


def _refresh_sync_index(
result: WorkflowFilePatchResult,
*,
expected_version_token: str,
path: str,
content: bytes | None,
executable: bool | None = None,
purpose: str | None = None,
) -> None:
"""Bring the local mirror in line with the change that just landed.

A tracked mirror is a directory on disk plus an index recording a hash per
file and the version it is synced at. Leaving any of that stale after a
single-path change makes the next `goodeye skills sync push` report a change
that is not real, or send the old copy back and revert the one just made, so
the command would work against itself.

Only mirrors recorded at the version the change was written against are
updated, and each one moves its directory, its recorded file state, and its
sync point together onto the new version. A mirror sitting at any other
version has a different base and is left for a pull. When no mirror matches
there is nothing to update and the index is left untouched.

``content`` is the bytes just written, or None for a removal.

Best-effort: the write already succeeded, so an unreadable index or an
unwritable mirror is reported and moves on rather than failing the command.
"""
from goodeye_cli import sync

try:
paths = get_config_paths()
state = sync.load_sync_state(paths)
if content is None:
touched = sync.record_file_removed(
state,
result,
expected_version_token=expected_version_token,
path=path,
)
else:
touched = sync.record_file_written(
state,
result,
expected_version_token=expected_version_token,
path=path,
content=content,
executable=executable,
purpose=purpose,
)
if touched:
sync.save_sync_state(state, paths)
except Exception:
_log.debug("could not refresh the local sync index", exc_info=True)
Console(stderr=True).print(
"[yellow]Note[/yellow] the local copy could not be updated; "
"run `goodeye skills sync pull` to resync."
)


def _print_file_change(result: WorkflowFilePatchResult) -> None:
"""Report what the change touched, and what it left alone.

Paths and the skill name come back from the server, so they are escaped: a
path containing square brackets would otherwise read as Rich markup.
"""
console = Console()
console.print(
f"[green]Updated[/green] {rich_escape(result.name)} v{result.version} "
f"(skill_id={result.workflow_id}, version_token={result.version_token})"
)
if result.changed:
console.print(f" changed: {rich_escape(', '.join(result.changed))}")
if result.deleted:
console.print(f" deleted: {rich_escape(', '.join(result.deleted))}")
console.print(f" kept unchanged: {result.carried_forward} file(s)")
_print_authoring_notes(result.authoring_notes)


@app.command("put-file")
def put_file(
skill_id: str = typer.Argument(..., help="Skill UUID or name."),
path: str = typer.Argument(
...,
help=(
"Path of the file inside the skill, relative and POSIX-style "
"(e.g. references/rubric.md). Use SKILL.md to rewrite the runbook."
),
),
from_file: Path | None = typer.Option(
None, "--from-file", help="Read the new content for PATH from this local file."
),
stdin: bool = typer.Option(
False, "--stdin", help="Read the new content for PATH from standard input."
),
executable: bool | None = typer.Option(
None,
"--executable/--no-executable",
help=(
"Mark the file executable when extracted, or clear that mark. "
"Omit both to keep the file's current setting."
),
),
purpose: str | None = typer.Option(
None,
"--purpose",
help=(
"Short label for the file's role in the skill. Omit to keep the file's current label."
),
),
expected_version_token: str | None = typer.Option(
None,
"--expected-version-token",
help=(
"Token for the version you are changing, for scripts that already "
"hold one. Omit to read the current token first."
),
),
) -> None:
"""Write one file in a hosted skill, keeping every other path unchanged.

Only the path you name changes: the rest of the skill's files ride forward
untouched, unlike `goodeye skills publish <dir>`, which replaces the whole
tree so any path missing from the directory is deleted. Send the file's
complete new content, not a patch or a diff.

\b
goodeye skills put-file my-skill references/rubric.md --from-file ./rubric.md
cat rubric.md | goodeye skills put-file my-skill references/rubric.md --stdin

The file's executable mark and role label keep their current values unless
you set them, and the skill's description, outcome, tags, and verifier
references carry forward untouched. This writes the next version of the
skill and requires edit access.
"""
if from_file is not None and stdin:
raise ValidationFailed(
slug="validation_error",
message="Use either --from-file or --stdin, not both.",
)
if from_file is None and not stdin:
raise ValidationFailed(
slug="validation_error",
message="No content given for the file.",
hint="Pass --from-file <path> to read a local file, or --stdin to pipe it in.",
)

from goodeye_cli.sync import inline_content_field

# Exactly one source is set by the checks above, so an unset --from-file
# means the content is on stdin.
raw = _read_stdin_bytes() if from_file is None else _read_file_bytes(from_file)
entry: dict[str, object] = {"path": path, **inline_content_field(raw)}
# An absent `executable` or `purpose` means "keep the current value" on this
# route, so a flag the caller did not pass must be left off entirely rather
# than sent as a default that would reset a setting they never mentioned.
if executable is not None:
entry["executable"] = executable
if purpose is not None:
entry["purpose"] = purpose

with _client(require_auth=True) as client:
token = _resolve_expected_version_token(client, skill_id, expected_version_token)
result = client.patch_workflow_files(
skill_id,
expected_version_token=token,
files=[entry],
)

_print_file_change(result)
_refresh_sync_index(
result,
expected_version_token=token,
path=path,
content=raw,
executable=executable,
purpose=purpose,
)


@app.command("rm-file")
def rm_file(
skill_id: str = typer.Argument(..., help="Skill UUID or name."),
path: str = typer.Argument(
...,
help=(
"Path of the file to remove from the skill, relative and "
"POSIX-style (e.g. references/rubric.md)."
),
),
expected_version_token: str | None = typer.Option(
None,
"--expected-version-token",
help=(
"Token for the version you are changing, for scripts that already "
"hold one. Omit to read the current token first."
),
),
) -> None:
"""Remove one file from a hosted skill, keeping every other path unchanged.

Only the path you name is removed: the rest of the skill's files ride
forward untouched, unlike `goodeye skills publish <dir>`, which replaces the
whole tree so any path missing from the directory is deleted. The path must
already be in the skill, and the runbook (SKILL.md) cannot be removed.

This writes the next version of the skill and requires edit access.
"""
with _client(require_auth=True) as client:
token = _resolve_expected_version_token(client, skill_id, expected_version_token)
result = client.patch_workflow_files(
skill_id,
expected_version_token=token,
delete_paths=[path],
)

_print_file_change(result)
_refresh_sync_index(result, expected_version_token=token, path=path, content=None)


@app.command("lineage")
def lineage(
skill_id: str = typer.Argument(..., help="Skill UUID or name."),
Expand Down Expand Up @@ -1178,7 +1450,9 @@ def audit(
"optimize",
"optimize_description",
"publish",
"put_file",
"revoke_grant",
"rm_file",
"teach",
"transfer_ownership",
"unarchive",
Expand Down
Loading