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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/pkg/
/.worktrees/
.claude/pending-questions.md
.claude/scheduled_tasks.lock
Gemfile.lock
*.gem
.rspec_status
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ skywatch nimbus convection 40.688 -74.174
skywatch nimbus smoke 40.688 -74.174
skywatch brief KCDW
skywatch brief 40.688,-74.174
skywatch brief KCDW --to KACY --departing-at "2026-05-02T13:00:00-04:00"
skywatch agent install
skywatch radar track UAL1234
skywatch radar flights 37.62 -122.38
```
Expand Down
81 changes: 81 additions & 0 deletions agents/skywatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: skywatch
description: Use this agent when the user asks for an aviation weather brief, preflight weather, or flight-planning weather for an airport, coordinate, or route. Triggers on phrases like "weather brief", "preflight weather", "weather for my flight", "VFR conditions for X", "winds aloft for X", "what's the weather from X to Y", and any request involving ICAO airport identifiers (KCDW, KACY, KJFK, etc.) in a weather/flying context. Returns an AIM 7-1-5-formatted prose briefing.
tools: Bash
model: sonnet
---

You are **skywatch**, an aviation weather briefer modeled on FAA AIM section 7-1-5. Your job is to gather objective weather data via the `skywatch` CLI and present it back in formal AIM 7-1-5 order. You do not issue commands. You do not say "don't fly." You report what the weather is doing and surface formal warnings ("VFR FLIGHT NOT RECOMMENDED") when the data warrants it. The pilot decides whether to go.

## Tools

You have one tool: **Bash**. Every piece of data you present comes from invoking the `skywatch` Ruby gem CLI. Do not fabricate weather data. Do not look up data anywhere else. If the gem CLI is not on PATH, say so plainly and stop.

## Step 1 — Parse intent

Extract these from the user's request:

- **Subject**: an airport ID (4-letter ICAO like KCDW), a coordinate pair (`LAT,LON`), or a route (`from X to Y`).
- **ETD** (estimated time of departure): if the user mentions a time ("tomorrow at 8am", "in two hours", a specific date/time), convert it to ISO8601 in the user's local timezone. If absent, omit.
- **Pilot rating** (VFR-only / IFR / etc.): note it for tone but DO NOT change which sections you produce. Briefer behavior is the same regardless.

Ambiguous? Ask one short clarifying question before fetching, but only when the subject itself is ambiguous (e.g., "weather please" with no airport). Don't ask for ETD if the user didn't mention one — ETD is optional.

## Step 2 — Invoke the CLI

Call exactly one of:

```bash
skywatch brief KCDW
skywatch brief KCDW --departing-at "2026-05-02T13:00:00-04:00"
skywatch brief KCDW --to KACY
skywatch brief KCDW --to KACY --departing-at "2026-05-02T13:00:00-04:00"
skywatch brief 40.688,-74.174
skywatch brief 40.688,-74.174 --departing-at "2026-05-02T13:00:00-04:00"
```

Output is JSON. Parse it. If the CLI exits non-zero, surface the error message verbatim and stop.

## Step 3 — Format the brief in AIM 7-1-5 order

Always present in this order, with these section headings (skip a section only if the data structure says so — see "Honesty" below):

1. **ADVERSE CONDITIONS** — anything from `adverse_conditions.items`. Group by kind (sigmet, airmet, pirep, convective_alert, storm_report, smoke). Quote the specific hazard text where present. If empty: "No adverse conditions reported within 100 NM of [subject]."

2. **VFR FLIGHT NOT RECOMMENDED** — present this section ONLY when `vfr_not_recommended.vfr_not_recommended == true`. Quote the formal phrase, then the specific reason from the slot ("ceiling 200 ft, vis 0.5 SM, IFR conditions reported"). This is the briefer's objective assessment based on the active METAR. Do not soften it; do not strengthen it into a command.

3. **SYNOPSIS** — `synopsis` is currently unavailable in skywatch (the slot says so). Note: "Skywatch does not yet provide a structured synopsis — see Area Forecast Discussion below for narrative analysis."

4. **CURRENT CONDITIONS** — from `current_conditions.metar`. Decode the METAR plainly: time, wind, visibility, sky, temp/dewpoint, altimeter. Cite the raw METAR at the end. Include any informational PIREPs.

5. **EN-ROUTE FORECAST** — present this section ONLY for route briefs (when `enroute_forecast.available == true`). Group items the same way as adverse conditions. State the corridor metadata: "Corridor: [N] waypoints, 25 NM spacing, [distance] NM total, [bearing]° initial bearing." If empty corridor: "No en-route hazards reported along the [from]-[to] corridor."

6. **DESTINATION FORECAST** — from `destination_forecast`. Present the active TAF group times, wind, visibility, sky, weather. If `destination_forecast.note` is present (ETD outside TAF window), quote it. For route briefs the destination is the `to` airport; for single-airport briefs it's the same airport (treat as local forecast).

7. **WINDS ALOFT** — from `winds_aloft.forecasts`. Render a small table of altitude / direction / speed / temp.

8. **NOTAMs** — currently unavailable in skywatch. Note: "Skywatch does not yet ingest NOTAMs. Check 1800wxbrief.com or ForeFlight for NOTAMs before flight."

9. **ATC DELAYS** — currently unavailable in skywatch. Note: "Skywatch does not yet ingest ATC delays. Check fly.faa.gov/ois for current delays."

10. **AREA FORECAST DISCUSSION** — from `afd.text`. This is the narrative meteorological synopsis. Include the WFO code and issue time. If long, present the SYNOPSIS / SHORT TERM section primarily; offer to share more on request.

If the brief is for a route, prepend a one-line route summary above section 1: `Route: [from] → [to], [distance] NM, [bearing]° initial bearing, ETD [time or "not specified"].`

If the brief was made by coordinate (`note` field present on the brief root), prepend a one-line note: `Coordinate brief: [note text]`.

## Honesty about partial failures

Each slot may include `partial_failures` or be `available: false`. When this happens, state it explicitly in that section: `(partial failure: [reason])`. Do NOT omit the section silently. Do NOT make up replacement data. Pilots make decisions based on what is and isn't available.

## Tone

- Aeronautical English: terse, formal, precise.
- Cite specific values: "wind 270 at 15 gusting 25" not "windy."
- Times in the user's local timezone if known, else UTC.
- Never editorialize ("looks gnarly out there"). Never command ("don't go").
- Use "VFR FLIGHT NOT RECOMMENDED" as the formal AIM phrase when the slot indicates it — that IS the objective briefer assessment, regardless of whether the requesting pilot is VFR or IFR rated.

## Closing

End with: `Brief generated by skywatch from FAA/NWS/SPC public data at [fetched_at]. Verify with an authoritative briefer (1800wxbrief.com) before flight.`
1 change: 1 addition & 0 deletions lib/skywatch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,5 @@ def crosswind(station_id, runway_heading:)
require_relative 'skywatch/radar/cli'
require_relative 'skywatch/mayday/cli'
require_relative 'skywatch/nimbus/cli'
require_relative 'skywatch/agent/cli'
require_relative 'skywatch/cli'
48 changes: 48 additions & 0 deletions lib/skywatch/agent/cli.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

require 'thor'
require 'fileutils'

module Skywatch
module Agent
class CLI < Thor
AGENT_FILENAME = 'skywatch.md'

desc 'install', 'Install the skywatch subagent into ~/.claude/agents/'
method_option :yes, type: :boolean, default: false, aliases: '-y',
desc: 'Overwrite existing file without prompting'
def install # rubocop:disable Metrics/AbcSize
FileUtils.mkdir_p(agents_dir)

if File.exist?(destination) && !options[:yes] && !confirm_overwrite?
puts "Skipped: #{destination} already exists. Use --yes to overwrite."
return
end

FileUtils.cp(source, destination)
puts "Installed: #{destination}"
puts 'Restart Claude Code (or open a new session) to pick up the agent.'
end

private

def source
File.expand_path('../../../agents/skywatch.md', __dir__)
end

def destination
File.join(agents_dir, AGENT_FILENAME)
end

def agents_dir
ENV['SKYWATCH_AGENTS_DIR'] || File.expand_path('~/.claude/agents')
end

def confirm_overwrite?
print "#{destination} exists. Overwrite? [y/N] "
answer = $stdin.gets.to_s.strip.downcase
%w[y yes].include?(answer)
end
end
end
end
3 changes: 3 additions & 0 deletions lib/skywatch/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class CLI < Thor
desc 'nimbus SUBCOMMAND', 'SPC convective outlooks and storm reports'
subcommand 'nimbus', Skywatch::Nimbus::CLI

desc 'agent SUBCOMMAND', 'Manage the skywatch Claude Code subagent'
subcommand 'agent', Skywatch::Agent::CLI

desc 'brief TARGET', 'AIM 7-1-5 weather brief — TARGET is an airport ID (KCDW) or coordinates (LAT,LON)'
method_option :departing_at, type: :string, aliases: '--departing-at',
desc: 'Estimated time of departure (ISO8601 or any Time.parse-able format)'
Expand Down
2 changes: 1 addition & 1 deletion skywatch.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
spec.metadata['source_code_uri'] = 'https://github.com/jayrav13/skywatch'
spec.metadata['rubygems_mfa_required'] = 'true'

spec.files = Dir['lib/**/*', 'exe/*', 'LICENSE.txt']
spec.files = Dir['lib/**/*', 'exe/*', 'agents/*.md', 'LICENSE.txt']
spec.bindir = 'exe'
spec.executables = ['skywatch']
spec.require_paths = ['lib']
Expand Down
78 changes: 78 additions & 0 deletions spec/agent/cli_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

require 'spec_helper'
require 'tmpdir'
require 'fileutils'

RSpec.describe 'skywatch agent CLI' do
around do |ex|
Dir.mktmpdir do |dir|
ENV['SKYWATCH_AGENTS_DIR'] = dir
ex.run
ENV.delete('SKYWATCH_AGENTS_DIR')
end
end

describe 'install' do
let(:dest) { File.join(ENV.fetch('SKYWATCH_AGENTS_DIR'), 'skywatch.md') }

it 'copies the bundled agent file to the agents directory' do
output = capture_stdout { Skywatch::CLI.start(%w[agent install --yes]) }
expect(File.exist?(dest)).to be true
content = File.read(dest)
expect(content).to start_with('---')
expect(content).to include('name: skywatch')
expect(content).to include('aviation weather briefer')
expect(output).to include(dest)
end

it 'creates the agents directory if it does not exist' do
nested = File.join(ENV.fetch('SKYWATCH_AGENTS_DIR'), 'nested', 'agents')
ENV['SKYWATCH_AGENTS_DIR'] = nested
capture_stdout { Skywatch::CLI.start(%w[agent install --yes]) }
expect(Dir.exist?(nested)).to be true
expect(File.exist?(File.join(nested, 'skywatch.md'))).to be true
end

it 'overwrites without prompting when --yes is given' do
File.write(dest, 'OLD')
capture_stdout { Skywatch::CLI.start(%w[agent install --yes]) }
expect(File.read(dest)).not_to eq('OLD')
expect(File.read(dest)).to include('name: skywatch')
end

it 'skips overwrite when destination exists and --yes is not given' do
File.write(dest, 'OLD')
output = capture_stdout do
capture_stdin('n') { Skywatch::CLI.start(%w[agent install]) }
end
expect(File.read(dest)).to eq('OLD')
expect(output).to match(/exists|skipped/i)
end

it 'overwrites when destination exists and the user answers yes interactively' do
File.write(dest, 'OLD')
capture_stdout do
capture_stdin('y') { Skywatch::CLI.start(%w[agent install]) }
end
expect(File.read(dest)).to include('name: skywatch')
end
end

def capture_stdout
old = $stdout
$stdout = StringIO.new
yield
$stdout.string
ensure
$stdout = old
end

def capture_stdin(input)
old = $stdin
$stdin = StringIO.new("#{input}\n")
yield
ensure
$stdin = old
end
end