Skip to content

Command structure UX improvements #104

@sagikazarmark

Description

@sagikazarmark

Problems

The current CLI has a few structural issues that hurt discoverability and consistency:

  1. Verbs and nouns are mixed at the root. Some top-level commands are actions (ssh, cp, port-forward), others are resource groups (playground, content, challenge, expose, auth). There is no single mental model for where to look for a command.

  2. Content types are fragmented across top-level groups. Playgrounds are managed under playground (create, update, remove), but every other authorable kind lives under content (create, push, pull, remove). Challenges are split again: catalog and runtime under challenge, with no authoring verbs at all. Users have to remember which container a given content kind lives in.

  3. Author workflows and user workflows share the same groups, and so do content definitions and running instances. These are two angles on the same problem. Under playground today, authoring commands (create, update, remove, manifest) operate on a content definition for an author audience, while user-facing commands (start, stop, list, machines) operate on a running instance for a consumer audience. One group, two audiences, two different resources that happen to share a name. challenge has the same split (definition vs. running attempt).

  4. Commands are not grouped in the CLI help output. labctl --help lists every top-level command in a single flat list. A purpose-based grouping (authoring vs. runtime vs. access, etc.) would make the help dramatically easier to scan, and would teach the structure just by looking at it.

Note

The current command reference is available at the bottom of this issue in the Reference section.

Ideas

A few directions worth considering:

  • Pure verb-based structure: start, stop, create, remove, get, ... at the root; nouns become arguments.
  • Pure noun-based structure: playground, challenge, tutorial, play, ... at the root; verbs live underneath. Matches the current direction but applied consistently.
  • Group all content-authoring commands together (playground definitions and every other content kind under one verb set or group).
  • Group all runtime commands together (start/stop/ssh/cp/expose all operate on a running play).
  • Group commands by purpose in --help so the entry point itself teaches the structure.

Proposal: kubectl-style interface

Use a kubectl-like design:

  • A small set of universal verbs at the root: get, create, remove, start, stop, push, pull, expose, unexpose, ...
  • Resources as arguments: get playgrounds, start playground <id>, remove tutorial <id>.
  • Introduce play as the runtime noun, distinct from playground (the content/template).
  • get subsumes list, catalog, and manifest inspection (via -o yaml).
  • --help groups commands by purpose (content authoring, runtime, access, meta).

This gives one mental model, separates template from instance, and removes the verb/noun split at the root.

Content kinds in scope: playground, challenge, tutorial, training, course, skill-path.

Brainstorm: content vs. running content

This is the trickiest part of the redesign. A challenge (or playground, tutorial, etc.) has two lives: the piece of content someone authored, and the running instance someone is using. The same noun means different things to different audiences. From a user's perspective a challenge is "something I am solving." From an author's perspective it is content they wrote. Any naming we pick has to support both without losing clarity.

Option A: Collapse runtime into a single play noun

  • get plays lists all running instances. A row shows what content backs each play: kind=challenge, content=kubernetes-pod-networking.
  • Filter shortcuts: get plays --kind challenge.
  • Content commands stay kind-named: get challenges means challenge content, get playgrounds means playground content.
  • Pro: one runtime noun, minimal new surface.
  • Con: breaks the user's mental model ("I am solving a challenge," not "I have a play of kind challenge"). The noun they care about disappears from the verb they use.

Option B: Keep a per-kind runtime noun (play, attempt, ...)

  • get plays for running playground instances.
  • get attempts for running challenge attempts.
  • Content commands stay kind-named as before.
  • Pro: matches each audience's mental model.
  • Con: every startable content kind needs its own runtime noun. Doesn't scale cleanly if tutorial/training/course also instantiate runtime.

Option C: Namespace content and runtime under the same verb

  • get <kind> means content; a prefix flags runtime: get running <kind>, or get running --kind challenge.
  • Alternatively explicit on both sides: get content challenges vs. get running challenges.
  • Pro: both perspectives get a first-class name; same verb works for both.
  • Con: "running" is an awkward word in this role, and more words per command. Less kubectl-native.

Option D: Use different verbs for content and runtime

  • Keep get for runtime resources only: get plays, get ports, get shells, get machines, and get challenges means running challenge attempts.
  • Use a different verb for content: a reading verb that pairs naturally with the author-facing verb set (create, remove, push, pull). Candidates: list, show, browse, find, describe, or keep catalog.
  • Example vocabulary split:
    • Author verbs: create, remove, push, pull, list/show/browse <kind>.
    • User verbs: start, stop, get, expose, ssh, cp, ...
  • Pro: cleanest separation of author and user command surfaces. Each verb has one meaning. Solves problem no. 3 at the vocabulary level, not just grouping.
  • Con: two verbs for "look at something" is more to teach, and the public catalog (what users browse to pick something to start) has to land on one side. Arguably browsing the catalog is a user action, so the content verb has to cover both "my authored items" and "public catalog" via a filter.

Option E: Context-sensitive defaults

  • get challenges means one thing to users (running attempts) and another to authors (content), based on role or flag.
  • Pro: short commands for the common case.
  • Con: magical; hard to teach; hard to script against safely.

Important

Author's preference: Option D. It turns the "author vs. user" problem into a vocabulary split rather than a grouping or filtering convention, and keeps each verb with a single meaning. The exact wording of the content-reading verb (list, show, browse, ...) is still up for debate. Option A stays the simplest fallback if we can accept "play" covering all runtime instances.

Proposed structure

The tables below assume Option D from the brainstorm. The content-reading verb is written as list for concreteness, but the exact word is open (see the brainstorm and open questions).

Top-level groups (all commands live at the root; grouping is only for documentation and --help rendering):

  • Auth: auth login, auth logout, auth whoami
  • Content management (author): create, remove, push, pull, list <kind>
  • Catalog & inspection (mixed): sits between authoring and use (browsing the public catalog is a user action; browsing --mine is an author action)
  • Runtime (plays) (user): start, stop, get plays, get machines
  • Play access (user, name TBD): expose, unexpose, cp, ssh, ssh-proxy, port-forward
  • Meta: completion, version

An alternative framing to consider: group explicitly by audience (common, author, user) rather than by purpose. Common = auth, catalog, meta. Author = content management. User = runtime + play access.

Runtime and play access are closely related (both operate on a running play), so they are listed adjacently. get is used on both sides (get plays, get machines in runtime; get ports, get shells in play access); we could split them out into a dedicated get section if that reads better, or just keep them where each resource belongs.

Auth

Keep auth as its own group. Strictly, a verbs-only root would promote these, but auth is a small, self-contained set and the grouping is worth the exception.

New Old Notes
auth login auth login unchanged
auth logout auth logout unchanged
auth whoami auth whoami unchanged

Alternative (not preferred, but worth listing): promote to login / logout / whoami at the root, or alias the root forms to auth *.

Content management

Author-facing verbs. Each operates on a content kind.

New Old Notes
create <kind> content create, playground create kinds: playground, challenge, tutorial, training, course, skill-path
remove <kind> <id> content remove, playground remove content only; runtime instances use stop
push <kind> [<id>] content push, playground update? see note below on ID
pull <kind> <id> content pull kind required
list <kind> content list (--mine for authored) reading verb; exact word TBD (list, show, browse, find, describe, catalog)

Notes:

  • push and the ID argument. push playground reads the name from the manifest today, while push challenge <id> would need the ID explicitly. Since each kind gets its own subcommand, per-kind signatures are fine: push playground takes no ID, push challenge <id> does. Flagging as a side issue, not a blocker.
  • push/pull naming is generic. Alternatives: upload/download, apply/fetch, sync. Precedent for push/pull exists (helm, docker, git), so the terms are familiar, but they do not convey "content authoring" on their own.
  • push vs. playground update remains open: collapse into one, or keep both with distinct semantics (iterative sync vs. one-shot manifest apply)?

Catalog & inspection

Under Option D, the catalog is content and uses the content-reading verb (shown as list here). Browsing the public catalog is a user action, and viewing --mine is an author action, so this group straddles audiences. If Option A wins, these all collapse to get <kind>.

New Old Notes
list playgrounds playground catalog catalog joins the content-reading verb
list challenges challenge catalog
list tutorials (none) new kind surfaced at root
list trainings (none) new kind surfaced at root
list courses (none) new kind surfaced at root
list skill-paths (none) new kind surfaced at root
list <kind> --mine content list filter to content the caller authored. Open: also support list content --mine as a cross-kind aggregator?
list <kind> <id> -o yaml playground manifest manifest view of a content definition. Open: also surface runtime state via get play <id> -o yaml (runtime side)?

Runtime (plays)

Under Option D, get is the user-facing read verb for runtime resources. get challenges therefore means "my running challenge attempts", not the challenge catalog.

New Old Notes
start <kind> <id> playground start, challenge start content kind + id produces a play. Open: do tutorial/training/course/skill-path instantiate runtime, or are they read-only?
stop <play-id> playground stop, challenge stop always a play id
get plays playground list running plays
get challenges challenge list running challenge attempts (user-facing naming)
get machines --play <id> playground machines scoped list; could fold into get play <id> output

Play access

The name needs work. "Exposure" is too narrow (doesn't cover ssh/cp). "Connectivity" doesn't cover expose. This group is really about interacting with a running play: exposing endpoints, connecting to the machine, moving files. Candidates: Play access, Interacting with plays, Play I/O.

New Old Notes
expose port expose port unchanged
expose shell expose shell unchanged
unexpose port <id> expose remove parallel to expose port; split by type. Alternative verb: close port <id>
unexpose shell <id> expose remove parallel to expose shell; split by type. Alternative verb: close shell <id>
get ports expose list split by type
get shells expose list split by type
cp cp unchanged
ssh ssh unchanged
ssh-proxy ssh-proxy unchanged
port-forward port-forward unchanged

Meta

New Old Notes
completion {bash|zsh|fish|powershell} completion ... unchanged
version version unchanged

Backwards compatibility

This redesign is almost entirely a renaming and regrouping exercise; no existing command's behavior needs to change. That opens a low-risk migration path:

  1. Keep existing commands in place. labctl playground start, labctl expose list, etc. keep working exactly as they do today. Scripts and pipelines that depend on the current CLI continue to function without any change.
  2. Hide the old commands from help output. New structure shows in labctl --help and the per-group help pages; old commands are accessible but hidden (e.g., marked hidden in the cobra command definitions). New users see only the new structure; existing users are not broken.
  3. Deprecation period. Old commands emit a deprecation notice on stderr pointing to the new equivalent. Length of the period is a product decision (one minor release, two releases, a fixed calendar window, ...).
  4. Removal. After the deprecation period, old commands are deleted. Any scripts still using them break loudly at that point, with a clear error that names the replacement.

This way we get the clean new structure in docs and tab completion immediately, without breaking anyone on day one.

Open: length of the deprecation window, and whether a runtime flag (or env var) can re-enable hidden commands in help for users who want to see both during the transition.

Open questions

  • Content vs. runtime strategy: which of Options A/B/C/D/E from the brainstorm? Author's lean is Option D.
  • Content-reading verb (if Option D): list, show, browse, find, describe, or keep catalog?
  • push/pull naming: keep (helm, docker, git precedent), or rename to upload/download, apply/fetch, sync?
  • push vs. update: collapse playground update into push playground, or keep both with distinct semantics (iterative file sync vs. one-shot manifest apply)?
  • Manifest view target: content definition via the content verb (list <kind> <id> -o yaml), runtime state via get play <id> -o yaml, or both?
  • Content cross-kind listing: keep a list content --mine aggregator, or force a specific kind?
  • Startable kinds: do tutorial/training/course/skill-path instantiate plays, or are they read-only content?
  • Grouping layout: group by purpose (auth / content / catalog / runtime / play access / meta) or by audience (common / author / user)?
  • Dedicated get section: get appears in both runtime and play access groups. Should the docs/help pull all get * commands into one section, or keep them colocated with their resources?
  • auth at root: keep as a group (author's lean) or promote to login/logout/whoami?
  • Play access group naming: what should we call the cluster that includes expose, unexpose, cp, ssh, ssh-proxy, port-forward?
  • unexpose vs. close: which verb for tearing down an exposed port or shell?
  • Aliases: preserve today's aliases (ch, p, e, ex, ...) during a deprecation period, or drop them with the rewrite?

Reference

Current command list
Command Aliases Description
labctl auth login Log in via browser one-time URL
labctl auth logout Log out current CLI session
labctl auth whoami Print current user info
labctl challenge catalog ch, challenges List available challenges (filter by category/status)
labctl challenge list List running challenge attempts
labctl challenge start Start a challenge attempt
labctl challenge stop Stop current challenge attempt
labctl completion bash Generate bash autocompletion script
labctl completion fish Generate fish autocompletion script
labctl completion powershell Generate powershell autocompletion script
labctl completion zsh Generate zsh autocompletion script
labctl content create c, contents Create a new piece of content
labctl content list List authored content (filter by kind)
labctl content pull Pull remote content files to local dir
labctl content push Push local content files to remote (inner author loop)
labctl content remove Remove authored content
labctl cp Copy files to/from target playground
labctl expose list e, ex List exposed ports and web terminals
labctl expose port Expose an HTTP(s) service from a playground
labctl expose remove Un-expose a previously exposed port/shell by ID
labctl expose shell Expose a web terminal session with a URL
labctl playground catalog p, playgrounds List playgrounds from catalog (filter by type)
labctl playground create Create a new playground from base + manifest
labctl playground list List current/recent playgrounds (up to 50)
labctl playground machines List machines of a playground by ID
labctl playground manifest View playground manifest
labctl playground remove Remove an authored playground
labctl playground start Start a new playground (optionally open in browser)
labctl playground stop Stop one or more playgrounds
labctl playground update Update existing playground from manifest
labctl port-forward Forward local/remote ports to a running playground
labctl ssh Start SSH session to target playground
labctl ssh-proxy Start SSH proxy to playground's machine
labctl version Print labctl version

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions