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.
- checksums.yaml +4 -4
- data/bin/shapeup +8 -0
- data/install.md +8 -5
- data/lib/shapeup_cli/client.rb +3 -2
- data/lib/shapeup_cli/commands/agents.rb +55 -0
- data/lib/shapeup_cli/commands/comments.rb +19 -4
- data/lib/shapeup_cli/commands/config_cmd.rb +80 -3
- data/lib/shapeup_cli/commands/connect.rb +103 -0
- data/lib/shapeup_cli/commands/doctor.rb +196 -0
- data/lib/shapeup_cli/commands/inbox.rb +51 -0
- data/lib/shapeup_cli/commands/login.rb +83 -45
- data/lib/shapeup_cli/commands/setup.rb +16 -17
- data/lib/shapeup_cli/commands.rb +23 -1
- data/lib/shapeup_cli/config.rb +30 -3
- data/lib/shapeup_cli/surface.rb +41 -0
- data/lib/shapeup_cli/version.rb +1 -1
- data/lib/shapeup_cli.rb +56 -18
- data/skills/shapeup/SKILL.md +25 -4
- data/skills/shapeup-connect/SKILL.md +142 -0
- metadata +7 -1
|
@@ -4,70 +4,108 @@ module ShapeupCli
|
|
|
4
4
|
module Commands
|
|
5
5
|
class Login
|
|
6
6
|
def self.run(args)
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
29
|
-
puts "
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 "\
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
puts "
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
install_skill(dir, ".claude/skills/shapeup/")
|
|
57
|
+
install_skills(File.join(".claude", "skills"), ".claude/skills")
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
65
|
+
dir = File.join(base, name)
|
|
66
|
+
FileUtils.mkdir_p(dir)
|
|
67
|
+
dest = File.join(dir, "SKILL.md")
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
data/lib/shapeup_cli/commands.rb
CHANGED
|
@@ -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"
|
data/lib/shapeup_cli/config.rb
CHANGED
|
@@ -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
|
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,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
|
|
112
|
+
exit EXIT_USAGE
|
|
100
113
|
end
|
|
101
|
-
rescue Client::AuthError
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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)" }
|
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,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 |
|
|
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 |
|
|
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
|