@netlify/agent-runner-cli 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,244 +1,185 @@
1
1
  ---
2
2
  name: netlify-image-cdn
3
- description: Transform and optimize images on-demand using Netlify Image CDN. Use when implementing responsive images, format conversion, resizing, cropping, or dynamic image optimization without build-time processing.
3
+ description: Transforms images on demand via Netlify Image CDN's /.netlify/images endpoint with query parameters for resizing/cropping/format/quality. Use when adding image optimization or responsive images, converting formats (WebP/AVIF/PNG), generating thumbnails or blur placeholders, serving remote/third-party images through the CDN, allowlisting remote domains in netlify.toml, setting up image redirects or cache headers, building user-uploaded image pipelines, or debugging a 404 on /.netlify/images. Also covers framework image handling for Angular/Astro/Gatsby/Next.js/Nuxt.
4
4
  ---
5
5
 
6
6
  # Netlify Image CDN
7
7
 
8
- Netlify Image CDN transforms and optimizes images on demand at the edge without impacting build times. It handles
9
- content negotiation to serve the most efficient format and caches transformed images for fast delivery.
8
+ Transform images by requesting the endpoint with a `url` query parameter. This is the current and only documented surface — there is no legacy form.
10
9
 
11
- ## Quick Start
12
-
13
- All Netlify projects have a `/.netlify/images` route without any additional enablement. Use it with query parameters to
14
- transform any image:
15
-
16
- ```html
17
- <!-- Resize to 400px wide, convert to WebP -->
18
- <img src="/.netlify/images?url=/photo.jpg&w=400&fm=webp" alt="Optimized photo" />
19
-
20
- <!-- Cover crop to 200x200 square -->
21
- <img src="/.netlify/images?url=/photo.jpg&w=200&h=200&fit=cover" alt="Cropped photo" />
22
-
23
- <!-- Transform a remote image -->
24
- <img src="/.netlify/images?url=https://example.com/image.jpg&w=800&q=80" alt="Remote image" />
10
+ ```
11
+ GET /.netlify/images?url=<source>[&w=][&h=][&fit=][&position=][&fm=][&q=]
25
12
  ```
26
13
 
27
- ## Query Parameters
28
-
29
- | Parameter | Required | Type | Description |
30
- |-----------|----------|------|-------------|
31
- | `url` | Yes | `string` | Source image path (relative) or URL (remote, requires config) |
32
- | `w` | No | `integer` | Width in pixels |
33
- | `h` | No | `integer` | Height in pixels |
34
- | `fit` | No | `string` | Resize mode: `contain` (default), `cover`, `fill` |
35
- | `position` | No | `string` | Crop anchor for `fit=cover`: `center` (default), `top`, `bottom`, `left`, `right` |
36
- | `fm` | No | `string` | Output format: `avif`, `jpg`, `png`, `webp`, `gif`, `blurhash` |
37
- | `q` | No | `integer` | Quality 1-100 for lossy formats (default: 75) |
38
-
39
- The `url` value must be URI-encoded when it contains special characters.
40
-
41
- ## Fit Modes
42
-
43
- | `fit=` | Aspect Ratio Maintained | Crops | Always Returns Exact Dimensions |
44
- |--------|------------------------|-------|-------------------------------|
45
- | `contain` (default) | Yes | No | No, one dimension may be smaller |
46
- | `cover` | Yes | Yes | Yes, crops excess after proportional resize |
47
- | `fill` | No | No | Yes, stretches/squishes to exact dimensions |
48
-
49
- ### `fit=contain` (default)
50
-
51
- Resizes proportionally to fit within the specified dimensions. If only one dimension is given, the other is calculated
52
- to maintain aspect ratio.
53
-
54
- ```html
55
- <img src="/.netlify/images?url=/photo.jpg&w=600&h=400&fit=contain" />
14
+ ```bash
15
+ # resize a deployed image to 50px wide
16
+ curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&w=50'
56
17
  ```
57
18
 
58
- ### `fit=cover`
19
+ `url` is required; all other parameters are optional.
59
20
 
60
- Resizes proportionally to fill the dimensions, then crops excess pixels. Requires **both** `w` and `h`. Use `position`
61
- to control which area is retained.
21
+ ## Query parameters
62
22
 
63
- ```html
64
- <img src="/.netlify/images?url=/photo.jpg&w=300&h=300&fit=cover&position=top" />
65
- ```
23
+ | Parameter | Purpose | Values | Default |
24
+ |-----------|---------|--------|---------|
25
+ | `url` | Source asset (required) | Relative path or remote URL | — |
26
+ | `w` | Width in pixels | Integer | — |
27
+ | `h` | Height in pixels | Integer | — |
28
+ | `fit` | Resize behavior | `contain`, `cover`, `fill` | `contain` |
29
+ | `position` | Crop anchor when `fit=cover` | `top`, `bottom`, `left`, `right`, `center` | `center` |
30
+ | `fm` | Output format | `avif`, `jpg`, `png`, `webp`, `gif`, `blurhash` | content-negotiated |
31
+ | `q` | Quality for lossy output | Integer `1`–`100` | `75` |
32
+
33
+ ## Common transformations
66
34
 
67
- ### `fit=fill`
35
+ ```bash
36
+ # resize + crop to a 50px square, retaining the left side
37
+ curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fit=cover&w=50&h=50&position=left'
68
38
 
69
- Stretches the image to exactly match the specified dimensions. May distort the image if aspect ratios differ.
39
+ # convert JPEG to PNG (response carries content-type: image/png)
40
+ curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fm=png'
70
41
 
71
- ```html
72
- <img src="/.netlify/images?url=/photo.jpg&w=400&h=200&fit=fill" />
42
+ # convert JPEG to AVIF at medium quality
43
+ curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fm=avif&q=50'
73
44
  ```
74
45
 
75
- ## Format Negotiation
46
+ ### fit behavior
76
47
 
77
- When `fm` is **not** specified, Image CDN automatically negotiates the best format using the browser's `Accept` header:
48
+ - **`contain` (default):** maintains aspect ratio; one dimension may come back smaller than requested. Supply one dimension and the other is computed.
49
+ - **`cover`:** fills exactly, cropping excess. **Requires BOTH `w` and `h`** — omitting either is invalid. Use `position` to choose what's retained.
50
+ - **`fill`:** fills exactly, stretching/squishing if aspect ratios differ.
78
51
 
79
- 1. Use `webp` if the browser accepts it
80
- 2. Otherwise use `avif` if accepted
81
- 3. Otherwise serve the original format
52
+ ### Format notes
82
53
 
83
- Explicitly set `fm` only when a specific format is required (e.g., `blurhash` for placeholders).
54
+ - `q` applies only when output is `avif`, `jpg`, `gif`, or `webp`.
55
+ - `webp` and `gif` can be static or animated.
56
+ - If `fm` is omitted, format is content-negotiated from the `Accept` header: `webp` if accepted, else `avif` if accepted, else the original format. A source-only request (no other params) still converts to `webp`/`avif` but keeps size and shape.
84
57
 
85
- ## Remote Image Configuration
58
+ ## Remote source images
86
59
 
87
- To transform images from external domains, allowlist them in `netlify.toml` using regex patterns:
60
+ Remote sources must be allowlisted in `netlify.toml` before transformation, or the request fails.
88
61
 
89
62
  ```toml
90
63
  [images]
91
- remote_images = [
92
- "https://my-images\\.com/.*",
93
- "https://animals\\.more-images\\.com/[bcr]at/.*"
94
- ]
64
+ remote_images = ['https://my-images\.com/.*', 'https://animals.more-images.com/[bcr]at/.*']
95
65
  ```
96
66
 
97
- Then reference the full URL:
67
+ Percent-encode remote source URLs before placing them in the `url` parameter with `encodeURIComponent` — a URL containing `?` or `&` breaks otherwise.
98
68
 
99
- ```html
100
- <img src="/.netlify/images?url=https://my-images.com/photo.jpg&w=400" />
69
+ ```js
70
+ const src = `/.netlify/images?url=${encodeURIComponent('https://my-images.com/owl.jpeg?v=2')}&w=400`;
101
71
  ```
102
72
 
103
- Only absolute URLs to external servers need to be in `remote_images`. Local site images work without configuration.
73
+ Constraints:
74
+ - Remote sources must be **publicly accessible**.
75
+ - Credential-bearing headers (`Authorization`, `Cookie`) are **NOT forwarded** when fetching a remote source. For authenticated sources, use URLs that carry their own authorization (e.g. S3 presigned URLs) and make sure your `remote_images` patterns match those full URLs.
104
76
 
105
- ## Redirects & Rewrites
77
+ ### remote_images regex escaping
106
78
 
107
- If you do not want to use the default `/.netlify/images` path, a redirect or rewrite can be used to have a different
108
- URL. When doing so, the parameters can remain parameters to pass in or can be statically defined.
79
+ The only meaningful escape is the literal dot (`\.`). Forward slashes are NOT metacharacters — never write `https:\/\/`. In `netlify.toml`, use single-quoted literal strings (`'https://example\.com/.*'`) or double the backslash in double-quoted strings (`"https://example\\.com/.*"`). A bare `\.` inside double quotes is invalid TOML.
109
80
 
110
- **Using `netlify.toml`:**
81
+ ## Response codes
111
82
 
112
- ```toml
113
- [[redirects]]
114
- from = "/transform-small/*"
115
- to = "/.netlify/images?url=/:splat&w=50&h=50"
116
- status = 200
117
- ```
83
+ - Invalid transformation parameter values → `404`.
84
+ - Valid new transformation → `200` with content and matching `content-type`.
85
+ - Previously transformed (cached) image → `304`.
86
+
87
+ ## Reusing parameters across images
118
88
 
119
- **Using `_redirects` file:**
89
+ Map a friendly path to the endpoint with a redirect/rewrite.
120
90
 
91
+ `_redirects`:
121
92
  ```
122
93
  /transform-small/* /.netlify/images?url=/:splat&w=50&h=50 200
123
94
  ```
124
95
 
125
- **Usage:**
126
-
127
- ```html
128
- <!-- Transforms /photo.jpg with w=50&h=50 -->
129
- <img src="/transform-small/photo.jpg" />
96
+ `netlify.toml`:
97
+ ```toml
98
+ [[redirects]]
99
+ from = "/transform-small/*"
100
+ to = "/.netlify/images?url=/:splat&w=50&h=50"
101
+ status = 200
130
102
  ```
131
103
 
132
- DO NOT create circular redirects where an image URL redirects to another URL that redirects back. This causes infinite
133
- loops and errors.
104
+ Then `GET /transform-small/owl.jpeg` returns the transformed image. **Cross-site redirects for transformations are NOT recommended** — they can degrade site performance.
134
105
 
135
- ## Custom Headers
106
+ ## Caching headers
136
107
 
137
- Apply custom headers to source images on your site's domain. Headers propagate to the transformed output. ONLY do this
138
- when explicitly asked.
139
-
140
- **Using `netlify.toml`:**
108
+ Apply custom headers to source images on the site's own domain; they carry through to the transformed output.
141
109
 
110
+ `netlify.toml`:
142
111
  ```toml
143
112
  [[headers]]
144
- for = "/source-images/*"
145
- [headers.values]
146
- Cache-Control = "public, max-age=604800, must-revalidate"
113
+ for = "/source-images/*"
114
+ [headers.values]
115
+ Cache-Control = "public, max-age=604800, must-revalidate"
147
116
  ```
148
117
 
149
- **Using `_headers` file:**
150
-
151
- ```
152
- /source-images/*
153
- Cache-Control: public, max-age=604800, must-revalidate
154
- ```
155
-
156
- **Limitations:**
157
- - Custom headers only work for images on the same domain
158
- - Cannot apply custom headers to remote source images
159
- - Netlify respects cache headers that external domains send with their images
160
- - `Cache-Control` headers on source images apply to browsers/CDNs, not the Netlify cache itself
161
-
162
- ## Framework Integration
163
-
164
- Many frameworks automatically use Netlify Image CDN when deployed to Netlify:
165
-
166
- | Framework | Setup | Remote Image Allowlist |
167
- |-----------|-------|----------------------|
168
- | **Angular** | Automatic via `NgOptimizedImage` | `[images] remote_images` in `netlify.toml` |
169
- | **Astro** | Automatic via `<Image />` component | `image.domains` or `image.remotePatterns` in `astro.config.mjs` |
170
- | **Gatsby 5.13+** | Set env var `NETLIFY_IMAGE_CDN=true` | `[images] remote_images` in `netlify.toml` |
171
- | **Next.js 13.5+** | Automatic with adapter v5 | `remotePatterns` in `next.config.js` |
172
- | **Nuxt** | Automatic via `nuxt/image` module | `image.domains` in `nuxt.config.ts` |
173
-
174
- ## Caching & Deploy Behavior
175
-
176
- - Transformed images are uniquely cached at the edge per transformation parameters
177
- - Future requests for the same transformation serve the cached version
178
- - After a new deploy, cached images are invalidated if the source changed
179
- - Source images are also cached to speed up future transformations
180
- - Use asset fingerprinting (content hashes in filenames) for fine-grained cache control
181
-
182
- ## Response Codes
183
-
184
- | Condition | Response |
185
- |-----------|----------|
186
- | Valid query, new transformation | `200` with transformed image |
187
- | Request for previously cached transformation | `304` Not Modified |
188
- | Invalid parameter values or missing source | `404` Not Found |
189
-
190
- ## Common Errors & Solutions
191
-
192
- ### "404 on image transformation"
118
+ - Custom headers can be applied to source images on the site's domain only — NOT to remote source images (Netlify does respect cache headers the external domain sends).
119
+ - `Cache-Control` on source images applies only to browsers and CDNs in front of Netlify, NOT the Netlify Cache itself.
193
120
 
194
- **Cause:** Invalid parameter values or source image not found.
121
+ ## Blur placeholders (fm=blurhash)
195
122
 
196
- **Fix:**
123
+ `fm=blurhash` returns a BlurHash **text string**, not image bytes. Pointing an `<img src>` (or CSS background) at it renders nothing. Fetch the string ahead of time, decode it client-side with a BlurHash library (https://blurha.sh), and load the real image as a separate request without `fm=blurhash`.
197
124
 
198
- 1. Verify the source image exists at the specified `url` path
199
- 2. Check parameter values are valid (e.g., `q` must be 1-100, `fit` must be `contain`, `cover`, or `fill`)
200
- 3. For remote images, ensure the domain is in `remote_images` config
125
+ ## Local development
201
126
 
202
- ### Blurry or low-quality output
127
+ The `/.netlify/images` endpoint, `[images]` allowlisting, and image redirects only exist under `netlify dev` (Netlify CLI). A local **404 on `/.netlify/images` almost always means a framework dev server (`vite`, `next dev`, `astro dev`) is running instead of `netlify dev`** — the URL itself is usually fine. Start the local environment with `netlify dev`.
203
128
 
204
- **Cause:** Quality setting too low or dimensions too small.
129
+ ## User-uploaded image pipelines
205
130
 
206
- **Fix:**
131
+ For pipelines composing Functions + Blobs + Image CDN (handling user-uploaded images), see `references/user-uploads.md`.
207
132
 
208
- 1. Increase the `q` parameter (default is 75, max is 100)
209
- 2. Ensure `w` and `h` are appropriate for the display size
210
- 3. Use `fm=png` for images that need lossless quality
133
+ ## Framework image handling
211
134
 
212
- ### Remote image not loading
135
+ Many frameworks route their built-in image optimization through Netlify Image CDN — use the framework's standard image component/syntax and only configure the remote allowlist. For unlisted frameworks, call `/.netlify/images` directly.
213
136
 
214
- **Cause:** External domain not allowlisted.
137
+ | Framework | Prerequisites | Remote allowlist location |
138
+ |-----------|---------------|---------------------------|
139
+ | Angular | None; `NgOptimizedImage` uses it automatically | `[images] remote_images` in `netlify.toml` |
140
+ | Astro | None; `<Image />` uses it automatically | `image.domains` or `image.remotePatterns` in `astro.config.mjs` |
141
+ | Gatsby (both 5.13+ and 5.11 or earlier) | Set env `NETLIFY_IMAGE_CDN=true`; use Contentful/Drupal/WordPress source plugins | `[images] remote_images` in `netlify.toml` |
142
+ | Next.js | Next.js 13.5+ and Next.js adapter v5 | `remotePatterns` in `next.config.js` |
143
+ | Nuxt | None; `nuxt/image` module uses it automatically | `image.domains` in `nuxt.config.ts` |
215
144
 
216
- **Fix:**
145
+ Setup guides: [Angular](https://docs.netlify.com/build/frameworks/framework-setup-guides/angular#netlify-image-cdn), [Astro](https://docs.netlify.com/build/frameworks/framework-setup-guides/astro#netlify-image-cdn), [Gatsby](https://docs.netlify.com/build/frameworks/framework-setup-guides/gatsby/#netlify-image-cdn), [Next.js](https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview), [Nuxt](https://docs.netlify.com/build/frameworks/framework-setup-guides/nuxt#netlify-image-cdn).
217
146
 
218
- 1. Add the domain to `remote_images` in `netlify.toml`:
219
- ```toml
220
- [images]
221
- remote_images = ["https://example\\.com/.*"]
222
- ```
223
- 2. Escape dots in regex patterns with double backslash
224
- 3. Redeploy after updating config
147
+ ## Additional constraints
225
148
 
226
- ### Images not updating after deploy
149
+ - Deploy behavior: transforms respect [atomic deploys](https://docs.netlify.com/build/caching/caching-overview#automatic-invalidation-with-atomic-deploys); changing a source image in a new deploy re-runs transforms on subsequent requests.
150
+ - [Split Testing](https://docs.netlify.com/manage/monitoring/split-testing/) is NOT supported — image results may be inconsistent across split test branches.
151
+ - Netlify Image CDN is NOT part of Netlify's HIPAA-compliant hosting offering.
227
152
 
228
- **Cause:** Browser or CDN cache serving stale version.
153
+ Interactive parameter playground: https://image-cdn-playground.netlify.app/
229
154
 
230
- **Fix:**
155
+ <!-- system: agent-context/image-cdn/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
156
+ # Netlify house rules (image-cdn)
231
157
 
232
- 1. Use asset fingerprinting (e.g., `/photo-abc123.jpg`) for cache busting
233
- 2. Clear browser cache or use hard refresh
234
- 3. Wait a few minutes for edge cache propagation
158
+ These are org conventions, not docs facts — merged into the rendered skill by
159
+ ctx-gen and never generated. Owned by the skills maintainer.
235
160
 
236
- ### Circular redirect error
161
+ 1. For user-uploaded image pipelines (Functions + Blobs + Image CDN
162
+ composed), see `references/user-uploads.md` in this skill — an authored
163
+ guide with no single docs source.
164
+ 2. Percent-encode remote source URLs before placing them in the `url`
165
+ parameter (`encodeURIComponent`) — URLs containing `?` or `&` break
166
+ otherwise.
167
+ 3. `fm=blurhash` returns a BlurHash TEXT string, not image bytes. Pointing an
168
+ `<img src>` (or CSS background) at it renders nothing — fetch the string
169
+ ahead of time, decode it client-side with a BlurHash library, and load the
170
+ real image as a separate request without `fm=blurhash`.
171
+ 4. A local 404 on `/.netlify/images` almost always means a framework dev
172
+ server (`vite`, `next dev`, `astro dev`) is running instead of
173
+ `netlify dev` — the endpoint, `[images]` allowlisting, and image redirects
174
+ only exist under `netlify dev`. The URL itself is usually fine.
175
+ 5. In `remote_images` patterns, the meaningful regex escape is the dot;
176
+ forward slashes are not metacharacters — do not write `https:\/\/`.
177
+ In `netlify.toml`, use a single-quoted literal string
178
+ (`'https://example\.com/.*'`) or double the backslash in a
179
+ double-quoted string (`"https://example\\.com/.*"`) — a bare `\.`
180
+ inside double quotes is invalid TOML.
237
181
 
238
- **Cause:** Redirect rule creates a loop with the image transformation endpoint.
239
182
 
240
- **Fix:**
183
+ ## Additional requirements
241
184
 
242
- 1. Ensure redirect `from` path does not overlap with the `to` path
243
- 2. Avoid redirecting `/.netlify/images` to another `/.netlify/images` URL
244
- 3. Use status `200` (rewrite) not `301`/`302` (redirect) when possible
185
+ Only add custom headers to source images when the user explicitly asks.
@@ -0,0 +1,156 @@
1
+ # User-Uploaded Images Pipeline
2
+
3
+ Compose Netlify Functions (upload handler) + Netlify Blobs (storage) + Image CDN (serving/transforming) to build a complete user-uploaded image pipeline.
4
+
5
+ ## Architecture
6
+
7
+ 1. **Upload** — A Netlify Function receives multipart form data, validates, and stores in Blobs
8
+ 2. **Storage** — Netlify Blobs stores the binary image with metadata
9
+ 3. **Serve** — A Netlify Function retrieves the blob and serves it at `/uploads/:key`
10
+ 4. **Transform** — A redirect maps `/img/:key` to `/.netlify/images?url=/uploads/:key` for CDN optimization
11
+
12
+ ## Dependencies
13
+
14
+ ```bash
15
+ npm install @netlify/blobs
16
+ ```
17
+
18
+ ## Upload Handler
19
+
20
+ ```typescript
21
+ // netlify/functions/upload.ts
22
+ import type { Context, Config } from "@netlify/functions";
23
+ import { getStore } from "@netlify/blobs";
24
+ import { randomUUID } from "crypto";
25
+
26
+ const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
27
+ const MAX_SIZE = 4 * 1024 * 1024; // 4 MB
28
+
29
+ export default async (req: Request, context: Context) => {
30
+ if (req.method !== "POST") {
31
+ return new Response("Method not allowed", { status: 405 });
32
+ }
33
+
34
+ const formData = await req.formData();
35
+ const image = formData.get("image") as File;
36
+
37
+ if (!image) return Response.json({ error: "No image provided" }, { status: 400 });
38
+ if (!ALLOWED_TYPES.includes(image.type)) return Response.json({ error: "Invalid type" }, { status: 400 });
39
+ if (image.size > MAX_SIZE) return Response.json({ error: "File too large" }, { status: 400 });
40
+
41
+ const extension = image.name.split(".").pop() || "jpg";
42
+ const key = `${randomUUID()}.${extension}`;
43
+ const store = getStore({ name: "images", consistency: "strong" });
44
+
45
+ await store.set(key, image, {
46
+ metadata: {
47
+ contentType: image.type,
48
+ originalFilename: image.name,
49
+ uploadedAt: new Date().toISOString(),
50
+ },
51
+ });
52
+
53
+ return Response.json({ success: true, key, url: `/img/${key}` });
54
+ };
55
+
56
+ export const config: Config = { path: "/api/upload", method: "POST" };
57
+ ```
58
+
59
+ ## Serve Handler
60
+
61
+ ```typescript
62
+ // netlify/functions/serve-image.ts
63
+ import type { Context, Config } from "@netlify/functions";
64
+ import { getStore } from "@netlify/blobs";
65
+
66
+ export default async (req: Request, context: Context) => {
67
+ const key = context.params.key;
68
+ const store = getStore({ name: "images", consistency: "strong" });
69
+
70
+ const result = await store.getWithMetadata(key, { type: "stream" });
71
+ if (!result) return new Response("Not found", { status: 404 });
72
+
73
+ return new Response(result.data, {
74
+ headers: {
75
+ "Content-Type": result.metadata?.contentType || "image/jpeg",
76
+ "Cache-Control": "public, max-age=31536000, immutable",
77
+ },
78
+ });
79
+ };
80
+
81
+ export const config: Config = { path: "/uploads/:key" };
82
+ ```
83
+
84
+ ## CDN Redirect
85
+
86
+ ```toml
87
+ # netlify.toml
88
+
89
+ # Basic optimized URL
90
+ [[redirects]]
91
+ from = "/img/:key"
92
+ to = "/.netlify/images?url=/uploads/:key"
93
+ status = 200
94
+
95
+ # Thumbnail preset
96
+ [[redirects]]
97
+ from = "/img/thumb/:key"
98
+ to = "/.netlify/images?url=/uploads/:key&w=150&h=150&fit=cover"
99
+ status = 200
100
+
101
+ # Hero preset
102
+ [[redirects]]
103
+ from = "/img/hero/:key"
104
+ to = "/.netlify/images?url=/uploads/:key&w=1200&h=675&fit=cover"
105
+ status = 200
106
+ ```
107
+
108
+ ## Client-Side Upload (React Example)
109
+
110
+ ```tsx
111
+ function ImageUpload({ onUpload }: { onUpload: (url: string) => void }) {
112
+ const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
113
+ const file = e.target.files?.[0];
114
+ if (!file) return;
115
+
116
+ const formData = new FormData();
117
+ formData.append("image", file);
118
+
119
+ const res = await fetch("/api/upload", { method: "POST", body: formData });
120
+ const { url } = await res.json();
121
+ onUpload(url);
122
+ };
123
+
124
+ return <input type="file" accept="image/*" onChange={handleChange} />;
125
+ }
126
+ ```
127
+
128
+ ## Astro Upload (API Route)
129
+
130
+ ```typescript
131
+ // src/pages/api/upload.ts
132
+ import type { APIRoute } from "astro";
133
+ import { getStore } from "@netlify/blobs";
134
+ import { randomUUID } from "crypto";
135
+
136
+ export const POST: APIRoute = async ({ request, redirect }) => {
137
+ const formData = await request.formData();
138
+ const image = formData.get("image") as File;
139
+ if (!image) return new Response("No image", { status: 400 });
140
+
141
+ const key = `${randomUUID()}.${image.name.split(".").pop() || "jpg"}`;
142
+ const store = getStore({ name: "images", consistency: "strong" });
143
+ await store.set(key, image, {
144
+ metadata: { contentType: image.type, originalFilename: image.name },
145
+ });
146
+
147
+ return redirect(`/gallery?uploaded=${key}`);
148
+ };
149
+ ```
150
+
151
+ ## Key Points
152
+
153
+ - Always validate file type and size on the server (client validation can be bypassed)
154
+ - Use `strong` consistency on Blobs for immediate reads after writes
155
+ - The serve handler's `Cache-Control: immutable` means the CDN caches the raw image permanently — Image CDN transformations layer on top
156
+ - Without `fm` parameter, Netlify auto-serves AVIF or WebP based on browser support
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/agent-runner-cli",
3
3
  "type": "module",
4
- "version": "2.7.0",
4
+ "version": "2.8.0",
5
5
  "description": "CLI tool for running Netlify agents",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -68,6 +68,7 @@
68
68
  "@eslint/js": "^10.0.1",
69
69
  "@netlify/axis": "^1.21.0",
70
70
  "@netlify/eslint-config-node": "^7.0.1",
71
+ "@netlify/skills": "1.7.0",
71
72
  "@types/node": "^24.5.0",
72
73
  "@typescript-eslint/eslint-plugin": "^8.0.0",
73
74
  "@typescript-eslint/parser": "^8.0.0",
@@ -0,0 +1,52 @@
1
+ import { appendFile, cp, mkdir, readdir, readFile, rm } from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const localSkillsDir = new URL('../src/skills/', import.meta.url)
8
+ const sharedPackagePath = createRequire(import.meta.url).resolve('@netlify/skills/package.json')
9
+ const sharedSkillsDir = path.join(path.dirname(sharedPackagePath), 'skills')
10
+ export const bundledSkillsDir = fileURLToPath(new URL('../dist/skills/', import.meta.url))
11
+ const selectedSharedSkills = [
12
+ { name: 'netlify-access-control' },
13
+ {
14
+ name: 'netlify-forms',
15
+ instructions: new URL('../src/skill-instructions/netlify-forms.md', import.meta.url),
16
+ assets: new URL('../src/skill-assets/netlify-forms/', import.meta.url),
17
+ },
18
+ {
19
+ name: 'netlify-image-cdn',
20
+ instructions: new URL('../src/skill-instructions/netlify-image-cdn.md', import.meta.url),
21
+ },
22
+ ]
23
+
24
+ export const buildSkills = async (targetDir = bundledSkillsDir) => {
25
+ await rm(targetDir, { recursive: true, force: true })
26
+ await cp(localSkillsDir, targetDir, { recursive: true })
27
+
28
+ for (const { name, instructions, assets } of selectedSharedSkills) {
29
+ await cp(path.join(sharedSkillsDir, name), path.join(targetDir, name), {
30
+ recursive: true,
31
+ force: false,
32
+ errorOnExist: true,
33
+ })
34
+ if (instructions) {
35
+ await appendFile(path.join(targetDir, name, 'SKILL.md'), `\n\n${await readFile(instructions, 'utf8')}`)
36
+ }
37
+ if (assets) {
38
+ const assetsDir = fileURLToPath(assets)
39
+ for (const entry of await readdir(assetsDir, { recursive: true, withFileTypes: true })) {
40
+ if (!entry.isFile()) continue
41
+ const source = path.join(entry.parentPath, entry.name)
42
+ const destination = path.join(targetDir, name, path.relative(assetsDir, source))
43
+ await mkdir(path.dirname(destination), { recursive: true })
44
+ await cp(source, destination, { force: false, errorOnExist: true })
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
+ await buildSkills()
52
+ }
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  /**
3
2
  * Installs the orchestrator's bundled skills into the agent's skill directory
4
3
  * by calling `setupAgentSkills` from `src/context.js` — the same function the
@@ -15,10 +14,15 @@
15
14
  * Omit `--agent` to install for all known agents in one pass.
16
15
  */
17
16
  import process from 'node:process'
17
+ import fs from 'node:fs/promises'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
18
20
  import { parseArgs } from 'node:util'
19
21
 
20
22
  import { createJiti } from 'jiti'
21
23
 
24
+ import { buildSkills } from '../build-skills.js'
25
+
22
26
  const jiti = createJiti(import.meta.url, { interopDefault: true })
23
27
  const { setupAgentSkills, resetSkillsCache } = await jiti.import('../../src/context.js')
24
28
 
@@ -43,11 +47,17 @@ const main = async () => {
43
47
 
44
48
  const agents = values.agent ? [values.agent] : ALL_AGENTS
45
49
 
46
- for (const agent of agents) {
47
- const runner = RUNNER_BY_AGENT[agent] ?? agent
48
- resetSkillsCache()
49
- const installed = await setupAgentSkills(runner, values['target-dir'] ? { targetDir: values['target-dir'] } : {})
50
- process.stderr.write(`[install-skills] ${runner}: ${installed.length} skill(s)\n`)
50
+ const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'scenario-skills-'))
51
+ try {
52
+ await buildSkills(sourceDir)
53
+ for (const agent of agents) {
54
+ const runner = RUNNER_BY_AGENT[agent] ?? agent
55
+ resetSkillsCache()
56
+ const installed = await setupAgentSkills(runner, { sourceDir, targetDir: values['target-dir'] })
57
+ process.stderr.write(`[install-skills] ${runner}: ${installed.length} skill(s)\n`)
58
+ }
59
+ } finally {
60
+ await fs.rm(sourceDir, { recursive: true, force: true })
51
61
  }
52
62
  }
53
63