Enough theory. Let us build something real.
In this article, you will build a complete CLI tool using AI — from the initial description to a published package on PyPI. Every prompt, every AI response, and every manual fix is shown. You will see where AI excels, where it struggles, and how to handle both.
The project: linkcheck — a markdown link checker that scans your documentation files and reports broken links. It is a small, useful tool with enough complexity to show real patterns.
Step 1: Describe the Tool
The first prompt sets the direction for the entire project. Be specific about what you want.
Prompt to Claude Code:
I want to build a Python CLI tool called "linkcheck" that
checks markdown files for broken links.
Requirements:
- Scan one file or an entire directory recursively
- Check both internal links (relative paths) and external
links (HTTP/HTTPS URLs)
- For external links, make HTTP HEAD requests to check
if they return 200
- Support timeout and retry configuration
- Output results in a clean table format
- Return exit code 1 if any broken links are found
(for CI/CD usage)
- Ignore patterns: skip links matching certain patterns
(e.g., localhost, example.com)
Tech stack:
- Python 3.11+
- Click for CLI framework
- httpx for async HTTP requests
- Rich for terminal output
Do not implement anything yet. First, create the project
structure and a specification document.
Claude Code response (summary):
Claude Code created:
linkcheck/
├── pyproject.toml
├── README.md
├── src/
│ └── linkcheck/
│ ├── __init__.py
│ ├── cli.py # Click commands
│ ├── scanner.py # Markdown parsing
│ ├── checker.py # Link validation
│ └── reporter.py # Output formatting
└── tests/
├── __init__.py
├── test_scanner.py
├── test_checker.py
└── test_reporter.py
Plus a specification document outlining the public API for each module. This is exactly the output you want at step 1 — structure, not implementation.
What I reviewed and changed:
- Moved the
README.mdout of the specification — we will generate that at the end - Changed
pyproject.tomlto usehatchlingas the build backend instead ofsetuptools(Claude defaulted to setuptools, but hatchling is simpler for modern Python) - Confirmed the module structure makes sense before moving forward
Step 2: Implement the Scanner
Now we implement one module at a time. Start with the core: parsing markdown files to extract links.
Prompt:
Implement src/linkcheck/scanner.py.
It should:
- Parse markdown files to find all links
- Extract both inline links [text](url) and
reference links [text][ref]
- Return a list of LinkInfo objects with: url, line_number,
source_file, link_text
- Handle relative paths by resolving them against the
source file's directory
- Ignore code blocks (```...```) and inline code (`...`)
Use regex for parsing. Do not use a full markdown AST
parser — we want minimal dependencies.
Follow the specification from step 1.
Claude Code response (key part of generated code):
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass
class LinkInfo:
url: str
line_number: int
source_file: Path
link_text: str
is_external: bool
# Patterns to match markdown links
INLINE_LINK = re.compile(r'\[([^\]]+)\]\(([^)]+)\)')
REFERENCE_LINK = re.compile(r'\[([^\]]+)\]\[([^\]]*)\]')
REFERENCE_DEF = re.compile(r'^\[([^\]]+)\]:\s*(.+)$', re.MULTILINE)
CODE_BLOCK = re.compile(r'```[\s\S]*?```')
INLINE_CODE = re.compile(r'`[^`]+`')
def scan_file(file_path: Path) -> list[LinkInfo]:
"""Extract all links from a markdown file."""
content = file_path.read_text(encoding='utf-8')
# Remove code blocks and inline code before scanning
cleaned = CODE_BLOCK.sub('', content)
cleaned = INLINE_CODE.sub('', cleaned)
# Build reference map
references: dict[str, str] = {}
for match in REFERENCE_DEF.finditer(content):
references[match.group(1).lower()] = match.group(2).strip()
links: list[LinkInfo] = []
lines = cleaned.split('\n')
for line_num, line in enumerate(lines, start=1):
# Find inline links
for match in INLINE_LINK.finditer(line):
url = match.group(2).strip()
links.append(LinkInfo(
url=url,
line_number=line_num,
source_file=file_path,
link_text=match.group(1),
is_external=url.startswith(('http://', 'https://')),
))
# Find reference links
for match in REFERENCE_LINK.finditer(line):
ref_key = (match.group(2) or match.group(1)).lower()
if ref_key in references:
url = references[ref_key]
links.append(LinkInfo(
url=url,
line_number=line_num,
source_file=file_path,
link_text=match.group(1),
is_external=url.startswith(('http://', 'https://')),
))
return links
def scan_directory(dir_path: Path, recursive: bool = True) -> list[LinkInfo]:
"""Scan all markdown files in a directory."""
pattern = '**/*.md' if recursive else '*.md'
links: list[LinkInfo] = []
for md_file in sorted(dir_path.glob(pattern)):
links.extend(scan_file(md_file))
return links
What I reviewed and fixed:
- Bug: Line numbers were wrong. The code removes code blocks before splitting into lines, which shifts line numbers. I pointed this out to Claude Code.
Follow-up prompt:
The line numbers are wrong. When you remove code blocks
from the content, the line count changes. You need to
track line numbers from the original content, not the
cleaned content.
Fix this by scanning the original lines but skipping
lines that are inside code blocks.
Claude Code rewrote the function to track code block state while iterating through original lines. The fix was correct.
- Missing edge case: links with titles. Markdown allows
[text](url "title"). The regex did not account for this. I asked Claude Code to update the regex and it handled it correctly.
Step 3: Implement the Link Checker
Prompt:
Implement src/linkcheck/checker.py.
It should:
- Check external links with async HTTP HEAD requests
- Check internal links by verifying the file exists
- Support configurable timeout (default 10 seconds)
- Support configurable retry count (default 2)
- Support concurrent requests (max 10 parallel)
- Return CheckResult for each link: url, status (ok/broken/
timeout/skipped), status_code (for HTTP), error_message
Use httpx.AsyncClient for HTTP requests.
Use asyncio.Semaphore to limit concurrency.
Handle these edge cases:
- Some servers block HEAD requests — fall back to GET
- Some servers return 403 for automated requests —
use a browser-like User-Agent
- Redirect chains — follow up to 5 redirects
- SSL certificate errors — report but do not crash
Claude Code generated the checker module. The key part:
async def check_external_link(
client: httpx.AsyncClient,
link: LinkInfo,
semaphore: asyncio.Semaphore,
config: CheckConfig,
) -> CheckResult:
"""Check a single external link."""
async with semaphore:
for attempt in range(config.retries + 1):
try:
# Try HEAD first (faster)
response = await client.head(
link.url,
timeout=config.timeout,
follow_redirects=True,
)
# Some servers block HEAD, fall back to GET
if response.status_code == 405:
response = await client.get(
link.url,
timeout=config.timeout,
follow_redirects=True,
)
if response.status_code < 400:
return CheckResult(
link=link,
status=Status.OK,
status_code=response.status_code,
)
return CheckResult(
link=link,
status=Status.BROKEN,
status_code=response.status_code,
error=f"HTTP {response.status_code}",
)
except httpx.TimeoutException:
if attempt < config.retries:
continue
return CheckResult(
link=link,
status=Status.TIMEOUT,
error="Request timed out",
)
except httpx.ConnectError as e:
return CheckResult(
link=link,
status=Status.BROKEN,
error=f"Connection failed: {e}",
)
What I reviewed and fixed:
Missing SSL error handling. The prompt asked for it, but Claude Code did not add a
try/exceptfor SSL errors. Follow-up prompt fixed it.The User-Agent was generic. Claude Code used
httpx/0.24as the User-Agent. I asked it to change to a browser-like User-Agent string to avoid false 403 responses.No rate limiting between retries. The retries happened immediately. I asked Claude Code to add a 1-second delay between retries.
Step 4: Implement the CLI and Reporter
Prompt:
Implement src/linkcheck/cli.py and src/linkcheck/reporter.py.
cli.py:
- Use Click for the CLI interface
- Main command: linkcheck <path>
- Options: --timeout, --retries, --concurrent, --ignore-pattern
(can be specified multiple times), --format (table/json)
- Return exit code 0 if all links are OK, 1 if any are broken
reporter.py:
- Table format using Rich: show file, line, link text,
URL (truncated to 60 chars), and status with color
(green for OK, red for broken, yellow for timeout)
- JSON format: output a JSON array of results
- Summary line: "Checked X links: Y OK, Z broken, W timeout"
Claude Code generated both files. The CLI code was clean and correct. The reporter used Rich’s Table class effectively.
What I fixed manually:
The --ignore-pattern option needed to support both exact matches and glob patterns. Claude Code only implemented exact string matching. I added fnmatch support myself — it was a 3-line change that was easier to write than to explain.
Step 5: Add Tests
Prompt:
Write tests for all three modules.
For scanner tests:
- Create test markdown files as fixtures
- Test inline links, reference links, links in code blocks
(should be ignored), and links with titles
- Test directory scanning with nested directories
For checker tests:
- Mock HTTP responses with httpx mock
- Test: successful links, 404 links, timeouts, retries,
HEAD-to-GET fallback, SSL errors
- Test internal link checking with temporary files
For CLI tests:
- Use Click's CliRunner
- Test: single file, directory, broken links (exit code 1),
ignore patterns, JSON output
Use pytest with pytest-asyncio for async tests.
Claude Code generated comprehensive tests. Most worked on the first run.
Issues found during testing:
Three tests failed because the mock setup did not match the actual httpx API. Claude Code used
httpx.MockTransportincorrectly. I pasted the test errors back and Claude Code fixed them.The CLI test for
--ignore-patternfailed because the pattern was applied to the full URL, but the test expected it to apply to the domain only. This was actually a design decision — I kept the full URL matching and fixed the test assertion.
Final test run:
$ pytest -v
========================= test session starts =========================
tests/test_scanner.py::test_inline_links PASSED
tests/test_scanner.py::test_reference_links PASSED
tests/test_scanner.py::test_ignores_code_blocks PASSED
tests/test_scanner.py::test_links_with_titles PASSED
tests/test_scanner.py::test_directory_scan PASSED
tests/test_checker.py::test_successful_link PASSED
tests/test_checker.py::test_broken_link_404 PASSED
tests/test_checker.py::test_timeout_with_retry PASSED
tests/test_checker.py::test_head_to_get_fallback PASSED
tests/test_checker.py::test_ssl_error PASSED
tests/test_checker.py::test_internal_link_exists PASSED
tests/test_checker.py::test_internal_link_missing PASSED
tests/test_cli.py::test_single_file PASSED
tests/test_cli.py::test_directory PASSED
tests/test_cli.py::test_broken_link_exit_code PASSED
tests/test_cli.py::test_ignore_pattern PASSED
tests/test_cli.py::test_json_output PASSED
========================= 17 passed in 2.34s ==========================
All 17 tests passing.
Step 6: Generate Documentation
Prompt:
Generate a README.md for the linkcheck project.
Include:
- One-line description
- Installation (pip install linkcheck)
- Quick start with 3 examples
- Full option reference
- Configuration file support (.linkcheckrc)
- CI/CD integration example (GitHub Actions)
- Contributing section
Keep it concise. Users should find what they need
in under 30 seconds.
Claude Code generated a solid README. I made two edits:
- Removed a section about configuration file support — we did not implement that feature, and Claude Code hallucinated it.
- Simplified the GitHub Actions example to the essentials.
This is a common pattern with AI documentation: it adds features that do not exist. Always cross-check the README against the actual code.
Step 7: Publish to PyPI
Prompt:
Update pyproject.toml for PyPI publishing.
Include:
- Package name: linkcheck-cli (linkcheck is taken)
- Version: 0.1.0
- Description, author, license (MIT)
- Python 3.11+ requirement
- All dependencies with version pins
- Entry point: linkcheck = linkcheck.cli:main
- Classifiers for PyPI
Then show me the commands to build and publish.
Claude Code updated the pyproject.toml and provided the publish commands:
# Build the package
python -m build
# Upload to PyPI (test first)
twine upload --repository testpypi dist/*
# Upload to production PyPI
twine upload dist/*
What I checked manually:
- The entry point was correct
- The dependencies had appropriate version pins (not too tight, not too loose)
- The classifiers were accurate
- The license file existed
Time Tracking: The Full Build
Here is how long each step took:
| Step | Time | AI vs Manual |
|---|---|---|
| Project structure | 3 min | AI: 2 min, Review: 1 min |
| Scanner module | 12 min | AI: 5 min, Review + fix: 7 min |
| Checker module | 15 min | AI: 6 min, Review + fix: 9 min |
| CLI + Reporter | 8 min | AI: 4 min, Review + fix: 4 min |
| Tests | 18 min | AI: 8 min, Fix failures: 10 min |
| Documentation | 5 min | AI: 3 min, Review + fix: 2 min |
| Publishing setup | 4 min | AI: 2 min, Review: 2 min |
| Total | 65 min | AI: 30 min, Human: 35 min |
Without AI, this project would take 4-6 hours. The AI handled the boilerplate, the patterns, and the structure. The human handled the edge cases, the bug fixes, and the design decisions.
The human time was not wasted — it was the most important part. Reviewing and fixing AI output is where quality comes from.
What Went Well
- Project scaffolding was perfect. AI excels at creating standard project structures.
- The happy path code was solid. The basic functionality worked on the first try.
- Test generation saved the most time. Writing 17 tests manually would have taken over an hour.
- Documentation was 80% right. Only needed minor fixes.
What Needed Manual Fixing
- Line number tracking. AI did not think through the side effects of removing code blocks from content.
- Edge cases in HTTP handling. SSL errors, rate limiting between retries, and User-Agent strings all needed manual prompting.
- Hallucinated features in docs. AI described a configuration file we never built.
- Test mock setup. The httpx mock API was not quite right and needed two rounds of fixing.
Lessons Learned
Break it into small steps. One module at a time, one concern at a time. Do not ask AI to build the entire tool in one prompt.
Review before moving forward. If step 2 has a bug, everything built on top of it inherits that bug. Review each module before starting the next one.
The first 80% is fast. The last 20% is where you earn your pay. AI gets you to a working prototype quickly. Making it production-ready — with proper error handling, edge cases, and testing — is where the human effort matters.
Name things specifically in your prompts. “Handle errors” is vague. “Return CheckResult with status TIMEOUT and the error message” is specific. The more specific your prompt, the fewer rounds of fixes you need.
Key Takeaways
- Start with structure, not code. Have AI create the project layout and specification first.
- Implement one module at a time. Review each piece before building the next.
- Test as you go. Generate tests for each module immediately after implementing it.
- Budget 50% of your time for review. AI writes fast, but review is where quality happens.
- Document at the end and verify against reality. AI will describe features that do not exist.
What’s Next?
In Building a REST API with AI — Full-Stack Vibe Coding, you will tackle a larger project — a REST API with a database, authentication, and deployment. The patterns from this CLI project scale to bigger builds.
Part 15 of the Vibe Coding series.