Module 8: Capstone project: Your first project with Claude Code

Setup: CLAUDE.md, Skills, and Hooks

Setup: CLAUDE.md, Skills, and Hooks

Project goal

Build a working CLI tool using the complete Claude Code workflow. In this capsule you complete Phase 1: Setup — the professional configuration that separates a casual user from someone who knows what they're doing.


What you built in previous modules

  • Module 03: You learned to create a professional CLAUDE.md with the 6 essential sections
  • Module 05: You learned to create skills (slash commands) and configure hooks (lifecycle events)

In this capsule you apply both on a real project.


What you'll add in this module

By the end of this capsule you'll have:

your-cli-project/
├── CLAUDE.md                          ← Professional context
├── .claude/
│   ├── skills/
│   │   ├── create-command.md          ← Skill 1: create commands
│   │   └── add-tests.md              ← Skill 2: generate tests
│   ├── settings.json                  ← Hooks configured
│   └── settings.local.json            ← Personal settings
├── src/                               ← (empty for now)
└── tests/                             ← (empty for now)

Step-by-step walkthrough

Step 1: Create the project directory

Open your terminal and create the project:

mkdir my-cli-project
cd my-cli-project
git init

Replace my-cli-project with the name of your CLI. For example, if you picked the file organizer: fileorg. If you picked the notes CLI: notes-cli.

Check that git is initialized:

git status

You should see:

On branch main

No commits yet

nothing to commit (create directory if you want)

Step 2: Create the directory structure

For Python:

mkdir -p src tests .claude/skills
touch src/__init__.py tests/__init__.py

For TypeScript:

mkdir -p src tests .claude/skills

The structure should look like this:

my-cli-project/
├── .claude/
│   └── skills/
├── src/
└── tests/

Step 3: Create CLAUDE.md

This is the most important file in the setup. Claude Code reads it at the start of every session and uses it as context for all its responses.

Create the CLAUDE.md file at the root of the project.

CLAUDE.md for Python (Click):

# [Your CLI's name]

CLI for [one-sentence description]. Command-line tool
built with Python and Click.

## Stack
- Python 3.12
- Click 8.1 (CLI framework)
- pytest 8.0 (testing)
- Ruff (linter and formatter)

## Structure
src/
├── __init__.py
├── cli.py              → Entry point, Click command group
├── commands/            → One file per command
│   ├── __init__.py
│   ├── command_one.py   → First command
│   ├── command_two.py   → Second command
│   └── command_three.py → Third command
└── utils.py             → Shared helper functions

tests/
├── __init__.py
├── test_command_one.py
├── test_command_two.py
└── test_command_three.py

## Conventions
- snake_case for files, functions, and variables
- Type hints on every public function
- Docstrings on command functions (they're used as help text)
- Click decorators for arguments and options
- Rich or click.echo for formatted output
- Don't use print() directly — use click.echo() or rich.print()

## Development commands
- Install: `pip install -e ".[dev]"`
- Run the CLI: `python -m src.cli [command]`
- Tests: `pytest`
- Verbose tests: `pytest -v`
- Lint: `ruff check src/`
- Format: `ruff format src/`

## Rules
- Each command in its own file inside src/commands/
- Every command is registered in src/cli.py
- Do NOT mix business logic with CLI logic (Click)
- Always include --help with a clear description
- Handle errors with click.ClickException, not sys.exit()
- Run the tests after implementing each command

CLAUDE.md for TypeScript (Commander):

# [Your CLI's name]

CLI for [one-sentence description]. Command-line tool
built with TypeScript and Commander.

## Stack
- Node.js 20, TypeScript 5.4
- Commander 12 (CLI framework)
- Vitest 1.6 (testing)
- ESLint + Prettier

## Structure
src/
├── index.ts             → Entry point, Commander program
├── commands/            → One file per command
│   ├── commandOne.ts    → First command
│   ├── commandTwo.ts    → Second command
│   └── commandThree.ts  → Third command
└── utils.ts             → Shared helper functions

tests/
├── commandOne.test.ts
├── commandTwo.test.ts
└── commandThree.test.ts

## Conventions
- camelCase for variables and functions, PascalCase for types
- Command files in camelCase: commandOne.ts
- Type annotations on every exported function
- Commander .description() for help text
- chalk for colored output
- Don't use console.log for user-facing output — use a formatted helper
- process.exit() only in the entry point, never inside commands

## Development commands
- Install: `npm install`
- Build: `npm run build`
- Run the CLI: `npx ts-node src/index.ts [command]`
- Tests: `npm test`
- Lint: `npm run lint`
- Format: `npm run format`

## Rules
- Each command in its own file inside src/commands/
- Every command is registered in src/index.ts
- Do NOT mix business logic with CLI logic (Commander)
- Always include .description() on every command
- Handle errors with throw, not process.exit() inside commands
- Run the tests after implementing each command

Notes on the CLAUDE.md

  • Customize the name and description — replace [Your CLI's name] and [one-sentence description]
  • Adjust the command names — replace command_one, command_two, command_three with the real names of your commands
  • < 200 lines — the example is ~50 lines. You have room to add more detail if you need it
  • Go back to Module 03 if you need a refresher on CLAUDE.md best practices

Step 4: Create Skills

Skills are markdown files in .claude/skills/ that Claude Code reads when you invoke them with a slash command. You'll create 2 skills specific to your CLI project.

Skill 1: /create-command

This skill tells Claude how to create a new CLI command following the project's conventions.

For Python — .claude/skills/create-command/SKILL.md:

# Create a CLI command

Create a new command for the CLI following the project's structure
and conventions.

## Instructions

1. Create the file src/commands/[command_name].py
2. Import click and any needed utilities
3. Create the command function with the @click.command() decorator
4. Add arguments and options with @click.argument() and @click.option()
5. Implement the command's logic
6. Register the command in src/cli.py by importing it and adding it to the group
7. Check that the command shows up in --help

## Command template

```python
"""Command [name]: [short description]."""

import click

from src.utils import format_output


@click.command()
@click.argument("input_value")
@click.option("--verbose", "-v", is_flag=True, help="Detailed output")
def command_name(input_value: str, verbose: bool) -> None:
    """[Command description that shows up in --help]."""
    try:
        result = process(input_value)
        if verbose:
            format_output(result, detail=True)
        else:
            format_output(result)
    except Exception as e:
        raise click.ClickException(str(e))

Registering it in cli.py

After creating the command, add it to the group in src/cli.py:

from src.commands.command_name import command_name
cli.add_command(command_name)

Rules

  • One file = one command
  • The function's docstring = the command's help text
  • Errors with click.ClickException, not sys.exit()
  • Type hints on every parameter
  • Output with click.echo() or rich, not print()

**For TypeScript — `.claude/skills/create-command/SKILL.md`:**

```markdown
# Create a CLI command

Create a new command for the CLI following the project's structure
and conventions.

## Instructions

1. Create the file src/commands/[commandName].ts
2. Import any needed utilities
3. Create an exported function that takes Commander's Command
4. Define arguments, options, and description
5. Implement the command's logic
6. Register the command in src/index.ts
7. Check that the command shows up in --help

## Command template

```typescript
import { Command } from "commander";
import { formatOutput } from "../utils";

export function registerCommandName(program: Command): void {
  program
    .command("name")
    .description("[Command description]")
    .argument("<input>", "argument description")
    .option("-v, --verbose", "Detailed output")
    .action(async (input: string, options: { verbose?: boolean }) => {
      try {
        const result = await process(input);
        formatOutput(result, options.verbose);
      } catch (error) {
        console.error(`Error: ${(error as Error).message}`);
        process.exitCode = 1;
      }
    });
}

Registering it in index.ts

After creating the command, register it in src/index.ts:

import { registerCommandName } from "./commands/commandName";
registerCommandName(program);

Rules

  • One file = one command
  • .description() is mandatory for help text
  • Errors with throw, not process.exit() inside the action
  • Type annotations on every parameter
  • Output through a formatted helper, not raw console.log

#### Skill 2: `/add-tests`

This skill tells Claude how to generate tests for a specific command.

**For Python — `.claude/skills/add-tests/SKILL.md`:**

```markdown
# Generate tests for a command

Create tests for a specific CLI command using pytest.

## Instructions

1. Identify the command to test in src/commands/
2. Analyze the command's arguments, options, and logic
3. Create the file tests/test_[command_name].py
4. Use Click's CliRunner to test commands
5. Include tests for:
   - Happy path (normal use with valid arguments)
   - Missing or invalid arguments
   - Options/flags (--verbose, etc.)
   - Edge cases specific to the command
6. Run the tests: pytest tests/test_[command_name].py -v

## Test template

```python
"""Tests for the [name] command."""

from click.testing import CliRunner

from src.cli import cli


class TestCommandName:
    """Tests for the [name] command."""

    def setup_method(self) -> None:
        """Setup for each test."""
        self.runner = CliRunner()

    def test_basic_usage(self) -> None:
        """Test basic usage with valid arguments."""
        result = self.runner.invoke(cli, ["name", "argument"])
        assert result.exit_code == 0
        assert "expected output" in result.output

    def test_missing_argument(self) -> None:
        """Test a missing argument."""
        result = self.runner.invoke(cli, ["name"])
        assert result.exit_code != 0

    def test_verbose_flag(self) -> None:
        """Test the --verbose flag."""
        result = self.runner.invoke(cli, ["name", "argument", "--verbose"])
        assert result.exit_code == 0

    def test_edge_case(self) -> None:
        """Test a specific edge case."""
        result = self.runner.invoke(cli, ["name", ""])
        assert result.exit_code != 0

Rules

  • At least 3 tests per command
  • Use CliRunner to invoke commands (not subprocess)
  • Descriptive names: test_[action]_[scenario]
  • Every test checks exit_code AND output
  • If the command modifies files, use the tmp_path fixture

**For TypeScript — `.claude/skills/add-tests/SKILL.md`:**

```markdown
# Generate tests for a command

Create tests for a specific CLI command using vitest.

## Instructions

1. Identify the command to test in src/commands/
2. Analyze the command's arguments, options, and logic
3. Create the file tests/[commandName].test.ts
4. Test the command's logic directly (not through subprocess)
5. Include tests for:
   - Happy path (normal use with valid arguments)
   - Missing or invalid arguments
   - Options/flags (--verbose, etc.)
   - Edge cases specific to the command
6. Run the tests: npm test -- tests/[commandName].test.ts

## Test template

```typescript
import { describe, it, expect, beforeEach } from "vitest";

describe("name command", () => {
  it("should handle basic usage correctly", () => {
    const result = processCommand("valid-input");
    expect(result).toBeDefined();
    expect(result.status).toBe("success");
  });

  it("should throw on missing argument", () => {
    expect(() => processCommand("")).toThrow();
  });

  it("should handle verbose flag", () => {
    const result = processCommand("input", { verbose: true });
    expect(result.details).toBeDefined();
  });

  it("should handle edge case", () => {
    const result = processCommand("edge-case-input");
    expect(result.status).toBe("expected");
  });
});

Rules

  • At least 3 tests per command
  • Test the logic, not the CLI wrapper
  • Descriptive names in English
  • Use the describe/it pattern
  • If the command modifies files, use a temp fs with cleanup

---

### Step 5: Configure Hooks

Hooks are configured in `.claude/settings.json`. They run scripts automatically at specific points in Claude Code's lifecycle.

You'll create 2 hooks:

1. **PostToolUse (Write):** Runs the linter after every file written
2. **PreToolUse (Execute → git commit):** Runs the tests before every commit

#### Create `.claude/settings.json`

**For Python:**

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "ruff check --fix $CLAUDE_FILE_PATH 2>/dev/null || true"
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Execute",
        "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'git commit'; then pytest --tb=short -q 2>/dev/null; fi"
      }
    ]
  }
}

For TypeScript:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "npx eslint --fix $CLAUDE_FILE_PATH 2>/dev/null || true"
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Execute",
        "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'git commit'; then npm test -- --run 2>/dev/null; fi"
      }
    ]
  }
}

What each hook does

Hook 1 — PostToolUse (Write) → Linter:

Claude writes a file
    │
    ▼
The hook fires (matcher: "Write")
    │
    ▼
It runs ruff check / eslint on the file
    │
    ▼
Style errors → fixed automatically (--fix)
Logic errors → Claude sees the output and fixes them

Hook 2 — PreToolUse (Execute → git commit) → Tests:

Claude tries to run "git commit"
    │
    ▼
The hook fires (matcher: "Execute", input contains "git commit")
    │
    ▼
It runs pytest / npm test
    │
    ▼
Tests pass → the commit proceeds
Tests fail → Claude sees the error, fixes it, and retries

Create settings.local.json (optional)

For personal settings you don't want to share with the team:

{
  "permissions": {
    "allow": [
      "Write",
      "Read",
      "Glob",
      "Grep"
    ]
  }
}

This file goes in .gitignore — it isn't versioned.


Step 6: Configure the base project

Before moving to the next capsule, you need the base project configured so you can install dependencies.

For Python — create pyproject.toml:

[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "my-cli"
version = "0.1.0"
description = "CLI tool built with Claude Code"
requires-python = ">=3.8"
dependencies = [
    "click>=8.1",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "ruff>=0.4",
]

[project.scripts]
my-cli = "src.cli:cli"

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.pytest.ini_options]
testpaths = ["tests"]

For TypeScript — create package.json:

{
  "name": "my-cli",
  "version": "0.1.0",
  "description": "CLI tool built with Claude Code",
  "type": "module",
  "main": "src/index.ts",
  "scripts": {
    "build": "tsc",
    "start": "npx ts-node src/index.ts",
    "test": "vitest",
    "lint": "eslint src/",
    "format": "prettier --write src/"
  },
  "dependencies": {
    "commander": "^12.0.0"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "ts-node": "^10.9.0",
    "vitest": "^1.6.0",
    "eslint": "^9.0.0",
    "prettier": "^3.2.0",
    "@types/node": "^20.0.0"
  }
}

Also create tsconfig.json if you chose TypeScript:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

Step 7: Create the base entry point

You're not implementing the commands yet — that comes in Capsule 04. But you need the entry point so the CLI can run.

For Python — create src/cli.py:

"""CLI entry point."""

import click


@click.group()
@click.version_option(version="0.1.0")
def cli() -> None:
    """[Your CLI's name] - [short description]."""
    pass


if __name__ == "__main__":
    cli()

For TypeScript — create src/index.ts:

import { Command } from "commander";

const program = new Command();

program
  .name("my-cli")
  .description("[Short description of your CLI]")
  .version("0.1.0");

program.parse();

Step 8: Install dependencies

For Python:

pip install -e ".[dev]"

For TypeScript:

npm install

Verify the installation:

Python:

python -m src.cli --help

TypeScript:

npx ts-node src/index.ts --help

You should see your CLI's help text (empty for now, no commands).


Step 9: Create .gitignore

# Python
__pycache__/
*.pyc
*.egg-info/
dist/
.venv/

# Node
node_modules/
dist/

# Claude Code
.claude/settings.local.json

# OS
.DS_Store

Note that .claude/settings.local.json is in .gitignore but .claude/settings.json and .claude/skills/ are not — those get versioned with git.


Step 10: Verify the complete setup

Before moving to the next capsule, check that everything is in place.

File checklist

my-cli-project/
├── CLAUDE.md                          ✅
├── .claude/
│   ├── skills/
│   │   ├── create-command.md          ✅
│   │   └── add-tests.md              ✅
│   └── settings.json                  ✅
├── src/
│   ├── __init__.py (Python)           ✅
│   └── cli.py / index.ts             ✅
├── tests/
│   └── __init__.py (Python)           ✅
├── pyproject.toml / package.json      ✅
├── .gitignore                         ✅
└── tsconfig.json (TypeScript)         ✅

Verify with:

find . -not -path './node_modules/*' -not -path './.git/*' -not -path './__pycache__/*' | sort

Verify Claude Code

Open Claude Code in the project directory:

claude

Claude should read your CLAUDE.md automatically. Check with a simple prompt:

> What project is this? What stack does it use?

Claude should answer with the information from your CLAUDE.md. If it doesn't, check that the file is named exactly CLAUDE.md (uppercase) and sits at the root of the project.

Verify Skills

Try invoking a skill:

> /create-command

Claude should recognize the skill and ask for the command name. Don't implement anything yet — just check that the skill loads. Exit the session with /exit or Ctrl+C.

Verify Hooks

To check that the hooks are configured, you can read the file directly:

cat .claude/settings.json

You'll see the hooks in action in Capsule 04, when Claude starts writing files and making commits.


Complete commented code

Summary of files created

FilePurposeLines
CLAUDE.mdPersistent context for Claude Code~50
.claude/skills/create-command/SKILL.mdSkill for creating CLI commands~40
.claude/skills/add-tests/SKILL.mdSkill for generating tests~40
.claude/settings.jsonLinter and test hooks~15
pyproject.toml / package.jsonProject configuration~25
src/cli.py / src/index.tsCLI entry point~12
.gitignoreFiles to ignore in git~15

Total: ~200 lines of configuration. It sounds like a lot, but every file has a clear purpose and you configured it exactly once.


Troubleshooting

"Claude doesn't read my CLAUDE.md"

Cause: The file isn't named exactly CLAUDE.md (uppercase), or it isn't at the root of the project.

Fix:

ls -la CLAUDE.md

If you see claude.md, Claude.md, or similar, rename it:

mv claude.md CLAUDE.md

"The skill isn't recognized"

Cause: The file isn't in .claude/skills/, or it has the wrong extension.

Fix:

ls -la .claude/skills/

Check that the files end in .md and live in the right directory.

"The hooks don't fire"

Cause: The settings.json file isn't in .claude/, or it has a JSON syntax error.

Fix:

cat .claude/settings.json | python -m json.tool

If there's a JSON error, the output tells you where it is. The most common ones:

  • A comma after the last element of an array
  • Missing double quotes
  • Unclosed braces

"pip install fails"

Cause: Your pyproject.toml isn't configured correctly, or setuptools is missing.

Fix:

pip install --upgrade pip setuptools wheel
pip install -e ".[dev]"

"npm install fails"

Cause: An incompatible Node.js version, or errors in package.json.

Fix:

node --version  # Must be 18+
npm cache clean --force
npm install

"The linter isn't installed"

Cause: You didn't install the development dependencies.

For Python:

pip install ruff

For TypeScript:

npm install --save-dev eslint

"ruff / eslint can't find the file in the hook"

Cause: The $CLAUDE_FILE_PATH variable isn't resolving correctly.

Temporary fix: Change the hook to run the linter on the whole src directory:

Python:

{
  "matcher": "Write",
  "command": "ruff check --fix src/ 2>/dev/null || true"
}

TypeScript:

{
  "matcher": "Write",
  "command": "npx eslint --fix src/ 2>/dev/null || true"
}

Completion checklist

Before moving on to Capsule 03, check that:

  • The project directory is created, with git init
  • A professional CLAUDE.md with the 6 sections
  • The /create-command skill is created in .claude/skills/
  • The /add-tests skill is created in .claude/skills/
  • Hooks are configured in .claude/settings.json
  • The CLI entry point is created (cli.py / index.ts)
  • Dependencies are installed (pip install / npm install)
  • The CLI runs with --help (no commands yet)
  • .gitignore is configured
  • Claude Code reads CLAUDE.md correctly

If everything is checked, you're ready for Phase 2.


Summary

What you did in this capsule:

  1. Created the project with the right directory structure
  2. Wrote CLAUDE.md with the 6 professional sections adapted to your CLI
  3. Created 2 skills: /create-command (create new commands) and /add-tests (generate tests)
  4. Configured 2 hooks: automatic linter after Write, tests before commit
  5. Configured the base project with dependencies and an entry point

All of that before writing a single line of functional code. That's the difference between a casual user and a professional: the professional configures the environment before implementing.

Next capsule: 03 - Explore and plan — where you'll open Claude Code and use Explore and Plan to design your CLI before implementing it.