⌂ Home
Gemini CLI: From Zero to Production
Track 2 — Skills & Agentic Workflows Module 4 of 18
📄 M03 ⏱ 55 min 💪 Beginner
← M02: GEMINI.md M04: Plan Mode →
Module 03 · Track 2: Skills & Agentic Workflows

skill.md & Custom Commands

Every TaskFlow developer runs the same prompts over and over: "write a test for this router", "summarize the PR diff", "check if the schema matches the model." Skills let you write each of those prompts once, store them in .gemini/skills/, and invoke them with a short slash command — with arguments, tab completion, and no retyping.

What You'll Learn

  • Understand what a skill.mdA Markdown file in .gemini/skills/ that registers a reusable custom slash command. Contains optional YAML frontmatter (name, description, arguments) plus a prompt body with {{placeholder}} template variables. file is and why it replaces retyped prompts
  • Read and write the two-part skill structure: YAML frontmatterThe --- delimited block at the top of a Markdown file. In skill files it declares name:, description:, and arguments: that appear in tab-completion hints. + prompt body
  • Use template variables{{arg_name}} placeholders in a skill's prompt body. Replaced with actual values at invocation time, so one skill file can handle many different inputs. to pass file paths and branch names into skills
  • Create project-level and global skills — and know which wins when names collide
  • Build a complete TaskFlow skill library: test-generator, pr-summary, schema-validator, endpoint-docs, migration-helper

What Are Skills?

Analogy — The TextExpander Snippet

BEFORE: Picture a customer support agent who types the same 12-sentence escalation email 30 times a day. Each time they start fresh: today they forgot to include the ticket number, yesterday they pasted the wrong SLA clause, last Friday they sent the entire email in lowercase. Every instance is slightly different, and the differences compound into real customer complaints.

PAIN: The same drift happens when you retype Gemini prompts by hand. Your "write tests for this router" prompt was great on Monday. By Thursday it's shorter, missing the "include error cases" instruction, and forgetting to reference @sample-project/taskflow/schemas.py. The inconsistency means your test coverage is inconsistent too.

MAPPING: A skill.md is a saved snippet for AI prompts. Instead of typing the full prompt each time, you write it once in .gemini/skills/test-generator.md, give it a name, and from that moment /test-generator file=routers/tasks.py expands into the complete, annotated, always-consistent prompt — including the right file references, the error-case requirement, and the schema import reminder.

Technical Definition

A skill.md is a Markdown file placed in .gemini/skills/ (project-level) or ~/.gemini/skills/ (global). It has two parts: optional YAML frontmatter between --- delimiters that declares name:, description:, and arguments:; and a prompt body in plain Markdown that is sent to Gemini verbatim when the skill is invoked. The name: field becomes the slash command. {{argument_name}} placeholders in the body are replaced with values you pass at call time.

On startup, Gemini CLI scans both skill directories and registers each file as a /skill-name command. Type / in the REPL to see all skills — yours and built-ins — with tab completion.

Why It Matters

Teams that standardize their code-review prompts catch 3–5x more issues than teams who free-form it each session. Skills enforce that consistency across your entire team. Better still: project-level skills live in .gemini/skills/ which gets committed to git — a new teammate who clones TaskFlow gets every skill immediately, zero onboarding steps.

Animation 1 — How a Skill Invocation Works
skill.md
on disk
You type
/skill args
Args fill
{{placeholders}}
Full prompt
sent to Gemini
Response
in terminal
Waiting for animation...

The .gemini/skills/ Directory

Skills live in one of two places. Project-level skills go inside your repo and are tracked by git. Global skills are personal — they apply to every project you open.

Priority Rule

When a project skill and a global skill share the same name:, the project-level skill always wins. This lets TaskFlow override your personal test-generator with a project-specific version that knows about schemas.py and the pytest-asyncio fixture setup — without touching your global toolkit.

Writing Your First Skill: test-generator

The most common TaskFlow prompt is "write pytest tests for this router." Instead of typing that prompt every day with slightly different wording, let's encode it once. The goal: run /test-generator file=routers/tasks.py and get a complete test file with auth fixtures, happy-path tests, and error cases.

Step 1 — Create the directory

# Navigate to the TaskFlow project root first
cd sample-project\taskflow

# Create the skills directory
New-Item -ItemType Directory -Force .gemini\skills

# Confirm it exists
Get-ChildItem .gemini\
WHAT: Creates .gemini\skills\ in the TaskFlow root. -Force means it won't error if the directory already exists — safe to run repeatedly.
# Navigate to the TaskFlow project root first
cd sample-project/taskflow

# Create the skills directory
mkdir -p .gemini/skills

# Confirm it exists
ls -la .gemini/

Step 2 — Write the skill file

sample-project/taskflow/.gemini/skills/test-generator.md
---
name: test-generator
description: Generate pytest tests for a TaskFlow router file
arguments:
  - name: file
    description: Router file path relative to project root (e.g. routers/tasks.py)
    required: true
---

You are writing pytest tests for the TaskFlow FastAPI application.

Target file: @{{file}}

Context files to read first:
- @sample-project/taskflow/schemas.py       — Pydantic models for request/response
- @sample-project/taskflow/models.py        — SQLAlchemy ORM models
- @sample-project/taskflow/auth.py          — get_current_user dependency

Requirements:
1. Use pytest with httpx.AsyncClient (already in requirements.txt)
2. Include a `test_client` fixture that creates a real in-memory SQLite DB
3. Include a `auth_headers` fixture that registers + logs in a test user
4. Test every endpoint in the file:
   - Happy path (201/200)
   - Auth required (401 when no token)
   - Not found (404 for missing IDs)
   - Validation error (422 for bad input)
5. Use descriptive test names: test_create_task_returns_201_with_valid_data
6. Add a docstring to each test explaining WHAT it validates and WHY it matters

Output: a complete, runnable test file. No pseudocode. No placeholders.
WHAT: The YAML frontmatter (lines 1–7) registers this as /test-generator with one required argument called file. The body (everything after the second ---) is the actual prompt Gemini receives. @{{file}} is resolved at runtime: @ tells Gemini to read the file, {{file}} inserts the argument value.
WHY: multiple context files matter. Gemini needs schemas.py to know what TaskCreate looks like, and models.py to understand the SQLAlchemy relationships. Without them, it writes tests that import wrong field names.
GOTCHA: Don't use absolute paths like @/home/user/project/routers/tasks.py in skill files — they break for every other developer. Use project-root-relative paths. Gemini CLI resolves @ references relative to the current working directory when Gemini was started.

Step 3 — Invoke it

# From sample-project\taskflow\
gemini

# Inside the REPL, type:
/test-generator file=routers/tasks.py
# From sample-project/taskflow/
gemini

# Inside the REPL, type:
/test-generator file=routers/tasks.py

What Just Happened?

Gemini CLI found .gemini/skills/test-generator.md, replaced {{file}} with routers/tasks.py, prepended @ to get @routers/tasks.py, read that file plus schemas.py, models.py, and auth.py, and sent the fully-assembled prompt to Gemini Pro. The result is a runnable test file that knows about TaskCreate, get_current_user, and the task_tags join table — all without you typing any of that context.

Writing a PR Summarizer Skill

After every feature branch, you need a PR description. Currently you're copying the diff, pasting it into a chat window, writing a vague "fix stuff" title. Let's automate a real description that lists changed endpoints, rationale, and test coverage notes.

sample-project/taskflow/.gemini/skills/pr-summary.md
---
name: pr-summary
description: Generate a structured PR description from a branch diff
arguments:
  - name: branch
    description: The feature branch name (source of the PR)
    required: true
  - name: base
    description: The base branch to diff against
    required: false
    default: main
---

You are generating a GitHub Pull Request description for the TaskFlow project.

Context:
- Project: FastAPI + SQLite task management API
- Key files: @sample-project/taskflow/main.py, @sample-project/taskflow/routers/tasks.py

Diff target: branch `{{branch}}` against `{{base}}`
Run this shell command first to get the diff: `git diff {{base}}...{{branch}}`

Then produce a PR description with these exact sections:

## Summary
(2-3 bullet points: what changed and why)

## Changed Endpoints
(list each API endpoint modified, with before/after behavior if applicable)

## Models / Schema Changes
(any SQLAlchemy model or Pydantic schema changes — none if unchanged)

## Test Coverage
(list test files affected or created; note any coverage gaps)

## Breaking Changes
(none, or list with migration instructions)

Keep it factual. Do not invent changes that aren't in the diff.
WHAT: base has a default: main — making it optional. When you call /pr-summary branch=feat/add-tag-filter without specifying a base, Gemini CLI substitutes main automatically.
WHY: the shell command instruction. Skills can instruct Gemini to run shell commands as part of their execution. The git diff instruction here means Gemini fetches live diff data rather than working from stale context in its window.
gemini> /pr-summary branch=feat/add-due-date-filter
✦ Resolving skill pr-summary...
✦ Running: git diff main...feat/add-due-date-filter
✦ Reading @sample-project/taskflow/main.py
✦ Reading @sample-project/taskflow/routers/tasks.py
## Summary
- Add `completed_after` query param to GET /tasks/ for date-range filtering
- Filter queries SQLAlchemy Task.created_at >= completed_after when provided
- No schema or model changes required (uses existing due_date field)
## Changed Endpoints
- GET /tasks/ — new optional query param `completed_after: datetime`
✓ Generated PR description (342 tokens)

What Just Happened?

Gemini ran git diff main...feat/add-due-date-filter in your shell, read the live diff alongside routers/tasks.py for context, then structured the output according to the template in the skill body. The base argument defaulted to main because you didn't specify it. The PR description reflects the actual changes — not a hallucinated summary.

Release Notes Skill

At release time, you need a CHANGELOG entry. This skill takes a version number and a previous git tag and produces CHANGELOG-style notes from the commit log.

sample-project/taskflow/.gemini/skills/release-notes.md
---
name: release-notes
description: Generate CHANGELOG entries from git commits between two tags
arguments:
  - name: version
    description: The new version being released (e.g. v1.2.0)
    required: true
  - name: since_tag
    description: The previous release tag to diff from (e.g. v1.1.0)
    required: true
---

Generate CHANGELOG-style release notes for TaskFlow version {{version}}.

First, fetch the commit history since the last release:
`git log {{since_tag}}..HEAD --oneline --no-merges`

Then read @sample-project/taskflow/main.py to understand the project structure.

Output the notes in Keep a Changelog format:

## [{{version}}] - {{today}}

### Added
(new features, new endpoints, new query params)

### Changed
(modified behavior in existing endpoints)

### Fixed
(bug fixes — include the commit hash in parentheses)

### Removed
(deprecated features removed in this release)

Rules:
- Group commits by type (feat/fix/chore/docs prefix)
- Skip pure chore/ci/docs commits in the output
- Use present tense ("Add filter", not "Added filter")
- If a commit message is ambiguous, note it as "(needs clarification)"
WHAT: Both version and since_tag are required — there's no safe default for either. The skill instructs Gemini to run git log to get real commit data, then formats it according to the industry-standard "Keep a Changelog" spec.
Invocation Example

/release-notes version=v1.2.0 since_tag=v1.1.0

Gemini fetches the actual commit log between v1.1.0 and HEAD, categorizes each commit, and produces a complete CHANGELOG section. Edit it for accuracy, then paste into your CHANGELOG.md.

Skill Argument Placeholders

Placeholder syntax controls how arguments behave: whether they're required, what the fallback is, and how they're referenced in the body.

Animation 2 — Argument Resolution
{{file}}waiting...
{{base}}waiting...
@{{file}}waiting...
Invocation: /test-generator file=routers/tasks.py

Syntax Reference

Argument declaration patterns
arguments:
  # Required — Gemini CLI will error if not provided
  - name: file
    description: Router file path relative to project root
    required: true

  # Optional with default — uses "main" if caller omits it
  - name: base
    description: Base branch for diff
    required: false
    default: main

  # Optional with no default — omitting leaves {{base}} unreplaced
  # (usually a bug — always provide a default for optional args)
  - name: since_tag
    description: Previous git tag
    required: false
GOTCHA: If you declare an argument as optional but don't provide a default:, the placeholder {{since_tag}} stays literally in the prompt body when the arg is omitted. Gemini will then see the string {{since_tag}} and either hallucinate a value or ask for clarification. Always pair required: false with a sensible default:.

Using Arguments in the Body

Placeholder usage patterns
# Plain text substitution
Analyze the module: {{file}}
# → Analyze the module: routers/tasks.py

# File read via @ prefix
Read and analyze: @{{file}}
# → Gemini reads routers/tasks.py contents

# In shell commands
`git diff {{base}}...{{branch}}`
# → git diff main...feat/add-filter

# In headings
## Review for version {{version}}
# → ## Review for version v1.2.0
WHY: the @ prefix matters. Plain {{file}} inserts the string routers/tasks.py as text. @{{file}} instructs Gemini CLI to actually read the file and embed its contents in the context window. For router analysis skills, you almost always want @{{file}} so Gemini sees the code, not just the filename.

Sharing Skills Across Projects

Animation 3 — Skill Scope Resolution
~/.gemini/skills/test-generator.md pending
~/.gemini/skills/commit-msg.md pending
.gemini/skills/test-generator.md pending
.gemini/skills/pr-summary.md pending
Final registry:

Global Skills: Your Personal Toolkit

Global skills live in ~/.gemini/skills/ and are available in every project you open. Good candidates are general-purpose prompts that don't reference project-specific files:

  • explain.md — explain a code snippet in plain English
  • commit-msg.md — generate a conventional commit message from staged diff
  • docs.md — write inline docstrings for a function
  • refactor.md — refactor for readability without changing behavior

Team Skills via a Shared Repo

The most scalable pattern is a skills/ repository your entire team shares:

  1. Create a team-skills git repo with all your standard skill files
  2. Each developer symlinks (or copies) the directory: ~/.gemini/skills/ -> ~/team-skills/
  3. When someone improves security-review.md, everyone gets the update on next pull
Warning — Secrets in Skill Files

Skill files are Markdown — they're committed to git. Never embed API keys, database URLs, or personal tokens in skill bodies. Instead, use a shell command in the skill body to read from environment variables: `echo $JIRA_TOKEN`. Gemini CLI runs that command at invocation time, not at commit time.

Building a TaskFlow Skill Library

Here are all five skills tailored to the TaskFlow codebase. Each one references real TaskFlow files so Gemini gets proper context every time.

/test-generator
file=<router_path>
Generates pytest file with fixtures, happy-path, auth, 404, and 422 tests for any TaskFlow router.
/pr-summary
branch=<name> [base=main]
Generates structured PR description with changed endpoints, schema changes, and coverage notes.
/schema-validator
(no args)
Compares models.py vs schemas.py to find fields present in the ORM model but missing from Pydantic schemas.
/endpoint-docs
endpoint=<function_name>
Writes a complete FastAPI docstring for a given endpoint including params, responses, and auth requirements.
/migration-helper
change=<description>
Drafts an Alembic migration script for a described model change (add column, rename, add index).

schema-validator — Full Skill File

sample-project/taskflow/.gemini/skills/schema-validator.md
---
name: schema-validator
description: Check that all SQLAlchemy model fields have matching Pydantic schemas
arguments: []
---

Compare the SQLAlchemy models and Pydantic schemas in TaskFlow.

Read these files:
- @sample-project/taskflow/models.py
- @sample-project/taskflow/schemas.py

For each SQLAlchemy model (User, Task, Tag), list:

1. **Fields in the model but missing from ALL schemas** — these are likely
   accidentally exposed or forgotten. Flag as WARNING.

2. **Fields in schemas but not in the model** — these are likely typos or
   removed fields still in the schema. Flag as ERROR.

3. **Type mismatches** — e.g., model uses DateTime but schema uses str.
   Flag as WARNING.

Output format per model:
### ModelName
- [OK] field_name: type matches
- [WARN] field_name: in model, missing from schema
- [ERROR] field_name: in schema, not in model

Finish with a summary: "X errors, Y warnings found."

endpoint-docs — Full Skill File

sample-project/taskflow/.gemini/skills/endpoint-docs.md
---
name: endpoint-docs
description: Generate a FastAPI docstring for a TaskFlow endpoint function
arguments:
  - name: endpoint
    description: The Python function name (e.g. list_tasks, create_task)
    required: true
  - name: file
    description: Router file containing the endpoint
    required: false
    default: routers/tasks.py
---

Generate a complete FastAPI docstring for the `{{endpoint}}` function in:
@{{file}}

Also read:
- @sample-project/taskflow/schemas.py  — for request/response types
- @sample-project/taskflow/auth.py     — to document auth requirements

The docstring must include:
- One-sentence summary (imperative mood: "List all tasks for the current user")
- Parameters section (path params, query params, request body fields)
- Returns section (HTTP status code + response schema)
- Raises section (401, 403, 404, 422 as applicable)
- Example section with a realistic curl command

Format as a Google-style Python docstring. Output only the docstring, ready to paste.

migration-helper — Full Skill File

sample-project/taskflow/.gemini/skills/migration-helper.md
---
name: migration-helper
description: Draft an Alembic migration for a TaskFlow model change
arguments:
  - name: change
    description: Plain-English description of the model change
    required: true
---

Generate an Alembic migration script for TaskFlow.

Change description: {{change}}

Read the current models first:
@sample-project/taskflow/models.py

Then produce a complete Alembic migration file with:
1. Correct `upgrade()` function using `op.add_column`, `op.drop_column`, etc.
2. Correct `downgrade()` function that reverses the upgrade
3. Realistic `revision`, `down_revision`, and `branch_labels` values
4. A comment explaining why this migration is needed

Constraints:
- Use SQLAlchemy column types from `sqlalchemy` (Integer, String, DateTime, Boolean)
- For nullable additions, use `nullable=True` and `server_default` if appropriate
- For index changes, use `op.create_index` / `op.drop_index`
- Do not import models directly — Alembic scripts use op.* only

Output only the Python migration file. No explanation text.

Lab: Build and Run Skills

Follow these steps to create the test-generator skill and run it against TaskFlow's routers/tasks.py. By the end you'll have a runnable test file and a feel for iterating on skill templates.

1

Start TaskFlow to confirm it runs

cd sample-project\taskflow
pip install -r requirements.txt
uvicorn main:app --reload
cd sample-project/taskflow
pip install -r requirements.txt
uvicorn main:app --reload

Expected: INFO: Application startup complete. — visit http://localhost:8000/health to confirm.

2

Create the skills directory and test-generator skill

New-Item -ItemType Directory -Force .gemini\skills
# Now create the file using your editor, or paste the content from section 3
mkdir -p .gemini/skills
# Now create the file using your editor, or paste the content from section 3
3

Open Gemini CLI and verify the skill appears

Inside Gemini REPL
gemini

# At the > prompt, type just a forward slash:
/

# Expected output: your skill appears in the list
  Built-in skills:
    /chat        Start a conversation
    ...
  Project skills (.gemini/skills/):
    /test-generator   Generate pytest tests for a TaskFlow router file
4

Run the skill on routers/tasks.py

REPL invocation
/test-generator file=routers/tasks.py

Gemini should produce a complete test file. Look for: test_client fixture, auth_headers fixture, tests for list_tasks, create_task, get_task, update_task, delete_task.

5

Save the output and run pytest

# Copy Gemini's output to a test file
# Then run:
pytest tests\test_tasks.py -v
pytest tests/test_tasks.py -v
6

Iterate on the skill template

If tests are missing error cases, open .gemini/skills/test-generator.md and add a line to the prompt: "Ensure every endpoint has a test for the case where no auth token is provided (should return 401)." Re-run the skill — the improvement applies instantly to every future invocation.

What Just Happened?

You built a reusable, team-shareable prompt template grounded in the real TaskFlow codebase. Every future test file generated from this skill will: read the actual router, understand the real schema shapes, include the right fixtures, and enforce the same quality bar — because the prompt is canonical, not retyped each time.

Knowledge Check

Five questions on skills. All based on the TaskFlow examples you just worked through.

1. You create ~/.gemini/skills/test-generator.md globally and also .gemini/skills/test-generator.md inside the TaskFlow project. When you run /test-generator from the TaskFlow directory, which skill runs?

The global skill at ~/.gemini/skills/test-generator.md
The project-level skill at .gemini/skills/test-generator.md
Both run and their outputs are merged
Gemini CLI throws an error about duplicate skill names

2. In a skill file, what is the difference between {{file}} and @{{file}} in the prompt body?

They are identical — @ is just a style convention
{{file}} inserts the filename as plain text; @{{file}} tells Gemini CLI to read the file and embed its contents
@{{file}} is only valid in the YAML frontmatter, not in the body
@{{file}} runs the file as a shell script

3. You want the pr-summary skill to default to main when no base branch is provided. Which frontmatter declaration is correct?

required: true, default: main
required: false, default: main
optional: true, fallback: main
Defaults cannot be set in skill files — you must always pass all arguments

4. The test-generator skill references @sample-project/taskflow/schemas.py in its prompt body. Why is this important for the TaskFlow use case specifically?

It speeds up Gemini's response by reducing the number of tokens processed
It is required by Gemini CLI syntax — you must reference at least two files
So Gemini knows the real field names in TaskCreate, TaskUpdate, and Tag schemas — without it, generated tests use hallucinated field names that cause ValidationError at runtime
It populates the database with test data before the tests run

5. Which of the following is the safest way to include an API key needed by a skill in the skill's prompt — without committing the secret to git?

Hard-code the key in the skill file and add the skill file to .gitignore
Pass the key as a skill argument every time: /my-skill key=sk-abc123
Use a shell command in the skill body to read from an environment variable: `echo $MY_API_KEY`
Store the key in GEMINI.md since that file is already excluded from GEMINI context