shapeup-cli 0.3.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 +7 -0
- data/bin/shapeup +7 -0
- data/install.md +94 -0
- data/lib/shapeup_cli/auth.rb +199 -0
- data/lib/shapeup_cli/client.rb +94 -0
- data/lib/shapeup_cli/commands/auth.rb +127 -0
- data/lib/shapeup_cli/commands/base.rb +113 -0
- data/lib/shapeup_cli/commands/comments.rb +86 -0
- data/lib/shapeup_cli/commands/config_cmd.rb +143 -0
- data/lib/shapeup_cli/commands/cycle.rb +66 -0
- data/lib/shapeup_cli/commands/issues.rb +336 -0
- data/lib/shapeup_cli/commands/login.rb +73 -0
- data/lib/shapeup_cli/commands/logout.rb +12 -0
- data/lib/shapeup_cli/commands/my_work.rb +38 -0
- data/lib/shapeup_cli/commands/orgs.rb +35 -0
- data/lib/shapeup_cli/commands/pitches.rb +170 -0
- data/lib/shapeup_cli/commands/scopes.rb +96 -0
- data/lib/shapeup_cli/commands/search.rb +33 -0
- data/lib/shapeup_cli/commands/setup.rb +85 -0
- data/lib/shapeup_cli/commands/tasks.rb +100 -0
- data/lib/shapeup_cli/commands.rb +161 -0
- data/lib/shapeup_cli/config.rb +155 -0
- data/lib/shapeup_cli/output.rb +230 -0
- data/lib/shapeup_cli.rb +137 -0
- data/skills/shapeup/SKILL.md +333 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 89595c1b4509ab13079e503ff5b94894c469220c070d1983389d8cd4ba3d2756
|
|
4
|
+
data.tar.gz: eb5c3bde6d0988fdfc125a78268216f5dfb8736a4ff7082ed588d71de0ea6587
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f7b3825df0045f80896ffbc17db90f57048084aa7eb2ac92ecb17b584c769d3affbcd7503fef3703fc7faf6d98c6127b557ffbdf5feb028d14e29db29a360424
|
|
7
|
+
data.tar.gz: 5edd8c20f3ae4da183309750cb18cb5204036c955322c443e1d2e75e971f0c88a6e3e508d25723b30da5dee994d5cf1627486548f8b8d8fd729c151d10979143
|
data/bin/shapeup
ADDED
data/install.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
I want you to install Agent Skills for ShapeUp for me. Execute all steps autonomously.
|
|
2
|
+
|
|
3
|
+
OBJECTIVE: Install ShapeUp agent skills so your agent can manage pitches, scopes, tasks, cycles, and more via the ShapeUp CLI.
|
|
4
|
+
|
|
5
|
+
DONE WHEN: `shapeup version` succeeds, `shapeup orgs` returns data, and the shapeup skill is installed in your agent.
|
|
6
|
+
|
|
7
|
+
## TODO
|
|
8
|
+
|
|
9
|
+
- [ ] Install ShapeUp CLI
|
|
10
|
+
- [ ] Authenticate with ShapeUp
|
|
11
|
+
- [ ] Set default organisation
|
|
12
|
+
- [ ] Install skills
|
|
13
|
+
|
|
14
|
+
## Step 0: Install ShapeUp CLI
|
|
15
|
+
|
|
16
|
+
Check if the ShapeUp CLI is available:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
shapeup version
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
If `shapeup` is not found, clone the repo and add to PATH:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/shapeup-cc/shapeup-cli ~/.shapeup-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Add to your shell profile (`~/.zshrc` or `~/.bashrc`):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
alias shapeup='ruby -I~/.shapeup-cli/cli/lib ~/.shapeup-cli/cli/bin/shapeup'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Verify:**
|
|
35
|
+
```bash
|
|
36
|
+
shapeup version
|
|
37
|
+
# Expected: shapeup 0.1.0
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Step 1: Authenticate with ShapeUp
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
shapeup login
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This opens a browser for OAuth. Grant access when prompted. Credentials are stored in `~/.config/shapeup/credentials.json`.
|
|
47
|
+
|
|
48
|
+
For local development:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
shapeup login --host http://localhost:3000
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Verify:**
|
|
55
|
+
```bash
|
|
56
|
+
shapeup orgs
|
|
57
|
+
# Expected: List of organisations
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Step 2: Set default organisation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
shapeup config set org "Your Org Name"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or for per-project config:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
shapeup config init "Your Org Name"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Verify:**
|
|
73
|
+
```bash
|
|
74
|
+
shapeup config show
|
|
75
|
+
# Expected: org set to your organisation
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Step 3: Install skills
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
shapeup setup claude
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This copies the ShapeUp skill into `~/.claude/skills/shapeup/SKILL.md` so Claude Code can use ShapeUp commands when triggered.
|
|
85
|
+
|
|
86
|
+
**Verify:**
|
|
87
|
+
```bash
|
|
88
|
+
cat ~/.claude/skills/shapeup/SKILL.md | head -5
|
|
89
|
+
# Expected: YAML frontmatter with name: shapeup
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Restart your agent session to pick up the new skills. The shapeup skill provides full CLI coverage: pitches, scopes, tasks, cycles, search, assignments, and more.
|
|
93
|
+
|
|
94
|
+
EXECUTE NOW: Start with Step 0. Mark TODO items complete as you go. Stop when all steps succeed.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Auth
|
|
5
|
+
CLIENT_NAME = "ShapeUp CLI"
|
|
6
|
+
|
|
7
|
+
# OAuth 2.1 PKCE login flow:
|
|
8
|
+
# 1. Discover OAuth metadata from /.well-known/oauth-authorization-server
|
|
9
|
+
# 2. Dynamically register client (loopback redirect)
|
|
10
|
+
# 3. Start local callback server
|
|
11
|
+
# 4. Open browser for authorisation
|
|
12
|
+
# 5. Receive code on callback
|
|
13
|
+
# 6. Exchange code for token via PKCE
|
|
14
|
+
|
|
15
|
+
def self.login(host: Config.host)
|
|
16
|
+
metadata = discover_metadata(host)
|
|
17
|
+
callback_port = find_available_port
|
|
18
|
+
redirect_uri = "http://127.0.0.1:#{callback_port}/callback"
|
|
19
|
+
|
|
20
|
+
client = register_client(metadata["registration_endpoint"], redirect_uri)
|
|
21
|
+
code_verifier = generate_code_verifier
|
|
22
|
+
code_challenge = generate_code_challenge(code_verifier)
|
|
23
|
+
state = SecureRandom.hex(16)
|
|
24
|
+
|
|
25
|
+
auth_url = build_auth_url(
|
|
26
|
+
metadata["authorization_endpoint"],
|
|
27
|
+
client_id: client["client_id"],
|
|
28
|
+
redirect_uri: redirect_uri,
|
|
29
|
+
code_challenge: code_challenge,
|
|
30
|
+
state: state
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
puts "Opening browser for authentication..."
|
|
34
|
+
puts "If the browser doesn't open, visit:"
|
|
35
|
+
puts " #{auth_url}"
|
|
36
|
+
puts
|
|
37
|
+
|
|
38
|
+
open_browser(auth_url)
|
|
39
|
+
|
|
40
|
+
code = wait_for_callback(callback_port, state)
|
|
41
|
+
|
|
42
|
+
token_response = exchange_code(
|
|
43
|
+
metadata["token_endpoint"],
|
|
44
|
+
code: code,
|
|
45
|
+
client_id: client["client_id"],
|
|
46
|
+
redirect_uri: redirect_uri,
|
|
47
|
+
code_verifier: code_verifier
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
token_response
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.token
|
|
54
|
+
Config.token
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- Private helpers ---
|
|
58
|
+
|
|
59
|
+
def self.discover_metadata(host)
|
|
60
|
+
uri = URI.parse("#{host}/.well-known/oauth-authorization-server")
|
|
61
|
+
response = Net::HTTP.get_response(uri)
|
|
62
|
+
|
|
63
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
64
|
+
raise Client::ApiError, "Failed to discover OAuth metadata at #{host}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
JSON.parse(response.body)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.register_client(endpoint, redirect_uri)
|
|
71
|
+
uri = URI.parse(endpoint)
|
|
72
|
+
request = Net::HTTP::Post.new(uri)
|
|
73
|
+
request["Content-Type"] = "application/json"
|
|
74
|
+
request.body = JSON.generate(
|
|
75
|
+
client_name: CLIENT_NAME,
|
|
76
|
+
redirect_uris: [ redirect_uri ],
|
|
77
|
+
token_endpoint_auth_method: "none",
|
|
78
|
+
grant_types: [ "authorization_code" ],
|
|
79
|
+
response_types: [ "code" ],
|
|
80
|
+
scope: "read write"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
84
|
+
http.use_ssl = uri.scheme == "https"
|
|
85
|
+
response = http.request(request)
|
|
86
|
+
|
|
87
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
88
|
+
raise Client::ApiError, "Failed to register OAuth client: #{response.body}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
JSON.parse(response.body)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.build_auth_url(endpoint, client_id:, redirect_uri:, code_challenge:, state:)
|
|
95
|
+
uri = URI.parse(endpoint)
|
|
96
|
+
uri.query = URI.encode_www_form(
|
|
97
|
+
response_type: "code",
|
|
98
|
+
client_id: client_id,
|
|
99
|
+
redirect_uri: redirect_uri,
|
|
100
|
+
code_challenge: code_challenge,
|
|
101
|
+
code_challenge_method: "S256",
|
|
102
|
+
scope: "write",
|
|
103
|
+
state: state
|
|
104
|
+
)
|
|
105
|
+
uri.to_s
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.exchange_code(endpoint, code:, client_id:, redirect_uri:, code_verifier:)
|
|
109
|
+
uri = URI.parse(endpoint)
|
|
110
|
+
request = Net::HTTP::Post.new(uri)
|
|
111
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
112
|
+
request.body = URI.encode_www_form(
|
|
113
|
+
grant_type: "authorization_code",
|
|
114
|
+
code: code,
|
|
115
|
+
client_id: client_id,
|
|
116
|
+
redirect_uri: redirect_uri,
|
|
117
|
+
code_verifier: code_verifier
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
121
|
+
http.use_ssl = uri.scheme == "https"
|
|
122
|
+
response = http.request(request)
|
|
123
|
+
|
|
124
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
125
|
+
raise Client::ApiError, "Token exchange failed: #{response.body}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
JSON.parse(response.body)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.wait_for_callback(port, expected_state)
|
|
132
|
+
server = TCPServer.new("127.0.0.1", port)
|
|
133
|
+
client = server.accept
|
|
134
|
+
|
|
135
|
+
# Read the HTTP request
|
|
136
|
+
request_line = client.gets
|
|
137
|
+
headers = {}
|
|
138
|
+
while (line = client.gets) && line.strip != ""
|
|
139
|
+
key, value = line.split(": ", 2)
|
|
140
|
+
headers[key.downcase] = value&.strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Parse query params from GET /callback?code=...&state=...
|
|
144
|
+
path, query_string = request_line.split(" ")[1].split("?", 2)
|
|
145
|
+
params = URI.decode_www_form(query_string || "").to_h
|
|
146
|
+
|
|
147
|
+
code = nil
|
|
148
|
+
error = nil
|
|
149
|
+
|
|
150
|
+
if params["error"]
|
|
151
|
+
error = params["error_description"] || params["error"]
|
|
152
|
+
body = "<html><body><h1>Authentication failed</h1><p>#{error}</p><p>You can close this tab.</p></body></html>"
|
|
153
|
+
elsif params["state"] != expected_state
|
|
154
|
+
error = "State mismatch"
|
|
155
|
+
body = "<html><body><h1>Authentication failed</h1><p>State mismatch.</p></body></html>"
|
|
156
|
+
else
|
|
157
|
+
code = params["code"]
|
|
158
|
+
body = "<html><body><h1>Authenticated!</h1><p>You can close this tab and return to the terminal.</p></body></html>"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
162
|
+
client.close
|
|
163
|
+
server.close
|
|
164
|
+
|
|
165
|
+
raise Client::ApiError, "Authentication failed: #{error}" if error
|
|
166
|
+
raise Client::ApiError, "No authorization code received" unless code
|
|
167
|
+
|
|
168
|
+
code
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.generate_code_verifier
|
|
172
|
+
SecureRandom.urlsafe_base64(32)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.generate_code_challenge(verifier)
|
|
176
|
+
digest = Digest::SHA256.digest(verifier)
|
|
177
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.find_available_port
|
|
181
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
182
|
+
port = server.addr[1]
|
|
183
|
+
server.close
|
|
184
|
+
port
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.open_browser(url)
|
|
188
|
+
case RUBY_PLATFORM
|
|
189
|
+
when /darwin/ then system("open", url)
|
|
190
|
+
when /linux/ then system("xdg-open", url)
|
|
191
|
+
when /mswin|mingw/ then system("start", url)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private_class_method :discover_metadata, :register_client, :build_auth_url,
|
|
196
|
+
:exchange_code, :wait_for_callback, :generate_code_verifier,
|
|
197
|
+
:generate_code_challenge, :find_available_port, :open_browser
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
class Client
|
|
5
|
+
class ApiError < StandardError; end
|
|
6
|
+
class AuthError < ApiError; end
|
|
7
|
+
class NotFoundError < ApiError; end
|
|
8
|
+
class PermissionError < ApiError; end
|
|
9
|
+
class RateLimitError < ApiError; end
|
|
10
|
+
|
|
11
|
+
MCP_PROTOCOL_VERSION = "2025-06-18"
|
|
12
|
+
|
|
13
|
+
def initialize(host: Config.host, token: Auth.token)
|
|
14
|
+
raise AuthError, "Not authenticated" unless token
|
|
15
|
+
@host = host
|
|
16
|
+
@token = token
|
|
17
|
+
@request_id = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Initialise the MCP session
|
|
21
|
+
def initialize_session
|
|
22
|
+
call_method("initialize", protocolVersion: MCP_PROTOCOL_VERSION)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# List available tools (useful for --agent --help introspection)
|
|
26
|
+
def list_tools
|
|
27
|
+
call_method("tools/list")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Call an MCP tool by name with arguments
|
|
31
|
+
def call_tool(name, **arguments)
|
|
32
|
+
call_method("tools/call", name: name, arguments: arguments)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# List available resources
|
|
36
|
+
def list_resources
|
|
37
|
+
call_method("resources/list")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Read a resource by URI
|
|
41
|
+
def read_resource(uri)
|
|
42
|
+
call_method("resources/read", uri: uri)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
def call_method(method, **params)
|
|
47
|
+
@request_id += 1
|
|
48
|
+
|
|
49
|
+
uri = URI.parse("#{@host}/mcp")
|
|
50
|
+
request = Net::HTTP::Post.new(uri)
|
|
51
|
+
request["Content-Type"] = "application/json"
|
|
52
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
53
|
+
request["MCP-Protocol-Version"] = MCP_PROTOCOL_VERSION
|
|
54
|
+
|
|
55
|
+
body = {
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
id: @request_id,
|
|
58
|
+
method: method
|
|
59
|
+
}
|
|
60
|
+
body[:params] = params unless params.empty?
|
|
61
|
+
request.body = JSON.generate(body)
|
|
62
|
+
|
|
63
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
64
|
+
http.use_ssl = uri.scheme == "https"
|
|
65
|
+
http.open_timeout = 5
|
|
66
|
+
http.read_timeout = 30
|
|
67
|
+
|
|
68
|
+
response = http.request(request)
|
|
69
|
+
|
|
70
|
+
case response
|
|
71
|
+
when Net::HTTPUnauthorized
|
|
72
|
+
raise AuthError, "Authentication failed — run 'shapeup login'"
|
|
73
|
+
when Net::HTTPForbidden
|
|
74
|
+
raise PermissionError, "Check your subscription or permissions"
|
|
75
|
+
when Net::HTTPTooManyRequests
|
|
76
|
+
raise RateLimitError, "Too many requests"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
parsed = JSON.parse(response.body)
|
|
80
|
+
|
|
81
|
+
if parsed["error"]
|
|
82
|
+
message = parsed["error"]["message"] || "Unknown error"
|
|
83
|
+
|
|
84
|
+
case message
|
|
85
|
+
when /not found/i then raise NotFoundError, message
|
|
86
|
+
when /access/i then raise PermissionError, message
|
|
87
|
+
else raise ApiError, message
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
parsed["result"]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Commands
|
|
5
|
+
class Auth < Base
|
|
6
|
+
def self.metadata
|
|
7
|
+
{
|
|
8
|
+
command: "auth",
|
|
9
|
+
path: "shapeup auth",
|
|
10
|
+
short: "Manage authentication profiles",
|
|
11
|
+
subcommands: [
|
|
12
|
+
{ name: "status", short: "Check authentication status", path: "shapeup auth status" },
|
|
13
|
+
{ name: "list", short: "List configured profiles", path: "shapeup auth list" },
|
|
14
|
+
{ name: "switch", short: "Switch active profile", path: "shapeup auth switch <name>" },
|
|
15
|
+
{ name: "remove", short: "Remove a profile", path: "shapeup auth remove <name>" }
|
|
16
|
+
],
|
|
17
|
+
flags: [],
|
|
18
|
+
notes: [
|
|
19
|
+
"Use 'shapeup login' to create a new profile via OAuth",
|
|
20
|
+
"SHAPEUP_PROFILE env var overrides the default profile"
|
|
21
|
+
],
|
|
22
|
+
examples: [
|
|
23
|
+
"shapeup auth status",
|
|
24
|
+
"shapeup auth list",
|
|
25
|
+
"shapeup auth switch compass-labs",
|
|
26
|
+
"shapeup auth remove old-profile"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def execute
|
|
32
|
+
subcommand = positional_arg(0)
|
|
33
|
+
|
|
34
|
+
case subcommand
|
|
35
|
+
when "status" then status
|
|
36
|
+
when "list" then list
|
|
37
|
+
when "switch" then switch_profile
|
|
38
|
+
when "remove" then remove
|
|
39
|
+
when "help", nil then help
|
|
40
|
+
else help
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
def status
|
|
46
|
+
profile = Config.current_profile
|
|
47
|
+
|
|
48
|
+
if profile
|
|
49
|
+
data = {
|
|
50
|
+
"authenticated" => true,
|
|
51
|
+
"profile" => profile["profile_name"],
|
|
52
|
+
"organisation" => profile["name"],
|
|
53
|
+
"organisation_id" => profile["organisation_id"],
|
|
54
|
+
"host" => profile["host"]
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
data = { "authenticated" => false }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if @mode == :json || @mode == :agent
|
|
61
|
+
Output.render(
|
|
62
|
+
{ "content" => [ { "type" => "text", "text" => JSON.generate(data) } ] },
|
|
63
|
+
mode: @mode,
|
|
64
|
+
summary: "Auth Status"
|
|
65
|
+
)
|
|
66
|
+
else
|
|
67
|
+
if profile
|
|
68
|
+
puts "Authenticated"
|
|
69
|
+
puts " profile #{profile["profile_name"]}"
|
|
70
|
+
puts " org #{profile["name"]}"
|
|
71
|
+
puts " host #{profile["host"]}"
|
|
72
|
+
else
|
|
73
|
+
puts "Not authenticated. Run 'shapeup login' to connect."
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def list
|
|
79
|
+
profiles = Config.list_profiles
|
|
80
|
+
|
|
81
|
+
if profiles.empty?
|
|
82
|
+
puts "No profiles configured. Run 'shapeup login' to create one."
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
profiles.each do |p|
|
|
87
|
+
marker = p[:default] ? "*" : " "
|
|
88
|
+
puts " #{marker} #{p[:name].ljust(20)} #{p[:display_name]} (org: #{p[:organisation_id]})"
|
|
89
|
+
end
|
|
90
|
+
puts
|
|
91
|
+
puts " * = active profile"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def switch_profile
|
|
95
|
+
name = positional_arg(1) || abort("Usage: shapeup auth switch <profile-name>")
|
|
96
|
+
Config.switch_profile(name)
|
|
97
|
+
puts "Switched to profile: #{name}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def remove
|
|
101
|
+
name = positional_arg(1) || abort("Usage: shapeup auth remove <profile-name>")
|
|
102
|
+
Config.delete_profile(name)
|
|
103
|
+
puts "Removed profile: #{name}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def help
|
|
107
|
+
puts <<~HELP
|
|
108
|
+
Usage: shapeup auth <subcommand>
|
|
109
|
+
|
|
110
|
+
Subcommands:
|
|
111
|
+
status Check if authenticated
|
|
112
|
+
list List all profiles
|
|
113
|
+
switch <name> Switch active profile
|
|
114
|
+
remove <name> Remove a profile
|
|
115
|
+
|
|
116
|
+
Profiles store separate tokens and org defaults. Create profiles
|
|
117
|
+
with 'shapeup login', then switch between them.
|
|
118
|
+
|
|
119
|
+
Override with SHAPEUP_PROFILE env var.
|
|
120
|
+
HELP
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Override: auth doesn't need an org_id
|
|
124
|
+
def org_id = nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Commands
|
|
5
|
+
class Base
|
|
6
|
+
def self.run(args)
|
|
7
|
+
instance = new(args)
|
|
8
|
+
if instance.agent_help?
|
|
9
|
+
puts JSON.pretty_generate(instance.class.metadata)
|
|
10
|
+
else
|
|
11
|
+
instance.execute
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Override in subclasses to define structured command metadata
|
|
16
|
+
def self.metadata
|
|
17
|
+
{ command: name.split("::").last.downcase, description: "No metadata defined" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(args)
|
|
21
|
+
@mode, remaining = Output.parse_mode(args)
|
|
22
|
+
@agent_help = @mode == :agent && remaining.include?("--help")
|
|
23
|
+
remaining.delete("--help") if @agent_help
|
|
24
|
+
@org_id, @remaining = extract_flags(remaining)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def agent_help?
|
|
28
|
+
@agent_help
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def client
|
|
33
|
+
@client ||= Client.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def org_id
|
|
37
|
+
resolve_org(@org_id || Config.organisation_id || abort("No organisation set. Run 'shapeup orgs' to see available orgs, then pass --org <id> or --org <name>."))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call_tool(name, **args)
|
|
41
|
+
client.call_tool(name, organisation: org_id.to_s, **args)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Accept org ID (numeric) or name (string) — resolve name to ID via list_organisations
|
|
45
|
+
def resolve_org(value)
|
|
46
|
+
return value if value.to_s.match?(/\A\d+\z/)
|
|
47
|
+
|
|
48
|
+
result = client.call_tool("list_organisations")
|
|
49
|
+
data = Output.extract_data(result)
|
|
50
|
+
orgs = data.is_a?(Hash) ? (data["organisations"] || []) : Array(data)
|
|
51
|
+
|
|
52
|
+
match = orgs.find { |o| o["name"]&.downcase == value.downcase }
|
|
53
|
+
|
|
54
|
+
if match
|
|
55
|
+
match["id"]
|
|
56
|
+
else
|
|
57
|
+
names = orgs.map { |o| " #{o["id"]} #{o["name"]}" }.join("\n")
|
|
58
|
+
abort "Organisation '#{value}' not found. Available:\n#{names}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def render(result, breadcrumbs: [], summary: nil)
|
|
63
|
+
Output.render(result, breadcrumbs: breadcrumbs, mode: @mode, summary: summary)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_flags(args)
|
|
67
|
+
org = nil
|
|
68
|
+
remaining = []
|
|
69
|
+
skip_next = false
|
|
70
|
+
|
|
71
|
+
args.each_with_index do |arg, i|
|
|
72
|
+
if skip_next
|
|
73
|
+
skip_next = false
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
case arg
|
|
78
|
+
when "--org"
|
|
79
|
+
org = args[i + 1]
|
|
80
|
+
skip_next = true
|
|
81
|
+
when /\A--org=(.+)\z/
|
|
82
|
+
org = $1
|
|
83
|
+
else
|
|
84
|
+
remaining << arg
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
[ org, remaining ]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Parse --flag value pairs from remaining args
|
|
92
|
+
def extract_option(flag)
|
|
93
|
+
idx = @remaining.index(flag)
|
|
94
|
+
return nil unless idx
|
|
95
|
+
value = @remaining[idx + 1]
|
|
96
|
+
@remaining.delete_at(idx + 1)
|
|
97
|
+
@remaining.delete_at(idx)
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get the first positional arg (not a flag)
|
|
102
|
+
def positional_arg(position = 0)
|
|
103
|
+
positionals = @remaining.reject { |a| a.start_with?("--") }
|
|
104
|
+
positionals[position]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get all positional args
|
|
108
|
+
def positional_args
|
|
109
|
+
@remaining.reject { |a| a.start_with?("--") }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|