aboutsummaryrefslogtreecommitdiffstats
path: root/skills
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-04-11 20:43:41 -0700
committerClawd <ai@clawd.bot>2026-04-11 20:43:41 -0700
commitd0ae31c24c3c98ae89eebd67227c0c0d01606ed5 (patch)
treec684469e0f7d3b65477cfc631ecdaafa3c6a218a /skills
parent5548b36e0953c17dbe30f6b63c892b7c83196b20 (diff)
Add ship-* Claude skills and plan
Introduces a skills/ directory with 8 Claude skills that reimagine ship as a set of composable, human-driven deployment tools backed by Claude's reasoning rather than a rigid CLI. Skills: - ship-setup: one-time VPS config, saves host to ~/.config/ship/config.json - ship-status: derives live state from server, no local state file - ship-env: read/write env vars with merge semantics, never overwrites - ship-binary: deploy Go binaries with SQLite backup, correct restart behavior - ship-caddy: manage per-app Caddyfile with validate-before-reload - ship-service: systemd management and log inspection - ship-static: rsync static sites with SPA routing support - ship-deploy: orchestration runbook tying the others together Also adds SKILLS_PLAN.md documenting the architecture and rationale. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'skills')
-rw-r--r--skills/ship-binary/SKILL.md168
-rw-r--r--skills/ship-caddy/SKILL.md135
-rw-r--r--skills/ship-deploy/SKILL.md126
-rw-r--r--skills/ship-env/SKILL.md84
-rw-r--r--skills/ship-service/SKILL.md133
-rw-r--r--skills/ship-setup/SKILL.md122
-rw-r--r--skills/ship-static/SKILL.md121
-rw-r--r--skills/ship-status/SKILL.md85
8 files changed, 974 insertions, 0 deletions
diff --git a/skills/ship-binary/SKILL.md b/skills/ship-binary/SKILL.md
new file mode 100644
index 0000000..16056ff
--- /dev/null
+++ b/skills/ship-binary/SKILL.md
@@ -0,0 +1,168 @@
1---
2name: ship-binary
3description: Upload and deploy a pre-built binary to a ship VPS. Handles port allocation, systemd service, env vars, Caddy config, and SQLite backup. Use when deploying a Go binary or other compiled executable.
4argument-hint: "<path-to-binary> <app-name> [host-nickname]"
5---
6
7# ship-binary
8
9Upload a pre-built binary and deploy it as a systemd service with Caddy reverse proxy.
10
11## Read Config
12
13```bash
14python3 -c "
15import json, os
16cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
17nick = '<nickname-or-default>'
18h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']])
19print(h['host'])
20print(h['domain'])
21"
22```
23
24## Inputs
25
26- **Binary path** — local path to the compiled binary
27- **App name** — short lowercase name, becomes the service name and subdomain (e.g. `foodtracker`)
28- **Domain** — defaults to `<app-name>.<base-domain>` from config, ask if different
29- **Env vars** — ask if there are any env vars to set (beyond PORT/SHIP_NAME/SHIP_URL)
30- **Host** — use default unless specified
31
32## Steps
33
34### 1. Check if app already exists
35
36```bash
37ssh <host> "test -f /etc/ship/ports/<app-name> && echo exists || echo new"
38```
39
40This determines whether to allocate a new port or reuse the existing one.
41
42### 2. Backup SQLite databases (if app exists)
43
44Before touching anything, check for SQLite files in the app's data directory:
45
46```bash
47ssh <host> "find /var/lib/<app-name>/ -name '*.db' 2>/dev/null"
48```
49
50If any `.db` files are found, back them up:
51
52```bash
53ssh <host> "sudo mkdir -p /var/lib/<app-name>/backups && sudo cp /var/lib/<app-name>/data/<name>.db /var/lib/<app-name>/backups/<name>-\$(date +%Y%m%d-%H%M%S).db"
54```
55
56Tell the user what was backed up before proceeding.
57
58### 3. Allocate or retrieve port
59
60**New app** — find the highest port in use and add 1:
61```bash
62ssh <host> "sudo bash -c 'max=9000; for f in /etc/ship/ports/*; do p=\$(cat \$f 2>/dev/null); [ \"\$p\" -gt \"\$max\" ] && max=\$p; done; port=\$((max+1)); echo \$port | tee /etc/ship/ports/<app-name>; echo \$port'"
63```
64
65**Existing app** — reuse the existing port:
66```bash
67ssh <host> "cat /etc/ship/ports/<app-name>"
68```
69
70### 4. Upload binary
71
72```bash
73scp <binary-path> <host>:/tmp/<app-name>
74ssh <host> "sudo mv /tmp/<app-name> /usr/local/bin/<app-name> && sudo chmod +x /usr/local/bin/<app-name>"
75```
76
77### 5. Create work directory and service user
78
79```bash
80ssh <host> "sudo mkdir -p /var/lib/<app-name>/data && sudo useradd -r -s /bin/false <app-name> 2>/dev/null || true && sudo chown -R <app-name>:<app-name> /var/lib/<app-name>"
81```
82
83### 6. Write env file
84
85Build the env file content merging ship-managed vars with any user-provided vars.
86If the file already exists, read it first and merge (new values win, old values survive):
87
88```bash
89ssh <host> "sudo cat /etc/ship/env/<app-name>.env 2>/dev/null"
90```
91
92Then write merged result:
93```bash
94ssh <host> "sudo tee /etc/ship/env/<app-name>.env > /dev/null << 'EOF'
95PORT=<port>
96SHIP_NAME=<app-name>
97SHIP_URL=https://<domain>
98<user-env-vars>
99EOF
100sudo chmod 600 /etc/ship/env/<app-name>.env"
101```
102
103### 7. Write systemd unit
104
105```bash
106ssh <host> "sudo tee /etc/systemd/system/<app-name>.service > /dev/null << 'EOF'
107[Unit]
108Description=<app-name>
109After=network.target
110
111[Service]
112Type=simple
113User=<app-name>
114WorkingDirectory=/var/lib/<app-name>
115EnvironmentFile=/etc/ship/env/<app-name>.env
116ExecStart=/usr/local/bin/<app-name>
117Restart=always
118RestartSec=5s
119NoNewPrivileges=true
120PrivateTmp=true
121
122[Install]
123WantedBy=multi-user.target
124EOF"
125```
126
127### 8. Start or restart service
128
129```bash
130ssh <host> "sudo systemctl daemon-reload"
131```
132
133**New app:**
134```bash
135ssh <host> "sudo systemctl enable --now <app-name>"
136```
137
138**Existing app:**
139```bash
140ssh <host> "sudo systemctl restart <app-name>"
141```
142
143### 9. Write Caddy config
144
145```bash
146ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
147<domain> {
148 reverse_proxy 127.0.0.1:<port>
149}
150EOF
151sudo systemctl reload caddy"
152```
153
154### 10. Confirm
155
156Tell the user:
157- App name, URL, and port
158- Whether it was a new deploy or update
159- Any SQLite backups made
160- Any env vars set
161
162## Notes
163
164- Always back up SQLite before swapping the binary
165- Always merge env vars — never replace the whole file
166- Use `systemctl restart` for existing apps, `enable --now` for new ones
167- The data directory `/var/lib/<app-name>/data/` persists across deploys
168- If the user doesn't specify env vars, ask if they need any before deploying
diff --git a/skills/ship-caddy/SKILL.md b/skills/ship-caddy/SKILL.md
new file mode 100644
index 0000000..df79e39
--- /dev/null
+++ b/skills/ship-caddy/SKILL.md
@@ -0,0 +1,135 @@
1---
2name: ship-caddy
3description: Manage Caddy configuration for a deployed app. Add, update, or remove site configs. Use when you need to change how Caddy serves an app — custom domains, redirects, auth, headers, etc.
4argument-hint: "<app-name> [host-nickname]"
5---
6
7# ship-caddy
8
9Manage per-app Caddy configuration on a ship VPS.
10
11## Read Config
12
13```bash
14python3 -c "
15import json, os
16cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
17nick = '<nickname-or-default>'
18h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']])
19print(h['host'])
20"
21```
22
23## Usage Patterns
24
25### View current Caddy config for an app
26
27```bash
28ssh <host> "sudo cat /etc/caddy/sites-enabled/<app-name>.caddy"
29```
30
31### Add or update a site config
32
33Read the current config first if it exists, then write the new one. Always reload
34Caddy after writing — validate first by checking syntax:
35
36```bash
37ssh <host> "sudo caddy validate --config /etc/caddy/Caddyfile 2>&1"
38```
39
40If validation passes:
41```bash
42ssh <host> "sudo systemctl reload caddy"
43```
44
45If validation fails, show the error and do NOT reload. Tell the user what the
46problem is.
47
48### Standard reverse proxy config (most apps)
49
50```bash
51ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
52<domain> {
53 reverse_proxy 127.0.0.1:<port>
54}
55EOF"
56```
57
58### Custom domain (in addition to default)
59
60```bash
61ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
62<custom-domain>, <default-domain> {
63 reverse_proxy 127.0.0.1:<port>
64}
65EOF"
66```
67
68### Basic auth
69
70```bash
71ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
72<domain> {
73 basicauth {
74 <username> <bcrypt-hash>
75 }
76 reverse_proxy 127.0.0.1:<port>
77}
78EOF"
79```
80
81To generate a bcrypt hash for a password:
82```bash
83ssh <host> "caddy hash-password --plaintext '<password>'"
84```
85
86### Redirect www to non-www
87
88```bash
89ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
90www.<domain> {
91 redir https://<domain>{uri} permanent
92}
93
94<domain> {
95 reverse_proxy 127.0.0.1:<port>
96}
97EOF"
98```
99
100### Static site
101
102```bash
103ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
104<domain> {
105 root * /var/www/<app-name>
106 file_server
107 encode gzip
108}
109EOF"
110```
111
112### Remove a site config
113
114```bash
115ssh <host> "sudo rm /etc/caddy/sites-enabled/<app-name>.caddy && sudo systemctl reload caddy"
116```
117
118Confirm with the user before removing.
119
120### View Caddy status and logs
121
122```bash
123ssh <host> "sudo systemctl status caddy --no-pager"
124ssh <host> "sudo journalctl -u caddy -n 50 --no-pager"
125```
126
127## Notes
128
129- Always validate before reloading — never reload with a broken config
130- The port for an app can be found at `/etc/ship/ports/<app-name>`
131- Caddy handles HTTPS automatically — no need to configure certificates
132- If the user asks for something not covered here, write the appropriate Caddy
133 directives — Caddy's config language is flexible and well documented
134- Main Caddyfile is at `/etc/caddy/Caddyfile` and imports all files in
135 `/etc/caddy/sites-enabled/`
diff --git a/skills/ship-deploy/SKILL.md b/skills/ship-deploy/SKILL.md
new file mode 100644
index 0000000..60cc263
--- /dev/null
+++ b/skills/ship-deploy/SKILL.md
@@ -0,0 +1,126 @@
1---
2name: ship-deploy
3description: Deploy an app to a ship VPS. Orchestrates ship-binary or ship-static depending on what you're deploying. Use when you want to deploy or redeploy an app and want Claude to guide the process.
4argument-hint: "<app-name> [host-nickname]"
5---
6
7# ship-deploy
8
9Orchestration runbook for deploying apps to a ship VPS. Guides the full deployment
10process by calling the appropriate ship skills in the right order.
11
12## Prerequisites
13
14- `/ship-setup` must have been run at least once
15- `~/.config/ship/config.json` must exist and contain at least one host
16- The app must already be built locally (binary compiled or dist folder ready)
17
18## Read Config
19
20```bash
21python3 -c "
22import json, os
23cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
24nick = '<nickname-or-default>'
25h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']])
26print(h['host'])
27print(h['domain'])
28"
29```
30
31## Step 1 — Understand what we're deploying
32
33Ask the user (or infer from context):
34
35- **What is the app name?** (e.g. `foodtracker`)
36- **What type?** Binary or static site?
37- **Where is the artifact?** Path to binary or dist folder
38- **What host?** Default unless specified
39- **Any env vars needed?** Especially for first-time deploys
40- **Custom domain?** Or use `<app-name>.<base-domain>`
41
42If any of these are unclear, ask before proceeding.
43
44## Step 2 — Check if app already exists
45
46```bash
47ssh <host> "test -f /etc/ship/ports/<app-name> && echo exists || echo new"
48```
49
50Tell the user whether this is a fresh deploy or an update to an existing app.
51
52## Step 3 — Deploy
53
54### For a binary app → follow ship-binary
55
56Key steps in order:
571. Backup any SQLite databases in `/var/lib/<app-name>/`
582. Allocate or retrieve port
593. Upload binary via scp
604. Write/merge env file
615. Write systemd unit
626. `systemctl restart` (existing) or `systemctl enable --now` (new)
637. Write Caddy config and reload
64
65### For a static site → follow ship-static
66
67Key steps in order:
681. Validate `index.html` exists in dist folder
692. Rsync dist folder to `/var/www/<app-name>/`
703. Fix ownership to `www-data`
714. Write Caddy config and reload
72
73## Step 4 — Verify
74
75After deploying, confirm the service came up:
76
77**Binary:**
78```bash
79ssh <host> "sudo systemctl is-active <app-name>"
80```
81
82If not active, immediately check logs:
83```bash
84ssh <host> "sudo journalctl -u <app-name> -n 30 --no-pager"
85```
86
87**Static:**
88```bash
89ssh <host> "curl -sI https://<domain> | head -5"
90```
91
92## Step 5 — Confirm to user
93
94Report:
95- App name and live URL
96- Type (binary / static)
97- New deploy or update
98- Port (binary only)
99- Any SQLite backups made
100- Any env vars that were set
101- Whether the service is running
102
103## Checklist (reference)
104
105Use this to make sure nothing is missed:
106
107- [ ] Config file read, host resolved
108- [ ] App type confirmed (binary / static)
109- [ ] Artifact path confirmed and exists locally
110- [ ] App name and domain confirmed
111- [ ] Existing app check done
112- [ ] SQLite backed up (binary, if db files exist)
113- [ ] Port allocated or retrieved
114- [ ] Artifact uploaded
115- [ ] Env file written with merge semantics
116- [ ] Systemd unit written and service started/restarted (binary)
117- [ ] Caddy config written and reloaded
118- [ ] Service confirmed running
119
120## Notes
121
122- Never skip the SQLite backup step for binary apps — always check even if you don't expect a db
123- Always merge env vars — never overwrite the whole env file
124- If anything fails mid-deploy, tell the user exactly where it failed and what state the server is in
125- Use `systemctl restart` for existing apps, `enable --now` for new ones — not the other way around
126- If the user says "deploy X" without more context, ask the minimum necessary questions before starting
diff --git a/skills/ship-env/SKILL.md b/skills/ship-env/SKILL.md
new file mode 100644
index 0000000..692904d
--- /dev/null
+++ b/skills/ship-env/SKILL.md
@@ -0,0 +1,84 @@
1---
2name: ship-env
3description: Read or write environment variables for a deployed app. Always merges — never overwrites existing vars. Use when you need to set, update, or view env vars for an app without redeploying.
4argument-hint: "<app-name> [KEY=VALUE ...]"
5---
6
7# ship-env
8
9Read and write environment variables for a deployed app on a ship VPS.
10Always merges new values into the existing file — existing vars are never lost.
11
12## Read Config
13
14Load the host from `~/.config/ship/config.json`:
15
16```bash
17python3 -c "
18import json, os
19cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
20nick = '<nickname-or-default>'
21h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']])
22print(h['host'])
23"
24```
25
26## Usage Patterns
27
28### View current env vars for an app
29
30```bash
31ssh <host> "sudo cat /etc/ship/env/<app-name>.env"
32```
33
34Show them to the user clearly. Remind them that PORT, SHIP_NAME, and SHIP_URL are
35managed by ship and shouldn't be manually changed.
36
37### Set or update one or more vars
38
39Given vars like `FOO=bar BAZ=qux`, merge them into the existing env file:
40
41```bash
42# Read current env
43ssh <host> "sudo cat /etc/ship/env/<app-name>.env 2>/dev/null"
44```
45
46Then write the merged result back:
47
48```bash
49ssh <host> "sudo tee /etc/ship/env/<app-name>.env > /dev/null << 'EOF'
50PORT=9013
51SHIP_NAME=myapp
52SHIP_URL=https://myapp.example.com
53FOO=bar
54BAZ=qux
55EOF"
56```
57
58**Merge rules:**
59- Start with all existing vars
60- Overwrite any keys that appear in the new set
61- Add any new keys that didn't exist before
62- Never remove existing keys unless explicitly asked
63
64### Delete a var
65
66Only delete a var if the user explicitly asks to remove it by name. Show them the
67current value and confirm before removing.
68
69### Restart after changes
70
71After writing new env vars, ask the user if they want to restart the service to apply
72them. If yes, use ship-service to restart:
73
74```bash
75ssh <host> "sudo systemctl restart <app-name>"
76```
77
78## Notes
79
80- Env file lives at `/etc/ship/env/<app-name>.env`
81- PORT, SHIP_NAME, and SHIP_URL are written by ship-binary/ship-deploy — don't remove them
82- If the env file doesn't exist, the app probably isn't deployed yet — say so
83- Use the default host unless a host nickname was specified (e.g. "set FOO=bar on staging")
84- After updating, remind the user the service needs a restart to pick up changes
diff --git a/skills/ship-service/SKILL.md b/skills/ship-service/SKILL.md
new file mode 100644
index 0000000..e4d7510
--- /dev/null
+++ b/skills/ship-service/SKILL.md
@@ -0,0 +1,133 @@
1---
2name: ship-service
3description: Manage systemd services for deployed apps. Start, stop, restart, view status and logs. Use when you need to control a running service or diagnose problems.
4argument-hint: "<app-name> [start|stop|restart|status|logs] [host-nickname]"
5---
6
7# ship-service
8
9Manage systemd services for apps deployed on a ship VPS.
10
11## Read Config
12
13```bash
14python3 -c "
15import json, os
16cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
17nick = '<nickname-or-default>'
18h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']])
19print(h['host'])
20"
21```
22
23## Usage Patterns
24
25### Status
26
27```bash
28ssh <host> "sudo systemctl status <app-name> --no-pager"
29```
30
31Shows whether the service is running, its PID, memory usage, and recent log lines.
32
33### Restart
34
35```bash
36ssh <host> "sudo systemctl restart <app-name>"
37```
38
39Use after changing env vars or replacing the binary.
40
41### Stop
42
43```bash
44ssh <host> "sudo systemctl stop <app-name>"
45```
46
47### Start
48
49```bash
50ssh <host> "sudo systemctl start <app-name>"
51```
52
53### View logs
54
55Recent logs (last 50 lines):
56```bash
57ssh <host> "sudo journalctl -u <app-name> -n 50 --no-pager"
58```
59
60Follow live logs:
61```bash
62ssh <host> "sudo journalctl -u <app-name> -f"
63```
64
65Logs since last boot:
66```bash
67ssh <host> "sudo journalctl -u <app-name> -b --no-pager"
68```
69
70### Enable (start on boot)
71
72```bash
73ssh <host> "sudo systemctl enable <app-name>"
74```
75
76### Disable (don't start on boot)
77
78```bash
79ssh <host> "sudo systemctl disable <app-name>"
80```
81
82### View the systemd unit file
83
84```bash
85ssh <host> "sudo cat /etc/systemd/system/<app-name>.service"
86```
87
88### Remove a service entirely
89
90Only do this if the user explicitly asks to remove/uninstall an app:
91
92```bash
93ssh <host> "sudo systemctl stop <app-name> && sudo systemctl disable <app-name> && sudo rm /etc/systemd/system/<app-name>.service && sudo systemctl daemon-reload"
94```
95
96Confirm with the user before removing. Note that this does not delete the binary,
97data directory, env file, or Caddy config — those are managed separately.
98
99## Diagnosing Problems
100
101If a service is failing, check:
102
1031. Service status for the error:
104```bash
105ssh <host> "sudo systemctl status <app-name> --no-pager"
106```
107
1082. Full logs for context:
109```bash
110ssh <host> "sudo journalctl -u <app-name> -n 100 --no-pager"
111```
112
1133. Whether the binary exists and is executable:
114```bash
115ssh <host> "ls -la /usr/local/bin/<app-name>"
116```
117
1184. Whether the env file exists and looks correct:
119```bash
120ssh <host> "sudo cat /etc/ship/env/<app-name>.env"
121```
122
1235. Whether the port is already in use by something else:
124```bash
125ssh <host> "sudo ss -tlnp | grep <port>"
126```
127
128## Notes
129
130- If the user just says "restart foodtracker" or "check the logs for myapp", infer the action
131- After a restart, give it a moment then check status to confirm it came up
132- If a service repeatedly crashes, look at the logs before suggesting a fix
133- Use default host unless another is specified
diff --git a/skills/ship-setup/SKILL.md b/skills/ship-setup/SKILL.md
new file mode 100644
index 0000000..e3d08ee
--- /dev/null
+++ b/skills/ship-setup/SKILL.md
@@ -0,0 +1,122 @@
1---
2name: ship-setup
3description: Set up ship for the first time or add a new VPS host. Saves host config to ~/.config/ship/config.json and installs server dependencies. Use when configuring ship for the first time, or adding/changing a host.
4argument-hint: "[host-nickname]"
5---
6
7# ship-setup
8
9Configure ship and prepare a VPS for deployments.
10
11## Config File
12
13All ship skills read from `~/.config/ship/config.json`. This skill creates or updates it.
14
15Structure:
16```json
17{
18 "default": "prod",
19 "hosts": {
20 "prod": {
21 "host": "ubuntu@1.2.3.4",
22 "domain": "example.com"
23 },
24 "staging": {
25 "host": "ubuntu@5.6.7.8",
26 "domain": "staging.example.com"
27 }
28 }
29}
30```
31
32## Steps
33
34### 1. Read existing config
35
36Check if `~/.config/ship/config.json` exists:
37
38```bash
39cat ~/.config/ship/config.json 2>/dev/null
40```
41
42If it exists, show the user the current hosts so they know what's already configured.
43
44### 2. Get host details
45
46If no nickname was provided as an argument, ask the user:
47- **Nickname** — a short name for this host (e.g. `prod`, `staging`, `vps`)
48- **SSH connection string** — e.g. `ubuntu@1.2.3.4` or an SSH config alias like `alaskav6`
49- **Base domain** — the domain pointing to this server (e.g. `example.com`)
50
51If this is the first host, ask if it should be the default. If hosts already exist, ask if this should replace the current default.
52
53### 3. Test SSH connection
54
55Verify the connection works before saving anything:
56
57```bash
58ssh -o ConnectTimeout=5 <host> "echo ok"
59```
60
61If it fails, tell the user and stop. Don't save config for an unreachable host.
62
63### 4. Save config
64
65Write or update `~/.config/ship/config.json` with the new host. Merge with existing hosts — never overwrite the whole file. Use Python to safely read/write JSON:
66
67```bash
68python3 -c "
69import json, os
70path = os.path.expanduser('~/.config/ship/config.json')
71os.makedirs(os.path.dirname(path), exist_ok=True)
72cfg = json.load(open(path)) if os.path.exists(path) else {'default': None, 'hosts': {}}
73cfg['hosts']['<nickname>'] = {'host': '<ssh-host>', 'domain': '<domain>'}
74if not cfg['default']:
75 cfg['default'] = '<nickname>'
76json.dump(cfg, open(path, 'w'), indent=2)
77print('saved')
78"
79```
80
81### 5. Install server dependencies
82
83SSH in and ensure the required directories and software exist. This is idempotent — safe to run multiple times.
84
85**Install Caddy** (if not present):
86```bash
87ssh <host> "which caddy || (sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list && sudo apt-get update && sudo apt-get install -y caddy)"
88```
89
90**Create directory structure:**
91```bash
92ssh <host> "sudo mkdir -p /etc/ship/env /etc/ship/ports /etc/caddy/sites-enabled /var/www && sudo chmod 755 /etc/ship"
93```
94
95**Configure main Caddyfile** (only if not already set up):
96```bash
97ssh <host> "sudo test -f /etc/caddy/Caddyfile && echo exists || echo '{
98}
99
100import /etc/caddy/sites-enabled/*' | sudo tee /etc/caddy/Caddyfile"
101```
102
103**Enable and start Caddy:**
104```bash
105ssh <host> "sudo systemctl enable caddy && sudo systemctl start caddy"
106```
107
108### 6. Confirm
109
110Tell the user:
111- Host nickname and SSH target saved
112- Whether it's the default host
113- That the server is ready for deployments
114- How to add another host: `/ship-setup <nickname>`
115- How to deploy: `/ship-deploy`
116
117## Notes
118
119- Never overwrite the entire config file — always merge
120- If a nickname already exists in config, confirm before overwriting it
121- The SSH host can be an alias from `~/.ssh/config` — no need to require raw IP
122- Default host is used by all other ship skills when no host is specified
diff --git a/skills/ship-static/SKILL.md b/skills/ship-static/SKILL.md
new file mode 100644
index 0000000..1ef74d3
--- /dev/null
+++ b/skills/ship-static/SKILL.md
@@ -0,0 +1,121 @@
1---
2name: ship-static
3description: Deploy a static site to a ship VPS. Rsyncs a local dist folder to the server and configures Caddy to serve it. Use when deploying a built frontend, docs site, or any folder of static files.
4argument-hint: "<path-to-dist> <app-name> [host-nickname]"
5---
6
7# ship-static
8
9Deploy a static site by rsyncing a local directory to the server and configuring Caddy.
10
11## Read Config
12
13```bash
14python3 -c "
15import json, os
16cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
17nick = '<nickname-or-default>'
18h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']])
19print(h['host'])
20print(h['domain'])
21"
22```
23
24## Inputs
25
26- **Dist path** — local directory containing the built static files (e.g. `./dist`, `./out`)
27- **App name** — short lowercase name, becomes the subdomain (e.g. `mysite`)
28- **Domain** — defaults to `<app-name>.<base-domain>`, ask if different
29- **Host** — use default unless specified
30
31## Steps
32
33### 1. Validate local path
34
35Check that the dist directory exists and contains an `index.html`:
36
37```bash
38ls <dist-path>/index.html
39```
40
41If not found, tell the user — they may need to build first.
42
43### 2. Create remote directory
44
45```bash
46ssh <host> "sudo mkdir -p /var/www/<app-name> && sudo chown $USER:$USER /var/www/<app-name>"
47```
48
49### 3. Sync files
50
51```bash
52rsync -avz --delete <dist-path>/ <host>:/var/www/<app-name>/
53```
54
55The `--delete` flag removes files on the server that no longer exist locally, keeping
56the deployment clean. Tell the user how many files were transferred.
57
58### 4. Fix ownership
59
60After rsync, ensure Caddy can read the files:
61
62```bash
63ssh <host> "sudo chown -R www-data:www-data /var/www/<app-name>"
64```
65
66### 5. Write Caddy config
67
68Check if a config already exists:
69
70```bash
71ssh <host> "cat /etc/caddy/sites-enabled/<app-name>.caddy 2>/dev/null"
72```
73
74Write (or overwrite) the config:
75
76```bash
77ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF'
78<domain> {
79 root * /var/www/<app-name>
80 file_server
81 encode gzip
82}
83EOF"
84```
85
86### 6. Validate and reload Caddy
87
88```bash
89ssh <host> "sudo caddy validate --config /etc/caddy/Caddyfile 2>&1"
90```
91
92If valid:
93```bash
94ssh <host> "sudo systemctl reload caddy"
95```
96
97If invalid, show the error and do not reload.
98
99### 7. Confirm
100
101Tell the user:
102- URL the site is live at
103- Number of files synced
104- Whether this was a new deployment or an update
105
106## Notes
107
108- Build before deploying — this skill does not run build commands
109- `--delete` in rsync means files removed locally will be removed from the server too
110- If the user wants a custom domain, use ship-caddy to update the config after deploying
111- For SPAs with client-side routing, the Caddy config may need a `try_files` directive:
112 ```
113 <domain> {
114 root * /var/www/<app-name>
115 try_files {path} /index.html
116 file_server
117 encode gzip
118 }
119 ```
120 Ask the user if their site uses client-side routing.
121- Use default host unless another is specified
diff --git a/skills/ship-status/SKILL.md b/skills/ship-status/SKILL.md
new file mode 100644
index 0000000..f47e081
--- /dev/null
+++ b/skills/ship-status/SKILL.md
@@ -0,0 +1,85 @@
1---
2name: ship-status
3description: Show what's running on a ship VPS. Derives state from the server — no local state file. Use when you want to know what apps are deployed, what ports they use, and whether they're running.
4argument-hint: "[host-nickname]"
5---
6
7# ship-status
8
9Show the current state of all deployments on a ship VPS by reading directly from the server.
10
11## Read Config
12
13Load the host from `~/.config/ship/config.json`. Use the default host unless a nickname was specified:
14
15```bash
16python3 -c "
17import json, os, sys
18cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json')))
19nick = sys.argv[1] if len(sys.argv) > 1 else cfg['default']
20h = cfg['hosts'][nick]
21print(h['host'])
22print(h['domain'])
23" <nickname-or-blank>
24```
25
26## Gather Server State
27
28Run all of these in a single SSH session to minimize round trips:
29
30**Systemd services (binary/docker apps):**
31```bash
32ssh <host> "systemctl list-units --type=service --state=active --no-pager --no-legend | grep -v '@' | awk '{print \$1}' | xargs -I{} sh -c 'name=\$(echo {} | sed s/.service//); port=\$(cat /etc/ship/ports/\$name 2>/dev/null); env=\$(cat /etc/ship/env/\$name.env 2>/dev/null | grep SHIP_URL | cut -d= -f2); echo \"\$name|\$port|\$env\"' 2>/dev/null | grep '|'"
33```
34
35**Static sites:**
36```bash
37ssh <host> "ls /var/www/ 2>/dev/null"
38```
39
40**Caddy configs (domains):**
41```bash
42ssh <host> "ls /etc/caddy/sites-enabled/ 2>/dev/null | grep -v '^$'"
43```
44
45**Caddy status:**
46```bash
47ssh <host> "systemctl is-active caddy"
48```
49
50**Disk usage for app data dirs:**
51```bash
52ssh <host> "du -sh /var/lib/*/ 2>/dev/null"
53```
54
55## Present Results
56
57Format as a clean summary, for example:
58
59```
60HOST: prod (ubuntu@1.2.3.4)
61
62SERVICES
63 foodtracker running :9013 https://foodtracker.example.com
64 myapi running :9014 https://api.example.com
65
66STATIC SITES
67 mysite https://mysite.example.com
68
69CADDY: running
70
71DATA
72 /var/lib/foodtracker/ 48M
73 /var/lib/myapi/ 2M
74```
75
76If a service appears in `/etc/ship/ports/` but is not active in systemd, flag it as **stopped**.
77
78If a Caddy config exists for a name but no service or static site matches, flag it as **orphaned config**.
79
80## Notes
81
82- No local state file is consulted — everything comes from the server
83- If `~/.config/ship/config.json` doesn't exist, tell the user to run `/ship-setup` first
84- If a nickname is given that doesn't exist in config, list available nicknames
85- Use default host if no argument given