From 119cc079ccb1a625b962ce2a7fcd9b0d58f1c126 Mon Sep 17 00:00:00 2001
From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com>
Date: Mon, 24 Aug 2026 10:08:28 -0400
Subject: [PATCH] feat: publish an image or a video with `stele attach`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The server has stored attachments since it learned to, and its skill
document already tells agents to run this command — so until now the
live contract named a command that did not parse. That is the same
failure `--ttl` had, in the other direction: last time the document was
stale, this time the client was.
The request is a `publish` with a binary content type, because on the
server an attachment is a page whose body is bytes. What earns it a
command of its own is the answer, not the request. An attachment has two
URLs — the bytes under `/static/`, and a viewer page about the file —
and mixing them up fails silently, because an `
` pointed at the
viewer renders nothing and both answer 200. So stdout is the bytes,
which is the value `src=$(stele attach chart.png)` captures and the one
a page-writing agent needs; the viewer goes to stderr under the
deadline, for the human who wants to send someone a link.
`--json` names both and calls neither of them `url`. That key means "the
viewer" on every other command, so whichever meaning it took here would
be wrong half the time and wrong silently — a caller reaching for `.url`
would get a working URL of the wrong kind. Absent, they get nothing,
which is a failure they can see.
An unrecognised extension is refused before the upload rather than
guessed at. `publish` may fall through to `text/html` because that is
what it is for; the same shrug on a JPEG spends the upload and comes
back as a 415 naming a type nobody chose. `--filename` defaults to the
file's own basename and is omitted rather than refused when that name is
one the server would reject — the caller never typed it, and the
server's fallback is a working download where a 400 about an unpassed
parameter is not. An explicit `--filename` travels untouched.
Exit 6 and its two messages widen to cover an attachment. The status is
the same and the code stays one, because the caller's next move has the
same shape either way; what differs is the advice, and 413 now carries
both halves rather than telling someone with an oversized video to drop
their inline images.
0.4.0, in this commit: the server version-gates on the User-Agent, and a
capability its skill references should be a version an operator can
require.
Co-Authored-By: Claude Opus 5
---
README.md | 32 ++--
Sources/SteleKit/Inputs.swift | 7 +
Sources/SteleKit/SteleError.swift | 15 +-
Sources/SteleKit/Version.swift | 2 +-
Sources/stele/Exit.swift | 9 +-
Sources/stele/PageCommands.swift | 222 +++++++++++++++++++++++++-
Sources/stele/Stele.swift | 1 +
Tests/SteleKitTests/InputsTests.swift | 10 ++
scripts/integration-smoke.sh | 100 ++++++++++--
9 files changed, 371 insertions(+), 27 deletions(-)
diff --git a/README.md b/README.md
index 835f0ea..c4cff8a 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,11 @@ $ stele publish report.html --slug q3-report --ttl never
https://stele.example.com/q3-report
kept until deleted
+$ stele attach chart.png --ttl never
+https://stele.example.com/static/plain-amber-heron
+kept until deleted
+page about it: https://stele.example.com/plain-amber-heron
+
$ stele update q3-report report.html
https://stele.example.com/q3-report
kept until deleted
@@ -48,6 +53,13 @@ document. The deadline under the URL is on stderr for that reason, and it is pri
because the server's default is *ephemeral* — a page published with no `--ttl` is eventually
unpublished, and a caller handed only a URL would find that out when the link broke.
+`stele attach` is the one command whose stdout is not the URL the server answered with. An
+attachment has two: the bytes, under `/static/`, and a viewer page about the file. The bytes are
+what a page embeds and the viewer is what you send a person, and mixing them up fails silently —
+an `
` pointed at the viewer renders nothing, and both answer `200`. So the bytes go to
+stdout, where `src=$(stele attach chart.png)` captures them, and the viewer goes to stderr
+beneath the deadline. `--json` names both and calls neither of them `url`.
+
## Commands
| Command | Who runs it | Does |
@@ -56,6 +68,7 @@ unpublished, and a caller handed only a URL would find that out when the link br
| `stele auth status` | agent or human | Host, client name, scopes, expiry — never the token. |
| `stele auth logout` | human | Forgets the local credential. Does not revoke it. |
| `stele publish [--slug ] [--ttl ]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. |
+| `stele attach [--slug ] [--ttl ] [--filename ]` | agent | `POST /pages` with a binary type. Publishes an image, video or PDF. Prints the URL of the *bytes* on stdout — the one that goes in an `
` — and the viewer's under the deadline on stderr. |
| `stele update ` | agent | `PUT /pages/:slug`. Never creates, and never changes the deadline. |
| `stele amend [--slug ] [--ttl ]` | agent | `PATCH /pages/:slug`. Renames a page, moves its deadline, or both, without touching a byte of it. A rename is a hard move — the old URL starts 404ing at once. |
| `stele delete ` | agent | `DELETE /pages/:slug`. Permanent and immediate — the slug is freed and returns to the pool, so every link to it breaks. Prints nothing on stdout; the confirmation is on stderr. |
@@ -206,7 +219,7 @@ table.
| 3 | the server rejected the credential — ask the user to log in again |
| 4 | valid credential, insufficient scope — an operator has to run this |
| 5 | that slug is taken — ask for a different `--slug` |
-| 6 | the page is too large or the wrong type |
+| 6 | the file is too large or a type the server will not store |
| 7 | no such page or client |
| 8 | the CLI is too old — reinstall it and retry once |
| 9 | could not reach the server — retryable |
@@ -402,7 +415,7 @@ mint the first client. Arguments may also arrive as `STELE_SMOKE_HOST`, `STELE_S
| `--psql ` | optional | A command that reaches the server's database, e.g. `docker exec stele-postgres psql -U stele -d stele_smoke`. Only the attribution check needs it, because no route reports who wrote a page — without it that one check prints `skip`. |
It walks the whole documented lifecycle — mint, log in, `auth status`, publish, fetch the bytes
-back and compare them, update, amend, `--expires-in` there *and* back, expiry actually enforced,
+back and compare them, attach, update, amend, `--expires-in` there *and* back, expiry enforced,
a publish-only credential answered `200` by whoami and `403` by the admin routes, revocation that
really stops working, the exit-code vocabulary, the version gate, and the byte-identity of every
`404`. It prints each check, stops at the first failure, and exits non-zero.
@@ -424,13 +437,14 @@ Two things it does not do, stated because a canary you trust wrongly is worse th
refuses a pipe; the script asserts the *refusal* — which is the load-bearing custody rule — and
then seeds the credential file at `0600` in the documented format for the rest of the run. The
prompt, the echo-off read and the verify-before-write are not covered.
-- **It leaves litter.** The server has a delete route but this CLI ships no command that reaches
- it, so each run leaves eight pages and three
- credential rows behind; it revokes every credential it mints, but a revoked row is still a row.
- Five of those pages expire on their own — the lifetime checks publish several deliberately —
- but three were published or amended to `--ttl never` and will still be there next year. The
- footer names those three, since they are the only ones a human ever has to clean up. Run it
- against a throwaway deployment, never against production.
+- **It leaves litter.** Each run leaves ten pages and three credential rows behind. The pages it
+ deletes it deletes on purpose, to prove the verb works; the rest are the evidence the later
+ checks read, so they outlive the run. Credentials cannot be cleaned up at all — it revokes
+ every one it mints, but a revoked row is still a row. Seven of those pages expire on their own
+ — the lifetime checks publish several deliberately — but three were published or amended to
+ `--ttl never` and will still be there next year. The footer names those three, since they are
+ the only ones a human ever has to clean up. Run it against a throwaway deployment, never
+ against production.
It also moves `~/.config/stele/credentials.json` aside and restores it on every exit path,
including a failed check — see the `HOME` note under Configuration for why it cannot simply use a
diff --git a/Sources/SteleKit/Inputs.swift b/Sources/SteleKit/Inputs.swift
index 3344d22..5cdd723 100644
--- a/Sources/SteleKit/Inputs.swift
+++ b/Sources/SteleKit/Inputs.swift
@@ -59,6 +59,13 @@ public enum ContentType {
/// What `stele attach --content-type` offers at TAB.
public static var knownAttachments: [String] { Set(attachmentsByExtension.values).sorted() }
+ /// The same table read the other way, for the message a refused file earns.
+ ///
+ /// Extensions rather than types, because they are what the person is holding. Told that a
+ /// deployment takes `image/png`, someone looking at `diagram.graffle` still has to work out
+ /// what to do; told the list is `png, jpg, …`, they already know.
+ public static var knownAttachmentExtensions: [String] { attachmentsByExtension.keys.sorted() }
+
/// The type to upload a file as, or nil when the extension says nothing.
///
/// Optional where `inferred` is not, and that difference is the whole point. `text/html` is
diff --git a/Sources/SteleKit/SteleError.swift b/Sources/SteleKit/SteleError.swift
index a85c05c..64539d4 100644
--- a/Sources/SteleKit/SteleError.swift
+++ b/Sources/SteleKit/SteleError.swift
@@ -95,16 +95,23 @@ public enum SteleError: Error, Equatable, CustomStringConvertible {
name, or retire the existing one with `stele admin clients revoke ` and \
mint again — rotating a credential is revoke-then-mint under the same name.
"""
+ // Two limits behind one status, and the advice differs by which was hit. A page's is
+ // small and is usually blown by something that did not need to be inline; an
+ // attachment's is far larger and blowing it means the file is genuinely too big. This
+ // side cannot tell them apart — the status is the same and the command is not in scope
+ // here — so both remedies are named rather than the wrong one being guessed at.
case .pageTooLarge(let detail):
return """
- the page is over the server's size limit\(Self.suffix(detail)) Drop inline \
- images and link them instead — the limit is on the HTML, and a data: URI \
- counts against it.
+ the file is over the server's size limit\(Self.suffix(detail)) For a page, \
+ publish images with `stele attach` and link them instead of inlining them — a \
+ data: URI counts against the HTML. For an attachment, it is over this \
+ deployment's ceiling, which is a much larger one.
"""
case .unsupportedContentType(let detail):
return """
the server will not store that content type\(Self.suffix(detail)) Publish one \
- of the types it lists, or leave the type to the CLI.
+ of the types it lists, or leave the type to the CLI — pages and attachments \
+ accept different ones.
"""
case .upgradeRequired(let detail):
return """
diff --git a/Sources/SteleKit/Version.swift b/Sources/SteleKit/Version.swift
index 39f664b..320797d 100644
--- a/Sources/SteleKit/Version.swift
+++ b/Sources/SteleKit/Version.swift
@@ -8,7 +8,7 @@
public enum SteleVersion {
/// Bumped by hand. Nothing derives it from git: a release built from a tarball or an
/// unclean tree must still report a version the server can compare.
- public static let current = "0.3.0"
+ public static let current = "0.4.0"
/// Sent on every request. The `stele-cli/` product token is what the server matches on.
public static var userAgent: String { "stele-cli/\(current)" }
diff --git a/Sources/stele/Exit.swift b/Sources/stele/Exit.swift
index 542602f..e24a3fb 100644
--- a/Sources/stele/Exit.swift
+++ b/Sources/stele/Exit.swift
@@ -40,8 +40,11 @@ enum Exit {
/// which is why the message carries the route's own advice and this table does not repeat it.
static let slugTaken: Int32 = 5
- /// `413` / `415` — the page itself is the problem. Retrying is pointless; the input changes
- /// or nothing does.
+ /// `413` / `415` — the file itself is the problem, whether it was a page or an attachment.
+ /// Retrying is pointless; the input changes or nothing does. The two are one code because
+ /// the caller's next move is the same shape either way, and `SteleError` carries the half
+ /// that differs: a page over the limit usually has something inline that `stele attach`
+ /// should have published, and an attachment over it is simply too big for the deployment.
static let pageRejected: Int32 = 6
/// `404` — an update to a slug that does not exist, an amendment or a deletion aimed at one
@@ -70,7 +73,7 @@ enum Exit {
(credentialRejected, "the server rejected the credential — ask the user to log in again"),
(forbidden, "valid credential, insufficient scope — an operator has to run this"),
(slugTaken, "that slug is taken — ask for a different --slug"),
- (pageRejected, "the page is too large or the wrong type"),
+ (pageRejected, "the file is too large or a type the server will not store"),
(notFound, "no such page or client"),
(upgradeRequired, "the CLI is too old — reinstall it and retry once"),
(unreachable, "could not reach the server — retryable"),
diff --git a/Sources/stele/PageCommands.swift b/Sources/stele/PageCommands.swift
index 4c5c8d2..edfc814 100644
--- a/Sources/stele/PageCommands.swift
+++ b/Sources/stele/PageCommands.swift
@@ -60,7 +60,10 @@ enum PageIO {
///
/// Absolute, never "in 7 days", for the reason `Format.moment` already gives: the follow-up
/// question to a relative date is always "so what date is that".
- private static func lifetime(_ location: PageLocation) -> String {
+ ///
+ /// Not private, because `attach` prints its own stdout line and this one underneath it. One
+ /// spelling of a deadline across every command that has one to report.
+ static func lifetime(_ location: PageLocation) -> String {
guard let expiry = location.expiresAt else { return "kept until deleted" }
return "expires \(Format.moment(expiry))"
}
@@ -146,6 +149,223 @@ struct PublishCommand: SteleCommand {
}
}
+/// Publishes bytes — an image, a video, a PDF — rather than a page.
+///
+/// The same route as `publish`, because on the server an attachment *is* a page whose body is
+/// bytes. What makes this a separate command is not the request but the answer: an attachment
+/// has two URLs, and which one a caller gets handed decides whether the page they write works.
+struct AttachCommand: SteleCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "attach",
+ abstract: "Publish an image, video or file. Prints the URL of the bytes.",
+ discussion: """
+ For the things a page links to rather than the page itself. Publish the file first, \
+ then put the URL you get back into the page you write:
+
+ src=$(stele attach screenshot.png)
+
+ Prints nothing but that URL on stdout, so the capture above works. It is \
+ deliberately the URL of the *bytes* — the one that belongs in an
or a \
+