← back to Cli Printing Press
feat(cli): async-job detection, --wait flag, jobs command
1c172850d5882ea89cf00d3e74090c47cdcab6de · 2026-04-17 22:58:58 -0400 · Matt Van Horn
Detect endpoints that submit long-running work (job-id-shaped response
field + sibling status endpoint) and wire --wait into the submitting
command. The generator emits a new jobs parent command (list/get/prune)
backed by a JSONL ledger at ~/.<cli>-pp-cli/jobs.jsonl.
Every --wait invocation polls the detected status endpoint with
exponential-backoff + jitter (2s initial, 30s cap, 10m default timeout)
and records state transitions to the ledger. The agent gets one-shot
"submit and block to final artifact" semantics instead of having to
write its own polling loop.
Mapped from the HeyGen CLI post (2026-04-13): "one command, submit
through delivery."
Files touched
A internal/generator/async_detect.goA internal/generator/async_detect_test.goM internal/generator/generator.goM internal/generator/templates/command_endpoint.go.tmplA internal/generator/templates/jobs.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/skill.md.tmpl
Diff
commit 1c172850d5882ea89cf00d3e74090c47cdcab6de
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Fri Apr 17 22:58:58 2026 -0400
feat(cli): async-job detection, --wait flag, jobs command
Detect endpoints that submit long-running work (job-id-shaped response
field + sibling status endpoint) and wire --wait into the submitting
command. The generator emits a new jobs parent command (list/get/prune)
backed by a JSONL ledger at ~/.<cli>-pp-cli/jobs.jsonl.
Every --wait invocation polls the detected status endpoint with
exponential-backoff + jitter (2s initial, 30s cap, 10m default timeout)
and records state transitions to the ledger. The agent gets one-shot
"submit and block to final artifact" semantics instead of having to
write its own polling loop.
Mapped from the HeyGen CLI post (2026-04-13): "one command, submit
through delivery."
---
internal/generator/async_detect.go | 179 +++++++++++
internal/generator/async_detect_test.go | 193 ++++++++++++
internal/generator/generator.go | 43 ++-
.../generator/templates/command_endpoint.go.tmpl | 60 ++++
internal/generator/templates/jobs.go.tmpl | 350 +++++++++++++++++++++
internal/generator/templates/root.go.tmpl | 3 +
internal/generator/templates/skill.md.tmpl | 20 ++
7 files changed, 844 insertions(+), 4 deletions(-)
diff --git a/internal/generator/async_detect.go b/internal/generator/async_detect.go
new file mode 100644
index 00000000..566ed0ed
--- /dev/null
+++ b/internal/generator/async_detect.go
@@ -0,0 +1,179 @@
+package generator
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+// AsyncJobInfo describes one async-job endpoint detected from a spec.
+// Populated at generation time and carried into template data so generated
+// commands can expose --wait and a shared jobs store.
+type AsyncJobInfo struct {
+ // ResourceName and EndpointName identify the submitting endpoint
+ // (the one that returns a job ID).
+ ResourceName string
+ EndpointName string
+
+ // JobIDField is the response field name that carries the job identifier
+ // (e.g. "job_id", "task_id"). Empty when detection fell back to
+ // path-sibling inference.
+ JobIDField string
+
+ // StatusResource and StatusEndpoint point at the sibling status
+ // endpoint used by the --wait polling loop. Empty only if no sibling
+ // was found (in which case the endpoint is not marked async).
+ StatusResource string
+ StatusEndpoint string
+
+ // StatusPath is the URL path template for the polling endpoint,
+ // copied from the sibling endpoint's Path. The runtime substitutes
+ // {id} or {job_id} with the actual job ID.
+ StatusPath string
+
+ // TerminalField and TerminalValues describe how the polling loop
+ // decides the job is done. Defaults to "status" with a common
+ // done/complete/completed/failed/errored set when the spec does not
+ // declare one explicitly.
+ TerminalField string
+ TerminalValues []string
+}
+
+// jobIDFieldPattern matches field names that typically carry a job or
+// long-running-operation identifier in API responses.
+var jobIDFieldPattern = regexp.MustCompile(`(?i)^(job|task|operation|request|async|run|batch)_?id$`)
+
+// terminalValueDefaults is the set of status values treated as terminal
+// when the spec does not declare a status enum on the sibling endpoint.
+var terminalValueDefaults = []string{"done", "complete", "completed", "success", "succeeded", "finished", "failed", "errored", "cancelled", "canceled"}
+
+// DetectAsyncJobs walks an APISpec and returns one AsyncJobInfo per detected
+// async-job endpoint, keyed by "<resourceName>/<endpointName>".
+//
+// Detection requires both strong signals:
+//
+// 1. The endpoint's response payload contains a field whose name matches
+// jobIDFieldPattern (job_id, task_id, operation_id, ...).
+// 2. A sibling endpoint exists that looks like a status probe for the same
+// resource - either a GET on the same resource (common get/status/show),
+// a same-resource sibling whose name contains "status" or "poll", or an
+// endpoint in a separate jobs/operations/status-flavored resource.
+//
+// Both signals are load-bearing: the job-id field names what to track and the
+// sibling names where to poll. A plain CRUD POST that happens to have a sibling
+// GET is not marked async - without a job-id-shaped response field, we do not
+// assume the endpoint is long-running.
+//
+// HTTP status code 202 would be a third signal; the internal spec model does
+// not track per-status-code responses, so we omit it and rely on the two
+// remaining signals.
+//
+// The function is pure - it does not mutate the spec. The caller decides
+// how to use the detection map (template data, dogfood checks, scorecard).
+func DetectAsyncJobs(s *spec.APISpec) map[string]AsyncJobInfo {
+ out := map[string]AsyncJobInfo{}
+ if s == nil {
+ return out
+ }
+
+ for rName, r := range s.Resources {
+ for eName, ep := range r.Endpoints {
+ info, ok := detectOne(s, rName, eName, ep)
+ if !ok {
+ continue
+ }
+ out[rName+"/"+eName] = info
+ }
+ }
+ return out
+}
+
+func detectOne(s *spec.APISpec, rName, eName string, ep spec.Endpoint) (AsyncJobInfo, bool) {
+ jobField := responseJobIDField(s, ep)
+ if jobField == "" {
+ return AsyncJobInfo{}, false
+ }
+ statusRes, statusEP := findStatusSibling(s, rName, eName)
+ if statusEP == "" {
+ return AsyncJobInfo{}, false
+ }
+
+ statusPath := ""
+ if r, ok := s.Resources[statusRes]; ok {
+ if sep, ok := r.Endpoints[statusEP]; ok {
+ statusPath = sep.Path
+ }
+ }
+
+ return AsyncJobInfo{
+ ResourceName: rName,
+ EndpointName: eName,
+ JobIDField: jobField,
+ StatusResource: statusRes,
+ StatusEndpoint: statusEP,
+ StatusPath: statusPath,
+ TerminalField: "status",
+ TerminalValues: terminalValueDefaults,
+ }, true
+}
+
+// responseJobIDField returns the matching job-id-shaped field name in the
+// endpoint's response type, or "" if none.
+func responseJobIDField(s *spec.APISpec, ep spec.Endpoint) string {
+ if ep.Response.Item == "" {
+ return ""
+ }
+ td, ok := s.Types[ep.Response.Item]
+ if !ok {
+ return ""
+ }
+ for _, f := range td.Fields {
+ if jobIDFieldPattern.MatchString(f.Name) {
+ return f.Name
+ }
+ }
+ return ""
+}
+
+// findStatusSibling looks for a sibling endpoint that functions as the
+// polling target for an async job submitted by (rName, eName). Returns the
+// resource/endpoint of the sibling, or ("","") if none is found.
+func findStatusSibling(s *spec.APISpec, rName, eName string) (string, string) {
+ // Same-resource sibling by common name
+ r, ok := s.Resources[rName]
+ if ok {
+ for _, candidate := range []string{"get", "status", "show", "retrieve"} {
+ if _, exists := r.Endpoints[candidate]; exists && candidate != eName {
+ return rName, candidate
+ }
+ }
+ // Name-based sibling: <eName>_status or status_<eName>
+ for cName := range r.Endpoints {
+ low := strings.ToLower(cName)
+ if cName == eName {
+ continue
+ }
+ if strings.Contains(low, "status") || strings.Contains(low, "poll") {
+ return rName, cName
+ }
+ }
+ }
+ // Separate resource named <rName>_status / <rName>_jobs
+ for otherName, other := range s.Resources {
+ if otherName == rName {
+ continue
+ }
+ low := strings.ToLower(otherName)
+ if !(strings.Contains(low, "status") || strings.Contains(low, "job") || strings.Contains(low, "task") || strings.Contains(low, "operation")) {
+ continue
+ }
+ for candName := range other.Endpoints {
+ cl := strings.ToLower(candName)
+ if strings.Contains(cl, "get") || strings.Contains(cl, "status") || strings.Contains(cl, "show") || strings.Contains(cl, "retrieve") {
+ return otherName, candName
+ }
+ }
+ }
+ return "", ""
+}
diff --git a/internal/generator/async_detect_test.go b/internal/generator/async_detect_test.go
new file mode 100644
index 00000000..0aa8015b
--- /dev/null
+++ b/internal/generator/async_detect_test.go
@@ -0,0 +1,193 @@
+package generator
+
+import (
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDetectAsyncJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ spec *spec.APISpec
+ wantKey string
+ wantStatus string
+ wantJobID string
+ wantCount int
+ }{
+ {
+ name: "POST + job_id response + sibling get: detected",
+ spec: &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "VideoJob": {Fields: []spec.TypeField{
+ {Name: "job_id", Type: "string"},
+ {Name: "status", Type: "string"},
+ }},
+ },
+ Resources: map[string]spec.Resource{
+ "videos": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {Method: "POST", Path: "/videos", Response: spec.ResponseDef{Type: "object", Item: "VideoJob"}},
+ "get": {Method: "GET", Path: "/videos/{id}", Response: spec.ResponseDef{Type: "object", Item: "VideoJob"}},
+ },
+ },
+ },
+ },
+ wantKey: "videos/create",
+ wantStatus: "get",
+ wantJobID: "job_id",
+ wantCount: 1,
+ },
+ {
+ name: "POST + task_id response + _status sibling: detected",
+ spec: &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "TaskResp": {Fields: []spec.TypeField{{Name: "task_id", Type: "string"}}},
+ },
+ Resources: map[string]spec.Resource{
+ "renders": {
+ Endpoints: map[string]spec.Endpoint{
+ "submit": {Method: "POST", Path: "/renders", Response: spec.ResponseDef{Type: "object", Item: "TaskResp"}},
+ "render_status": {Method: "GET", Path: "/renders/{id}/status"},
+ },
+ },
+ },
+ },
+ wantKey: "renders/submit",
+ wantStatus: "render_status",
+ wantJobID: "task_id",
+ wantCount: 1,
+ },
+ {
+ name: "cross-resource: jobs resource holds status endpoint",
+ spec: &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "OpResp": {Fields: []spec.TypeField{{Name: "operation_id", Type: "string"}}},
+ },
+ Resources: map[string]spec.Resource{
+ "transfers": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {Method: "POST", Path: "/transfers", Response: spec.ResponseDef{Type: "object", Item: "OpResp"}},
+ },
+ },
+ "operations": {
+ Endpoints: map[string]spec.Endpoint{
+ "get": {Method: "GET", Path: "/operations/{id}"},
+ },
+ },
+ },
+ },
+ wantKey: "transfers/create",
+ wantStatus: "get",
+ wantJobID: "operation_id",
+ wantCount: 1,
+ },
+ {
+ name: "POST with id-field but no sibling: NOT detected (status is load-bearing)",
+ spec: &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "R": {Fields: []spec.TypeField{{Name: "job_id", Type: "string"}}},
+ },
+ Resources: map[string]spec.Resource{
+ "things": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {Method: "POST", Path: "/things", Response: spec.ResponseDef{Type: "object", Item: "R"}},
+ },
+ },
+ },
+ },
+ wantCount: 0,
+ },
+ {
+ name: "GET with job_id-field and sibling: detected (field+sibling both fire)",
+ spec: &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "R": {Fields: []spec.TypeField{{Name: "job_id", Type: "string"}}},
+ },
+ Resources: map[string]spec.Resource{
+ "things": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/things", Response: spec.ResponseDef{Type: "array", Item: "R"}},
+ "get": {Method: "GET", Path: "/things/{id}"},
+ },
+ },
+ },
+ },
+ wantKey: "things/list",
+ wantStatus: "get",
+ wantJobID: "job_id",
+ wantCount: 1,
+ },
+ {
+ name: "plain CRUD POST without id-shaped field: NOT detected",
+ spec: &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "User": {Fields: []spec.TypeField{
+ {Name: "id", Type: "string"},
+ {Name: "name", Type: "string"},
+ }},
+ },
+ Resources: map[string]spec.Resource{
+ "users": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {Method: "POST", Path: "/users", Response: spec.ResponseDef{Type: "object", Item: "User"}},
+ "get": {Method: "GET", Path: "/users/{id}"},
+ },
+ },
+ },
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DetectAsyncJobs(tc.spec)
+ assert.Len(t, got, tc.wantCount, "detection count")
+ if tc.wantKey != "" {
+ info, ok := got[tc.wantKey]
+ assert.True(t, ok, "expected detection at %q", tc.wantKey)
+ assert.Equal(t, tc.wantStatus, info.StatusEndpoint, "status endpoint")
+ assert.Equal(t, tc.wantJobID, info.JobIDField, "job id field")
+ }
+ })
+ }
+}
+
+func TestDetectAsyncJobs_NilSpec(t *testing.T) {
+ assert.Empty(t, DetectAsyncJobs(nil))
+}
+
+func TestResponseJobIDField_VariousNames(t *testing.T) {
+ cases := []struct {
+ field string
+ expect bool
+ }{
+ {"job_id", true},
+ {"jobId", true},
+ {"task_id", true},
+ {"operation_id", true},
+ {"request_id", true},
+ {"async_id", true},
+ {"run_id", true},
+ {"batch_id", true},
+ {"id", false},
+ {"user_id", false},
+ {"jobs", false},
+ }
+ for _, c := range cases {
+ s := &spec.APISpec{
+ Types: map[string]spec.TypeDef{
+ "T": {Fields: []spec.TypeField{{Name: c.field, Type: "string"}}},
+ },
+ }
+ ep := spec.Endpoint{Response: spec.ResponseDef{Item: "T"}}
+ got := responseJobIDField(s, ep)
+ if c.expect {
+ assert.Equal(t, c.field, got, "should match: %s", c.field)
+ } else {
+ assert.Empty(t, got, "should not match: %s", c.field)
+ }
+ }
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 47a6c7a3..37d2b3ec 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -116,10 +116,11 @@ type Generator struct {
OutputDir string
VisionSet VisionTemplateSet
FixtureSet *websniff.FixtureSet
- Sources []ReadmeSource // Ecosystem tools to credit in README
- DiscoveryPages []string // Pages visited during sniff discovery
- NovelFeatures []NovelFeature // Transcendence features for README/SKILL
- Narrative *ReadmeNarrative // LLM-authored prose for README/SKILL; optional
+ Sources []ReadmeSource // Ecosystem tools to credit in README
+ DiscoveryPages []string // Pages visited during sniff discovery
+ NovelFeatures []NovelFeature // Transcendence features for README/SKILL
+ Narrative *ReadmeNarrative // LLM-authored prose for README/SKILL; optional
+ AsyncJobs map[string]AsyncJobInfo // Detected async-job endpoints, keyed by "<resource>/<endpoint>"
profile *profiler.APIProfile
funcs template.FuncMap
templates map[string]*template.Template
@@ -645,6 +646,13 @@ func (g *Generator) Generate() error {
g.profile = profiler.Profile(g.Spec)
}
+ // Detect async-job endpoints once per generation. Results flow into
+ // per-endpoint template data (for conditional --wait emission) and into
+ // the root template (for the jobs command registration).
+ if g.AsyncJobs == nil {
+ g.AsyncJobs = DetectAsyncJobs(g.Spec)
+ }
+
// Generate single files
singleFiles := map[string]string{
"main.go.tmpl": filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
@@ -753,6 +761,7 @@ func (g *Generator) Generate() error {
if promotedEndpointNames[name] == eName {
continue
}
+ asyncInfo, isAsync := g.AsyncJobs[name+"/"+eName]
epData := struct {
ResourceName string
FuncPrefix string
@@ -760,6 +769,8 @@ func (g *Generator) Generate() error {
EndpointName string
Endpoint spec.Endpoint
HasStore bool
+ IsAsync bool
+ Async AsyncJobInfo
*spec.APISpec
}{
ResourceName: name,
@@ -768,6 +779,8 @@ func (g *Generator) Generate() error {
EndpointName: eName,
Endpoint: endpoint,
HasStore: g.VisionSet.Store,
+ IsAsync: isAsync,
+ Async: asyncInfo,
APISpec: g.Spec,
}
epPath := filepath.Join("internal", "cli", name+"_"+eName+".go")
@@ -805,6 +818,8 @@ func (g *Generator) Generate() error {
}
for eName, endpoint := range subResource.Endpoints {
+ subKey := subName + "/" + eName
+ asyncInfo, isAsync := g.AsyncJobs[subKey]
epData := struct {
ResourceName string
FuncPrefix string
@@ -812,6 +827,8 @@ func (g *Generator) Generate() error {
EndpointName string
Endpoint spec.Endpoint
HasStore bool
+ IsAsync bool
+ Async AsyncJobInfo
*spec.APISpec
}{
ResourceName: subName,
@@ -820,6 +837,8 @@ func (g *Generator) Generate() error {
EndpointName: eName,
Endpoint: endpoint,
HasStore: g.VisionSet.Store,
+ IsAsync: isAsync,
+ Async: asyncInfo,
APISpec: g.Spec,
}
epPath := filepath.Join("internal", "cli", name+"_"+subName+"_"+eName+".go")
@@ -1138,6 +1157,8 @@ func (g *Generator) Generate() error {
Narrative *ReadmeNarrative
TopNovelFeatures []NovelFeature
NovelOverflowCount int
+ HasAsyncJobs bool
+ AsyncJobCount int
}{
APISpec: g.Spec,
VisionSet: g.VisionSet,
@@ -1149,10 +1170,24 @@ func (g *Generator) Generate() error {
Narrative: g.Narrative,
TopNovelFeatures: shownNovel,
NovelOverflowCount: overflow,
+ HasAsyncJobs: len(g.AsyncJobs) > 0,
+ AsyncJobCount: len(g.AsyncJobs),
}
if err := g.renderTemplate("root.go.tmpl", filepath.Join("internal", "cli", "root.go"), rootData); err != nil {
return fmt.Errorf("rendering root: %w", err)
}
+ if len(g.AsyncJobs) > 0 {
+ jobsData := struct {
+ *spec.APISpec
+ AsyncJobs map[string]AsyncJobInfo
+ }{
+ APISpec: g.Spec,
+ AsyncJobs: g.AsyncJobs,
+ }
+ if err := g.renderTemplate("jobs.go.tmpl", filepath.Join("internal", "cli", "jobs.go"), jobsData); err != nil {
+ return fmt.Errorf("rendering jobs: %w", err)
+ }
+ }
if err := g.renderTemplate("go.mod.tmpl", "go.mod", rootData); err != nil {
return fmt.Errorf("rendering go.mod: %w", err)
}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 582706e9..fa968dfd 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -4,12 +4,18 @@
package cli
import (
+{{- if .IsAsync}}
+ "context"
+{{- end}}
"encoding/json"
"fmt"
{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
"io"
{{- end}}
"os"
+{{- if .IsAsync}}
+ "time"
+{{- end}}
"github.com/spf13/cobra"
)
@@ -29,6 +35,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
var stdinBody bool
{{- end}}
+{{- if .IsAsync}}
+ var flagWait bool
+ var flagWaitTimeout time.Duration
+ var flagWaitInterval time.Duration
+{{- end}}
cmd := &cobra.Command{
Use: "{{.EndpointName}}{{positionalArgs .Endpoint}}",
@@ -249,6 +260,50 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
{{- end}}
+{{- if .IsAsync}}
+ if asyncJobID := ExtractJobID(data, "{{.Async.JobIDField}}"); asyncJobID != "" {
+ _ = RecordJob(JobRow{
+ JobID: asyncJobID,
+ Resource: "{{.ResourceName}}",
+ Endpoint: "{{.EndpointName}}",
+ Status: "submitted",
+ StatusResource: "{{.Async.StatusResource}}",
+ StatusEndpoint: "{{.Async.StatusEndpoint}}",
+ })
+ if flagWait {
+ ctx := cmd.Context()
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ final, werr := WaitForJob(ctx, c, "{{.Async.StatusPath}}", asyncJobID, WaitOptions{
+ Interval: flagWaitInterval,
+ Timeout: flagWaitTimeout,
+ })
+ if werr != nil {
+ _ = RecordJob(JobRow{
+ JobID: asyncJobID,
+ Resource: "{{.ResourceName}}",
+ Endpoint: "{{.EndpointName}}",
+ Status: "errored",
+ Error: werr.Error(),
+ })
+ return werr
+ }
+ if b, merr := json.Marshal(final); merr == nil {
+ data = b
+ }
+ if st, _ := final["status"].(string); st != "" {
+ _ = RecordJob(JobRow{
+ JobID: asyncJobID,
+ Resource: "{{.ResourceName}}",
+ Endpoint: "{{.EndpointName}}",
+ Status: st,
+ })
+ }
+ }
+ }
+{{- end}}
+
{{- if not (or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD"))}}
if wantsHumanTable(cmd.OutOrStdout(), flags) {
// Check if response contains an array (directly or wrapped in "data")
@@ -363,6 +418,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
{{- end}}
+{{- if .IsAsync}}
+ cmd.Flags().BoolVar(&flagWait, "wait", false, "Block until the submitted job reaches a terminal status")
+ cmd.Flags().DurationVar(&flagWaitTimeout, "wait-timeout", 10*time.Minute, "Maximum duration to wait when --wait is set (0 = no timeout)")
+ cmd.Flags().DurationVar(&flagWaitInterval, "wait-interval", 2*time.Second, "Initial poll interval when --wait is set")
+{{- end}}
return cmd
}
diff --git a/internal/generator/templates/jobs.go.tmpl b/internal/generator/templates/jobs.go.tmpl
new file mode 100644
index 00000000..61eecdcc
--- /dev/null
+++ b/internal/generator/templates/jobs.go.tmpl
@@ -0,0 +1,350 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "{{modulePath}}/internal/client"
+)
+
+// JobRow is one entry in the local jobs ledger. Rows are appended as NDJSON
+// to ~/.{{.Name}}-pp-cli/jobs.jsonl; the latest row for a given JobID wins
+// when listing. Pruning rewrites the file without old entries.
+type JobRow struct {
+ JobID string `json:"job_id"`
+ Resource string `json:"resource"`
+ Endpoint string `json:"endpoint"`
+ Status string `json:"status"`
+ SubmittedAt time.Time `json:"submitted_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ StatusResource string `json:"status_resource,omitempty"`
+ StatusEndpoint string `json:"status_endpoint,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func jobsFilePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".{{.Name}}-pp-cli")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating state dir: %w", err)
+ }
+ return filepath.Join(dir, "jobs.jsonl"), nil
+}
+
+// RecordJob appends a JobRow to the local ledger.
+func RecordJob(row JobRow) error {
+ p, err := jobsFilePath()
+ if err != nil {
+ return err
+ }
+ if row.SubmittedAt.IsZero() {
+ row.SubmittedAt = time.Now().UTC()
+ }
+ row.UpdatedAt = time.Now().UTC()
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening jobs ledger: %w", err)
+ }
+ defer f.Close()
+ return json.NewEncoder(f).Encode(row)
+}
+
+func readJobRows() ([]JobRow, error) {
+ p, err := jobsFilePath()
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("reading jobs ledger: %w", err)
+ }
+ var rows []JobRow
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var r JobRow
+ if err := json.Unmarshal([]byte(line), &r); err != nil {
+ continue // skip malformed rows rather than fail the whole list
+ }
+ rows = append(rows, r)
+ }
+ return rows, nil
+}
+
+// latestByJobID returns one row per JobID (the most recent by UpdatedAt).
+func latestByJobID(rows []JobRow) []JobRow {
+ byID := map[string]JobRow{}
+ for _, r := range rows {
+ existing, ok := byID[r.JobID]
+ if !ok || r.UpdatedAt.After(existing.UpdatedAt) {
+ byID[r.JobID] = r
+ }
+ }
+ out := make([]JobRow, 0, len(byID))
+ for _, r := range byID {
+ out = append(out, r)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].SubmittedAt.After(out[j].SubmittedAt) })
+ return out
+}
+
+// WaitOptions configures WaitForJob polling.
+type WaitOptions struct {
+ Interval time.Duration // initial poll interval; grows with backoff up to 30s
+ Timeout time.Duration // overall wait budget; 0 means no timeout
+}
+
+// WaitForJob polls statusPath with exponential backoff + jitter until the
+// response reports a terminal status or the timeout elapses. Returns the
+// final response body as a parsed map, or an error.
+//
+// Terminal statuses recognised:
+// done, complete, completed, success, succeeded, finished,
+// failed, errored, cancelled, canceled
+func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID string, opts WaitOptions) (map[string]any, error) {
+ interval := opts.Interval
+ if interval <= 0 {
+ interval = 2 * time.Second
+ }
+ deadline := time.Time{}
+ if opts.Timeout > 0 {
+ deadline = time.Now().Add(opts.Timeout)
+ }
+ maxInterval := 30 * time.Second
+
+ path := strings.ReplaceAll(statusPath, "{id}", jobID)
+ path = strings.ReplaceAll(path, "{job_id}", jobID)
+
+ for {
+ if !deadline.IsZero() && time.Now().After(deadline) {
+ return nil, fmt.Errorf("wait timed out after %s (job %s)", opts.Timeout, jobID)
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ resp, err := c.Get(path, nil)
+ if err == nil {
+ var body map[string]any
+ if uerr := json.Unmarshal(resp, &body); uerr == nil {
+ if isJobTerminal(body) {
+ return body, nil
+ }
+ }
+ }
+
+ // Jittered sleep: interval + random up to 25%.
+ jitter := time.Duration(rand.Int63n(int64(interval) / 4 + 1))
+ select {
+ case <-time.After(interval + jitter):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+
+ // Exponential-ish growth with cap.
+ interval = interval * 3 / 2
+ if interval > maxInterval {
+ interval = maxInterval
+ }
+ }
+}
+
+// ExtractJobID parses response bytes and returns the string value of the
+// named field (e.g. "job_id"). Returns "" if the response is not JSON or the
+// field is missing / not a string.
+func ExtractJobID(data []byte, field string) string {
+ if len(data) == 0 || field == "" {
+ return ""
+ }
+ var body map[string]any
+ if err := json.Unmarshal(data, &body); err != nil {
+ return ""
+ }
+ if v, ok := body[field].(string); ok {
+ return v
+ }
+ // Some APIs nest the job under "data" or "job".
+ for _, wrapper := range []string{"data", "job", "result"} {
+ if inner, ok := body[wrapper].(map[string]any); ok {
+ if v, ok := inner[field].(string); ok {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+func isJobTerminal(body map[string]any) bool {
+ status, _ := body["status"].(string)
+ if status == "" {
+ return false
+ }
+ switch strings.ToLower(status) {
+ case "done", "complete", "completed", "success", "succeeded", "finished",
+ "failed", "errored", "cancelled", "canceled":
+ return true
+ }
+ return false
+}
+
+// newJobsCmd registers the `jobs` parent command and its subcommands.
+func newJobsCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "jobs",
+ Short: "List and inspect async jobs tracked by this CLI",
+ Long: `Jobs tracked when you submit an async-capable endpoint land in
+~/.{{.Name}}-pp-cli/jobs.jsonl. This command lists, inspects, and prunes them.
+
+Submit an async endpoint with --wait to block until completion; submit
+without --wait to get the job ID back immediately and track it later.`,
+ }
+ cmd.AddCommand(newJobsListCmd(flags))
+ cmd.AddCommand(newJobsGetCmd(flags))
+ cmd.AddCommand(newJobsPruneCmd(flags))
+ return cmd
+}
+
+func newJobsListCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ var all bool
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List recent async jobs",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ rows, err := readJobRows()
+ if err != nil {
+ return err
+ }
+ if !all {
+ rows = latestByJobID(rows)
+ }
+ if limit > 0 && limit < len(rows) {
+ rows = rows[:limit]
+ }
+ if flags.asJSON {
+ return flags.printJSON(cmd, rows)
+ }
+ headers := []string{"JOB_ID", "RESOURCE", "STATUS", "SUBMITTED", "UPDATED"}
+ tbl := make([][]string, 0, len(rows))
+ for _, r := range rows {
+ tbl = append(tbl, []string{
+ r.JobID,
+ r.Resource + "/" + r.Endpoint,
+ r.Status,
+ r.SubmittedAt.Format(time.RFC3339),
+ r.UpdatedAt.Format(time.RFC3339),
+ })
+ }
+ return flags.printTable(cmd, headers, tbl)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 50, "Maximum rows to list (0 for unlimited)")
+ cmd.Flags().BoolVar(&all, "all", false, "Show every state transition rather than the latest row per job")
+ return cmd
+}
+
+func newJobsGetCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "get <job-id>",
+ Short: "Show the latest state row for a job",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rows, err := readJobRows()
+ if err != nil {
+ return err
+ }
+ rows = latestByJobID(rows)
+ for _, r := range rows {
+ if r.JobID == args[0] {
+ if flags.asJSON {
+ return flags.printJSON(cmd, r)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "job_id: %s\nresource: %s/%s\nstatus: %s\nsubmitted: %s\nupdated: %s\n",
+ r.JobID, r.Resource, r.Endpoint, r.Status, r.SubmittedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339))
+ if r.Error != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "error: %s\n", r.Error)
+ }
+ return nil
+ }
+ }
+ return fmt.Errorf("job %q not found in local ledger", args[0])
+ },
+ }
+}
+
+func newJobsPruneCmd(flags *rootFlags) *cobra.Command {
+ var olderThan time.Duration
+ cmd := &cobra.Command{
+ Use: "prune",
+ Short: "Remove job rows older than --older-than",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ rows, err := readJobRows()
+ if err != nil {
+ return err
+ }
+ cutoff := time.Now().Add(-olderThan)
+ var kept []JobRow
+ pruned := 0
+ for _, r := range rows {
+ if r.UpdatedAt.Before(cutoff) {
+ pruned++
+ continue
+ }
+ kept = append(kept, r)
+ }
+
+ p, err := jobsFilePath()
+ if err != nil {
+ return err
+ }
+ tmp := p + ".tmp"
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening tmp ledger: %w", err)
+ }
+ enc := json.NewEncoder(f)
+ for _, r := range kept {
+ if err := enc.Encode(r); err != nil {
+ _ = f.Close()
+ _ = os.Remove(tmp)
+ return fmt.Errorf("writing tmp ledger: %w", err)
+ }
+ }
+ if err := f.Close(); err != nil {
+ _ = os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, p); err != nil {
+ return fmt.Errorf("replacing ledger: %w", err)
+ }
+
+ if flags.asJSON {
+ return flags.printJSON(cmd, map[string]any{"pruned": pruned, "kept": len(kept)})
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "pruned %d, kept %d\n", pruned, len(kept))
+ return nil
+ },
+ }
+ cmd.Flags().DurationVar(&olderThan, "older-than", 7*24*time.Hour, "Prune rows whose latest update is older than this duration")
+ return cmd
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index ad5f87e9..2353582c 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -141,6 +141,9 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
rootCmd.AddCommand(newDoctorCmd(&flags))
rootCmd.AddCommand(newAuthCmd(&flags))
rootCmd.AddCommand(newAgentContextCmd(rootCmd))
+{{- if .HasAsyncJobs}}
+ rootCmd.AddCommand(newJobsCmd(&flags))
+{{- end}}
{{- if .VisionSet.Export}}
rootCmd.AddCommand(newExportCmd(&flags))
{{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index fefef615..0969497c 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -146,6 +146,26 @@ Add `--agent` to any command. Expands to: `--json --compact --no-input --no-colo
- **Cacheable** — GET responses cached for 5 minutes, bypass with `--no-cache`
- **Non-interactive** — never prompts, every input is a flag
+## Async Jobs
+
+For endpoints that submit long-running work, the generator detects the submit-then-poll pattern (a `job_id`/`task_id`/`operation_id` field in the response plus a sibling status endpoint) and wires up three extra flags on the submitting command:
+
+| Flag | Purpose |
+|------|---------|
+| `--wait` | Block until the job reaches a terminal status instead of returning the job ID immediately |
+| `--wait-timeout` | Maximum wait duration (default 10m, 0 means no timeout) |
+| `--wait-interval` | Initial poll interval (default 2s; grows with exponential backoff up to 30s) |
+
+Every `--wait` invocation records the job to `~/.<cli>-pp-cli/jobs.jsonl`. Inspect the ledger with:
+
+```
+<cli>-pp-cli jobs list [--json] [--limit N]
+<cli>-pp-cli jobs get <job-id>
+<cli>-pp-cli jobs prune --older-than 7d
+```
+
+Use async submission without `--wait` when you want to fire-and-forget; use `--wait` when you want one command to return the finished artifact.
+
## Exit Codes
| Code | Meaning |
← d4297f63 docs(cli): plan HeyGen CLI learnings application
·
back to Cli Printing Press
·
feat(cli): named-profile system for repeatable agent context 7140c3e7 →