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
11 changes: 6 additions & 5 deletions Formula/smith.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,18 @@ def install

rm -rf "$target_dir"
mkdir -p "$(dirname "$target_dir")"
cp -R "$source_dir" "$target_dir"
echo "Smith skill installed to: $target_dir"
ln -s "$source_dir" "$target_dir"
echo "Smith skill linked to: $target_dir"
BASH
chmod 0555, bin/"smith-install-skill"
end

def caveats
<<~EOS
To install or refresh the Smith skill:
To link or refresh the Smith skill:
smith-install-skill

By default it writes to ~/.agents/skills/smith.
By default it links to ~/.agents/skills/smith.
To choose another destination:
SMITH_SKILL_DIR=/path/to/skills/smith smith-install-skill
EOS
Expand All @@ -203,8 +203,9 @@ def caveats

skill_dir = testpath/"skills/smith"
with_env("SMITH_SKILL_DIR" => skill_dir.to_s) do
system bin/"smith-install-skill"
assert_match "Smith skill linked to:", shell_output(bin/"smith-install-skill")
end
assert_predicate skill_dir, :symlink?
assert_path_exists skill_dir/"SKILL.md"
end
end
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ smith github-public code grep my-repo "TODO" --format json

```bash
brew install faustodavid/tap/smith
smith-install-skill
smith config init
```

Homebrew installs the `smith` CLI and `ripgrep`. `smith-install-skill` installs the canonical Smith skill into `~/.agents/skills/smith`.
Homebrew installs the `smith` CLI and `ripgrep`. `smith config init` creates your config file and links the canonical Smith skill into `~/.agents/skills/smith`.

### Install with the standalone script

Expand Down Expand Up @@ -184,9 +184,10 @@ The installer keeps a managed Smith repo checkout at `~/.local/share/smith`, mir

```bash
brew upgrade faustodavid/tap/smith
smith-install-skill
```

The Homebrew skill link points at Homebrew's stable `opt/smith` path, so it stays current after `brew upgrade`. If you already have a config and only need to refresh the skill link, run `smith skill sync`.

If you installed with the standalone script instead, rerun `python3 ~/.local/share/smith/scripts/install.py`.

The standalone installer runs `uv tool update-shell` for you, but you may need to **restart your shell** (or open a new terminal) for PATH changes to take effect - especially on Windows, where `uv` writes the update to the user PATH in the registry.
Expand Down Expand Up @@ -342,7 +343,7 @@ Smith was built for AI agents from the ground up. `skills/smith/SKILL.md` is a s
- **Failure recovery** — specific handlers for 401 / 403, 429, truncation, empty results, and wrong-repo misses.
- **Answer contract** — evidence-first format with exact path citations and a `Sources` section.

The Homebrew formula and standalone installer both mirror the canonical skill into `~/.agents/skills/smith`. The standalone installer also keeps a managed repo checkout at `~/.local/share/smith`.
`smith config init` links the canonical skill into `~/.agents/skills/smith`. Homebrew installs use a symlink to Homebrew's stable `opt/smith` path, so the skill stays current after `brew upgrade`.

---

Expand Down Expand Up @@ -481,7 +482,7 @@ Provider MCPs expose a `get_file_contents`-style surface that downloads entire f

### Does Smith work with Claude Code, Cursor, Windsurf, GitHub Copilot, and Codex?

Yes. Smith ships a structured skill document (`skills/smith/SKILL.md`) that any LLM-powered editor can load as a rule / instruction / skill. The Homebrew formula and standalone installer mirror the canonical skill into `~/.agents/skills/smith`. See [Use with your AI editor](#use-with-your-ai-editor) for per-editor hints.
Yes. Smith ships a structured skill document (`skills/smith/SKILL.md`) that any LLM-powered editor can load as a rule / instruction / skill. `smith config init` links it into `~/.agents/skills/smith`. See [Use with your AI editor](#use-with-your-ai-editor) for per-editor hints.

### Is Smith read-only? Can an agent accidentally push or comment?

Expand Down
76 changes: 73 additions & 3 deletions src/smith/cli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
from smith.client import SmithClient
from smith.config import RemoteConfig, SmithConfig, _default_config_path, load_config, save_config
from smith.formatting import dumps_json, make_envelope, render_text
from smith.skill import (
SkillSyncResult,
default_skill_target_dir,
resolve_skill_source_dir,
skill_target_points_to_source,
sync_skill,
)

EXIT_OK = 0
EXIT_INVALID_ARGS = 2
Expand Down Expand Up @@ -263,13 +270,21 @@ def handle_config_init(client: SmithClient | None, args: argparse.Namespace) ->
exit_code=EXIT_INVALID_ARGS,
)

skill_result = sync_skill()
if args.output_format != "json":
stream = sys.stdout if skill_result.ok else sys.stderr
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)
print(file=stream)

if args.output_format == "json":
config = SmithConfig(remotes={}, defaults={})
save_config(config, config_path=path)
return _emit_success(
args=args,
command=args.command_id,
data={"path": str(path), "remotes_count": 0},
data={"path": str(path), "remotes_count": 0, "skill": skill_result.to_dict()},
partial=False,
)

Expand All @@ -294,20 +309,24 @@ def handle_config_init(client: SmithClient | None, args: argparse.Namespace) ->
raise SystemExit(1)
if not raw or raw == "1":
config = run_interactive_init(config_path=path)
if args.output_format != "json":
return EXIT_OK
return _emit_success(
args=args,
command=args.command_id,
data={"path": str(path), "remotes_count": len(config.remotes)},
data={"path": str(path), "remotes_count": len(config.remotes), "skill": skill_result.to_dict()},
partial=False,
)
if raw == "2":
config = SmithConfig(remotes={}, defaults={})
save_config(config, config_path=path)
_print_manual_setup_instructions(path)
if args.output_format != "json":
return EXIT_OK
return _emit_success(
args=args,
command=args.command_id,
data={"path": str(path), "remotes_count": 0},
data={"path": str(path), "remotes_count": 0, "skill": skill_result.to_dict()},
partial=False,
)
print(" Enter 1 or 2.")
Expand Down Expand Up @@ -340,6 +359,57 @@ def handle_config_path(client: SmithClient | None, args: argparse.Namespace) ->
)


def _skill_status_data(result: SkillSyncResult | None = None) -> dict[str, object]:
target = default_skill_target_dir()
source = resolve_skill_source_dir()
target_exists = target.exists() or target.is_symlink()
points_to_source = False
mode: str | None = None
if target_exists:
mode = "symlink" if target.is_symlink() else "directory"
if source is not None:
points_to_source = skill_target_points_to_source(target, source)
data: dict[str, object] = {
"target": str(target),
"source": str(source) if source is not None else None,
"exists": target_exists,
"mode": mode,
"current": bool(target_exists and points_to_source),
}
if result is not None:
data["sync"] = result.to_dict()
return data


def handle_skill_sync(client: SmithClient | None, args: argparse.Namespace) -> int:
del client
result = sync_skill()
if not result.ok:
return _emit_error(
args=args,
command=args.command_id,
code=result.status,
message=result.message,
exit_code=EXIT_INVALID_ARGS,
)
return _emit_success(
args=args,
command=args.command_id,
data=_skill_status_data(result),
partial=False,
)


def handle_skill_status(client: SmithClient | None, args: argparse.Namespace) -> int:
del client
return _emit_success(
args=args,
command=args.command_id,
data=_skill_status_data(),
partial=False,
)


def handle_config_enable(client: SmithClient | None, args: argparse.Namespace) -> int:
del client
config = load_config()
Expand Down
6 changes: 3 additions & 3 deletions src/smith/cli/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path

from smith.config import (
_RESERVED_REMOTE_NAMES,
_NEW_REMOTE_RESERVED_NAMES,
RemoteConfig,
SmithConfig,
_compute_api_url_for_remote,
Expand Down Expand Up @@ -116,8 +116,8 @@ def _prompt_yes_no(prompt: str, *, default: bool = True) -> bool:


def _validate_remote_name(name: str) -> str | None:
if name.lower() in _RESERVED_REMOTE_NAMES:
reserved = ", ".join(sorted(_RESERVED_REMOTE_NAMES))
if name.lower() in _NEW_REMOTE_RESERVED_NAMES:
reserved = ", ".join(sorted(_NEW_REMOTE_RESERVED_NAMES))
return f"'{name}' is reserved. Avoid: {reserved}"
if not name.replace("-", "").replace("_", "").isalnum():
return "Name must contain only letters, numbers, hyphens, and underscores."
Expand Down
28 changes: 28 additions & 0 deletions src/smith/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
handle_pr_list,
handle_pr_search,
handle_pr_threads,
handle_skill_status,
handle_skill_sync,
handle_work_get,
handle_work_mine,
handle_work_search,
Expand Down Expand Up @@ -341,6 +343,31 @@ def _add_config_group(root_subparsers: Any) -> None:
)


def _add_skill_group(root_subparsers: Any) -> None:
skill = _add_parser(root_subparsers, "skill", help_text="Manage the Smith agent skill")
skill_sub = skill.add_subparsers(dest="skill_action", required=True)

skill_sync = _add_parser(skill_sub, "sync", help_text="Install or refresh the Smith agent skill")
_add_output_format(skill_sync)
_set_handler(
skill_sync,
handle_skill_sync,
"skill.sync",
primary_path="skill sync",
requires_client=False,
)

skill_status = _add_parser(skill_sub, "status", help_text="Show Smith agent skill sync status")
_add_output_format(skill_status)
_set_handler(
skill_status,
handle_skill_status,
"skill.status",
primary_path="skill status",
requires_client=False,
)


def _add_cache_group(root_subparsers: Any, *, remotes: list[RemoteConfig]) -> None:
cache = _add_parser(root_subparsers, "cache", help_text="Manage local grep caches")
cache_sub = cache.add_subparsers(dest="cache_action", required=True)
Expand Down Expand Up @@ -738,6 +765,7 @@ def build_parser(*, smith_config: SmithConfig | None = None) -> argparse.Argumen
_add_global_code_group(root_subparsers)
_add_global_prs_group(root_subparsers)
_add_config_group(root_subparsers)
_add_skill_group(root_subparsers)
_add_cache_group(root_subparsers, remotes=remotes)
for remote in remotes:
_add_remote_command_tree(root_subparsers, remote=remote)
Expand Down
1 change: 1 addition & 0 deletions src/smith/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def _compute_api_url_for_remote(provider: str, host: str) -> str:


_RESERVED_REMOTE_NAMES = {"all", "cache", "code", "config", "help", "prs", "search"}
_NEW_REMOTE_RESERVED_NAMES = _RESERVED_REMOTE_NAMES | {"skill"}
Comment thread
faustodavid marked this conversation as resolved.


def _normalize_config_api_url(raw_api_url: Any) -> str:
Expand Down
29 changes: 29 additions & 0 deletions src/smith/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,32 @@ def _render_config_show(data: Any) -> str:
return "\n".join(lines)


def _render_config_init(data: Any) -> str:
if not isinstance(data, dict):
return ""
lines = [f"Config saved to {data.get('path')}"]
skill = data.get("skill")
if isinstance(skill, dict) and skill.get("message"):
lines.append(str(skill["message"]))
return "\n".join(lines)


def _render_skill_status(data: Any) -> str:
if not isinstance(data, dict):
return ""
lines = [
f"target: {data.get('target')}",
f"source: {data.get('source')}",
f"exists: {str(bool(data.get('exists'))).lower()}",
f"mode: {data.get('mode') or '-'}",
f"current: {str(bool(data.get('current'))).lower()}",
]
sync = data.get("sync")
if isinstance(sync, dict) and sync.get("message"):
lines.append(str(sync["message"]))
return "\n".join(lines)


def _render_pipelines_list(data: Any) -> str:
pipelines = data.get("pipelines", []) if isinstance(data, dict) else []
lines: list[str] = []
Expand Down Expand Up @@ -885,6 +911,9 @@ def _render_needs(needs: Any, *, name_to_id: dict[str, Any]) -> str:
"stories.mine": _render_story_table,
"config.list": _render_config_list,
"config.show": _render_config_show,
"config.init": _render_config_init,
"skill.sync": _render_skill_status,
"skill.status": _render_skill_status,
}


Expand Down
Loading
Loading