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 +4 -4
- data/lib/shapeup_cli/client.rb +3 -2
- data/lib/shapeup_cli/commands/config_cmd.rb +80 -3
- data/lib/shapeup_cli/commands/doctor.rb +196 -0
- data/lib/shapeup_cli/commands.rb +7 -1
- data/lib/shapeup_cli/config.rb +20 -0
- data/lib/shapeup_cli/surface.rb +41 -0
- data/lib/shapeup_cli/version.rb +1 -1
- data/lib/shapeup_cli.rb +38 -17
- data/skills/shapeup/SKILL.md +13 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3d008bb8caee611f3a41ac593cef3deed395c5057701a8e7a32b57aba6f58c6
|
|
4
|
+
data.tar.gz: ff36d3e5ccff60c016c9e48d055ef4e47821214da3d190c3b3a0cb082fc61f3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6adc9b1b1748d3bdfd398ec4a2e617d1d530d1c18f52ba91db6fe7e2d2db51219c7730e3642023b73139861508e66ac6009d18461a767f5835ca4f89d33282fc
|
|
7
|
+
data.tar.gz: afbee4b1da2544c0846ccafc864fb42db1a67ce1d50ff99509a420a5b02517253b895d7f1f8e5a175f60102d464c8e04ac9cd7908adfd1baa882618b12b7db7d
|
data/lib/shapeup_cli/client.rb
CHANGED
|
@@ -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
|
|
70
|
+
raise NetworkError, "The server took too long to respond."
|
|
70
71
|
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
|
|
71
|
-
raise
|
|
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"
|
|
34
|
-
when "show"
|
|
35
|
-
when "
|
|
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
|
data/lib/shapeup_cli/commands.rb
CHANGED
|
@@ -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"
|
data/lib/shapeup_cli/config.rb
CHANGED
|
@@ -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
|
data/lib/shapeup_cli/version.rb
CHANGED
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
|
|
27
|
+
EXIT_AUTH = 3
|
|
27
28
|
EXIT_PERMISSION = 4
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
105
|
+
exit EXIT_USAGE
|
|
100
106
|
end
|
|
101
|
-
rescue Client::AuthError
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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",
|
data/skills/shapeup/SKILL.md
CHANGED
|
@@ -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
|
|
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 |
|
|
103
|
-
| 6 |
|
|
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.
|
|
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
|