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
14 changes: 12 additions & 2 deletions .github/workflows/homebrew-formula.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,21 @@ jobs:
--tag "${RELEASE_TAG}" \
--check-release-tag

- name: Report tap workflow handoff
- name: Trigger tap formula update
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_REVISION: ${{ steps.metadata.outputs.revision }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_DISPATCH_TOKEN }}
run: |
set -euo pipefail
echo "Release ${RELEASE_TAG}@${RELEASE_REVISION} is valid."
echo "Update faustodavid/homebrew-tap with its Update Smith Formula workflow."
if [[ -z "${GH_TOKEN}" ]]; then
echo "HOMEBREW_TAP_DISPATCH_TOKEN is not configured."
echo "Update faustodavid/homebrew-tap manually with its Update Smith Formula workflow."
exit 0
fi
gh workflow run update-smith-formula.yml \
--repo faustodavid/homebrew-tap \
-f tag="${RELEASE_TAG}" \
-f revision="${RELEASE_REVISION}"
echo "Dispatched faustodavid/homebrew-tap Update Smith Formula workflow for ${RELEASE_TAG}."
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ irm https://raw.githubusercontent.com/faustodavid/smith/main/scripts/install.py
```

`smith config init` syncs the Smith agent skill to `~/.agents/skills/smith`.
The standalone installer does this during install too. Refresh the skill later
with:
The standalone installer does this during install too. The skill stays current
on its own after upgrades (set `SMITH_SKILL_CHECK=0` to opt out); to refresh it
manually run:

```bash
smith skill sync
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "smith"
version = "0.1.2"
version = "0.1.3"
description = "Read-only Azure DevOps and GitHub investigation CLI"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
40 changes: 36 additions & 4 deletions scripts/install.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Cross-platform installer for Smith. Works on macOS and Windows."""

import os
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -28,8 +29,37 @@ def require_tool(name: str, install_hint: str) -> None:
sys.exit(1)


def find_smith_executable() -> str | None:
"""Locate the smith CLI, including fresh installs where uv's bin dir is not on PATH yet."""
found = shutil.which("smith")
if found:
return found
Comment on lines +34 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer the freshly installed uv tool over PATH

When a user already has an older smith earlier on PATH (for example from Homebrew or a prior uv/pipx install), this returns that stale executable instead of the uv tool install result. The subsequent smith skill sync can therefore run the old sync implementation; in particular pre-marker versions sync successfully but do not write .smith-skill-meta.json, so the installer's copy is not recognized as smith-managed and will not auto-refresh on later upgrades. Since the script has just installed the intended CLI, it should locate/use uv's tool bin before falling back to PATH.

Useful? React with 👍 / 👎.

try:
result = subprocess.run(["uv", "tool", "dir", "--bin"], capture_output=True, text=True)
except OSError:
return None
if result.returncode != 0:
return None
name = "smith.exe" if sys.platform == "win32" else "smith"
candidate = Path(result.stdout.strip()) / name
if candidate.exists():
return str(candidate)
return None


def sync_skill_via_cli(source: Path) -> bool:
"""Sync the skill with the installed CLI so all install paths share one implementation."""
smith_bin = find_smith_executable()
if not smith_bin:
return False
env = dict(os.environ)
env["SMITH_SKILL_SOURCE_DIR"] = str(source)
result = subprocess.run([smith_bin, "skill", "sync"], env=env)
return result.returncode == 0


def sync_skill(source: Path, target: Path) -> None:
"""Copy skill directory to target."""
"""Copy skill directory to target. Fallback for when the smith CLI is not on PATH."""
target.parent.mkdir(parents=True, exist_ok=True)
temp_root = Path(tempfile.mkdtemp(prefix=f".{target.name}.tmp-", dir=target.parent))
staged = temp_root / "staged"
Expand Down Expand Up @@ -82,15 +112,17 @@ def main() -> None:
print(f"Error: skill directory not found after install: {SKILL_SOURCE}", file=sys.stderr)
sys.exit(1)

print("==> Syncing skill")
sync_skill(SKILL_SOURCE, TARGET_SKILL_DIR)

print("==> Installing smith CLI globally with uv")
run(["uv", "tool", "install", "-e", str(REPO_DIR), "--force"])

print("==> Ensuring smith is on PATH")
run(["uv", "tool", "update-shell"])

print("==> Syncing skill")
if not sync_skill_via_cli(SKILL_SOURCE):
target = Path(os.environ.get("SMITH_SKILL_DIR") or TARGET_SKILL_DIR).expanduser()
sync_skill(SKILL_SOURCE, target)

print()
print("Smith installed successfully!")
print()
Expand Down
144 changes: 128 additions & 16 deletions scripts/update_homebrew_formula.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,31 @@
r'(?P<middle>,\n\s+revision:\s+)"(?P<revision>[0-9a-f]{40})"',
re.MULTILINE,
)
TARBALL_PIN_RE = re.compile(
r'url "https://github\.com/faustodavid/smith/archive/refs/tags/(?P<tag>[^"/]+)\.tar\.gz"\n'
r'(?P<indent>[ \t]+)sha256 "(?P<sha256>[0-9a-f]{64})"'
)
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
TAG_RE = re.compile(r"^v[0-9A-Za-z][0-9A-Za-z._-]*$")
# Build backends needed to install the vendored sdist resources without build
# isolation. Single source of truth for the formula's bootstrap_resources list
# and for `brew update-python-resources --extra-packages` in the tap workflow.
BOOTSTRAP_RESOURCES = (
"setuptools",
"flit-core",
"packaging",
"wheel",
"cython",
"pathspec",
"pluggy",
"trove-classifiers",
"hatchling",
"vcs-versioning",
"setuptools-scm",
"hatch-vcs",
)
BOOTSTRAP_RE = re.compile(r"(?P<indent>[ \t]+)bootstrap_resources = %w\[\n(?:[ \t]*[^\s\]]+\n)+[ \t]*\]")
CAVEATS_RE = re.compile(r"\n+\s+def caveats\n\s+<<~EOS\n.*?\n\s+EOS\n\s+end", re.DOTALL)
CAVEATS_METHOD = """\
def caveats
Expand Down Expand Up @@ -96,28 +119,66 @@ def validate_tag(tag: str) -> None:
raise FormulaUpdateError(f"tag must start with v and contain only letters, numbers, dots, underscores, and hyphens, got {tag!r}")


def parse_formula_pin(text: str) -> tuple[str, str]:
def validate_sha256(sha256: str) -> None:
if not SHA256_RE.fullmatch(sha256):
raise FormulaUpdateError(f"sha256 must be a 64-character lowercase hex digest, got {sha256!r}")


@dataclass(frozen=True)
class FormulaPin:
tag: str
revision: str | None
sha256: str | None

@property
def checksum(self) -> str:
return self.sha256 or self.revision or ""


def parse_formula_pin(text: str) -> FormulaPin:
match = PIN_RE.search(text)
if not match:
raise FormulaUpdateError("could not find the Smith formula url tag/revision pin")
return match.group("tag"), match.group("revision")
if match:
return FormulaPin(tag=match.group("tag"), revision=match.group("revision"), sha256=None)
tarball = TARBALL_PIN_RE.search(text)
if tarball:
return FormulaPin(tag=tarball.group("tag"), revision=None, sha256=tarball.group("sha256"))
raise FormulaUpdateError("could not find the Smith formula url pin")


def _tarball_pin_block(tag: str, sha256: str) -> str:
return f'url "https://github.com/faustodavid/smith/archive/refs/tags/{tag}.tar.gz"\n sha256 "{sha256}"'

def update_formula_pin_text(text: str, tag: str, revision: str) -> str:

def update_formula_pin_text(text: str, tag: str, revision: str | None, sha256: str | None = None) -> str:
validate_tag(tag)

if sha256 is not None:
validate_sha256(sha256)
replacement = _tarball_pin_block(tag, sha256)
updated, count = TARBALL_PIN_RE.subn(replacement, text, count=1)
if count != 1:
updated, count = PIN_RE.subn(replacement, text, count=1)
if count != 1:
raise FormulaUpdateError("could not find the Smith formula url pin")
return updated

if revision is None:
raise FormulaUpdateError("a revision is required to update the git pin; pass --sha256 to pin a release tarball instead")
validate_revision(revision)

def replace(match: re.Match[str]) -> str:
return f'{match.group("prefix")}"{tag}"{match.group("middle")}"{revision}"'

updated, count = PIN_RE.subn(replace, text, count=1)
if count != 1:
if TARBALL_PIN_RE.search(text):
raise FormulaUpdateError("formula pins a release tarball; pass --sha256 to update it")
raise FormulaUpdateError("could not find the Smith formula url tag/revision pin")
return updated


def prepare_formula_update(text: str, tag: str, revision: str) -> FormulaUpdate:
pin_updated = update_formula_pin_text(text, tag, revision)
def prepare_formula_update(text: str, tag: str, revision: str | None, sha256: str | None = None) -> FormulaUpdate:
pin_updated = update_formula_pin_text(text, tag, revision, sha256)
caveats_updated = ensure_formula_caveats(pin_updated)
return FormulaUpdate(
text=caveats_updated,
Expand All @@ -126,8 +187,37 @@ def prepare_formula_update(text: str, tag: str, revision: str) -> FormulaUpdate:
)


def update_formula_text(text: str, tag: str, revision: str) -> str:
return prepare_formula_update(text, tag, revision).text
def update_formula_text(text: str, tag: str, revision: str | None, sha256: str | None = None) -> str:
return prepare_formula_update(text, tag, revision, sha256).text


def render_bootstrap_block(indent: str) -> str:
entries = "\n".join(f"{indent} {name}" for name in BOOTSTRAP_RESOURCES)
return f"{indent}bootstrap_resources = %w[\n{entries}\n{indent}]"


def ensure_bootstrap_resources(text: str) -> str:
match = BOOTSTRAP_RE.search(text)
if not match:
raise FormulaUpdateError("could not find the bootstrap_resources list in the formula")
missing = [name for name in BOOTSTRAP_RESOURCES if f'resource "{name}" do' not in text]
if missing:
raise FormulaUpdateError(
f"bootstrap resources have no matching resource stanza: {', '.join(missing)}. "
"Run `brew update-python-resources` with the --extra-packages list from --print-extra-packages first."
)
return text[: match.start()] + render_bootstrap_block(match.group("indent")) + text[match.end() :]


def sync_bootstrap_resources(path: Path, *, check: bool) -> bool:
text = path.read_text(encoding="utf-8")
updated = ensure_bootstrap_resources(text)
changed = updated != text
if check and changed:
raise FormulaUpdateError(f"{path} bootstrap_resources list is stale. Rerun this script with --sync-bootstrap-resources.")
if changed:
path.write_text(updated, encoding="utf-8")
return changed


def ensure_formula_caveats(text: str) -> str:
Expand All @@ -149,14 +239,14 @@ def _join_with_caveats(prefix: str, suffix: str) -> str:
return f"{prefix.rstrip()}\n\n{CAVEATS_METHOD}{suffix}"


def update_formula(path: Path, tag: str, revision: str, *, check: bool) -> bool:
def update_formula(path: Path, tag: str, revision: str | None, *, sha256: str | None = None, check: bool) -> bool:
text = path.read_text(encoding="utf-8")
update = prepare_formula_update(text, tag, revision)
update = prepare_formula_update(text, tag, revision, sha256)
if check and update.changed:
current_tag, current_revision = parse_formula_pin(text)
current = parse_formula_pin(text)
problems: list[str] = []
if update.pin_changed:
problems.append(f"pins {current_tag}@{current_revision}; expected {tag}@{revision}")
problems.append(f"pins {current.tag}@{current.checksum}; expected {tag}@{sha256 or revision}")
if update.caveats_changed:
problems.append("Homebrew caveats are missing or stale")
raise FormulaUpdateError(f"{path} {'; '.join(problems)}. Run scripts/update_homebrew_formula.py --formula <path> to refresh it.")
Expand All @@ -172,19 +262,41 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--version", help="Project version to convert to a v-prefixed tag. Defaults to pyproject.toml.")
parser.add_argument("--tag", help="Release tag to pin. Defaults to v{project.version}.")
parser.add_argument("--revision", help="Release commit SHA. Defaults to resolving the selected tag with git.")
parser.add_argument("--sha256", help="Release tarball sha256. When set, the formula pins the GitHub tag tarball, not a git revision.")
parser.add_argument("--check", action="store_true", help="Fail if the formula is not already up to date.")
parser.add_argument(
"--check-release-tag",
action="store_true",
help="Validate that the selected release tag matches project.version, then exit without reading a formula.",
)
parser.add_argument(
"--sync-bootstrap-resources",
action="store_true",
help="Rewrite the formula's bootstrap_resources list from the canonical list and exit. Run after `brew update-python-resources`.",
)
parser.add_argument(
"--print-extra-packages",
action="store_true",
help="Print the canonical bootstrap package list for `brew update-python-resources --extra-packages`, then exit.",
)
return parser


def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)

if args.print_extra_packages:
print(",".join(BOOTSTRAP_RESOURCES))
return 0

if args.sync_bootstrap_resources:
if args.formula is None:
parser.error("--formula is required with --sync-bootstrap-resources")
changed = sync_bootstrap_resources(args.formula, check=args.check)
print(f"{args.formula}: bootstrap resources {'updated' if changed else 'already current'}")
return 0

version = args.version or load_project_version(args.pyproject)
tag = args.tag or expected_tag(version)
validate_tag(tag)
Expand All @@ -197,10 +309,10 @@ def main(argv: list[str] | None = None) -> int:
if args.formula is None:
parser.error("--formula is required unless --check-release-tag is set")

revision = args.revision or resolve_tag_revision(tag)
changed = update_formula(args.formula, tag, revision, check=args.check)
revision = None if args.sha256 else (args.revision or resolve_tag_revision(tag))
changed = update_formula(args.formula, tag, revision, sha256=args.sha256, check=args.check)
status = "updated" if changed else "already current"
print(f"{args.formula}: {status} at {tag}@{revision}")
print(f"{args.formula}: {status} at {tag}@{args.sha256 or revision}")
return 0


Expand Down
2 changes: 2 additions & 0 deletions src/smith/cli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ def handle_config_init(client: SmithClient | None, args: argparse.Namespace) ->
print(skill_result.message, file=stream)
if skill_result.ok and skill_result.mode == "symlink":
print("The skill will stay current when Smith is upgraded.", file=stream)
elif skill_result.ok and skill_result.mode == "copy":
print("Smith will refresh this copy automatically after upgrades.", file=stream)
print(file=stream)

manual_init = bool(getattr(args, "manual", False) or args.output_format == "json")
Expand Down
2 changes: 2 additions & 0 deletions src/smith/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from smith.cli.parser import build_parser
from smith.errors import SmithApiError, SmithAuthError
from smith.skill import ensure_skill_fresh

_SMITH_CLI_HANDLER_ATTR = "_smith_cli_handler"

Expand Down Expand Up @@ -51,6 +52,7 @@ def main(argv: list[str] | None = None) -> int:
return EXIT_INVALID_ARGS

command = getattr(args, "command_id", "unknown")
ensure_skill_fresh(command)

try:
validate_args_for_remote(args)
Expand Down
Loading
Loading