shapeup-cli 0.9.0 → 0.10.2
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 +10 -1
- data/lib/shapeup_cli/commands/base.rb +19 -0
- data/lib/shapeup_cli/commands/comments.rb +12 -19
- data/lib/shapeup_cli/commands/connect.rb +30 -5
- data/lib/shapeup_cli/version.rb +1 -1
- data/skills/shapeup/SKILL.md +1 -1
- data/skills/shapeup-connect/SKILL.md +35 -14
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4000fd1f4308ab79838118f7d7b7dd14b2b58401d4afc5eba1627de201c7c563
|
|
4
|
+
data.tar.gz: 80940c7771815abf3c296eb42f1754f791cf1bb49c1392c3afedbf4af5cd0424
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 027de0d927844bfa5b56725b5cb64bfc0645f0c90f38d988df4f4e4bd9bc31ca909f70778f9f96ba165acb1e8a388986c312d92970eaae14cdc66eeb58119472
|
|
7
|
+
data.tar.gz: ef74608be6b19b2e6ce61c0288337144c1359ba55d6168032fd7fa4f1ae4e4bbc804cec1f3a4b9f4ccd54791f3ad86f332b3f3e75f9644dfde19829040d1e52a
|
data/lib/shapeup_cli/client.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
3
5
|
module ShapeupCli
|
|
4
6
|
class Client
|
|
5
7
|
class ApiError < StandardError; end
|
|
@@ -11,6 +13,12 @@ module ShapeupCli
|
|
|
11
13
|
|
|
12
14
|
MCP_PROTOCOL_VERSION = "2025-06-18"
|
|
13
15
|
|
|
16
|
+
DROPPED_CONNECTION_ERRORS = [
|
|
17
|
+
SocketError, IOError, OpenSSL::SSL::SSLError,
|
|
18
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
|
|
19
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ENETDOWN, Errno::ETIMEDOUT
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
14
22
|
def initialize(host: Config.host, token: Auth.token)
|
|
15
23
|
raise AuthError, "Not authenticated" unless token
|
|
16
24
|
@host = host
|
|
@@ -68,7 +76,7 @@ module ShapeupCli
|
|
|
68
76
|
yield
|
|
69
77
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
70
78
|
raise NetworkError, "The server took too long to respond."
|
|
71
|
-
rescue
|
|
79
|
+
rescue *DROPPED_CONNECTION_ERRORS => e
|
|
72
80
|
raise NetworkError, "Couldn't reach #{@host} (#{e.class})."
|
|
73
81
|
end
|
|
74
82
|
|
|
@@ -77,6 +85,7 @@ module ShapeupCli
|
|
|
77
85
|
when 401 then raise AuthError, "Authentication failed — run 'shapeup login'"
|
|
78
86
|
when 403 then raise PermissionError, "Check your subscription or permissions"
|
|
79
87
|
when 429 then raise RateLimitError, "Too many requests — slow down and retry"
|
|
88
|
+
when 502..504 then raise NetworkError, "Couldn't reach #{@host} (HTTP #{response.code})."
|
|
80
89
|
when 200..299 then nil
|
|
81
90
|
else raise ApiError, "The server returned an error (HTTP #{response.code})."
|
|
82
91
|
end
|
|
@@ -7,11 +7,30 @@ module ShapeupCli
|
|
|
7
7
|
instance = new(args)
|
|
8
8
|
if instance.agent_help?
|
|
9
9
|
puts JSON.pretty_generate(instance.class.metadata)
|
|
10
|
+
elsif args.intersect?(%w[ --help -h ])
|
|
11
|
+
print_help
|
|
10
12
|
else
|
|
11
13
|
instance.execute
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
def self.print_help
|
|
18
|
+
about = metadata
|
|
19
|
+
puts "#{about[:path]}: #{about[:short]}"
|
|
20
|
+
about[:subcommands]&.then do |subcommands|
|
|
21
|
+
puts "\nSubcommands:"
|
|
22
|
+
subcommands.each { |sub| puts " #{sub[:path].to_s.ljust(48)} #{sub[:short]}" }
|
|
23
|
+
end
|
|
24
|
+
about[:flags]&.then do |flags|
|
|
25
|
+
puts "\nFlags:" if flags.any?
|
|
26
|
+
flags.each { |flag| puts " --#{flag[:name].to_s.ljust(46)} #{flag[:usage]}" }
|
|
27
|
+
end
|
|
28
|
+
about[:examples]&.then do |examples|
|
|
29
|
+
puts "\nExamples:"
|
|
30
|
+
examples.each { |example| puts " #{example}" }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
15
34
|
# Override in subclasses to define structured command metadata
|
|
16
35
|
def self.metadata
|
|
17
36
|
{ command: name.split("::").last.downcase, description: "No metadata defined" }
|
|
@@ -66,12 +66,9 @@ module ShapeupCli
|
|
|
66
66
|
attach_paths = extract_options("--attach")
|
|
67
67
|
text = positional_arg(1) || abort('Usage: shapeup comments add --issue <id> [--parent <comment_id>] [--attach <image>] "Comment text"')
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
images += attach_paths.map { |path| [ path, nil ] }
|
|
71
|
-
|
|
72
|
-
arguments = { commentable_type: type, commentable_id: id.to_s, content: text }
|
|
69
|
+
arguments = { commentable_type: type, commentable_id: id.to_s, content: place_local_images(text) }
|
|
73
70
|
arguments[:parent_id] = parent.to_s if parent
|
|
74
|
-
arguments[:attachments] =
|
|
71
|
+
arguments[:attachments] = attach_paths.map { |path| upload_image(path, nil) } if attach_paths.any?
|
|
75
72
|
result = call_tool("create_comment", **arguments)
|
|
76
73
|
|
|
77
74
|
render result,
|
|
@@ -85,7 +82,7 @@ module ShapeupCli
|
|
|
85
82
|
comment_id = positional_arg(1) || abort('Usage: shapeup comments edit <comment_id> "New text"')
|
|
86
83
|
text = positional_arg(2) || abort('Usage: shapeup comments edit <comment_id> "New text"')
|
|
87
84
|
|
|
88
|
-
result = call_tool("update_comment", comment_id: comment_id.to_s, content: text)
|
|
85
|
+
result = call_tool("update_comment", comment_id: comment_id.to_s, content: place_local_images(text))
|
|
89
86
|
|
|
90
87
|
render result, summary: "Comment ##{comment_id} updated"
|
|
91
88
|
end
|
|
@@ -111,20 +108,16 @@ module ShapeupCli
|
|
|
111
108
|
|
|
112
109
|
IMAGE_TYPES = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp" }.freeze
|
|
113
110
|
IMAGE_MAX_SIZE = 10 * 1024 * 1024
|
|
114
|
-
MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)\s]+)
|
|
115
|
-
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
images << [ path, alt ]
|
|
124
|
-
""
|
|
125
|
-
end
|
|
111
|
+
MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/
|
|
112
|
+
|
|
113
|
+
def place_local_images(text)
|
|
114
|
+
text.gsub(MARKDOWN_IMAGE) do |markdown|
|
|
115
|
+
alt, path, caption = $1, $2, $3
|
|
116
|
+
next markdown if path.match?(%r{\A[a-z][a-z0-9+.-]*:}i) || !File.file?(path)
|
|
117
|
+
|
|
118
|
+
uploaded = upload_image(path, alt)
|
|
119
|
+
%(![#{uploaded[:alt]}](attachment:#{uploaded[:attachment]}#{%( "#{caption}") if caption}))
|
|
126
120
|
end
|
|
127
|
-
[ text.strip, images ]
|
|
128
121
|
end
|
|
129
122
|
|
|
130
123
|
def upload_image(path, alt)
|
|
@@ -5,6 +5,7 @@ module ShapeupCli
|
|
|
5
5
|
class Connect < Base
|
|
6
6
|
DEFAULT_INTERVAL = 30
|
|
7
7
|
TRUST_LEVELS = %w[ owner members ].freeze
|
|
8
|
+
RETRY_DELAYS = [ 5, 10, 20, 40, 80, 120 ].freeze
|
|
8
9
|
|
|
9
10
|
def self.metadata
|
|
10
11
|
{
|
|
@@ -34,16 +35,35 @@ module ShapeupCli
|
|
|
34
35
|
|
|
35
36
|
$stdout.sync = true
|
|
36
37
|
@handed_out = Set.new
|
|
37
|
-
@profile, @agent_client = sign_in_as_agent(handle)
|
|
38
|
+
@profile, @agent_client = keep_trying(forever: !once) { sign_in_as_agent(handle) }
|
|
38
39
|
|
|
39
|
-
poll
|
|
40
|
+
poll
|
|
40
41
|
until once
|
|
41
|
-
|
|
42
|
+
pause @interval
|
|
42
43
|
poll
|
|
43
44
|
end
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
private
|
|
48
|
+
def keep_trying(forever:)
|
|
49
|
+
attempt = 0
|
|
50
|
+
begin
|
|
51
|
+
yield
|
|
52
|
+
rescue Client::NetworkError, Client::RateLimitError => error
|
|
53
|
+
raise unless forever
|
|
54
|
+
|
|
55
|
+
delay = RETRY_DELAYS[attempt] || RETRY_DELAYS.last
|
|
56
|
+
emit(type: "error", message: error.message, retryable: true, retrying_in: delay)
|
|
57
|
+
pause delay
|
|
58
|
+
attempt += 1
|
|
59
|
+
retry
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def pause(seconds)
|
|
64
|
+
sleep seconds
|
|
65
|
+
end
|
|
66
|
+
|
|
47
67
|
def sign_in_as_agent(handle)
|
|
48
68
|
mine = Output.extract_data(call_tool("list_my_agents"))
|
|
49
69
|
return [ ENV["SHAPEUP_PROFILE"], client ] if mine["signed_in_as_agent"]
|
|
@@ -72,11 +92,11 @@ module ShapeupCli
|
|
|
72
92
|
exit EXIT_USAGE
|
|
73
93
|
end
|
|
74
94
|
|
|
75
|
-
def poll
|
|
95
|
+
def poll
|
|
76
96
|
inbox = Output.extract_data(@agent_client.call_tool("list_inbox", organisation: org_id.to_s))
|
|
77
97
|
agent = inbox["agent"]
|
|
78
98
|
|
|
79
|
-
|
|
99
|
+
announce(agent) unless @connected
|
|
80
100
|
|
|
81
101
|
inbox["items"].reverse_each do |item|
|
|
82
102
|
next unless @handed_out.add?(item["id"])
|
|
@@ -91,6 +111,11 @@ module ShapeupCli
|
|
|
91
111
|
emit(type: "error", message: error.message, retryable: true)
|
|
92
112
|
end
|
|
93
113
|
|
|
114
|
+
def announce(agent)
|
|
115
|
+
emit(type: "connected", agent: agent, profile: @profile, organisation_id: org_id.to_s, trust: @trust, interval: @interval)
|
|
116
|
+
@connected = true
|
|
117
|
+
end
|
|
118
|
+
|
|
94
119
|
def trusted?(requester, agent)
|
|
95
120
|
@trust == "members" || requester["id"] == agent.dig("owner", "id")
|
|
96
121
|
end
|
data/lib/shapeup_cli/version.rb
CHANGED
data/skills/shapeup/SKILL.md
CHANGED
|
@@ -222,7 +222,7 @@ Branch on `retryable` to decide retry-vs-abandon; `false` means "no known reason
|
|
|
222
222
|
| Watch the inbox as JSON lines | `SHAPEUP_PROFILE=<agent> shapeup connect` |
|
|
223
223
|
| Reply under a comment | `shapeup comments add --issue <id> --parent <comment_id> "Reply"` |
|
|
224
224
|
| React to a comment | `shapeup comments react <comment_id> 👀` |
|
|
225
|
-
| Attach screenshots to a comment | `shapeup comments add --issue <id> "Text "` or `--attach shot.png` |
|
|
225
|
+
| Attach screenshots to a comment | `shapeup comments add --issue <id> "Text "` (stays where you put it; images on consecutive lines sit side by side) or `--attach shot.png` (added at the end) |
|
|
226
226
|
|
|
227
227
|
## Common Workflows
|
|
228
228
|
|
|
@@ -70,8 +70,8 @@ themselves.
|
|
|
70
70
|
|
|
71
71
|
| You find | Add |
|
|
72
72
|
|----------|-----|
|
|
73
|
-
| `bin/rails` | `"Bash(bin/rails test *)"` |
|
|
74
|
-
| `Rakefile` | `"Bash(bundle exec rake *)"` |
|
|
73
|
+
| `bin/rails` | `"Bash(bin/rails test *)"`, and nothing broader: never `rake *` or `bin/rails *`, which would also approve tasks that wipe databases |
|
|
74
|
+
| `Rakefile` and no `bin/rails` | `"Bash(bundle exec rake test *)"` |
|
|
75
75
|
| a `test/` folder of Ruby files and no Rakefile | `"Bash(ruby test/*)"` |
|
|
76
76
|
| `package.json` with a `test` script | `"Bash(npm test *)"` (or the yarn/pnpm equivalent the lockfile shows) |
|
|
77
77
|
| `Makefile` with a test or check target | `"Bash(make test *)"` / `"Bash(make check *)"` |
|
|
@@ -79,7 +79,10 @@ themselves.
|
|
|
79
79
|
| `go.mod` | `"Bash(go test *)"` |
|
|
80
80
|
| `Cargo.toml` | `"Bash(cargo test *)"` |
|
|
81
81
|
|
|
82
|
-
If you can't tell, ask the user how they run the tests rather than leaving it out.
|
|
82
|
+
If you can't tell, ask the user how they run the tests rather than leaving it out. Add the
|
|
83
|
+
project's linter the same way when it has one, such as `"Bash(bin/rubocop *)"`,
|
|
84
|
+
`"Bash(npm run lint *)"` or `"Bash(make check *)"`. Keep every rule to the one command it
|
|
85
|
+
names.
|
|
83
86
|
|
|
84
87
|
Merge with what is there, never replace it, and make sure `.claude/settings.local.json` is in
|
|
85
88
|
`.gitignore`. If they say no, carry on: they will approve each command instead. If prompts keep
|
|
@@ -109,7 +112,7 @@ again straight away, without telling the user. Every line is one JSON event:
|
|
|
109
112
|
| `connected` | Tell the user the agent is listening. Note `profile`: from now on, run every ShapeUp command for this agent as `shapeup --profile <profile> ...` (leave out `--profile` when it is null). |
|
|
110
113
|
| `request` | Handle it, below. |
|
|
111
114
|
| `ignored` | Tell the user in one line who asked and that they are not trusted. Do nothing else. |
|
|
112
|
-
| `error` | Nothing while `retryable` is true. Otherwise tell the user the `message`. If the connector exits, restart it once. |
|
|
115
|
+
| `error` | Nothing while `retryable` is true: the connector keeps retrying by itself, pausing longer each time, up to two minutes. Otherwise tell the user the `message`. If the connector exits after a network error (`code` is `network`, or the message mentions a connection), wait a minute and restart it; give up and tell the user after five restarts in a row that never reach `connected`. For any other exit, restart it once. |
|
|
113
116
|
|
|
114
117
|
Don't poll the inbox yourself. The connector does that.
|
|
115
118
|
|
|
@@ -142,18 +145,36 @@ Give the subagent this, filled in:
|
|
|
142
145
|
> commit there. Never push, deploy, merge or delete branches unless the owner said so in this
|
|
143
146
|
> session.
|
|
144
147
|
> 4. If the ask is unclear or you are blocked, ask on the issue rather than guess.
|
|
145
|
-
> 5. If the work changes anything people see,
|
|
146
|
-
>
|
|
147
|
-
>
|
|
148
|
-
>
|
|
148
|
+
> 5. If the work changes anything people see, compare it the way a careful reviewer would:
|
|
149
|
+
> - From your diff, list the screens and states it touches, including the ones it could break.
|
|
150
|
+
> - Run the main branch beside yours: check main out into a git worktree outside this
|
|
151
|
+
> repository and start it on a second port, so both are up at once. How to start and sign in
|
|
152
|
+
> to the app should be in the project's CLAUDE.md or README; if it isn't, ask on the issue.
|
|
153
|
+
> - With whatever browser tool you have, capture each screen in both, at the same size and with
|
|
154
|
+
> the same content, plus a phone width and dark mode where they matter. Save PNGs outside the
|
|
155
|
+
> repository, named for what they show.
|
|
156
|
+
> - Stop the second server and remove the worktree when you're done.
|
|
149
157
|
> 6. Reply once, under the comment that asked:
|
|
150
158
|
> `shapeup --profile <profile> comments add --issue <issue.id> --parent <reply_to.comment_id> "<reply>"`
|
|
151
|
-
> (leave out `--parent` when `reply_to.comment_id` is null). Start with `@<requester.name
|
|
152
|
-
>
|
|
153
|
-
>
|
|
154
|
-
>
|
|
155
|
-
>
|
|
156
|
-
>
|
|
159
|
+
> (leave out `--parent` when `reply_to.comment_id` is null). Start with `@<requester.name>`,
|
|
160
|
+
> then say what changed, the branch and commit, and how you checked it. Keep it plain.
|
|
161
|
+
>
|
|
162
|
+
> When you have screenshots, write the rest like a review: a `## heading` per area, a line on
|
|
163
|
+
> what changed there, then its before and after as local Markdown images on consecutive lines,
|
|
164
|
+
> so they sit side by side:
|
|
165
|
+
>
|
|
166
|
+
> ```markdown
|
|
167
|
+
> ## Toolbar
|
|
168
|
+
> Looks the same. Dropdowns now open with focus on the first item.
|
|
169
|
+
>
|
|
170
|
+
> 
|
|
171
|
+
> 
|
|
172
|
+
> ```
|
|
173
|
+
>
|
|
174
|
+
> The CLI uploads each image where it stands. The words in brackets are its description for
|
|
175
|
+
> people who can't see it, so say what it shows; the quoted word is its caption. End with a
|
|
176
|
+
> `## Worth a look` section for anything that seems off, and mention whoever the requester asked
|
|
177
|
+
> you to hand over to.
|
|
157
178
|
> 7. Then take it out of the inbox: `shapeup --profile <profile> inbox done <id>`.
|
|
158
179
|
> 8. Report back in one line.
|
|
159
180
|
|