shapeup-cli 0.6.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3f4bd862f49cc66a46223d4c0f5749a92150d3b2a9fa3a6164933a6bf3970f7
4
- data.tar.gz: 2a018829f56a8cbd42679fef24e16a7e8645c22ddeb7b4be2ca6b7b6c3c88211
3
+ metadata.gz: b3d008bb8caee611f3a41ac593cef3deed395c5057701a8e7a32b57aba6f58c6
4
+ data.tar.gz: ff36d3e5ccff60c016c9e48d055ef4e47821214da3d190c3b3a0cb082fc61f3c
5
5
  SHA512:
6
- metadata.gz: 5857b6c507783ce2dadaaf2fbd6c4df72defe1ea46c09e93e91cec3f9bb74b2e1bfc5dcd4d1a10eddd4b92eece7cb1b6f597e0bd0599841d1947e1682494589b
7
- data.tar.gz: d1bfc879a720dd2a486e5a754cecbdb958a8a2bdefe930b9d1a86d34841c625d201ffdf8e151005423cd9813b9533d54db953fbaf59f5877a8874040e6543582
6
+ metadata.gz: 6adc9b1b1748d3bdfd398ec4a2e617d1d530d1c18f52ba91db6fe7e2d2db51219c7730e3642023b73139861508e66ac6009d18461a767f5835ca4f89d33282fc
7
+ data.tar.gz: afbee4b1da2544c0846ccafc864fb42db1a67ce1d50ff99509a420a5b02517253b895d7f1f8e5a175f60102d464c8e04ac9cd7908adfd1baa882618b12b7db7d
@@ -7,6 +7,7 @@ module ShapeupCli
7
7
  class NotFoundError < ApiError; end
8
8
  class PermissionError < ApiError; end
9
9
  class RateLimitError < ApiError; end
10
+ class NetworkError < ApiError; end
10
11
 
11
12
  MCP_PROTOCOL_VERSION = "2025-06-18"
12
13
 
@@ -66,9 +67,9 @@ module ShapeupCli
66
67
  def with_network_error_handling
67
68
  yield
68
69
  rescue Net::OpenTimeout, Net::ReadTimeout
69
- raise ApiError, "The server took too long to respond. Please try again."
70
+ raise NetworkError, "The server took too long to respond."
70
71
  rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
71
- raise ApiError, "Couldn't reach #{@host} (#{e.class}). Check your connection and 'shapeup config show'."
72
+ raise NetworkError, "Couldn't reach #{@host} (#{e.class})."
72
73
  end
73
74
 
74
75
  def handle_response(response)
@@ -10,6 +10,7 @@ module ShapeupCli
10
10
  short: "Show and manage CLI configuration",
11
11
  subcommands: [
12
12
  { name: "show", short: "Show current config (default)", path: "shapeup config show" },
13
+ { name: "explain", short: "Trace where each setting's value comes from", path: "shapeup config explain" },
13
14
  { name: "set", short: "Set a config value", path: "shapeup config set <key> <value>" },
14
15
  { name: "init", short: "Create .shapeup/config.json for this directory", path: "shapeup config init <org>" }
15
16
  ],
@@ -30,9 +31,10 @@ module ShapeupCli
30
31
  subcommand = positional_arg(0)
31
32
 
32
33
  case subcommand
33
- when "set" then set
34
- when "show" then show
35
- when "init" then init_project
34
+ when "set" then set
35
+ when "show" then show
36
+ when "explain" then explain
37
+ when "init" then init_project
36
38
  else show
37
39
  end
38
40
  end
@@ -96,6 +98,81 @@ module ShapeupCli
96
98
  end
97
99
  end
98
100
 
101
+ # Trace every setting through its full precedence chain, showing each
102
+ # candidate and which one won. Token values are never printed.
103
+ def explain
104
+ settings = {
105
+ "profile" => profile_candidates,
106
+ "org" => org_candidates,
107
+ "host" => host_candidates,
108
+ "token" => token_candidates
109
+ }
110
+
111
+ if @mode == :styled || @mode == :markdown
112
+ settings.each { |name, candidates| print_setting(name, candidates) }
113
+ else
114
+ data = settings.transform_values do |candidates|
115
+ selected = candidates.find { |c| c[:selected] }
116
+ { value: selected&.dig(:value), source: selected&.dig(:source), candidates: candidates }
117
+ end
118
+ render data
119
+ end
120
+ end
121
+
122
+ def profile_candidates
123
+ select_first [
124
+ { source: "SHAPEUP_PROFILE env", value: ENV["SHAPEUP_PROFILE"] },
125
+ { source: "profiles.json default", value: Config.saved_default_profile }
126
+ ]
127
+ end
128
+
129
+ def org_candidates
130
+ select_first [
131
+ { source: "--org flag", value: @org_id },
132
+ { source: "SHAPEUP_ORG env", value: ENV["SHAPEUP_ORG"] },
133
+ { source: project_config_source, value: Config.project_config["organisation_id"] },
134
+ { source: "~/.config/shapeup/config.json", value: Config.global_config["organisation_id"] },
135
+ { source: "profile '#{Config.current_profile_name}'", value: Config.current_profile&.dig("organisation_id") }
136
+ ]
137
+ end
138
+
139
+ def host_candidates
140
+ select_first [
141
+ { source: "SHAPEUP_HOST env", value: ENV["SHAPEUP_HOST"] },
142
+ { source: project_config_source, value: Config.project_config["host"] },
143
+ { source: "~/.config/shapeup/config.json", value: Config.global_config["host"] },
144
+ { source: "profile '#{Config.current_profile_name}'", value: Config.current_profile&.dig("host") },
145
+ { source: "built-in default", value: ShapeupCli::DEFAULT_HOST }
146
+ ]
147
+ end
148
+
149
+ def token_candidates
150
+ select_first [
151
+ { source: "SHAPEUP_TOKEN env", value: ENV["SHAPEUP_TOKEN"] && "configured in environment" },
152
+ { source: "profile '#{Config.current_profile_name}'", value: Config.current_profile&.dig("token") && "configured in profile" }
153
+ ]
154
+ end
155
+
156
+ def project_config_source
157
+ Config.project_config_path || Config::PROJECT_CONFIG_NAME
158
+ end
159
+
160
+ def select_first(candidates)
161
+ winner = candidates.find { |c| !c[:value].nil? && c[:value] != "" }
162
+ candidates.each { |c| c[:selected] = c.equal?(winner) }
163
+ end
164
+
165
+ def print_setting(name, candidates)
166
+ puts name
167
+ width = candidates.map { |c| c[:source].length }.max
168
+ candidates.each do |c|
169
+ marker = c[:selected] ? " <- selected" : ""
170
+ value = c[:value].nil? ? "(unset)" : c[:value]
171
+ puts " #{c[:source].ljust(width)} #{value}#{marker}"
172
+ end
173
+ puts
174
+ end
175
+
99
176
  def find_project_display
100
177
  dir = Dir.pwd
101
178
  loop do
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Doctor < Base
6
+ Check = Struct.new(:name, :status, :message, :hint, keyword_init: true)
7
+
8
+ def self.metadata
9
+ {
10
+ command: "doctor",
11
+ path: "shapeup doctor",
12
+ short: "Diagnose CLI setup: config, auth, connectivity, and agent skills",
13
+ subcommands: [],
14
+ flags: [],
15
+ notes: [
16
+ "Each check reports pass, warn, fail, or skip with a fix hint",
17
+ "Exits 0 when nothing failed, 1 otherwise"
18
+ ],
19
+ examples: [ "shapeup doctor", "shapeup doctor --json" ]
20
+ }
21
+ end
22
+
23
+ def execute
24
+ checks = [
25
+ check_cli_version,
26
+ check_ruby_version,
27
+ check_config_dir,
28
+ check_profiles_file,
29
+ check_config_file,
30
+ check_project_config,
31
+ check_token,
32
+ check_host_url,
33
+ check_api_reachable,
34
+ check_auth,
35
+ check_org_access,
36
+ check_claude_skill
37
+ ]
38
+
39
+ report(checks)
40
+ exit 1 if checks.any? { |c| c.status == :fail }
41
+ end
42
+
43
+ private
44
+ def check_cli_version
45
+ pass "cli", "shapeup #{VERSION}"
46
+ end
47
+
48
+ def check_ruby_version
49
+ pass "ruby", RUBY_VERSION
50
+ end
51
+
52
+ def check_config_dir
53
+ if File.directory?(Config::CONFIG_DIR)
54
+ File.writable?(Config::CONFIG_DIR) ?
55
+ pass("config dir", Config::CONFIG_DIR) :
56
+ fail!("config dir", "#{Config::CONFIG_DIR} is not writable")
57
+ else
58
+ warn! "config dir", "#{Config::CONFIG_DIR} does not exist yet", hint: "shapeup login"
59
+ end
60
+ end
61
+
62
+ def check_profiles_file
63
+ return warn!("profiles", "no profiles saved", hint: "shapeup login") unless File.exist?(Config::PROFILES_FILE)
64
+
65
+ data = JSON.parse(File.read(Config::PROFILES_FILE))
66
+ names = (data["profiles"] || {}).keys
67
+ pass "profiles", "#{names.length} saved (default: #{data["default"] || "none"})"
68
+ rescue JSON::ParserError
69
+ fail! "profiles", "#{Config::PROFILES_FILE} is not valid JSON", hint: "shapeup logout"
70
+ end
71
+
72
+ def check_config_file
73
+ return pass("global config", "(none)") unless File.exist?(Config::CONFIG_FILE)
74
+
75
+ JSON.parse(File.read(Config::CONFIG_FILE))
76
+ pass "global config", Config::CONFIG_FILE
77
+ rescue JSON::ParserError
78
+ fail! "global config", "#{Config::CONFIG_FILE} is not valid JSON"
79
+ end
80
+
81
+ def check_project_config
82
+ path = Config.project_config_path
83
+ return skip("project config", "none found") unless path
84
+
85
+ JSON.parse(File.read(path))
86
+ pass "project config", path
87
+ rescue JSON::ParserError
88
+ fail! "project config", "#{path} is not valid JSON"
89
+ end
90
+
91
+ def check_token
92
+ if ENV["SHAPEUP_TOKEN"]
93
+ pass "token", "from SHAPEUP_TOKEN"
94
+ elsif Config.current_profile&.dig("token")
95
+ pass "token", "from profile '#{Config.current_profile_name}'"
96
+ else
97
+ fail! "token", "no credentials found", hint: "shapeup login"
98
+ end
99
+ end
100
+
101
+ def check_host_url
102
+ uri = URI.parse(Config.host)
103
+ if uri.is_a?(URI::HTTP) && uri.host
104
+ pass "host", Config.host
105
+ else
106
+ fail! "host", "#{Config.host.inspect} is not a valid URL", hint: "shapeup config set host <url>"
107
+ end
108
+ rescue URI::InvalidURIError
109
+ fail! "host", "#{Config.host.inspect} is not a valid URL", hint: "shapeup config set host <url>"
110
+ end
111
+
112
+ def check_api_reachable
113
+ uri = URI.parse("#{Config.host}/up")
114
+ http = Net::HTTP.new(uri.host, uri.port)
115
+ http.use_ssl = uri.scheme == "https"
116
+ http.open_timeout = 5
117
+ http.read_timeout = 5
118
+ response = http.get(uri.path)
119
+
120
+ response.code.to_i < 500 ?
121
+ pass("server", "#{Config.host} reachable") :
122
+ fail!("server", "#{Config.host} responded with HTTP #{response.code}")
123
+ rescue StandardError => e
124
+ fail! "server", "couldn't reach #{Config.host} (#{e.class})", hint: "shapeup config explain"
125
+ end
126
+
127
+ def check_auth
128
+ return skip("auth", "no token to test") unless Config.token
129
+
130
+ result = Output.extract_data(Client.new.call_tool("list_organisations"))
131
+ @organisations = result.is_a?(Hash) ? (result["organisations"] || []) : Array(result)
132
+ pass "auth", "signed in, #{@organisations.length} organisation(s)"
133
+ rescue Client::AuthError
134
+ fail! "auth", "token was rejected", hint: "shapeup login"
135
+ rescue Client::ApiError => e
136
+ fail! "auth", e.message
137
+ end
138
+
139
+ def check_org_access
140
+ return skip("org", "auth not verified") unless @organisations
141
+
142
+ org = Config.organisation_id
143
+ return warn!("org", "no default organisation set", hint: "shapeup config set org <name>") unless org
144
+
145
+ if @organisations.any? { |o| o["id"].to_s == org.to_s }
146
+ pass "org", "##{org} accessible"
147
+ else
148
+ fail! "org", "##{org} is not among your organisations", hint: "shapeup orgs"
149
+ end
150
+ end
151
+
152
+ def check_claude_skill
153
+ installed = File.join(Dir.home, ".claude", "skills", "shapeup", "SKILL.md")
154
+ return warn!("claude skill", "not installed", hint: "shapeup setup claude") unless File.exist?(installed)
155
+
156
+ if File.read(installed) == File.read(Setup::SKILL_SOURCE)
157
+ pass "claude skill", "installed and current"
158
+ else
159
+ warn! "claude skill", "installed but stale", hint: "shapeup setup claude"
160
+ end
161
+ end
162
+
163
+ def pass(name, message) = Check.new(name: name, status: :pass, message: message)
164
+ def warn!(name, message, hint: nil) = Check.new(name: name, status: :warn, message: message, hint: hint)
165
+ def fail!(name, message, hint: nil) = Check.new(name: name, status: :fail, message: message, hint: hint)
166
+ def skip(name, message) = Check.new(name: name, status: :skip, message: message)
167
+
168
+ STATUS_MARKS = { pass: "ok", warn: "!!", fail: "XX", skip: "--" }.freeze
169
+
170
+ def report(checks)
171
+ counts = checks.group_by(&:status).transform_values(&:length)
172
+ summary = "#{counts.fetch(:pass, 0)} passed, #{counts.fetch(:warn, 0)} warnings, #{counts.fetch(:fail, 0)} failed, #{counts.fetch(:skip, 0)} skipped"
173
+ breadcrumbs = checks.select { |c| c.hint }.map { |c| { cmd: c.hint, description: "fix: #{c.name}" } }
174
+
175
+ if @mode == :styled
176
+ width = checks.map { |c| c.name.length }.max
177
+ checks.each do |c|
178
+ line = " #{STATUS_MARKS[c.status]} #{c.name.ljust(width)} #{c.message}"
179
+ line += " (#{c.hint})" if c.hint
180
+ puts line
181
+ end
182
+ puts
183
+ puts summary
184
+ if breadcrumbs.any?
185
+ puts
186
+ puts "Next:"
187
+ breadcrumbs.each { |b| puts " #{b[:cmd]} # #{b[:description]}" }
188
+ end
189
+ else
190
+ render({ "checks" => checks.map { |c| c.to_h.compact }, "summary" => counts },
191
+ breadcrumbs: breadcrumbs, summary: summary)
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -113,10 +113,14 @@ module ShapeupCli
113
113
 
114
114
  Config:
115
115
  config show Show current config
116
+ config explain Trace where each setting's value comes from
116
117
  config set org "Name" Set default organisation (name or ID)
117
118
  config set host <url> Set ShapeUp host
118
119
  config init "Name" Create .shapeup/config.json for this directory
119
120
 
121
+ Doctor:
122
+ doctor Diagnose setup: config, auth, connectivity, skills
123
+
120
124
  Setup:
121
125
  setup claude Install skill into Claude Code
122
126
  setup cursor Install skill into Cursor
@@ -176,7 +180,8 @@ module ShapeupCli
176
180
  tags List/add/remove tags on pitches and issues
177
181
  my-work / me Show my assigned work
178
182
  search Search everything
179
- config Show/set config (set, show, init)
183
+ config Show/set config (set, show, explain, init)
184
+ doctor Diagnose setup (config, auth, connectivity, skills)
180
185
  setup Install agent skills (claude, cursor, project)
181
186
  commands This list
182
187
  help Usage guide
@@ -200,6 +205,7 @@ require_relative "commands/my_work"
200
205
  require_relative "commands/search"
201
206
  require_relative "commands/auth"
202
207
  require_relative "commands/config_cmd"
208
+ require_relative "commands/doctor"
203
209
  require_relative "commands/setup"
204
210
  require_relative "commands/comments"
205
211
  require_relative "commands/checklist"
@@ -113,6 +113,26 @@ module ShapeupCli
113
113
  ENV["SHAPEUP_TOKEN"] || current_profile&.dig("token")
114
114
  end
115
115
 
116
+ # --- Introspection (for `config explain` and `doctor`) ---
117
+
118
+ def self.saved_default_profile
119
+ load_profiles_raw["default"]
120
+ end
121
+
122
+ def self.global_config
123
+ load_config_raw
124
+ end
125
+
126
+ def self.project_config_path
127
+ find_project_config
128
+ end
129
+
130
+ def self.project_config
131
+ path = find_project_config
132
+ return {} unless path
133
+ JSON.parse(File.read(path)) rescue {}
134
+ end
135
+
116
136
  # --- Pipe detection ---
117
137
 
118
138
  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.7.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,6 +44,7 @@ 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,
@@ -49,6 +53,7 @@ module ShapeupCli
49
53
 
50
54
  def self.run(argv)
51
55
  args = argv.dup
56
+ @machine_output = args.intersect?(%w[ --json --agent --quiet -q ]) || !$stdout.tty?
52
57
 
53
58
  # Top-level: shapeup --agent --help
54
59
  if args.include?("--agent") && args.include?("--help")
@@ -87,6 +92,7 @@ module ShapeupCli
87
92
  when "my-work", "me" then Commands::MyWork.run(args)
88
93
  when "search" then Commands::Search.run(args)
89
94
  when "config" then Commands::ConfigCmd.run(args)
95
+ when "doctor" then Commands::Doctor.run(args)
90
96
  when "setup" then Commands::Setup.run(args)
91
97
  when "commands" then Commands.list_commands
92
98
  when "version", "-v", "--version"
@@ -96,28 +102,43 @@ module ShapeupCli
96
102
  else
97
103
  $stderr.puts "Unknown command: #{command}"
98
104
  $stderr.puts "Run 'shapeup help' for usage"
99
- exit 1
105
+ exit EXIT_USAGE
100
106
  end
101
- rescue Client::AuthError => e
102
- $stderr.puts "Not authenticated. Run 'shapeup login' first."
103
- exit EXIT_AUTH
107
+ rescue Client::AuthError
108
+ fail_with "Not authenticated.", code: "auth_required", exit_code: EXIT_AUTH,
109
+ retryable: false, hint: "Run 'shapeup login' or set SHAPEUP_TOKEN"
104
110
  rescue Client::NotFoundError => e
105
- $stderr.puts "Not found: #{e.message}"
106
- exit EXIT_NOT_FOUND
111
+ fail_with "Not found: #{e.message}", code: "not_found", exit_code: EXIT_NOT_FOUND, retryable: false
107
112
  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
113
+ fail_with "Access denied: #{e.message}", code: "forbidden", exit_code: EXIT_PERMISSION, retryable: false
114
+ rescue Client::RateLimitError
115
+ fail_with "Rate limited.", code: "rate_limit", exit_code: EXIT_RATE_LIMIT,
116
+ retryable: true, hint: "Wait a moment and retry"
117
+ rescue Client::NetworkError => e
118
+ fail_with e.message, code: "network", exit_code: EXIT_NETWORK,
119
+ retryable: true, hint: "Check your connection and 'shapeup config show'"
113
120
  rescue Client::ApiError => e
114
- $stderr.puts "Error: #{e.message}"
115
- exit EXIT_API_ERROR
121
+ fail_with "Error: #{e.message}", code: "api_error", exit_code: EXIT_API_ERROR, retryable: false
116
122
  rescue Interrupt
117
123
  $stderr.puts "\nAborted."
118
124
  exit EXIT_INTERRUPTED
119
125
  end
120
126
 
127
+ # Errors reach machine consumers as a JSON envelope on stdout with a
128
+ # `retryable` verdict, and humans as plain text on stderr. `retryable: false`
129
+ # means "no known reason a retry helps", not proof of permanence.
130
+ def self.fail_with(message, code:, exit_code:, retryable:, hint: nil)
131
+ if @machine_output
132
+ envelope = { ok: false, error: message, code: code, retryable: retryable }
133
+ envelope[:hint] = hint if hint
134
+ puts JSON.generate(envelope)
135
+ else
136
+ $stderr.puts message
137
+ $stderr.puts hint if hint
138
+ end
139
+ exit exit_code
140
+ end
141
+
121
142
  def self.top_level_metadata
122
143
  {
123
144
  command: "shapeup",
@@ -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,10 +99,16 @@ 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 |
@@ -112,6 +118,8 @@ Manage pitches, scopes, tasks, issues, and cycles via the ShapeUp CLI. Columns a
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` |
@@ -366,6 +374,8 @@ Creates `.shapeup/config.json` in the current directory. All commands in this di
366
374
  ```bash
367
375
  shapeup config set org "Compass Labs"
368
376
  shapeup config set host https://shapeup.cc
377
+ shapeup doctor # full setup diagnosis with fix hints
378
+ shapeup config explain # why is the CLI using this org/host/token?
369
379
  ```
370
380
 
371
381
  ### Environment Variables
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shapeup-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ShapeUp
@@ -31,6 +31,7 @@ files:
31
31
  - lib/shapeup_cli/commands/comments.rb
32
32
  - lib/shapeup_cli/commands/config_cmd.rb
33
33
  - lib/shapeup_cli/commands/cycle.rb
34
+ - lib/shapeup_cli/commands/doctor.rb
34
35
  - lib/shapeup_cli/commands/issues.rb
35
36
  - lib/shapeup_cli/commands/login.rb
36
37
  - lib/shapeup_cli/commands/logout.rb
@@ -45,6 +46,7 @@ files:
45
46
  - lib/shapeup_cli/commands/tasks.rb
46
47
  - lib/shapeup_cli/config.rb
47
48
  - lib/shapeup_cli/output.rb
49
+ - lib/shapeup_cli/surface.rb
48
50
  - lib/shapeup_cli/version.rb
49
51
  - skills/shapeup/SKILL.md
50
52
  homepage: https://github.com/shapeupcc/shapeup-cli