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: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
python -m pip install -e .[dev]
- name: Lint
run: ruff check .
- name: Format
run: ruff format --check .
- name: Typecheck
run: mypy src
- name: Unit tests
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
rev: v0.15.8
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
rev: v1.19.1
hooks:
- id: mypy
args: [--config-file=pyproject.toml, src]
pass_filenames: false
additional_dependencies:
- types-requests>=2.32.0.20241016
- types-requests==2.33.0.20260327
6 changes: 2 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@
## Setup

```bash
python3 -m pip install -e .[dev]
python3 -m pip install -e '.[dev]'
```

## Quality Gates

Run before opening a PR:

```bash
ruff check .
mypy src
pytest -q
make check
```

## Contract Stability
Expand Down
4 changes: 2 additions & 2 deletions Formula/smith.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ class Smith < Formula
desc "Read-only source-of-truth investigation CLI for AI agents"
homepage "https://github.com/faustodavid/smith"
url "https://github.com/faustodavid/smith.git",
tag: "v0.1.0",
revision: "a36a75ea9b84e9bcd27d6308903cdb29776986f4"
tag: "v0.1.1",
revision: "0a4bc1eadf0d0a20fb4af3ed20c3974d33fcc63d"
license "MIT"
head "https://github.com/faustodavid/smith.git", branch: "main"

Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
.PHONY: lint typecheck test-unit test-contract test-integration test skill-validate check install install-global
.PHONY: lint format typecheck test-unit test-contract test-integration test skill-validate check install install-global

lint:
uv run --extra dev ruff check .

format:
uv run --extra dev ruff format --check .

typecheck:
uv run --extra dev mypy src

Expand All @@ -22,7 +25,7 @@ test:
skill-validate:
uv run --extra dev python scripts/validate_skill_quality.py --mode all

check: lint typecheck test skill-validate
check: lint format typecheck test skill-validate

install:
uv pip install -e ".[dev]"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ smith github-public code grep my-repo "TODO" --format json
### Prerequisites

- **[Homebrew](https://brew.sh/)** for the recommended macOS / Linux install
- **git** and **[uv](https://docs.astral.sh/uv/)** for the standalone installer
- **git**, **ripgrep** (`rg`), and **[uv](https://docs.astral.sh/uv/)** for the standalone installer

### Install with Homebrew

Expand Down
10 changes: 4 additions & 6 deletions 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.0"
version = "0.1.1"
description = "Read-only Azure DevOps and GitHub investigation CLI"
readme = "README.md"
requires-python = ">=3.12"
Expand All @@ -24,13 +24,13 @@ dependencies = [
dev = [
"coverage>=7.13.5",
"mcp>=1.26.0",
"mypy>=1.19.1",
"mypy==1.19.1",
"openai-agents>=0.13.2",
"pytest>=9.0.2",
"pytest-asyncio>=0.26.0",
"pytest-cov>=7.1.0",
"ruff>=0.15.8",
"types-requests>=2.33.0.20260327"
"ruff==0.15.8",
"types-requests==2.33.0.20260327"
]
bench = [
"aiohttp>=3.13.4",
Expand All @@ -53,7 +53,6 @@ where = ["src"]
target-version = "py312"
line-length = 140
src = ["src", "scripts", "tests"]
extend-exclude = ["scripts"]

[tool.ruff.lint]
select = ["E", "F", "I"]
Expand All @@ -77,4 +76,3 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["scripts.*"]
ignore_errors = true

2 changes: 1 addition & 1 deletion scripts/check_targeted_coverage.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
# ruff: noqa: I001
from __future__ import annotations

import sys
from pathlib import Path

from coverage import Coverage


MODULE_FLOORS: dict[str, float] = {
"smith.cli.main": 95.0,
"smith.cli.handlers": 85.0,
Expand Down
25 changes: 20 additions & 5 deletions scripts/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

REPO_URL = "https://github.com/faustodavid/smith.git"
Expand All @@ -29,9 +30,21 @@ def require_tool(name: str, install_hint: str) -> None:

def sync_skill(source: Path, target: Path) -> None:
"""Copy skill directory to target."""
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target)
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"
backup = temp_root / "backup"
try:
shutil.copytree(source, staged)
if target.exists():
target.replace(backup)
staged.replace(target)
except Exception:
if backup.exists() and not target.exists():
backup.replace(target)
raise
finally:
shutil.rmtree(temp_root, ignore_errors=True)
print(f" Synced skill to: {target}")


Expand Down Expand Up @@ -59,8 +72,10 @@ def main() -> None:
run(["git", "-C", str(REPO_DIR), "pull", "--ff-only", "origin", "main"])
else:
print(f"==> Cloning smith to {REPO_DIR}")
if REPO_DIR.exists():
shutil.rmtree(REPO_DIR)
if REPO_DIR.exists() and any(REPO_DIR.iterdir()):
print(f"Error: refusing to replace non-git directory: {REPO_DIR}", file=sys.stderr)
print("Move or remove it before installing Smith.", file=sys.stderr)
sys.exit(1)
run(["git", "clone", REPO_URL, str(REPO_DIR)])

if not SKILL_SOURCE.exists():
Expand Down
1 change: 1 addition & 0 deletions scripts/run_runtime_benchmark.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# ruff: noqa: E402, I001
from __future__ import annotations

import sys
Expand Down
11 changes: 10 additions & 1 deletion scripts/smith_format.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# ruff: noqa: E402, I001
from __future__ import annotations

import sys
Expand All @@ -9,7 +10,15 @@
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))

from smith.formatting import dumps_json, format_grep_matches, glob_to_regex, make_envelope, normalize_branch_name, render_text, truncate_output # noqa: E402
from smith.formatting import (
dumps_json,
format_grep_matches,
glob_to_regex,
make_envelope,
normalize_branch_name,
render_text,
truncate_output,
) # noqa: E402

__all__ = [
"make_envelope",
Expand Down
61 changes: 53 additions & 8 deletions scripts/validate_skill_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
TRIGGER_CASES_DOC = SKILL_DIR / "references" / "trigger-cases.md"
BEHAVIOR_GATES_DOC = SKILL_DIR / "references" / "behavioral-quality-gates.md"
FAILURE_PLAYBOOK_DOC = SKILL_DIR / "references" / "failure-playbook.md"
TOKEN_ENV_VARS = (
"GITHUB_TOKEN",
"GITLAB_TOKEN",
"AZURE_DEVOPS_PAT",
"YOUTRACK_TOKEN",
)


def _candidate_roots() -> list[Path]:
candidates: list[Path] = []
Expand Down Expand Up @@ -96,6 +103,17 @@ def _extract_frontmatter(skill_text: str) -> dict[str, str]:
def classify_trigger(prompt: str) -> str:
text = prompt.lower()

write_targets = r"(?:work\s+item|github\s+issue|issue|ticket|story|pr|pull\s+request|merge\s+request)"
write_patterns = [
rf"\bcreate\b.+\b{write_targets}\b",
rf"\bupdate\b.+\b{write_targets}\b",
r"\bapprove\b.+\b(?:pr|pull\s+request|merge\s+request)\b",
rf"\bcomment\s+on\b.+\b{write_targets}\b",
rf"\bclose\b.+\b{write_targets}\b",
]
if any(re.search(pattern, text) for pattern in write_patterns):
return "negative"

negative_terms = [
"create work item",
"create a work item",
Expand Down Expand Up @@ -175,6 +193,37 @@ def _missing_marker_errors(text: str, markers: list[str], template: str) -> list
return [template.format(marker=marker) for marker in markers if marker not in text]


def _strip_markdown_command_prefix(line: str) -> str:
stripped = line.strip()
while stripped.startswith(">"):
stripped = stripped[1:].lstrip()
while len(stripped) >= 2 and stripped[:2] in {"- ", "* ", "+ "}:
stripped = stripped[2:].lstrip()
if stripped.startswith("$ "):
stripped = stripped[2:].lstrip()
return stripped


def _validate_auth_troubleshooting_token_safety(auth_text: str) -> list[str]:
token_names = "|".join(re.escape(token) for token in TOKEN_ENV_VARS)
unsafe_patterns = [
re.compile(r"^\s*(?:env|printenv|set)(?:\s*(?:\||>|$))"),
re.compile(rf"^\s*printenv\s+({token_names})(?:\s|$)"),
re.compile(rf"^\s*echo\b.*\$(?:{{)?({token_names})(?::-[^}}]*)?(?:}})?"),
re.compile(rf"^\s*printf\b.*\$(?:{{)?({token_names})(?::-[^}}]*)?(?:}})?"),
re.compile(rf"^\s*(?:env|printenv)\b.*\|\s*grep\b.*(?:{token_names})"),
]

errors: list[str] = []
for line_no, line in enumerate(auth_text.splitlines(), start=1):
command = _strip_markdown_command_prefix(line)
if command.startswith("#"):
continue
if any(pattern.search(command) for pattern in unsafe_patterns):
errors.append(f"Auth troubleshooting must not print token values: line {line_no}")
return errors


def _validate_trigger_frontmatter(skill_text: str, description: str) -> list[str]:
errors: list[str] = []
if "Use when" not in description:
Expand Down Expand Up @@ -338,23 +387,19 @@ def run_behavior_checks() -> list[str]:

skill_text = _read(SKILL_MD)
recipes_text = _read(USAGE_RECIPES)
auth_text = _read(AUTH_TROUBLE)
failure_text = _read(FAILURE_PLAYBOOK_DOC)
errors.extend(_validate_auth_troubleshooting_token_safety(auth_text))

required_skill_sections = [
"## Investigation Algorithm",
"## Stop Conditions",
"## Failure Handling",
]
errors.extend(
_missing_marker_errors(skill_text, required_skill_sections, "SKILL.md missing behavioral section: {marker}")
)
errors.extend(_missing_marker_errors(skill_text, required_skill_sections, "SKILL.md missing behavioral section: {marker}"))

recovery_terms = ["401 or 403", "429", "Truncation", "Empty results", "Wrong repository"]
errors.extend(
f"Recovery flow missing term: {term}"
for term in recovery_terms
if term not in skill_text and term not in failure_text
)
errors.extend(f"Recovery flow missing term: {term}" for term in recovery_terms if term not in skill_text and term not in failure_text)

command_markers = [
"smith <azdo-remote-name> orgs",
Expand Down
10 changes: 5 additions & 5 deletions skills/smith/references/auth-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ If the needed remote is missing, add it to the active config or point `SMITH_CON
## Missing token env var

```bash
printenv GITHUB_TOKEN
printenv GITLAB_TOKEN
printenv AZURE_DEVOPS_PAT
printenv YOUTRACK_TOKEN
test -n "${GITHUB_TOKEN:-}" && echo GITHUB_TOKEN=set || echo GITHUB_TOKEN=missing
test -n "${GITLAB_TOKEN:-}" && echo GITLAB_TOKEN=set || echo GITLAB_TOKEN=missing
test -n "${AZURE_DEVOPS_PAT:-}" && echo AZURE_DEVOPS_PAT=set || echo AZURE_DEVOPS_PAT=missing
test -n "${YOUTRACK_TOKEN:-}" && echo YOUTRACK_TOKEN=set || echo YOUTRACK_TOKEN=missing
```

Set the env var named by the remote's `token_env` field.
Do not print token values. Set the env var named by the remote's `token_env` field.

## Provider-specific login

Expand Down
4 changes: 1 addition & 3 deletions src/smith/benchmark/codex_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ def copy_codex_auth_into_home(
source_home = resolve_codex_auth_home(env)
source_auth = source_home / CODEX_AUTH_FILENAME
if not source_auth.exists():
raise FileNotFoundError(
f"Could not find Codex auth at {source_auth}. Run `codex login` before using --executor codex."
)
raise FileNotFoundError(f"Could not find Codex auth at {source_auth}. Run `codex login` before using --executor codex.")
codex_home.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_auth, codex_home / CODEX_AUTH_FILENAME)

Expand Down
8 changes: 2 additions & 6 deletions src/smith/benchmark/copilot_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ def resolve_copilot_sdk_path(env: dict[str, str] | None = None) -> str:

candidates = list(Path.home().glob(COPILOT_SDK_GLOB))
if not candidates:
raise FileNotFoundError(
"Could not locate the Copilot SDK. Set COPILOT_SDK_PATH to a copilot-sdk/index.js file."
)
raise FileNotFoundError("Could not locate the Copilot SDK. Set COPILOT_SDK_PATH to a copilot-sdk/index.js file.")
latest = max(candidates, key=lambda path: path.stat().st_mtime)
return str(latest)

Expand Down Expand Up @@ -122,9 +120,7 @@ def build_github_copilot_payload(
) -> dict[str, Any]:
return {
"cliArgs": list(COPILOT_DEFAULT_CLI_ARGS),
"availableTools": [
copilot_tool_name(GITHUB_MCP_SERVER_NAME, tool_name) for tool_name in GITHUB_MCP_TOOL_NAMES
],
"availableTools": [copilot_tool_name(GITHUB_MCP_SERVER_NAME, tool_name) for tool_name in GITHUB_MCP_TOOL_NAMES],
"mcpServers": {
GITHUB_MCP_SERVER_NAME: {
"type": "http",
Expand Down
3 changes: 1 addition & 2 deletions src/smith/benchmark/github_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ def resolve_github_mcp_token(env: Mapping[str, str] | None = None) -> str:
return fallback

raise RuntimeError(
"GitHub MCP authentication is unavailable. Set GITHUB_TOKEN, GH_TOKEN, or "
"COPILOT_GITHUB_TOKEN, or run `gh auth login`."
"GitHub MCP authentication is unavailable. Set GITHUB_TOKEN, GH_TOKEN, or COPILOT_GITHUB_TOKEN, or run `gh auth login`."
)


Expand Down
Loading
Loading