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 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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "shapeup_cli"
6
+
7
+ ShapeupCli.run(ARGV)
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