diff --git a/CLAUDE.md b/CLAUDE.md index 4a93363..e5ccd8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,14 +84,25 @@ pins the sentence that says whose step `stele auth login` is. The skill's job is the credential out of the model's reach; prose that hands it back would pass every other test in the suite. -`PATCH /pages/:slug` lands in the same trap and is resolved the same way — see -`documentsTheAmendRoute`, which pins the route-table row, the sentence saying no `stele` -command runs it, and the sentence attributing that to the *tool* rather than the server. Its -negative counterpart `doesNotClaimALifetimeIsUnchangeable` is the one to keep: the document -used to state four separate times that a lifetime could never change, and every one of those -had to become a claim about the client ("no command you can run"). A future edit drifting -back to the absolute would read fine, pass everything else here, and teach an agent to refuse -something the server does. +`PATCH /pages/:slug` was in that trap and is now out of it, which is the more instructive +half. `documentsTheAmendRoute` used to pin the sentence saying no `stele` command ran the +verb, plus the sentence attributing that absence to the *tool* rather than the server — +honest wording, and both became lies the day `stele amend` shipped. It now pins the command +and the two things an agent gets wrong by carrying over what it knows from `publish`: that +omitting `--ttl` here leaves the deadline alone rather than applying the default, and that +dropping `--slug` is not the escape from a `409` that it is on a publish. + +Its negative counterpart `doesNotClaimALifetimeIsUnchangeable` is the one to keep, and it has +now grown twice for the same reason. The document first stated four times that a lifetime +could never change; each of those became a claim about the client ("no command you can run"), +which was true until it wasn't. **The client-scoped hedge is not a safer way to say it** — it +is the same claim with a shorter shelf life, so both spellings are in the sieve. A future edit +drifting back to either would read fine, pass everything else here, and teach an agent to +refuse something it could do. + +One mechanical constraint that suite learned the hard way: the markdown is a wrapped raw +string, so an assertion phrase that straddles a line break cannot be pinned at all. Prose +carrying an assertion gets reflowed to keep the claim on one line — not the reverse. `documentsTheDeleteRoute` is where those two jobs pull against each other, and the shape it settled on is deliberate. `DELETE /pages/:slug` exists on the server, but the CLI ships no diff --git a/README.md b/README.md index e5e1c6f..1d70712 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ http://localhost:8080/radiant-surf-gecko ``` The `stele` CLI ([stele-cli](https://github.com/ProJedi1234/stele-cli)) holds the -credential so that whoever runs it — usually an agent — never has to. Underneath it is an -ordinary HTTP request, and curl still works if you are holding a token yourself — which is -today the only way to reach `PATCH /pages/:slug`, since the CLI exposes `--ttl` on a publish -but has no command that renames or retimes a page afterwards: +credential so that whoever runs it — usually an agent — never has to. `POST`, `PUT` and +`PATCH` each have a command in front of them — `stele publish`, `stele update` and +`stele amend` — which leaves `DELETE /pages/:slug` as the one write with no command that +reaches it. Underneath it is an ordinary HTTP request, and curl still works if you are holding +a token yourself, which is what taking a page down early still takes: ``` $ curl -X POST "localhost:8080/pages?ttl=never" \ diff --git a/Sources/SteleCore/PublishSkill.swift b/Sources/SteleCore/PublishSkill.swift index 1f24972..b709dcb 100644 --- a/Sources/SteleCore/PublishSkill.swift +++ b/Sources/SteleCore/PublishSkill.swift @@ -297,8 +297,9 @@ struct PublishSkill: Sendable { That command takes the default lifetime — \#(PageLifetime.defaultDays) days — and the page stops being served when it runs out. Read "How long the page lives" below before - you run it; `\#(SteleCLI.ttlFlag)` is how you choose something else, and no command - you can run will change it afterwards. + you run it; `\#(SteleCLI.ttlFlag)` is how you choose something else. A deadline can be + moved afterwards, with `stele amend`, but the link you hand back is only as good as the + date you chose here — so choose it now rather than planning to fix it. The URL goes to stdout and nothing else does, so `url=$(stele publish page.html)` captures it cleanly; the page's deadline is printed under it on stderr. **That URL is @@ -337,9 +338,11 @@ struct PublishSkill: Sendable { A page's deadline is stored to the day, so anything finer is refused rather than rounded to a lifetime you did not ask for: `12h` is an error, not half a day. Ask for - the lifetime the user actually wants — nothing you can run changes it once the page - exists, so it is a choice to make rather than one to inherit from an example, and a - user expecting a permanent link needs to hear if they did not get one. + the lifetime the user actually wants. `stele amend \#(SteleCLI.ttlFlag)` can move it + later, but only for as long as the page is still alive — a deadline that has already + passed cannot be extended, only republished at a new name — so this is a choice to + make rather than one to inherit from an example, and a user expecting a permanent link + needs to hear if they did not get one. Underneath, the server reads the lifetime as a query parameter on the write, and that is where the bounds live: @@ -357,22 +360,25 @@ struct PublishSkill: Sendable { is how it gets there. That table is the rule **when a page is being published**. On - `PATCH /\#(ServerRoute.pages)/:slug` the first row does not apply: an omitted - `?\#(PageLifetime.queryParameter)=` there means *leave the deadline exactly as it is*, - not \#(PageLifetime.defaultDays) days. The other two rows mean what they say on both - verbs. Read that difference carefully before reporting what a rename did to a page's - lifetime — on a permanent page the two readings differ by the page's whole future. + `PATCH /\#(ServerRoute.pages)/:slug` — which is what `stele amend` runs — the first row + does not apply: an omitted `?\#(PageLifetime.queryParameter)=` there means *leave the + deadline exactly as it is*, not \#(PageLifetime.defaultDays) days. The other two rows + mean what they say on both verbs. That difference is why renaming a page with + `\#(SteleCLI.slugFlag)` alone does not re-date it, and it is the thing to be careful + about when reporting what an amendment did — on a permanent page the two readings + differ by the page's whole future. + + The other difference is where the clock starts. A lifetime given to `stele amend` is + counted from the moment you run it, not from when the page was published, so + `\#(SteleCLI.ttlFlag) 30` on a three-week-old page grants thirty fresh days rather than + the nine that were left. It is a new lease, not an adjustment to the old one. The expiry belongs to the page, not to its current contents: **replacing a page does not extend it**, which is why `stele update` has no `\#(SteleCLI.ttlFlag)` and the server refuses that query parameter on a `PUT` with a `400` rather than accepting it and moving nothing. A deadline is a property of the page, and rewriting the page's - contents is not a reason to move it. - - The server does have a verb that retimes a page — `PATCH /\#(ServerRoute.pages)/:slug`, - in the route table below — but see "Renaming a page, and changing its deadline": no - `stele` command reaches it, so from where you are standing a lifetime is still chosen - once, at publication. + contents is not a reason to move it. Moving a deadline is a separate act with its own + command — see "Renaming a page, and changing its deadline". ### Choosing your own slug @@ -409,33 +415,45 @@ struct PublishSkill: Sendable { ### Renaming a page, and changing its deadline - **`stele` has no rename command and no retime command**, so neither is something you - can do. The server grew a `PATCH /\#(ServerRoute.pages)/:slug` that takes `?slug=` to - move a page to a new name and `?\#(PageLifetime.queryParameter)=` to give it a new - deadline — either, or both in one request — and the route table below lists it, but - reaching it means holding a credential, which is the one thing this arrangement exists - to spare you. A user who wants a published page renamed, or wants its deadline moved, - is asking for a newer `stele`; say so, and do not work around it. - - Say it as a limitation of the tool rather than of the server, because that is what it - is. "There is no way to do that" would be the same wrong answer that once had an agent - refuse a permanent page over a flag that already existed. - - What to tell them in the meantime: - - - **A deadline is the case with no workaround.** You cannot republish over the page — - `stele publish \#(SteleCLI.slugFlag) ` is a `409` — and you cannot take - the old one down first, because there is no delete command either. A page that must - outlive its date has to be published again at a *different* name. - - **A rename usually is not what the user needs.** If the page is not published yet, - `stele publish page.html \#(SteleCLI.slugFlag) ` picks the name up front, - which is the moment to ask. If it is, `stele update` gives them new content at the - name they already have. - - And renaming is the harsher tool anyway, for the same reason deleting is. The move is + ```sh + stele amend my-page \#(SteleCLI.slugFlag) better-name + stele amend my-page \#(SteleCLI.ttlFlag) \#(PageLifetime.neverKeyword) + ``` + + `stele amend` changes a page's name, its deadline, or both in one command, and nothing + else. It sends no file: the contents, the content type and the record of who published + them come through untouched. It prints the page's URL *after* the amendment, which is + not necessarily the one you passed in — report that one, and never assemble the new URL + yourself from the name you asked for. + + Omitting `\#(SteleCLI.ttlFlag)` leaves the existing deadline exactly where it is. This + is the one place the flag does not mean what it means on `stele publish`, where + omitting it takes the default — so a rename with no `\#(SteleCLI.ttlFlag)` does not + quietly put \#(PageLifetime.defaultDays) days on a page somebody published to keep. + + It never creates and never revives. Exit `7` means there is no live page at that name, + and a page that has already expired counts as none — so `\#(SteleCLI.ttlFlag) + \#(PageLifetime.neverKeyword)` cannot bring one back, and republishing at a new name is + the only answer for a page that missed its date. + + Exit `5` means another live page holds the name you asked for. Dropping + `\#(SteleCLI.slugFlag)` is not an escape here the way it is on a publish: there it asks + for a generated name, here it asks for no rename at all. + + **Renaming is the harsher tool, and usually not what the user needs.** The move is hard: the old name is released the instant it commits, with no redirect and nothing left behind, so a link already in somebody's hands starts serving the ordinary 404 and - the name goes back into the pool for the next page — anybody's — to claim. + the name goes back into the pool for the next page — anybody's — to claim. So the + question to ask before renaming is not whether a better name would be nicer, it is who + already has the old one. A URL that has not left your terminal renames freely, and that + is what this is for. A URL already sitting in somebody's inbox is a different matter — + `stele update` gives them new contents at the name they already have, which is what the + request usually means. + + If `stele amend` comes back saying it does not recognise the command, the installed + client predates it: run `\#(SteleCLI.installCommand)` and try once more. That is a + different failure from every exit code in the table below, and the only one whose fix + is reinstalling rather than rewording. ### Deleting a page @@ -468,6 +486,7 @@ struct PublishSkill: Sendable { | `stele auth logout` | **the user** | Forgets the stored credential for a host. Not yours to run either. | | `stele publish [\#(SteleCLI.slugFlag) ] [\#(SteleCLI.ttlFlag) ] [\#(SteleCLI.contentTypeFlag) ]` | you | Publishes a page, prints its URL. | | `stele update [\#(SteleCLI.contentTypeFlag) ]` | you | Replaces a page already published at that name. | + | `stele amend [\#(SteleCLI.slugFlag) ] [\#(SteleCLI.ttlFlag) ]` | you | Renames a page, moves its deadline, or both. Sends no file and changes no contents. | | `stele skill` | you | Prints this document, fetched live from the server. | | `stele admin clients` (`create`, `list`, `revoke`) | **an operator** | Mints, lists and revokes credentials. Needs the `\#(ClientScope.admin.rawValue)` scope, which yours does not have. | @@ -490,8 +509,9 @@ struct PublishSkill: Sendable { - A failure with no status at all — exit `9` — is usually not the server being down: check `stele auth status` first, then that you are pointed at the right host. - **A successful publish is not a promise the page will still be there.** The default - is \#(PageLifetime.defaultDays) days, not forever, and no command you can run moves - that date once it is set. Tell the user which they got. + is \#(PageLifetime.defaultDays) days, not forever. `stele amend \#(SteleCLI.ttlFlag)` + can move that date while the page is alive, but nothing recovers one that has already + passed. Tell the user which they got. ## What a failure looks like @@ -537,7 +557,7 @@ struct PublishSkill: Sendable { | `GET \#(PublishSkill.path)` | none | This document | | `POST /\#(ServerRoute.pages)` | `\#(ClientScope.publish.rawValue)` | Stores the body, takes `?slug=` and `?\#(PageLifetime.queryParameter)=`, returns `{slug, url, expires}` as `201` | | `PUT /\#(ServerRoute.pages)/:slug` | `\#(ClientScope.publish.rawValue)` | Replaces a stored page, returns `{slug, url, expires}` as `200` | - | `PATCH /\#(ServerRoute.pages)/:slug` | `\#(ClientScope.publish.rawValue)` | Renames a page with `?slug=` and retimes it with `?\#(PageLifetime.queryParameter)=`, leaving its contents alone; returns `{slug, url, expires}` as `200`. No command reaches it — see "Renaming a page, and changing its deadline". | + | `PATCH /\#(ServerRoute.pages)/:slug` | `\#(ClientScope.publish.rawValue)` | Renames a page with `?slug=` and retimes it with `?\#(PageLifetime.queryParameter)=`, leaving its contents alone; returns `{slug, url, expires}` as `200`. This is what `stele amend` runs — see "Renaming a page, and changing its deadline". | | `DELETE /\#(ServerRoute.pages)/:slug` | `\#(ClientScope.publish.rawValue)` | Removes a stored page and frees the slug, returns `204`. No command reaches it — see "Deleting a page". | | `GET /\#(ServerRoute.admin)/\#(ServerRoute.adminWhoami)` | any credential | Reports the credential you hold — name, scopes, expiry. This is what `stele auth status` asks. | | `POST /\#(ServerRoute.admin)/\#(ServerRoute.adminClients)` | `\#(ClientScope.admin.rawValue)` | Mints a credential. The operator's route, not yours. | @@ -578,8 +598,9 @@ struct PublishSkill: Sendable { give it back to you. - **A URL is not a permanent address.** Deleting and renaming both retire the page rather than the name, so a link you published can be occupied by somebody else's - page afterwards. If a link has already gone where you cannot reach it, replace the - page with PUT rather than deleting or moving it. + page afterwards. Renaming is the half you can cause yourself, with + `stele amend \#(SteleCLI.slugFlag)`. If a link has already gone where you cannot + reach it, `stele update` replaces the page without moving it. """# } } diff --git a/Sources/SteleCore/SteleCLI.swift b/Sources/SteleCore/SteleCLI.swift index 6fcc103..5d95490 100644 --- a/Sources/SteleCore/SteleCLI.swift +++ b/Sources/SteleCore/SteleCLI.swift @@ -102,7 +102,11 @@ public enum SteleCLI { CLIExit( 5, "That slug is taken.", - "Choose another `\(slugFlag)`, or omit it and take a generated one." + """ + Choose another `\(slugFlag)`. On `stele publish` you can instead omit it and take \ + a generated name; on `stele amend` omitting it asks for no rename at all, so there \ + it is the only way out. + """ ), CLIExit( 6, diff --git a/Tests/SteleCoreTests/PublishSkillTests.swift b/Tests/SteleCoreTests/PublishSkillTests.swift index 077e256..72b61b8 100644 --- a/Tests/SteleCoreTests/PublishSkillTests.swift +++ b/Tests/SteleCoreTests/PublishSkillTests.swift @@ -420,7 +420,7 @@ struct PublishSkillTests { /// `command not found` and no instruction covering it. @Test(arguments: [ "stele auth status", "stele auth login", "stele auth logout", "stele publish", - "stele update", "stele skill", "stele admin clients", + "stele update", "stele amend", "stele skill", "stele admin clients", ]) func documentsEveryCommandTheAgentNeeds(command: String) { #expect(skillDocument.markdown.contains(command), "\(command)") @@ -616,18 +616,24 @@ struct PublishSkillTests { #expect(!markdown.contains("| `204` |")) } - /// `PATCH` is the delete row's problem a second time, and it is settled the same way: - /// the route table has to name the verb, because a verb missing from that table is one - /// the agent will not use, and the prose has to say in the same breath that no command - /// runs it. Naming it and stopping there is the failure — the only way to act on the row - /// would be to go find a credential. + /// `PATCH` was the delete row's problem — a verb the server had and the client could not + /// reach — right up until `stele` grew `amend`, and this test is what that arrival turned + /// over. The route row still has to be here, for the reason the delete row's does: a verb + /// missing from the table is one the agent will not use. What changed is the prose beside + /// it, which said in so many words that no command ran it. /// - /// Where it differs from delete is what the agent should *say*. A user asking to change a - /// deadline has no workaround at all: republishing over the name is a `409` and there is - /// no delete command to clear it first. So the section has to be honest that this is the - /// tool lagging the server rather than the server refusing — the distinction the `--ttl` - /// incident turned on, where prose describing a limitation that had stopped existing read - /// as policy. When `stele` grows these commands, this test is where to start. + /// So the assertions invert. The old ones pinned an absence — "**`stele` has no rename + /// command and no retime command**", and the sentence attributing that absence to the tool + /// rather than the server — and both are now the lie they were written to prevent. In their + /// place: the command itself, and the two things about it an agent gets wrong by carrying + /// over what it knows from `publish`. + /// + /// The first is `--ttl`'s inverted default. Omitting it on a publish takes the server's + /// default; omitting it here means *leave the deadline alone*, and an agent that assumed + /// otherwise would report a rename as having re-dated a permanent page — or, worse, avoid + /// renaming one for fear that it had. The second is that `--slug` has no escape hatch here: + /// on a publish a `409` is answered by dropping the flag and taking a generated name, and + /// on an amendment dropping it means asking for no rename at all. @Test func documentsTheAmendRoute() { let markdown = skillDocument.markdown #expect( @@ -635,20 +641,46 @@ struct PublishSkillTests { "| `PATCH /\(ServerRoute.pages)/:slug` | `\(ClientScope.publish.rawValue)` |" ) ) - #expect(markdown.contains("**`stele` has no rename command and no retime command**")) - // Both query parameters are named, because the row is the only place an agent - // learns the verb takes them — and `?ttl=` here is a different contract from - // `?ttl=` on POST, which is the one an agent already knows. - #expect(markdown.contains("`?slug=` to")) - #expect(markdown.contains("`?\(PageLifetime.queryParameter)=` to give it a new")) - // The limitation is attributed to the client, not the server. This is the sentence - // that keeps a future reader from "simplifying" the section into "the server cannot - // do that", which is the exact shape of the bug this whole arrangement exists for. + // The command, named as something the agent runs. `documentsEveryCommandTheAgentNeeds` + // proves the string is somewhere in the document; this proves the section about + // renaming is where it is. + #expect(markdown.contains("`stele amend` changes a page's name, its deadline, or both")) + // Both query parameters stay named in the route row, because that row is the only + // place an agent learns the verb takes them. + #expect(markdown.contains("Renames a page with `?slug=`")) + #expect(markdown.contains("retimes it with `?\(PageLifetime.queryParameter)=`")) + // The inverted default, pinned as a sentence. This is the one claim in the section + // whose loss would be silent: everything else here fails loudly the first time an + // agent tries it, and this one produces a page with a deadline nobody chose. #expect( markdown.contains( - "Say it as a limitation of the tool rather than of the server" + "Omitting `\(SteleCLI.ttlFlag)` leaves the existing deadline exactly where it is" ) ) + // And the missing escape hatch on a 409, which reads as an oversight rather than a + // rule unless it is said outright. + // + // Asserted as one contiguous phrase, which is a constraint on the *document*: the + // markdown is a wrapped raw string, so a claim that straddles a line break cannot be + // pinned at all. This one already failed that way once. Prose carrying an assertion + // has to be reflowed to keep it on a single line, not the other way round. + #expect( + markdown.contains( + "`\(SteleCLI.slugFlag)` is not an escape here the way it is on a publish" + ) + ) + // The whole mitigation for a client older than this command, and the reason + // `minimumCLIVersion` was deliberately *not* raised when `amend` shipped: the wire + // contract did not move, so gating every write — including the publishes that still + // work perfectly — would have been a disproportionate answer to a missing subcommand. + // What an old client gets instead is an ArgumentParser error carrying no exit code + // from the table below, and this sentence is the only thing that tells an agent the + // fix is a reinstall rather than a rewording. Delete it and the failure becomes + // indistinguishable from a malformed invocation. + #expect( + markdown.contains("saying it does not recognise the command, the installed") + ) + #expect(markdown.contains(SteleCLI.installCommand)) } /// The negative half of `documentsTheAmendRoute`, and the one that would rot silently. @@ -668,6 +700,15 @@ struct PublishSkillTests { /// the four it was written against. A code review caught it, not the suite. The lesson is /// that the list is a sieve rather than a proof — when a claim about permanence changes, /// read the document, then add whatever wording you found here. + /// The list grew a second time, and the reason is worth recording because it is the same + /// lesson at one remove. When the server learned to retime a page, every absolute claim + /// here became a claim about the *client* — "no command you can run", "nothing you can + /// run changes it" — which was the honest wording for exactly as long as that was true. + /// `stele amend` shipped, and those sentences turned into the identical failure the + /// absolutes had been: prose confident enough to be quoted back at a user as policy, + /// describing a limitation that had stopped existing. The client-scoped hedge is not a + /// safer way to say it; it is the same claim with a shorter shelf life, so both spellings + /// live in the sieve now. @Test(arguments: [ "cannot be changed afterwards", "there is no way to change it afterwards", @@ -675,6 +716,9 @@ struct PublishSkillTests { "cannot be chosen later", "fixed at publication", "fixed when it is published", + "no command you can run", + "nothing you can run changes it", + "still chosen once, at publication", ]) func doesNotClaimALifetimeIsUnchangeable(phrase: String) { #expect(!skillDocument.markdown.contains(phrase), "\(phrase)")