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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3f4bd862f49cc66a46223d4c0f5749a92150d3b2a9fa3a6164933a6bf3970f7
4
- data.tar.gz: 2a018829f56a8cbd42679fef24e16a7e8645c22ddeb7b4be2ca6b7b6c3c88211
3
+ metadata.gz: c4b97dfcf5b4550677ef419ad28ae3900f770c5f3c3795e7b005d2a09582f472
4
+ data.tar.gz: 8ad78fe52812199514232eadbf8d5e52506e916c391ac6c240ad7730edd6d8fe
5
5
  SHA512:
6
- metadata.gz: 5857b6c507783ce2dadaaf2fbd6c4df72defe1ea46c09e93e91cec3f9bb74b2e1bfc5dcd4d1a10eddd4b92eece7cb1b6f597e0bd0599841d1947e1682494589b
7
- data.tar.gz: d1bfc879a720dd2a486e5a754cecbdb958a8a2bdefe930b9d1a86d34841c625d201ffdf8e151005423cd9813b9533d54db953fbaf59f5877a8874040e6543582
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, clone the repo and add to PATH:
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
- git clone https://github.com/shapeup-cc/shapeup-cli ~/.shapeup-cli
27
+ gem install shapeup-cli
26
28
  ```
27
29
 
28
- Add to your shell profile (`~/.zshrc` or `~/.bashrc`):
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
- alias shapeup='ruby -I~/.shapeup-cli/cli/lib ~/.shapeup-cli/cli/bin/shapeup'
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.1.0
40
+ # Expected: shapeup <version>, e.g. shapeup 0.7.0
38
41
  ```
39
42
 
40
43
  ## Step 1: Authenticate with ShapeUp
@@ -7,6 +7,7 @@ module ShapeupCli
7
7
  class NotFoundError < ApiError; end
8
8
  class PermissionError < ApiError; end
9
9
  class RateLimitError < ApiError; end
10
+ class NetworkError < ApiError; end
10
11
 
11
12
  MCP_PROTOCOL_VERSION = "2025-06-18"
12
13
 
@@ -66,9 +67,9 @@ module ShapeupCli
66
67
  def with_network_error_handling
67
68
  yield
68
69
  rescue Net::OpenTimeout, Net::ReadTimeout
69
- raise ApiError, "The server took too long to respond. Please try again."
70
+ raise NetworkError, "The server took too long to respond."
70
71
  rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
71
- raise ApiError, "Couldn't reach #{@host} (#{e.class}). Check your connection and 'shapeup config show'."
72
+ raise NetworkError, "Couldn't reach #{@host} (#{e.class})."
72
73
  end
73
74
 
74
75
  def handle_response(response)
@@ -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
- text = positional_arg(1) || abort('Usage: shapeup comments add --issue <id> "Comment text"')
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
- result = call_tool("create_comment", commentable_type: type, commentable_id: id.to_s, content: text)
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" then set
34
- when "show" then show
35
- when "init" then init_project
34
+ when "set" then set
35
+ when "show" then show
36
+ when "explain" then explain
37
+ when "init" then init_project
36
38
  else show
37
39
  end
38
40
  end
@@ -96,6 +98,81 @@ module ShapeupCli
96
98
  end
97
99
  end
98
100
 
101
+ # Trace every setting through its full precedence chain, showing each
102
+ # candidate and which one won. Token values are never printed.
103
+ def explain
104
+ settings = {
105
+ "profile" => profile_candidates,
106
+ "org" => org_candidates,
107
+ "host" => host_candidates,
108
+ "token" => token_candidates
109
+ }
110
+
111
+ if @mode == :styled || @mode == :markdown
112
+ settings.each { |name, candidates| print_setting(name, candidates) }
113
+ else
114
+ data = settings.transform_values do |candidates|
115
+ selected = candidates.find { |c| c[:selected] }
116
+ { value: selected&.dig(:value), source: selected&.dig(:source), candidates: candidates }
117
+ end
118
+ render data
119
+ end
120
+ end
121
+
122
+ def profile_candidates
123
+ select_first [
124
+ { source: "SHAPEUP_PROFILE env", value: ENV["SHAPEUP_PROFILE"] },
125
+ { source: "profiles.json default", value: Config.saved_default_profile }
126
+ ]
127
+ end
128
+
129
+ def org_candidates
130
+ select_first [
131
+ { source: "--org flag", value: @org_id },
132
+ { source: "SHAPEUP_ORG env", value: ENV["SHAPEUP_ORG"] },
133
+ { source: project_config_source, value: Config.project_config["organisation_id"] },
134
+ { source: "~/.config/shapeup/config.json", value: Config.global_config["organisation_id"] },
135
+ { source: "profile '#{Config.current_profile_name}'", value: Config.current_profile&.dig("organisation_id") }
136
+ ]
137
+ end
138
+
139
+ def host_candidates
140
+ select_first [
141
+ { source: "SHAPEUP_HOST env", value: ENV["SHAPEUP_HOST"] },
142
+ { source: project_config_source, value: Config.project_config["host"] },
143
+ { source: "~/.config/shapeup/config.json", value: Config.global_config["host"] },
144
+ { source: "profile '#{Config.current_profile_name}'", value: Config.current_profile&.dig("host") },
145
+ { source: "built-in default", value: ShapeupCli::DEFAULT_HOST }
146
+ ]
147
+ end
148
+
149
+ def token_candidates
150
+ select_first [
151
+ { source: "SHAPEUP_TOKEN env", value: ENV["SHAPEUP_TOKEN"] && "configured in environment" },
152
+ { source: "profile '#{Config.current_profile_name}'", value: Config.current_profile&.dig("token") && "configured in profile" }
153
+ ]
154
+ end
155
+
156
+ def project_config_source
157
+ Config.project_config_path || Config::PROJECT_CONFIG_NAME
158
+ end
159
+
160
+ def select_first(candidates)
161
+ winner = candidates.find { |c| !c[:value].nil? && c[:value] != "" }
162
+ candidates.each { |c| c[:selected] = c.equal?(winner) }
163
+ end
164
+
165
+ def print_setting(name, candidates)
166
+ puts name
167
+ width = candidates.map { |c| c[:source].length }.max
168
+ candidates.each do |c|
169
+ marker = c[:selected] ? " <- selected" : ""
170
+ value = c[:value].nil? ? "(unset)" : c[:value]
171
+ puts " #{c[:source].ljust(width)} #{value}#{marker}"
172
+ end
173
+ puts
174
+ end
175
+
99
176
  def find_project_display
100
177
  dir = Dir.pwd
101
178
  loop do
@@ -0,0 +1,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