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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4b97dfcf5b4550677ef419ad28ae3900f770c5f3c3795e7b005d2a09582f472
|
|
4
|
+
data.tar.gz: 8ad78fe52812199514232eadbf8d5e52506e916c391ac6c240ad7730edd6d8fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0467cb5be9b5e7d830406d00495cb9929dfd15772aa7ae09e8abb30237ebceb4e8cc2ad3286e2ef191d5a4186ced7cf900b1d66016edb9f31719a4bead6b06da
|
|
7
|
+
data.tar.gz: 9a8eea1d289458f647e4c9352a479e8f2bcc726d268a4577e3659c0b4f00132bf0d06a62f8d8f1a5251b01080c69611fe2baea7e0ebe54c53d203ed1432221f7
|
data/bin/shapeup
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
# Guard before requiring any code that uses Ruby 3.x syntax, so users on an
|
|
5
|
+
# old Ruby (e.g. macOS system Ruby 2.6) get a clear message, not a SyntaxError.
|
|
6
|
+
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1")
|
|
7
|
+
abort "shapeup requires Ruby 3.1 or newer (you have #{RUBY_VERSION}).\n" \
|
|
8
|
+
"Install a newer Ruby with Homebrew (brew install ruby), mise, or rbenv, " \
|
|
9
|
+
"and make sure it comes before /usr/bin in your PATH."
|
|
10
|
+
end
|
|
11
|
+
|
|
4
12
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
5
13
|
require "shapeup_cli"
|
|
6
14
|
|
data/install.md
CHANGED
|
@@ -19,22 +19,25 @@ Check if the ShapeUp CLI is available:
|
|
|
19
19
|
shapeup version
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
If `shapeup` is not found,
|
|
22
|
+
If `shapeup` is not found, first check that Ruby 3.1+ is available (`ruby -v`). macOS system Ruby (2.6) is too old — install a newer Ruby with Homebrew (`brew install ruby`), mise, or rbenv, and ensure it comes before `/usr/bin` in PATH.
|
|
23
|
+
|
|
24
|
+
Then install the gem:
|
|
23
25
|
|
|
24
26
|
```bash
|
|
25
|
-
|
|
27
|
+
gem install shapeup-cli
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
If installing the gem is not an option, clone the repo instead and add to your shell profile (`~/.zshrc` or `~/.bashrc`):
|
|
29
31
|
|
|
30
32
|
```bash
|
|
31
|
-
|
|
33
|
+
git clone https://github.com/shapeupcc/shapeup-cli.git ~/.shapeup-cli
|
|
34
|
+
export PATH="$HOME/.shapeup-cli/bin:$PATH"
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
**Verify:**
|
|
35
38
|
```bash
|
|
36
39
|
shapeup version
|
|
37
|
-
# Expected: shapeup 0.
|
|
40
|
+
# Expected: shapeup <version>, e.g. shapeup 0.7.0
|
|
38
41
|
```
|
|
39
42
|
|
|
40
43
|
## Step 1: Authenticate with ShapeUp
|
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)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Commands
|
|
5
|
+
class Agents < Base
|
|
6
|
+
def self.metadata
|
|
7
|
+
{
|
|
8
|
+
command: "agents",
|
|
9
|
+
path: "shapeup agents",
|
|
10
|
+
short: "List your agents, or add one (Pro, one per member)",
|
|
11
|
+
subcommands: [
|
|
12
|
+
{ name: "list", short: "List your agents in this organisation", path: "shapeup agents" },
|
|
13
|
+
{ name: "add", short: "Add your agent", path: 'shapeup agents add "Bond" [--handle bond]' }
|
|
14
|
+
],
|
|
15
|
+
flags: [
|
|
16
|
+
{ name: "handle", type: "string", usage: "The agent's handle (default: its name, lowercased)" }
|
|
17
|
+
],
|
|
18
|
+
examples: [
|
|
19
|
+
"shapeup agents",
|
|
20
|
+
'shapeup agents add "Bond"',
|
|
21
|
+
"shapeup connect"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def execute
|
|
27
|
+
case positional_arg(0)
|
|
28
|
+
when "add" then add
|
|
29
|
+
else list
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
def list
|
|
35
|
+
render call_tool("list_my_agents"),
|
|
36
|
+
summary: "Your agents",
|
|
37
|
+
breadcrumbs: [
|
|
38
|
+
{ cmd: 'shapeup agents add "Name"', description: "Add your agent" },
|
|
39
|
+
{ cmd: "shapeup connect", description: "Listen for requests as your agent" }
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add
|
|
44
|
+
handle = extract_option("--handle")
|
|
45
|
+
name = positional_arg(1) || abort('Usage: shapeup agents add "Name" [--handle handle]')
|
|
46
|
+
|
|
47
|
+
arguments = { display_name: name }
|
|
48
|
+
arguments[:name] = handle if handle
|
|
49
|
+
render call_tool("add_my_agent", **arguments),
|
|
50
|
+
summary: "Agent added",
|
|
51
|
+
breadcrumbs: [ { cmd: "shapeup connect", description: "Listen for requests as your agent" } ]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -12,13 +12,15 @@ module ShapeupCli
|
|
|
12
12
|
{ name: "list", short: "List comments", path: "shapeup comments list --issue <id>" },
|
|
13
13
|
{ name: "add", short: "Add a comment", path: 'shapeup comments add --issue <id> "Comment text"' },
|
|
14
14
|
{ name: "edit", short: "Edit your own comment", path: 'shapeup comments edit <comment_id> "New text"' },
|
|
15
|
-
{ name: "remove", short: "Delete your own comment", path: "shapeup comments remove <comment_id>" }
|
|
15
|
+
{ name: "remove", short: "Delete your own comment", path: "shapeup comments remove <comment_id>" },
|
|
16
|
+
{ name: "react", short: "React to a comment with an emoji", path: "shapeup comments react <comment_id> <emoji>" }
|
|
16
17
|
],
|
|
17
18
|
flags: [
|
|
18
19
|
{ name: "issue", type: "string", usage: "Issue ID" },
|
|
19
20
|
{ name: "pitch", type: "string", usage: "Pitch ID" },
|
|
20
21
|
{ name: "scope", type: "string", usage: "Scope ID" },
|
|
21
|
-
{ name: "task", type: "string", usage: "Task ID" }
|
|
22
|
+
{ name: "task", type: "string", usage: "Task ID" },
|
|
23
|
+
{ name: "parent", type: "string", usage: "Comment ID to reply under" }
|
|
22
24
|
],
|
|
23
25
|
examples: [
|
|
24
26
|
"shapeup comments list --issue 42",
|
|
@@ -38,6 +40,7 @@ module ShapeupCli
|
|
|
38
40
|
when "add" then add
|
|
39
41
|
when "edit" then edit
|
|
40
42
|
when "remove" then remove
|
|
43
|
+
when "react" then react
|
|
41
44
|
when "list", nil then list
|
|
42
45
|
else list
|
|
43
46
|
end
|
|
@@ -58,9 +61,12 @@ module ShapeupCli
|
|
|
58
61
|
|
|
59
62
|
def add
|
|
60
63
|
type, id = resolve_commentable
|
|
61
|
-
|
|
64
|
+
parent = extract_option("--parent")
|
|
65
|
+
text = positional_arg(1) || abort('Usage: shapeup comments add --issue <id> [--parent <comment_id>] "Comment text"')
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
arguments = { commentable_type: type, commentable_id: id.to_s, content: text }
|
|
68
|
+
arguments[:parent_id] = parent.to_s if parent
|
|
69
|
+
result = call_tool("create_comment", **arguments)
|
|
64
70
|
|
|
65
71
|
render result,
|
|
66
72
|
summary: "Comment added to #{type} ##{id}",
|
|
@@ -88,6 +94,15 @@ module ShapeupCli
|
|
|
88
94
|
render result, summary: "Comment ##{comment_id} deleted"
|
|
89
95
|
end
|
|
90
96
|
|
|
97
|
+
def react
|
|
98
|
+
comment_id = positional_arg(1) || abort("Usage: shapeup comments react <comment_id> <emoji>")
|
|
99
|
+
emoji = positional_arg(2) || abort("Usage: shapeup comments react <comment_id> <emoji>")
|
|
100
|
+
|
|
101
|
+
result = call_tool("add_reaction", comment_id: comment_id.to_s, emoji: emoji)
|
|
102
|
+
|
|
103
|
+
render result, summary: "Reacted #{emoji} to comment ##{comment_id}"
|
|
104
|
+
end
|
|
105
|
+
|
|
91
106
|
def resolve_commentable
|
|
92
107
|
issue = extract_option("--issue")
|
|
93
108
|
pitch = extract_option("--pitch")
|
|
@@ -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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Commands
|
|
5
|
+
class Connect < Base
|
|
6
|
+
DEFAULT_INTERVAL = 30
|
|
7
|
+
TRUST_LEVELS = %w[ owner members ].freeze
|
|
8
|
+
|
|
9
|
+
def self.metadata
|
|
10
|
+
{
|
|
11
|
+
command: "connect",
|
|
12
|
+
path: "shapeup connect",
|
|
13
|
+
short: "Listen for requests as your agent, printing each one as a line of JSON",
|
|
14
|
+
flags: [
|
|
15
|
+
{ name: "as", type: "string", usage: "Which of your agents, by handle (default: your only one)" },
|
|
16
|
+
{ name: "interval", type: "integer", usage: "Seconds between checks (default: #{DEFAULT_INTERVAL})" },
|
|
17
|
+
{ name: "trust", type: "string", usage: "Whose requests to pass on: owner (default) or members" },
|
|
18
|
+
{ name: "once", type: "bool", usage: "Check once and exit" }
|
|
19
|
+
],
|
|
20
|
+
examples: [
|
|
21
|
+
"shapeup connect",
|
|
22
|
+
"shapeup connect --as bond --trust members",
|
|
23
|
+
"SHAPEUP_PROFILE=bond shapeup connect --once"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute
|
|
29
|
+
handle = extract_option("--as")
|
|
30
|
+
@interval = (extract_option("--interval") || DEFAULT_INTERVAL).to_i.clamp(5, 3600)
|
|
31
|
+
@trust = extract_option("--trust") || "owner"
|
|
32
|
+
abort "--trust must be one of: #{TRUST_LEVELS.join(", ")}" unless TRUST_LEVELS.include?(@trust)
|
|
33
|
+
once = consume_flag("--once")
|
|
34
|
+
|
|
35
|
+
$stdout.sync = true
|
|
36
|
+
@handed_out = Set.new
|
|
37
|
+
@profile, @agent_client = sign_in_as_agent(handle)
|
|
38
|
+
|
|
39
|
+
poll(announce: true)
|
|
40
|
+
until once
|
|
41
|
+
sleep @interval
|
|
42
|
+
poll
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
def sign_in_as_agent(handle)
|
|
48
|
+
mine = Output.extract_data(call_tool("list_my_agents"))
|
|
49
|
+
return [ ENV["SHAPEUP_PROFILE"], client ] if mine["signed_in_as_agent"]
|
|
50
|
+
|
|
51
|
+
agent = choose_agent(mine["agents"], handle, can_add: mine["can_add"])
|
|
52
|
+
token = Output.extract_data(call_tool("issue_my_agent_token", agent: agent["id"].to_s))["token"]
|
|
53
|
+
Config.save_profile(agent["name"], token: token, host: Config.host, organisation_id: org_id.to_s, display_name: agent["display_name"])
|
|
54
|
+
|
|
55
|
+
[ agent["name"], Client.new(host: Config.host, token: token) ]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def choose_agent(agents, handle, can_add:)
|
|
59
|
+
if handle
|
|
60
|
+
agents.find { |agent| agent["name"] == handle.delete_prefix("@") } or fail_with_agents("You have no agent called @#{handle} here.", agents)
|
|
61
|
+
elsif agents.one?
|
|
62
|
+
agents.first
|
|
63
|
+
elsif agents.none?
|
|
64
|
+
fail_with_agents(can_add ? 'You don\'t have an agent here yet. Add one with: shapeup agents add "Name"' : "Agents need a Pro organisation where you're a member.", agents)
|
|
65
|
+
else
|
|
66
|
+
fail_with_agents("You have several agents here. Pick one with --as <handle>.", agents)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fail_with_agents(message, agents)
|
|
71
|
+
emit(type: "error", message: message, retryable: false, agents: agents.map { |agent| agent["name"] })
|
|
72
|
+
exit EXIT_USAGE
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def poll(announce: false)
|
|
76
|
+
inbox = Output.extract_data(@agent_client.call_tool("list_inbox", organisation: org_id.to_s))
|
|
77
|
+
agent = inbox["agent"]
|
|
78
|
+
|
|
79
|
+
emit(type: "connected", agent: agent, profile: @profile, organisation_id: org_id.to_s, trust: @trust, interval: @interval) if announce
|
|
80
|
+
|
|
81
|
+
inbox["items"].reverse_each do |item|
|
|
82
|
+
next unless @handed_out.add?(item["id"])
|
|
83
|
+
|
|
84
|
+
if trusted?(item["requester"], agent)
|
|
85
|
+
emit(type: "request", **item.transform_keys(&:to_sym))
|
|
86
|
+
else
|
|
87
|
+
emit(type: "ignored", id: item["id"], requester: item["requester"], why: "#{item["requester"]["name"]} is not trusted (--trust #{@trust})")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue Client::NetworkError, Client::RateLimitError => error
|
|
91
|
+
emit(type: "error", message: error.message, retryable: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def trusted?(requester, agent)
|
|
95
|
+
@trust == "members" || requester["id"] == agent.dig("owner", "id")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def emit(**event)
|
|
99
|
+
puts JSON.generate(event)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Commands
|
|
5
|
+
class Inbox < Base
|
|
6
|
+
def self.metadata
|
|
7
|
+
{
|
|
8
|
+
command: "inbox",
|
|
9
|
+
path: "shapeup inbox",
|
|
10
|
+
short: "For agents: requests waiting on issues, and marking them done",
|
|
11
|
+
subcommands: [
|
|
12
|
+
{ name: "list", short: "List requests waiting for this agent", path: "shapeup inbox" },
|
|
13
|
+
{ name: "done", short: "Take a handled request out of the inbox", path: "shapeup inbox done <id>" }
|
|
14
|
+
],
|
|
15
|
+
examples: [
|
|
16
|
+
"shapeup inbox",
|
|
17
|
+
"shapeup inbox --json",
|
|
18
|
+
"shapeup inbox done 812"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute
|
|
24
|
+
case positional_arg(0)
|
|
25
|
+
when "done" then done
|
|
26
|
+
else list
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def list
|
|
32
|
+
result = call_tool("list_inbox")
|
|
33
|
+
|
|
34
|
+
render result,
|
|
35
|
+
summary: "Inbox",
|
|
36
|
+
breadcrumbs: [
|
|
37
|
+
{ cmd: "shapeup issue <id>", description: "Read the issue in full" },
|
|
38
|
+
{ cmd: 'shapeup comments add --issue <id> "Reply"', description: "Reply on the issue" },
|
|
39
|
+
{ cmd: "shapeup inbox done <id>", description: "Mark a request handled" }
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def done
|
|
44
|
+
id = positional_arg(1) || abort("Usage: shapeup inbox done <id>")
|
|
45
|
+
result = call_tool("complete_inbox_item", item: id.to_s)
|
|
46
|
+
|
|
47
|
+
render result, summary: "Request #{id} done"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|