From 1cc3ff6a201e8d72ea9ffbeeca2722ca0bb21015 Mon Sep 17 00:00:00 2001 From: Jay Ravaliya Date: Fri, 1 May 2026 22:08:51 -0400 Subject: [PATCH] feat(agent): skywatch Claude Code subagent + install command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Claude Code subagent that lets users ask for AIM 7-1-5 weather briefs in natural language ("plan a flight from KCDW to KACY tomorrow at 8am, give me a brief"). The parent agent dispatches to the skywatch subagent, which invokes the gem CLI and returns a prose briefing in formal AIM 7-1-5 section order. - agents/skywatch.md — subagent definition (frontmatter + system prompt) - Tool surface: Bash only (gem CLI is the single data source) - Output: AIM 7-1-5 prose with formal section headings - Surfaces "VFR FLIGHT NOT RECOMMENDED" objectively when the slot indicates it; never issues commands ("don't fly") — pilots decide - lib/skywatch/agent/cli.rb — `skywatch agent install` Thor subcommand - Copies the bundled file to ~/.claude/agents/skywatch.md - Honors SKYWATCH_AGENTS_DIR env var for testability - Prompts before overwrite (--yes to skip prompt) - gemspec — includes agents/*.md in the published gem Closes the v1 agent task. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + CLAUDE.md | 2 + agents/skywatch.md | 81 +++++++++++++++++++++++++++++++++++++++ lib/skywatch.rb | 1 + lib/skywatch/agent/cli.rb | 48 +++++++++++++++++++++++ lib/skywatch/cli.rb | 3 ++ skywatch.gemspec | 2 +- spec/agent/cli_spec.rb | 78 +++++++++++++++++++++++++++++++++++++ 8 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 agents/skywatch.md create mode 100644 lib/skywatch/agent/cli.rb create mode 100644 spec/agent/cli_spec.rb diff --git a/.gitignore b/.gitignore index 9e2546d..2b2e925 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /pkg/ /.worktrees/ .claude/pending-questions.md +.claude/scheduled_tasks.lock Gemfile.lock *.gem .rspec_status diff --git a/CLAUDE.md b/CLAUDE.md index 99ea4bd..ec82d6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` diff --git a/agents/skywatch.md b/agents/skywatch.md new file mode 100644 index 0000000..696749d --- /dev/null +++ b/agents/skywatch.md @@ -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.` diff --git a/lib/skywatch.rb b/lib/skywatch.rb index 105202d..a30ac43 100644 --- a/lib/skywatch.rb +++ b/lib/skywatch.rb @@ -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' diff --git a/lib/skywatch/agent/cli.rb b/lib/skywatch/agent/cli.rb new file mode 100644 index 0000000..caafcfc --- /dev/null +++ b/lib/skywatch/agent/cli.rb @@ -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 diff --git a/lib/skywatch/cli.rb b/lib/skywatch/cli.rb index 69a6727..2e6c632 100644 --- a/lib/skywatch/cli.rb +++ b/lib/skywatch/cli.rb @@ -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)' diff --git a/skywatch.gemspec b/skywatch.gemspec index 0ad38ee..ca6c7cc 100644 --- a/skywatch.gemspec +++ b/skywatch.gemspec @@ -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'] diff --git a/spec/agent/cli_spec.rb b/spec/agent/cli_spec.rb new file mode 100644 index 0000000..06b04b2 --- /dev/null +++ b/spec/agent/cli_spec.rb @@ -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