aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--SKILLS_PLAN.md135
-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
9 files changed, 1109 insertions, 0 deletions
diff --git a/SKILLS_PLAN.md b/SKILLS_PLAN.md
new file mode 100644
index 0000000..ded2b38
--- /dev/null
+++ b/SKILLS_PLAN.md
@@ -0,0 +1,135 @@
1# Ship Skills — Reimagining Ship as Claude Skills
2
3## The Idea
4
5Rather than a monolithic CLI that bakes in rigid assumptions, ship becomes a family of
6narrow, composable Claude skills. Each skill knows how to do one thing well. Claude
7provides the reasoning and orchestration. The server is the source of truth.
8
9Skills are completely generic — no hostnames, app names, or passwords baked in. The
10same skills work for anyone. Share them with a friend, point them at a different VPS,
11they just work.
12
13## Shared Configuration
14
15A single static file at `~/.config/ship/config.json` holds the VPS host (and little
16else). All skills read from this file. No vault dependency — works for anyone.
17
18```json
19{
20 "host": "ubuntu@1.2.3.4",
21 "domain": "example.com"
22}
23```
24
25The server itself is the source of truth for everything else — what services are
26running, what ports are allocated, what Caddy configs exist. No local state file that
27can go stale.
28
29## The Skills
30
31### `ship-setup`
32One-time setup. Asks for VPS host if not configured, saves to `~/.config/ship/config.json`,
33SSHes in and installs server dependencies (Caddy, directory structure, etc).
34All other skills depend on this having been run once.
35
36### `ship-status`
37Derives current state entirely from the server at runtime:
38- Running apps → `systemctl list-units --type=service`
39- Ports → `/etc/ship/ports/` or env files
40- Domains → parse Caddy configs in `sites-enabled/`
41- Static sites → list `/var/www/`
42
43No state file needed. Always accurate. Replaces the need for any local tracking.
44
45### `ship-env`
46Read and write env vars with merge semantics. Never overwrites — reads existing file
47first, merges new values on top, writes result. Old vars survive redeployments.
48
49### `ship-caddy`
50Manage per-app Caddyfile config. Knows Caddy syntax. Diffs before writing. Validates
51before reloading. Never regenerates from scratch — only touches what needs changing.
52
53### `ship-service`
54Systemd management. Handles the difference between a new service (enable + start) and
55an existing one (restart). Status, logs, restart, stop — all covered.
56
57### `ship-binary`
58Upload and install a pre-built binary. SCP to `/tmp`, move to `/usr/local/bin/`,
59chmod +x, set up work directory and service user. Calls `ship-service` and `ship-env`
60to complete the deployment.
61
62### `ship-static`
63Rsync a local dist folder to `/var/www/{name}` on the server. Calls `ship-caddy` to
64configure serving.
65
66### `ship-deploy`
67A runbook skill that orchestrates the others in the right order for a full deployment.
68Not imperative code — just a checklist of steps with enough context for Claude to
69reason about what to do. Adapts based on what the user tells it (binary vs static,
70what env vars are needed, etc).
71
72## What the Server Knows
73
74All persistent state lives on the server in conventional locations:
75
76```
77/etc/caddy/sites-enabled/{name}.caddy # per-app Caddy config
78/etc/ship/env/{name}.env # environment variables
79/etc/ship/ports/{name} # allocated port number
80/etc/systemd/system/{name}.service # systemd unit
81/var/www/{name}/ # static site files
82/var/lib/{name}/ # app work directory (binary, data)
83/usr/local/bin/{name} # binary executable
84```
85
86## Why This Is Better Than the CLI
87
88- **Transparent** — Claude tells you what it's about to do before doing it
89- **Flexible** — no rigid assumptions, Claude reasons about edge cases
90- **Mergeable** — env files, Caddy configs never blindly overwritten
91- **Debuggable** — if something goes wrong, just ask Claude to fix it
92- **Shareable** — no app-specific knowledge baked in, works for anyone
93- **No stale state** — server is always the source of truth
94
95## Per-App Notes (Optional)
96
97The server can't know things like "this app needs FOODTRACKER_PASSWORD on redeploy"
98or "this app has SQLite at /var/lib/foodtracker/data/". That's documentation, not
99state. Users can keep these as plain notes in whatever system they prefer — a vault,
100a README, a comment in a script. The skills don't depend on it.
101
102## SQLite Backup
103
104Before swapping a binary, `ship-binary` checks `/var/lib/{name}/` for any `.db` files
105and backs them up to `/var/lib/{name}/backups/{timestamp}.db` before proceeding. Silent
106and automatic — you never lose data from a bad deploy.
107
108## Multi-Host Support
109
110Config supports multiple named hosts. One is marked as default. All skills use the
111default unless told otherwise.
112
113```json
114{
115 "default": "prod",
116 "hosts": {
117 "prod": {
118 "host": "ubuntu@1.2.3.4",
119 "domain": "example.com"
120 },
121 "staging": {
122 "host": "ubuntu@5.6.7.8",
123 "domain": "staging.example.com"
124 }
125 }
126}
127```
128
129Usage is natural — "deploy foodtracker to staging" and Claude picks the right host.
130`ship-setup` can be run multiple times to add new hosts. The default can be changed
131at any time.
132
133## Out of Scope (For Now)
134
135- Health checks — skipping initially, can add later if needed
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