Install
npm i -g @antasphere/slideless exposes the slideless binary (the package
publishes under the Antasphere org’s npm scope; the binary name is unchanged).
The published package is a single self-contained bundle (dist/bin.js, built
with esbuild): the workspace-internal @slideless/contract, @slideless/sdk,
@antasphere/cli-core, and commander are inlined at build time, so the
published manifest carries zero runtime dependencies — nothing internal or
git-pinned leaks into a public install.
From a checkout:
Configuration: profiles, flags, environment
Config lives in the shared Antasphere CLI config home (provided by@antasphere/cli-core — one home for the whole tool family), under the
slideless namespace: $XDG_CONFIG_HOME/antasphere/tools/slideless.json
(default ~/.config/antasphere/tools/slideless.json), directories 0700,
file 0600:
~/.config/slideless/config.json. On first run, if that file still exists,
holds at least one profile, and the new home has no slideless profiles yet,
it is imported automatically and non-destructively — the old file stays in
place (older CLI builds keep working) but stops being read. Note the
corollary: after slideless config clear, a still-present legacy file is
imported again on the next run; delete ~/.config/slideless/config.json too
if you want a truly clean slate.
Every command accepts --api-url (alias --url), --api-key, --profile,
and --json. Resolution order:
argv: a value passed as --api-key slk_… or
--password … is visible to every process on the machine (ps) and lands in
the shell history. Both have alternatives that do not touch the command line:
--api-key-stdin reads the key from the first line of stdin
(pass show slideless | slideless --api-key-stdin files list), SLIDELESS_API_KEY
carries it in the environment, and share / share-email take
--password-stdin or SLIDELESS_SHARE_PASSWORD. stdin can only be spent
once per invocation — asking twice is a usage error rather than two commands
silently sharing one secret.
There is deliberately no default URL: a self-hosted CLI must name its
instance explicitly (flag, env, or saved profile) rather than silently talking
to the wrong host.
Cloud instances: connect through antasphere login
On an Antasphere-cloud instance you never run a Slideless-specific login.
When no direct key resolves, the CLI asks discovery (GET /api/v1/instance)
whether the instance signs in through the hub (auth.methods contains
antasphere); if so, it exchanges the stored antasphere login credential
for a user-scoped tool-local slk_ key (hub → tool; see
Your Antasphere account) and
caches it in the profile per hub profile (connectKeys — ONE
key per hub account, valid for every org; the org is a per-request
selection, never part of the credential):
- The exchange names no organization (the hub credential identifies the USER, never one org); a single cached key serves whatever org context is active. Second and later runs make zero hub calls and mint nothing.
- The hub key is sent to the hub only; the instance sees a short-lived user-scoped JWT (plus its own one-time offline grant, relayed once and never stored by the CLI) and answers with an ordinary local key.
- A cached key is only ever replayed against the instance it was minted on
(the profile’s
baseUrlscopes the cache). slideless logouton a hub-connected profile self-revokes the cached key(s) server-side (DELETE /cli/auth/key— the presenting key revokes exactly itself), then evicts them. An OLDER instance whose machine allowlist predates the self-revoke refuses (403) and keeps the key valid server-side — the CLI says so; revoke it from the dashboard. A classic single-key profile logs out exactly as before.
oss) instances never take this branch: the flows above
(auth login-request, login, SLIDELESS_API_KEY, --api-key) resolve
exactly as documented, and the hub is never contacted.
Sign in
The OTP flow is the self-host entrance. On an Antasphere-cloud instance it refuses — the CLI detects cloud via discovery and steers you toantasphere login (see “Cloud instances” above); server-side the endpoints
answer 403 cli_otp_disabled (cloud instances are hub-login-only). The
flow needs the instance to have a delivering email driver
(EMAIL_DRIVER=smtp|resend); it signs in existing accounts only — sign-up
stays closed (accounts enter via setup, workspace invitations, or
collaborator claims):
login-complete mints an slk_ API key server-side (scopes
presentations:read + presentations:write, never data:export) and stores
it as the active profile; --key-name <name> names the key as the dashboard
lists it, and --expires-in-days <n> gives it a TTL (it never expires
otherwise). Accounts with 2FA enabled are refused
(two_factor_required), and an instance with no email driver has no OTP at
all — mint a key in the dashboard instead (API keys, Create key; tick
presentations:write, which the dialog leaves unchecked, for push and share)
and paste it:
Author: push / pull / dev
precheck (the server answers which blobs it is missing) →
upload exactly those → commit the manifest. Unchanged files are never
re-uploaded.
-
New deck vs new version: the first push writes
.slideless.jsoninto the deck folder ({ presentationId, baseUrl }). Later pushes find it and commit a new version of that deck with optimistic concurrency (expectedBaseVersion; a concurrent push answers a clean retry error).--id <deckId>targets a deck explicitly;--newforces a fresh deck. A link pointing at a different instance errors loudly instead of silently targeting a foreign id. -
Ignores:
node_modules,Thumbs.db,.slideless.json,.slidelessignore, every dot-prefixed file or directory (.git,.DS_Store,.env,.github, …) andpackage.json/ the common lockfiles are always skipped. That is not a taste call: a manifest path is whatslideless pullwrites onto someone’s disk, so those names are refused by the wire contract at commit and by the CLI again at pull —.git/hooks/pre-commit,.envandpackage.jsonare all “write this and something else runs it later” paths. Pushing a single dotfile (slideless push .env) errors instead of uploading it. A.slidelessignorein the deck root adds gitignore-style rules (a pragmatic subset:#comments,*,?,**; trailing/= directories only; patterns with/anchor to the deck root, without/they match any path segment; no!negation). Patterns are capped at 256 characters and 8**wildcards, and a longer or heavier one errors — an unbounded pattern used to hang the scan. Symlinks are never followed. -
Entry detection:
--entrywins, elseindex.html, else the only.htmlfile, else an error listing candidates. A single-file push (slideless push deck.html) uses that file as the entry. -
The deck’s page: every push answers with the deck’s own page on the
instance,
<instance>/decks/<id>/present(the master page: the owner’s full view, where share links are minted from — no link is created by a push; The deck is the artifact describes the page and its bar). The human summary prints it asurl:;--jsoncarries it asurlnext topresentationandversion. The first push of a folder (the one that creates the deck) also opens that page in your default browser; later pushes only print it.--openopens it on any push,--no-opennever does. A run whose stdout is not a terminal, or any--jsonrun, never opens a browser — whatever the flags say — so a push in CI or under an agent stays silent. The opener is the platform’s own (open,xdg-open, the Windows URL handler) with the URL as an argument, never a shell string. -
Attachments: a
downloads/folder at the root of the deck is the version’s attachment set (Attachments, and the Downloads page for the URLs and the API): files handed to a link’s recipient as downloads, never rendered. The bar over the share link offers them (a Download button listing each file, and Download all for the zip); on a link made with--no-bar, or inside an embed, the bar is absent and your deck must link its own files for a recipient to find them:<a href="downloads/figures.csv">for one,<a href="downloads.zip">for the set, relative to the deck’s own URL. The push classifies them by the same rule the server uses and prints anAttachments: N files, X MB (downloads/)line under the summary when the folder carries some;--jsoncarriesattachments: { count, sizeBytes }. They ride the same content-addressed protocol as every file: an unchanged attachment is never re-uploaded when you iterate on the HTML (the precheck reports it present), and only what changed travels. -
The per-file cap, refused before anything is uploaded: every file of
the deck is checked against the instance’s per-file cap before the upload
session, the precheck or any upload. A file over it names itself and the
cap, and no byte has left the machine:
The cap is
limits.maxFileSizeMbfromGET /api/v1/instancewhen the instance exposes it, else the documented default of 100 MB. Without this check the instance answered413to the oversized blob only after its upload, with the smaller files already stored. -
Flags:
--title,--entry,--kind presentation|app|plan,--interactive,--id,--new,--open/--no-open.
.slideless.json (deck id + instance) and composes the same URL a push
prints, with no key and no network call. --json prints
{ presentationId, baseUrl, url } instead of opening. An unlinked folder is
an error pointing at push, and a link file whose instance is not an
http(s) URL is refused before anything reaches the opener (the file can
arrive with a cloned folder; the opener would dispatch any scheme).
pull downloads a version’s manifest and streams every blob to disk —
byte-identical to what was pushed, attachments under downloads/ included
(the summary counts them) — then writes/refreshes .slideless.json
so a later push in that folder targets the same deck. It treats the
instance’s answer as untrusted input: every manifest path is re-validated
locally, each blob is capped at the size the manifest declared and must hash
to the sha256 the manifest claims before anything is written, writes refuse
to follow a symlink (file or directory) and never leave a file executable,
and a destination whose .slideless.json names a different instance errors
loudly — the same refusal push has always had.
dev serves the folder locally with the exact public-viewer posture —
Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads, nosniff, no-referrer, no-store — so what
you preview is exactly what share-link recipients get (same isolation, same
relative paths). Live reload is injected into HTML responses; any file change
reloads the browser. No backend, no credentials.
It serves the deck folder and nothing else: a path is resolved with
realpath and re-checked against the root, so a symlink inside the folder
cannot serve a file from elsewhere on the disk; dot-prefixed paths 404
outright; and requests whose Host header is not the address the server
bound answer 403, which is what stops a DNS-rebinding page from reading your
deck (and anything else) through the browser.
Share
A share link is a secret URL made on top of the deck, public to whoever holds it, following the latest version or pinned to one, with its own switches, expiry, password and counts: Share links is the model, Versions the latest-or-pinned story.share-email mints and mails a fresh secret per
send).
Embedding from the CLI: share --json always carries an embed object
({ script, iframe, embedJsUrl }) alongside the token and URL, so an agent
that both manages decks and builds websites mints the link and pastes the
snippet in one step — no second command, nothing to assemble by hand. In
human output, --embed prints the same two snippets. Both come from the
same builder as the dashboard’s copy dialog (identical sandbox attributes);
--placement <label> bakes a per-spot analytics label into them (it shows
up per view in slideless views). Details: the Embedding page under
Sharing & review.
tokens lists every link of the deck, the dashboard’s own preview links
included: rows named Dashboard preview that expire within the hour and
count nothing; the dashboard hides them, the CLI and the API do not. The human table has no column for the
bar: --json carries showBar on every token, which is how a bare link is told apart from the
others. Access stats count entry loads only, de-duplicated per browser within a short window (VIEW_DEDUPE_WINDOW_MINUTES, default 10 min) — so one human
open is one count, while cookie-less fetches (CLI, curl) count each time.
“Last opened” is the last counted open. The downloads column is the link’s
downloadCount (one per attachment taken through the link, one per zip,
never a view) when the link allows downloads, and no downloads when it was
minted with --no-download (canDownload: false; the deck still opens, the
file URLs answer 404). --json carries both fields on every token.
Collecting form responses: slideless responses with no filters prints
the summary first (responses per form × link × source × placement, with the
latest activity), then the most recent rows. Filters narrow the row listing:
--form to one named form, --link to one share link (token id or link
name, never a share URL: secrets are hash-only server-side and cannot be
resolved back to a token), --source link|embed and --placement <label>
to one distribution spot, --since <ISO datetime> to a time window (a
response created OR edited at or after it — an edit is activity, so an
edited answer resurfaces). --all
follows pagination, --json prints the wire shape for agents, and --csv
writes a spreadsheet-safe CSV (cells are guarded against formula injection)
built client-side from the same rows. Links minted with --no-forms refuse
submissions (403 forms_disabled); everything else about them works
unchanged. Every edit is kept as a revision:
slideless response <id> <responseId> prints the current answer and every
kept revision (at most
100 per response; the first and the latest 99 always survive), each with the
link and the moment it was written through. slideless notify <id> --off
silences the owner mails (one on a new response, a different one on an edit,
never carrying the answers, at most one per deck per ten minutes) without
touching forms. Details: the Forms page under Sharing & review.
Collaborators
Decks, files, export
Paging
Every listing command (list, versions, tokens, views, responses,
files list) answers one page at a time, newest first: --limit <n> sets the
page size (1 to 100), --cursor <cursor> resumes from the nextCursor a
previous page printed, and --all follows the cursors until every page is
fetched.
Shell completion
Machine use
Add--json to any command for the wire shape; every error prints to stderr
and exits non-zero. Typical agent loop:
--json push never opens a browser; hand .url (the deck’s page on the
instance, behind the owner’s session) to the person, and mint a share link
only when a recipient needs one.
Server endpoints behind auth login-*
POST /api/v1/cli/auth/request and POST /api/v1/cli/auth/complete are
public pre-auth endpoints (like /setup), riding the better-auth email-OTP
plugin with disableSignUp — an unknown email gets a generic success and no
mail (no account enumeration, no account creation), codes are attempt-limited
(3) and both endpoints sit behind the instance’s OTP/login rate walls. The
key is returned exactly once; the flow’s throwaway session is deleted
server-side. Without an email driver both answer 400 otp_unavailable; on
the cloud edition both answer 403 cli_otp_disabled (hub-only login — mint
through antasphere login instead). DELETE /cli/auth/key is the logout
counterpart on both editions: an authenticated route where the presenting
API key revokes exactly ITSELF (machine-allowed under presentations:write
in the fail-closed scope allowlist; sessions are refused — the dashboard is
their key surface).