shapeup-cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,70 +4,108 @@ module ShapeupCli
4
4
  module Commands
5
5
  class Login
6
6
  def self.run(args)
7
- host = nil
8
- profile_name = nil
7
+ new(args).run
8
+ end
9
+
10
+ def initialize(args)
11
+ @host = Config.host
12
+ @profile_name = nil
13
+ @requested_org = nil
14
+ @token = nil
15
+
9
16
  args.each_with_index do |arg, i|
10
17
  case arg
11
- when "--host" then host = args[i + 1]
12
- when /\A--host=(.+)\z/ then host = $1
13
- when "--profile" then profile_name = args[i + 1]
14
- when /\A--profile=(.+)\z/ then profile_name = $1
18
+ when "--host" then @host = args[i + 1]
19
+ when /\A--host=(.+)\z/ then @host = $1
20
+ when "--profile" then @profile_name = args[i + 1]
21
+ when /\A--profile=(.+)\z/ then @profile_name = $1
22
+ when "--org" then @requested_org = args[i + 1]
23
+ when /\A--org=(.+)\z/ then @requested_org = $1
24
+ when "--token" then @token = args[i + 1]
25
+ when /\A--token=(.+)\z/ then @token = $1
15
26
  end
16
27
  end
28
+ end
17
29
 
18
- host ||= Config.host
19
- token_response = ShapeupCli::Auth.login(host: host)
20
- token = token_response["access_token"]
30
+ def run
31
+ token = @token || ShapeupCli::Auth.login(host: @host)["access_token"]
32
+ orgs = fetch_organisations(token)
33
+ org = choose_organisation(orgs)
21
34
 
22
- # Fetch orgs to let user pick one for this profile
23
- client = Client.new(host: host, token: token)
24
- result = client.call_tool("list_organisations")
25
- data = Output.extract_data(result)
26
- orgs = data.is_a?(Hash) ? (data["organisations"] || []) : []
35
+ profile_name = @profile_name || agent_name(token, org) || (org ? slug(org["name"]) : slug(URI.parse(@host).host))
36
+ Config.save_profile(profile_name, token: token, host: @host, organisation_id: org&.dig("id"), display_name: org&.dig("name") || profile_name)
27
37
 
28
- if orgs.empty?
29
- puts "No organisations found."
38
+ if @token && org
39
+ puts "\nProfile '#{profile_name}' saved. Your default profile is unchanged."
40
+ puts " org: #{org["name"]} (#{org["id"]})"
41
+ puts " host: #{@host}"
42
+ puts "\nUse it with: SHAPEUP_PROFILE=#{profile_name} shapeup me"
30
43
  return
31
44
  end
32
45
 
33
- # If only one org, use it automatically
34
- org = if orgs.length == 1
35
- orgs.first
46
+ Config.switch_profile(profile_name)
47
+
48
+ if org
49
+ puts "\nProfile '#{profile_name}' created and set as default."
50
+ puts " org: #{org["name"]} (#{org["id"]})"
51
+ puts " host: #{@host}"
52
+ puts "\nTo add another profile, run 'shapeup login' again."
53
+ puts "To switch: 'shapeup auth switch <name>'"
36
54
  else
37
- puts "\nChoose an organisation for this profile:\n"
38
- orgs.each_with_index do |o, i|
39
- puts " #{i + 1}) #{o["name"]}"
55
+ puts "\nSigned in and saved to profile '#{profile_name}', but no organisation is set yet."
56
+ puts "Choose one with: shapeup config set org <name>"
57
+ list_organisations(orgs)
58
+ exit EXIT_USAGE if @requested_org
59
+ end
60
+ end
61
+
62
+ private
63
+ def fetch_organisations(token)
64
+ result = Client.new(host: @host, token: token).call_tool("list_organisations")
65
+ data = Output.extract_data(result)
66
+ data.is_a?(Hash) ? (data["organisations"] || []) : []
67
+ end
68
+
69
+ def agent_name(token, org)
70
+ return unless @token && org
71
+
72
+ data = Output.extract_data(Client.new(host: @host, token: token).call_tool("list_inbox", organisation: org["id"].to_s))
73
+ data.dig("agent", "name") if data.is_a?(Hash)
74
+ rescue Client::ApiError
75
+ nil
76
+ end
77
+
78
+ def choose_organisation(orgs)
79
+ if @requested_org
80
+ find_organisation(orgs, @requested_org)
81
+ elsif orgs.length == 1
82
+ orgs.first
83
+ elsif orgs.any? && $stdin.tty?
84
+ prompt_for_organisation(orgs)
85
+ end
86
+ end
87
+
88
+ def find_organisation(orgs, value)
89
+ orgs.find { |o| o["id"].to_s == value.to_s || o["name"].casecmp?(value) }.tap do |org|
90
+ $stderr.puts "Organisation '#{value}' not found." unless org
40
91
  end
92
+ end
93
+
94
+ def prompt_for_organisation(orgs)
95
+ puts "\nChoose an organisation for this profile:\n"
96
+ list_organisations(orgs)
41
97
  print "\nEnter number (1-#{orgs.length}): "
42
98
  choice = $stdin.gets&.strip&.to_i
43
99
  orgs[choice - 1] if choice&.between?(1, orgs.length)
44
100
  end
45
101
 
46
- unless org
47
- puts "No organisation selected."
48
- return
102
+ def list_organisations(orgs)
103
+ orgs.each_with_index { |o, i| puts " #{i + 1}) #{o["name"]} (#{o["id"]})" }
49
104
  end
50
105
 
51
- # Generate profile name from org name (slug it)
52
- profile_name ||= org["name"].downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
53
-
54
- Config.save_profile(
55
- profile_name,
56
- token: token,
57
- host: host,
58
- organisation_id: org["id"],
59
- display_name: org["name"]
60
- )
61
-
62
- # Also set as default
63
- Config.switch_profile(profile_name)
64
-
65
- puts "\nProfile '#{profile_name}' created and set as default."
66
- puts " org: #{org["name"]} (#{org["id"]})"
67
- puts " host: #{host}"
68
- puts "\nTo add another profile, run 'shapeup login' again."
69
- puts "To switch: 'shapeup auth switch <name>'"
70
- end
106
+ def slug(name)
107
+ name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
108
+ end
71
109
  end
72
110
  end
73
111
  end
@@ -3,7 +3,9 @@
3
3
  module ShapeupCli
4
4
  module Commands
5
5
  class Setup < Base
6
- SKILL_SOURCE = File.expand_path("../../../skills/shapeup/SKILL.md", __dir__)
6
+ SKILLS_DIR = File.expand_path("../../../skills", __dir__)
7
+ SKILL_SOURCE = File.join(SKILLS_DIR, "shapeup", "SKILL.md")
8
+ SKILL_NAMES = %w[ shapeup shapeup-connect ].freeze
7
9
 
8
10
  def self.metadata
9
11
  {
@@ -48,37 +50,34 @@ module ShapeupCli
48
50
 
49
51
  private
50
52
  def install_global(agent)
51
- dir = File.join(Dir.home, ".#{agent}", "skills", "shapeup")
52
- install_skill(dir, "~/.#{agent}/skills/shapeup/")
53
+ install_skills(File.join(Dir.home, ".#{agent}", "skills"), "~/.#{agent}/skills")
53
54
  end
54
55
 
55
56
  def install_project
56
- dir = File.join(".claude", "skills", "shapeup")
57
- install_skill(dir, ".claude/skills/shapeup/")
57
+ install_skills(File.join(".claude", "skills"), ".claude/skills")
58
58
  end
59
59
 
60
- def install_skill(dir, display_path)
61
- unless File.exist?(SKILL_SOURCE)
62
- abort "SKILL.md not found at #{SKILL_SOURCE}. Is the CLI installed correctly?"
63
- end
60
+ def install_skills(base, display_base)
61
+ SKILL_NAMES.each do |name|
62
+ source = File.join(SKILLS_DIR, name, "SKILL.md")
63
+ abort "SKILL.md not found at #{source}. Is the CLI installed correctly?" unless File.exist?(source)
64
64
 
65
- FileUtils.mkdir_p(dir)
66
- dest = File.join(dir, "SKILL.md")
65
+ dir = File.join(base, name)
66
+ FileUtils.mkdir_p(dir)
67
+ dest = File.join(dir, "SKILL.md")
67
68
 
68
- if File.exist?(dest)
69
- puts "Updating #{display_path}SKILL.md"
70
- else
71
- puts "Installing #{display_path}SKILL.md"
69
+ puts "#{File.exist?(dest) ? "Updating" : "Installing"} #{display_base}/#{name}/SKILL.md"
70
+ FileUtils.cp(source, dest)
72
71
  end
73
72
 
74
- FileUtils.cp(SKILL_SOURCE, dest)
75
-
76
73
  puts "Done! Restart your agent session to pick up the skill."
77
74
  puts
78
75
  puts "The skill triggers on phrases like:"
79
76
  puts " 'my tasks', 'list pitches', 'cycle progress', 'shapeup', etc."
80
77
  puts
81
78
  puts "Your agent will use the ShapeUp CLI to handle these requests."
79
+ puts
80
+ puts "To let your ShapeUp agent pick up requests from issues, run /shapeup-connect."
82
81
  end
83
82
  end
84
83
  end
@@ -81,6 +81,13 @@ module ShapeupCli
81
81
  issues add-to-pitch <id> --pitch <pid>
82
82
  Fold issue into an existing pitch
83
83
 
84
+ Agents:
85
+ agents List your agents
86
+ agents add "Name" Add your agent (Pro, one per member)
87
+ connect Listen for requests as your agent (JSON lines)
88
+ inbox Requests waiting for this agent
89
+ inbox done <id> Mark a request handled
90
+
84
91
  Comments:
85
92
  comments list --issue <id> List comments on an issue
86
93
  comments list --pitch <id> List comments on a pitch
@@ -113,15 +120,22 @@ module ShapeupCli
113
120
 
114
121
  Config:
115
122
  config show Show current config
123
+ config explain Trace where each setting's value comes from
116
124
  config set org "Name" Set default organisation (name or ID)
117
125
  config set host <url> Set ShapeUp host
118
126
  config init "Name" Create .shapeup/config.json for this directory
119
127
 
128
+ Doctor:
129
+ doctor Diagnose setup: config, auth, connectivity, skills
130
+
120
131
  Setup:
121
132
  setup claude Install skill into Claude Code
122
133
  setup cursor Install skill into Cursor
123
134
  setup project Install skill into current project
124
135
 
136
+ Profiles (append to any command):
137
+ --profile <name> Use a saved profile, e.g. your agent's
138
+
125
139
  Output modes (append to any command):
126
140
  --json Full JSON envelope with breadcrumbs
127
141
  --md Markdown tables
@@ -175,8 +189,12 @@ module ShapeupCli
175
189
  checklist Manage checklist items on pitches/issues (list, add, tick, untick, edit, remove)
176
190
  tags List/add/remove tags on pitches and issues
177
191
  my-work / me Show my assigned work
192
+ inbox Agents: requests waiting on issues (done <id> to clear)
193
+ agents List your agents, or add one
194
+ connect Listen for requests as your agent (prints JSON lines)
178
195
  search Search everything
179
- config Show/set config (set, show, init)
196
+ config Show/set config (set, show, explain, init)
197
+ doctor Diagnose setup (config, auth, connectivity, skills)
180
198
  setup Install agent skills (claude, cursor, project)
181
199
  commands This list
182
200
  help Usage guide
@@ -200,7 +218,11 @@ require_relative "commands/my_work"
200
218
  require_relative "commands/search"
201
219
  require_relative "commands/auth"
202
220
  require_relative "commands/config_cmd"
221
+ require_relative "commands/doctor"
203
222
  require_relative "commands/setup"
204
223
  require_relative "commands/comments"
205
224
  require_relative "commands/checklist"
206
225
  require_relative "commands/tags"
226
+ require_relative "commands/inbox"
227
+ require_relative "commands/connect"
228
+ require_relative "commands/agents"
@@ -100,19 +100,46 @@ module ShapeupCli
100
100
  data
101
101
  end
102
102
 
103
- # Resolution order: env var > project config > global config > profile > default
103
+ # Resolution order: env var > profile picked with SHAPEUP_PROFILE > project config > global config > default profile > default
104
104
  def self.host
105
- ENV["SHAPEUP_HOST"] || load_config["host"] || current_profile&.dig("host") || ShapeupCli::DEFAULT_HOST
105
+ ENV["SHAPEUP_HOST"] || chosen_profile_value("host") || load_config["host"] || current_profile&.dig("host") || ShapeupCli::DEFAULT_HOST
106
106
  end
107
107
 
108
108
  def self.organisation_id
109
- ENV["SHAPEUP_ORG"] || load_config["organisation_id"] || current_profile&.dig("organisation_id")&.then { |v| v.empty? ? nil : v }
109
+ ENV["SHAPEUP_ORG"] || chosen_profile_value("organisation_id") || load_config["organisation_id"] || current_profile&.dig("organisation_id")&.then { |v| v.empty? ? nil : v }
110
+ end
111
+
112
+ def self.chosen_profile_value(key)
113
+ return unless ENV["SHAPEUP_PROFILE"]
114
+
115
+ value = current_profile&.dig(key)
116
+ value unless value.to_s.empty?
110
117
  end
111
118
 
112
119
  def self.token
113
120
  ENV["SHAPEUP_TOKEN"] || current_profile&.dig("token")
114
121
  end
115
122
 
123
+ # --- Introspection (for `config explain` and `doctor`) ---
124
+
125
+ def self.saved_default_profile
126
+ load_profiles_raw["default"]
127
+ end
128
+
129
+ def self.global_config
130
+ load_config_raw
131
+ end
132
+
133
+ def self.project_config_path
134
+ find_project_config
135
+ end
136
+
137
+ def self.project_config
138
+ path = find_project_config
139
+ return {} unless path
140
+ JSON.parse(File.read(path)) rescue {}
141
+ end
142
+
116
143
  # --- Pipe detection ---
117
144
 
118
145
  def self.piped?
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ # Serializes the entire CLI surface — commands, subcommands, flags — into
5
+ # sorted lines, snapshotted in SURFACE.txt and gated by surface_test.rb so
6
+ # no command or flag appears or disappears without a reviewed diff.
7
+ module Surface
8
+ TOP_LEVEL_COMMANDS = %w[ login logout commands help version ].freeze
9
+
10
+ def self.lines
11
+ lines = []
12
+
13
+ TOP_LEVEL_COMMANDS.each { |name| lines << "CMD shapeup #{name}" }
14
+
15
+ ShapeupCli.top_level_metadata[:shortcuts].each_key do |shortcut|
16
+ lines << "CMD shapeup #{shortcut.split.first}"
17
+ end
18
+
19
+ ShapeupCli.top_level_metadata[:inherited_flags].each do |flag|
20
+ lines << "FLAG shapeup --#{flag[:name]} #{flag[:type]}"
21
+ end
22
+
23
+ ShapeupCli::COMMAND_MAP.each do |name, klass|
24
+ lines << "CMD shapeup #{name}"
25
+ metadata = klass.metadata
26
+ (metadata[:subcommands] || []).each do |sub|
27
+ lines << "SUB shapeup #{name} #{sub[:name]}"
28
+ end
29
+ (metadata[:flags] || []).each do |flag|
30
+ lines << "FLAG shapeup #{name} --#{flag[:name]} #{flag[:type]}"
31
+ end
32
+ end
33
+
34
+ lines.uniq.sort
35
+ end
36
+
37
+ def self.render
38
+ lines.join("\n") + "\n"
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ShapeupCli
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/shapeup_cli.rb CHANGED
@@ -19,14 +19,17 @@ require_relative "shapeup_cli/commands"
19
19
  module ShapeupCli
20
20
  DEFAULT_HOST = "https://shapeup.cc"
21
21
 
22
- # Exit codes
22
+ # Exit codes, matching the rubric shared by the Basecamp-family CLIs so
23
+ # agents that know one CLI can read all of them.
23
24
  EXIT_OK = 0
24
25
  EXIT_USAGE = 1
25
26
  EXIT_NOT_FOUND = 2
26
- EXIT_AUTH = 3
27
+ EXIT_AUTH = 3
27
28
  EXIT_PERMISSION = 4
28
- EXIT_API_ERROR = 5
29
- EXIT_RATE_LIMIT = 6
29
+ EXIT_RATE_LIMIT = 5
30
+ EXIT_NETWORK = 6
31
+ EXIT_API_ERROR = 7
32
+ EXIT_AMBIGUOUS = 8
30
33
  EXIT_INTERRUPTED = 130
31
34
 
32
35
  COMMAND_MAP = {
@@ -41,14 +44,20 @@ module ShapeupCli
41
44
  "search" => Commands::Search,
42
45
  "auth" => Commands::Auth,
43
46
  "config" => Commands::ConfigCmd,
47
+ "doctor" => Commands::Doctor,
44
48
  "setup" => Commands::Setup,
45
49
  "comments" => Commands::Comments,
46
50
  "checklist" => Commands::Checklist,
47
- "tags" => Commands::Tags
51
+ "tags" => Commands::Tags,
52
+ "inbox" => Commands::Inbox,
53
+ "connect" => Commands::Connect,
54
+ "agents" => Commands::Agents
48
55
  }.freeze
49
56
 
50
57
  def self.run(argv)
51
58
  args = argv.dup
59
+ use_profile_from!(args) unless args.first == "login"
60
+ @machine_output = args.intersect?(%w[ --json --agent --quiet -q ]) || !$stdout.tty?
52
61
 
53
62
  # Top-level: shapeup --agent --help
54
63
  if args.include?("--agent") && args.include?("--help")
@@ -85,8 +94,12 @@ module ShapeupCli
85
94
  when "checklist" then Commands::Checklist.run(args)
86
95
  when "tags" then Commands::Tags.run(args)
87
96
  when "my-work", "me" then Commands::MyWork.run(args)
97
+ when "inbox" then Commands::Inbox.run(args)
98
+ when "connect" then Commands::Connect.run(args)
99
+ when "agents" then Commands::Agents.run(args)
88
100
  when "search" then Commands::Search.run(args)
89
101
  when "config" then Commands::ConfigCmd.run(args)
102
+ when "doctor" then Commands::Doctor.run(args)
90
103
  when "setup" then Commands::Setup.run(args)
91
104
  when "commands" then Commands.list_commands
92
105
  when "version", "-v", "--version"
@@ -96,28 +109,52 @@ module ShapeupCli
96
109
  else
97
110
  $stderr.puts "Unknown command: #{command}"
98
111
  $stderr.puts "Run 'shapeup help' for usage"
99
- exit 1
112
+ exit EXIT_USAGE
100
113
  end
101
- rescue Client::AuthError => e
102
- $stderr.puts "Not authenticated. Run 'shapeup login' first."
103
- exit EXIT_AUTH
114
+ rescue Client::AuthError
115
+ fail_with "Not authenticated.", code: "auth_required", exit_code: EXIT_AUTH,
116
+ retryable: false, hint: "Run 'shapeup login' or set SHAPEUP_TOKEN"
104
117
  rescue Client::NotFoundError => e
105
- $stderr.puts "Not found: #{e.message}"
106
- exit EXIT_NOT_FOUND
118
+ fail_with "Not found: #{e.message}", code: "not_found", exit_code: EXIT_NOT_FOUND, retryable: false
107
119
  rescue Client::PermissionError => e
108
- $stderr.puts "Access denied: #{e.message}"
109
- exit EXIT_PERMISSION
110
- rescue Client::RateLimitError => e
111
- $stderr.puts "Rate limited — please wait and try again."
112
- exit EXIT_RATE_LIMIT
120
+ fail_with "Access denied: #{e.message}", code: "forbidden", exit_code: EXIT_PERMISSION, retryable: false
121
+ rescue Client::RateLimitError
122
+ fail_with "Rate limited.", code: "rate_limit", exit_code: EXIT_RATE_LIMIT,
123
+ retryable: true, hint: "Wait a moment and retry"
124
+ rescue Client::NetworkError => e
125
+ fail_with e.message, code: "network", exit_code: EXIT_NETWORK,
126
+ retryable: true, hint: "Check your connection and 'shapeup config show'"
113
127
  rescue Client::ApiError => e
114
- $stderr.puts "Error: #{e.message}"
115
- exit EXIT_API_ERROR
128
+ fail_with "Error: #{e.message}", code: "api_error", exit_code: EXIT_API_ERROR, retryable: false
116
129
  rescue Interrupt
117
130
  $stderr.puts "\nAborted."
118
131
  exit EXIT_INTERRUPTED
119
132
  end
120
133
 
134
+ # Errors reach machine consumers as a JSON envelope on stdout with a
135
+ # `retryable` verdict, and humans as plain text on stderr. `retryable: false`
136
+ # means "no known reason a retry helps", not proof of permanence.
137
+ def self.fail_with(message, code:, exit_code:, retryable:, hint: nil)
138
+ if @machine_output
139
+ envelope = { ok: false, error: message, code: code, retryable: retryable }
140
+ envelope[:hint] = hint if hint
141
+ puts JSON.generate(envelope)
142
+ else
143
+ $stderr.puts message
144
+ $stderr.puts hint if hint
145
+ end
146
+ exit exit_code
147
+ end
148
+
149
+ def self.use_profile_from!(args)
150
+ if (index = args.index("--profile"))
151
+ ENV["SHAPEUP_PROFILE"] = args.delete_at(index + 1) || abort("Usage: --profile <name>")
152
+ args.delete_at(index)
153
+ elsif (flag = args.find { |arg| arg.start_with?("--profile=") })
154
+ ENV["SHAPEUP_PROFILE"] = args.delete(flag).delete_prefix("--profile=")
155
+ end
156
+ end
157
+
121
158
  def self.top_level_metadata
122
159
  {
123
160
  command: "shapeup",
@@ -136,6 +173,7 @@ module ShapeupCli
136
173
  },
137
174
  inherited_flags: [
138
175
  { name: "org", type: "string", usage: "Organisation ID or name" },
176
+ { name: "profile", type: "string", usage: "Saved profile to use, e.g. your agent's (same as SHAPEUP_PROFILE)" },
139
177
  { name: "json", type: "bool", usage: "Full JSON envelope with breadcrumbs" },
140
178
  { name: "md", type: "bool", usage: "Markdown output" },
141
179
  { name: "agent", type: "bool", usage: "Raw JSON data only (for AI agents)" }
@@ -77,7 +77,7 @@ Manage pitches, scopes, tasks, issues, and cycles via the ShapeUp CLI. Columns a
77
77
  4. **Follow breadcrumbs** — JSON responses include a `breadcrumbs` array with suggested next commands. Use these to chain workflows.
78
78
  5. **"Pitch" = "Package" in code** — users say "pitch", the API uses "package". The CLI uses "pitch" everywhere.
79
79
  6. **Use 'me' and 'none'** — `--assignee me` for current user, `--assignee none` for unassigned items.
80
- 7. **Check exit codes** — 0=OK, 2=not found, 3=auth error, 4=permission denied, 5=API error. Branch on exit code without parsing error text.
80
+ 7. **Check exit codes** — 0=OK, 2=not found, 3=auth, 4=forbidden, 5=rate limit, 6=network, 7=API error. Branch on the exit code or the JSON envelope's `retryable` field without parsing error text.
81
81
  8. **Deletes need confirmation** — `delete`/`remove` commands (pitches, scopes, tasks, issues, comments) prompt `[y/N]` interactively and **refuse when run non-interactively** unless you pass `--yes` (or `-y`). As an agent you have no TTY, so add `--yes` only after you have confirmed the deletion is intended.
82
82
 
83
83
  ### Output Modes
@@ -99,19 +99,27 @@ Manage pitches, scopes, tasks, issues, and cycles via the ShapeUp CLI. Columns a
99
99
  | 2 | Not found |
100
100
  | 3 | Auth error |
101
101
  | 4 | Permission denied |
102
- | 5 | API error |
103
- | 6 | Rate limited |
102
+ | 5 | Rate limited (retryable) |
103
+ | 6 | Network error (retryable) |
104
+ | 7 | API error |
105
+ | 8 | Ambiguous selector |
104
106
  | 130 | Interrupted (Ctrl-C) |
105
107
 
108
+ With `--json`, `--agent`, or piped output, errors arrive as a JSON envelope on stdout:
109
+ `{"ok": false, "error": "...", "code": "rate_limit", "retryable": true, "hint": "..."}`.
110
+ Branch on `retryable` to decide retry-vs-abandon; `false` means "no known reason a retry helps".
111
+
106
112
  ## Quick Reference
107
113
 
108
114
  | Task | Command |
109
115
  |------|---------|
110
116
  | **Auth & Context** | |
111
- | Login | `shapeup login` |
117
+ | Login | `shapeup login` (opens a browser; from an agent or script pass `--org <name>` so no terminal prompt is needed) |
112
118
  | Auth status | `shapeup auth status` |
113
119
  | List orgs | `shapeup orgs --json` |
114
120
  | Show current org | `shapeup config show` |
121
+ | Trace config precedence | `shapeup config explain` |
122
+ | Diagnose setup problems | `shapeup doctor` |
115
123
  | Set default org | `shapeup config set org "Compass Labs"` |
116
124
  | Per-directory config | `shapeup config init "Compass Labs"` |
117
125
  | Install skill | `shapeup setup claude` |
@@ -203,6 +211,17 @@ Manage pitches, scopes, tasks, issues, and cycles via the ShapeUp CLI. Columns a
203
211
  | All my assignments | `shapeup me --json` |
204
212
  | My work (alias) | `shapeup my-work --json` |
205
213
  | Search everything | `shapeup search "query" --json` |
214
+ | **Agents** | |
215
+ | Your agents | `shapeup agents --json` |
216
+ | Add your agent | `shapeup agents add "Bond"` |
217
+ | Listen for requests as your agent | `shapeup connect` (or `/shapeup-connect` in Claude Code) |
218
+ | Save an agent token as a profile | `shapeup login --token <token>` |
219
+ | Requests waiting for this agent | `shapeup inbox --json` |
220
+ | Mark a request handled | `shapeup inbox done <id>` |
221
+ | Save an agent token as a profile | `shapeup login --token <token>` |
222
+ | Watch the inbox as JSON lines | `SHAPEUP_PROFILE=<agent> shapeup connect` |
223
+ | Reply under a comment | `shapeup comments add --issue <id> --parent <comment_id> "Reply"` |
224
+ | React to a comment | `shapeup comments react <comment_id> 👀` |
206
225
 
207
226
  ## Common Workflows
208
227
 
@@ -366,6 +385,8 @@ Creates `.shapeup/config.json` in the current directory. All commands in this di
366
385
  ```bash
367
386
  shapeup config set org "Compass Labs"
368
387
  shapeup config set host https://shapeup.cc
388
+ shapeup doctor # full setup diagnosis with fix hints
389
+ shapeup config explain # why is the CLI using this org/host/token?
369
390
  ```
370
391
 
371
392
  ### Environment Variables