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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Commands
|
|
5
|
+
def self.help
|
|
6
|
+
puts <<~HELP
|
|
7
|
+
ShapeUp CLI — Manage pitches, scopes, tasks, and cycles from the terminal.
|
|
8
|
+
|
|
9
|
+
Usage: shapeup <command> [options]
|
|
10
|
+
|
|
11
|
+
Auth:
|
|
12
|
+
login Authenticate via OAuth (creates a profile)
|
|
13
|
+
logout Clear all credentials
|
|
14
|
+
auth status Check authentication status
|
|
15
|
+
auth list List configured profiles
|
|
16
|
+
auth switch <name> Switch active profile
|
|
17
|
+
auth remove <name> Remove a profile
|
|
18
|
+
|
|
19
|
+
Discovery:
|
|
20
|
+
orgs List your organisations
|
|
21
|
+
commands List all available commands
|
|
22
|
+
|
|
23
|
+
Pitches:
|
|
24
|
+
pitches list List pitches (packages)
|
|
25
|
+
pitches show <id> Show pitch details with scopes and tasks
|
|
26
|
+
pitch <id> Shortcut for pitches show
|
|
27
|
+
|
|
28
|
+
Cycles:
|
|
29
|
+
cycles List all cycles
|
|
30
|
+
cycle show <id> Show cycle details with pitches and progress
|
|
31
|
+
|
|
32
|
+
Scopes:
|
|
33
|
+
scopes list --pitch <id> List scopes for a pitch
|
|
34
|
+
scopes create --pitch <id> "Title"
|
|
35
|
+
scopes update <id> --title "New title"
|
|
36
|
+
|
|
37
|
+
Tasks:
|
|
38
|
+
tasks list --scope <id> List tasks for a scope
|
|
39
|
+
todo "Description" --pitch <id> [--scope <id>]
|
|
40
|
+
done <id> [<id>...] Mark task(s) as complete
|
|
41
|
+
|
|
42
|
+
Issues:
|
|
43
|
+
issues List open issues
|
|
44
|
+
issues --all Include done/closed
|
|
45
|
+
issues --column triage Filter by column name
|
|
46
|
+
issues --assignee me Issues assigned to me
|
|
47
|
+
issues --assignee none Unassigned issues
|
|
48
|
+
issues --tag seo Filter by tag
|
|
49
|
+
issues --stream "Name" Filter by stream name
|
|
50
|
+
issues --kind bug Filter by kind (bug/request)
|
|
51
|
+
issue <id> Show issue details
|
|
52
|
+
issues create "Title" --stream "Name" [--content "..."]
|
|
53
|
+
issues move <id> --column doing
|
|
54
|
+
issues done <id> Mark issue as done
|
|
55
|
+
issues close <id> Close issue (won't fix)
|
|
56
|
+
issues reopen <id> Reopen a done/closed issue
|
|
57
|
+
issues icebox <id> Move to icebox
|
|
58
|
+
issues defrost <id> Restore from icebox
|
|
59
|
+
issues assign <id> Assign yourself (or --user <id>)
|
|
60
|
+
issues unassign <id> Unassign yourself (or --user <id>)
|
|
61
|
+
issues watch <id> Watch an issue
|
|
62
|
+
issues unwatch <id> Stop watching
|
|
63
|
+
watching List issues you are watching
|
|
64
|
+
issues delete <id> Delete an issue
|
|
65
|
+
|
|
66
|
+
Comments:
|
|
67
|
+
comments list --issue <id> List comments on an issue
|
|
68
|
+
comments list --pitch <id> List comments on a pitch
|
|
69
|
+
comments add --issue <id> "Text" Add a comment to an issue
|
|
70
|
+
comments add --pitch <id> "Text" Add a comment to a pitch
|
|
71
|
+
|
|
72
|
+
My Work:
|
|
73
|
+
my-work, me Show everything assigned to me
|
|
74
|
+
|
|
75
|
+
Search:
|
|
76
|
+
search "query" Search across pitches, scopes, tasks, issues
|
|
77
|
+
|
|
78
|
+
Config:
|
|
79
|
+
config show Show current config
|
|
80
|
+
config set org "Name" Set default organisation (name or ID)
|
|
81
|
+
config set host <url> Set ShapeUp host
|
|
82
|
+
config init "Name" Create .shapeup/config.json for this directory
|
|
83
|
+
|
|
84
|
+
Setup:
|
|
85
|
+
setup claude Install skill into Claude Code
|
|
86
|
+
setup cursor Install skill into Cursor
|
|
87
|
+
setup project Install skill into current project
|
|
88
|
+
|
|
89
|
+
Output modes (append to any command):
|
|
90
|
+
--json Full JSON envelope with breadcrumbs
|
|
91
|
+
--md Markdown tables
|
|
92
|
+
--agent Raw JSON data only (for AI agents)
|
|
93
|
+
--quiet, -q Same as --agent
|
|
94
|
+
--ids-only Print only IDs (one per line)
|
|
95
|
+
(piped output auto-switches to --json)
|
|
96
|
+
|
|
97
|
+
Flags:
|
|
98
|
+
--org <id|name> Override default organisation
|
|
99
|
+
--host <url> Override ShapeUp host (default: https://shapeup.cc)
|
|
100
|
+
|
|
101
|
+
Environment variables:
|
|
102
|
+
SHAPEUP_TOKEN Bearer token (skips OAuth, for CI/scripts)
|
|
103
|
+
SHAPEUP_ORG Default organisation ID
|
|
104
|
+
SHAPEUP_HOST API host URL
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
shapeup login
|
|
108
|
+
shapeup config set org "Compass Labs"
|
|
109
|
+
shapeup pitches list --json
|
|
110
|
+
shapeup pitch 42
|
|
111
|
+
shapeup todo "Fix login bug" --pitch 42 --scope 7
|
|
112
|
+
shapeup done 123 124 125
|
|
113
|
+
shapeup me --md
|
|
114
|
+
HELP
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.list_commands
|
|
118
|
+
puts <<~COMMANDS
|
|
119
|
+
login Authenticate (creates a profile)
|
|
120
|
+
logout Clear all credentials
|
|
121
|
+
auth Manage profiles (status, list, switch, remove)
|
|
122
|
+
orgs List organisations
|
|
123
|
+
pitches List/show pitches (list, show)
|
|
124
|
+
pitch Show a pitch (shortcut)
|
|
125
|
+
cycles List cycles
|
|
126
|
+
cycle Show cycle details (list, show)
|
|
127
|
+
scopes Manage scopes (list, create, update)
|
|
128
|
+
tasks Manage tasks (list, create, complete)
|
|
129
|
+
todo Create a task (shortcut)
|
|
130
|
+
done Complete task(s) (shortcut)
|
|
131
|
+
issues Manage issues (list, show, create, move, icebox, watch)
|
|
132
|
+
issue Show an issue (shortcut)
|
|
133
|
+
watching List watched issues (shortcut)
|
|
134
|
+
comments List and add comments (list, add)
|
|
135
|
+
my-work / me Show my assigned work
|
|
136
|
+
search Search everything
|
|
137
|
+
config Show/set config (set, show, init)
|
|
138
|
+
setup Install agent skills (claude, cursor, project)
|
|
139
|
+
commands This list
|
|
140
|
+
help Usage guide
|
|
141
|
+
version Show version
|
|
142
|
+
COMMANDS
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
require_relative "commands/base"
|
|
148
|
+
require_relative "commands/login"
|
|
149
|
+
require_relative "commands/logout"
|
|
150
|
+
require_relative "commands/orgs"
|
|
151
|
+
require_relative "commands/pitches"
|
|
152
|
+
require_relative "commands/cycle"
|
|
153
|
+
require_relative "commands/scopes"
|
|
154
|
+
require_relative "commands/tasks"
|
|
155
|
+
require_relative "commands/issues"
|
|
156
|
+
require_relative "commands/my_work"
|
|
157
|
+
require_relative "commands/search"
|
|
158
|
+
require_relative "commands/auth"
|
|
159
|
+
require_relative "commands/config_cmd"
|
|
160
|
+
require_relative "commands/setup"
|
|
161
|
+
require_relative "commands/comments"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Config
|
|
5
|
+
CONFIG_DIR = File.join(Dir.home, ".config", "shapeup")
|
|
6
|
+
PROFILES_FILE = File.join(CONFIG_DIR, "profiles.json")
|
|
7
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
|
|
8
|
+
|
|
9
|
+
# Project-level config — walks up from current directory
|
|
10
|
+
PROJECT_CONFIG_NAME = ".shapeup/config.json"
|
|
11
|
+
|
|
12
|
+
def self.ensure_config_dir
|
|
13
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# --- Profiles ---
|
|
17
|
+
#
|
|
18
|
+
# profiles.json stores named profiles:
|
|
19
|
+
# {
|
|
20
|
+
# "default": "compass-labs",
|
|
21
|
+
# "profiles": {
|
|
22
|
+
# "acme-corp": { "token": "...", "host": "https://shapeup.cc", "organisation_id": "2", "name": "Acme Corp" },
|
|
23
|
+
# "side-project": { "token": "...", "host": "https://shapeup.cc", "organisation_id": "5", "name": "Side Project" }
|
|
24
|
+
# }
|
|
25
|
+
# }
|
|
26
|
+
|
|
27
|
+
def self.save_profile(name, token:, host:, organisation_id:, display_name: nil)
|
|
28
|
+
ensure_config_dir
|
|
29
|
+
data = load_profiles_raw
|
|
30
|
+
data["profiles"] ||= {}
|
|
31
|
+
data["profiles"][name] = {
|
|
32
|
+
"token" => token,
|
|
33
|
+
"host" => host,
|
|
34
|
+
"organisation_id" => organisation_id.to_s,
|
|
35
|
+
"name" => display_name || name
|
|
36
|
+
}
|
|
37
|
+
# Set as default if it's the first profile
|
|
38
|
+
data["default"] ||= name
|
|
39
|
+
File.write(PROFILES_FILE, JSON.pretty_generate(data))
|
|
40
|
+
File.chmod(0600, PROFILES_FILE)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.switch_profile(name)
|
|
44
|
+
data = load_profiles_raw
|
|
45
|
+
unless data.dig("profiles", name)
|
|
46
|
+
available = (data["profiles"] || {}).keys
|
|
47
|
+
abort "Profile '#{name}' not found. Available: #{available.join(", ")}"
|
|
48
|
+
end
|
|
49
|
+
data["default"] = name
|
|
50
|
+
File.write(PROFILES_FILE, JSON.pretty_generate(data))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.delete_profile(name)
|
|
54
|
+
data = load_profiles_raw
|
|
55
|
+
data["profiles"]&.delete(name)
|
|
56
|
+
data["default"] = data["profiles"]&.keys&.first if data["default"] == name
|
|
57
|
+
File.write(PROFILES_FILE, JSON.pretty_generate(data))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.list_profiles
|
|
61
|
+
data = load_profiles_raw
|
|
62
|
+
default = data["default"]
|
|
63
|
+
(data["profiles"] || {}).map do |key, profile|
|
|
64
|
+
{ name: key, display_name: profile["name"], organisation_id: profile["organisation_id"],
|
|
65
|
+
host: profile["host"], default: key == default }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.current_profile
|
|
70
|
+
data = load_profiles_raw
|
|
71
|
+
name = ENV["SHAPEUP_PROFILE"] || data["default"]
|
|
72
|
+
return nil unless name
|
|
73
|
+
profile = data.dig("profiles", name)
|
|
74
|
+
return nil unless profile
|
|
75
|
+
profile.merge("profile_name" => name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.clear_credentials
|
|
79
|
+
File.delete(PROFILES_FILE) if File.exist?(PROFILES_FILE)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- Config (defaults) ---
|
|
83
|
+
|
|
84
|
+
def self.save_config(key, value)
|
|
85
|
+
ensure_config_dir
|
|
86
|
+
data = load_config_raw
|
|
87
|
+
data[key] = value
|
|
88
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(data))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.load_config
|
|
92
|
+
data = load_config_raw
|
|
93
|
+
|
|
94
|
+
# Merge project-level config (walks up directory tree, takes precedence)
|
|
95
|
+
if (project_config = find_project_config)
|
|
96
|
+
project = JSON.parse(File.read(project_config)) rescue {}
|
|
97
|
+
data.merge!(project)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
data
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Resolution order: env var > project config > global config > profile > default
|
|
104
|
+
def self.host
|
|
105
|
+
ENV["SHAPEUP_HOST"] || load_config["host"] || current_profile&.dig("host") || ShapeupCli::DEFAULT_HOST
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.organisation_id
|
|
109
|
+
ENV["SHAPEUP_ORG"] || load_config["organisation_id"] || current_profile&.dig("organisation_id")&.then { |v| v.empty? ? nil : v }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.token
|
|
113
|
+
ENV["SHAPEUP_TOKEN"] || current_profile&.dig("token")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# --- Pipe detection ---
|
|
117
|
+
|
|
118
|
+
def self.piped?
|
|
119
|
+
!$stdout.tty?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- Auth status (for plugin hook) ---
|
|
123
|
+
|
|
124
|
+
def self.authenticated?
|
|
125
|
+
!!token
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.current_profile_name
|
|
129
|
+
ENV["SHAPEUP_PROFILE"] || load_profiles_raw["default"]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_class_method def self.load_profiles_raw
|
|
133
|
+
return {} unless File.exist?(PROFILES_FILE)
|
|
134
|
+
JSON.parse(File.read(PROFILES_FILE)) rescue {}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private_class_method def self.load_config_raw
|
|
138
|
+
return {} unless File.exist?(CONFIG_FILE)
|
|
139
|
+
JSON.parse(File.read(CONFIG_FILE)) rescue {}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Walk up from current directory looking for .shapeup/config.json
|
|
143
|
+
private_class_method def self.find_project_config
|
|
144
|
+
dir = Dir.pwd
|
|
145
|
+
loop do
|
|
146
|
+
candidate = File.join(dir, PROJECT_CONFIG_NAME)
|
|
147
|
+
return candidate if File.exist?(candidate)
|
|
148
|
+
parent = File.dirname(dir)
|
|
149
|
+
break if parent == dir # reached root
|
|
150
|
+
dir = parent
|
|
151
|
+
end
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShapeupCli
|
|
4
|
+
module Output
|
|
5
|
+
# Parse output mode flags from args, returns [mode, remaining_args]
|
|
6
|
+
def self.parse_mode(args)
|
|
7
|
+
mode = nil
|
|
8
|
+
remaining = []
|
|
9
|
+
|
|
10
|
+
args.each do |arg|
|
|
11
|
+
case arg
|
|
12
|
+
when "--json" then mode = :json
|
|
13
|
+
when "--md", "-m" then mode = :markdown
|
|
14
|
+
when "--agent" then mode = :agent
|
|
15
|
+
when "--quiet", "-q" then mode = :agent
|
|
16
|
+
when "--ids-only" then mode = :ids_only
|
|
17
|
+
else remaining << arg
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Default: auto-detect from TTY. Piped output → JSON, terminal → styled.
|
|
22
|
+
mode ||= Config.piped? ? :json : :styled
|
|
23
|
+
|
|
24
|
+
[ mode, remaining ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Render a tool result with breadcrumbs in the specified output mode
|
|
28
|
+
def self.render(result, breadcrumbs: [], mode: :styled, summary: nil)
|
|
29
|
+
# Extract text content from MCP tool result
|
|
30
|
+
data = extract_data(result)
|
|
31
|
+
|
|
32
|
+
case mode
|
|
33
|
+
when :json
|
|
34
|
+
render_json(data, breadcrumbs: breadcrumbs, summary: summary)
|
|
35
|
+
when :agent
|
|
36
|
+
render_agent(data)
|
|
37
|
+
when :markdown
|
|
38
|
+
render_markdown(data, summary: summary)
|
|
39
|
+
when :ids_only
|
|
40
|
+
render_ids_only(data)
|
|
41
|
+
else
|
|
42
|
+
render_styled(data, breadcrumbs: breadcrumbs, summary: summary)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.extract_data(result)
|
|
47
|
+
return result unless result.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
# MCP tool results come wrapped: { content: [{ type: "text", text: "..." }] }
|
|
50
|
+
if result["content"]&.is_a?(Array)
|
|
51
|
+
text = result["content"].filter_map { |c| c["text"] if c["type"] == "text" }.join
|
|
52
|
+
JSON.parse(text) rescue text
|
|
53
|
+
else
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.render_json(data, breadcrumbs: [], summary: nil)
|
|
59
|
+
envelope = { ok: true, data: data }
|
|
60
|
+
envelope[:summary] = summary if summary
|
|
61
|
+
envelope[:breadcrumbs] = breadcrumbs if breadcrumbs.any?
|
|
62
|
+
puts JSON.pretty_generate(envelope)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.render_agent(data)
|
|
66
|
+
# Minimal: just the data, no envelope
|
|
67
|
+
if data.is_a?(String)
|
|
68
|
+
puts data
|
|
69
|
+
else
|
|
70
|
+
puts JSON.generate(data)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.render_ids_only(data)
|
|
75
|
+
items = case data
|
|
76
|
+
when Array then data
|
|
77
|
+
when Hash then data.values.find { |v| v.is_a?(Array) } || [ data ]
|
|
78
|
+
else [ data ]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
items.each do |item|
|
|
82
|
+
puts item.is_a?(Hash) ? item["id"] : item
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.render_markdown(data, summary: nil)
|
|
87
|
+
puts "**#{summary}**\n" if summary
|
|
88
|
+
|
|
89
|
+
case data
|
|
90
|
+
when Array
|
|
91
|
+
render_markdown_table(data)
|
|
92
|
+
when Hash
|
|
93
|
+
render_markdown_hash(data)
|
|
94
|
+
else
|
|
95
|
+
puts data.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.render_styled(data, breadcrumbs: [], summary: nil)
|
|
100
|
+
puts summary if summary
|
|
101
|
+
puts
|
|
102
|
+
|
|
103
|
+
case data
|
|
104
|
+
when Array
|
|
105
|
+
render_styled_list(data)
|
|
106
|
+
when Hash
|
|
107
|
+
render_styled_hash(data)
|
|
108
|
+
else
|
|
109
|
+
puts data.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if breadcrumbs.any?
|
|
113
|
+
puts
|
|
114
|
+
puts "Next:"
|
|
115
|
+
breadcrumbs.each do |b|
|
|
116
|
+
puts " #{b[:cmd]} # #{b[:description]}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Styled renderers ---
|
|
122
|
+
|
|
123
|
+
def self.render_styled_list(items)
|
|
124
|
+
return puts(" (none)") if items.empty?
|
|
125
|
+
|
|
126
|
+
items.each do |item|
|
|
127
|
+
case item
|
|
128
|
+
when Hash
|
|
129
|
+
render_styled_list_item(item)
|
|
130
|
+
else
|
|
131
|
+
puts " #{item}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.render_styled_list_item(item)
|
|
137
|
+
# Smart display: detect common fields
|
|
138
|
+
id = item["id"]
|
|
139
|
+
title = item["title"] || item["name"] || item["description"]
|
|
140
|
+
status = item["status"]
|
|
141
|
+
|
|
142
|
+
line = " #{id}"
|
|
143
|
+
line += " #{title}" if title
|
|
144
|
+
line += " (#{status})" if status
|
|
145
|
+
puts line
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.render_styled_hash(data)
|
|
149
|
+
max_key = data.keys.map { |k| k.to_s.length }.max || 0
|
|
150
|
+
|
|
151
|
+
data.each do |key, value|
|
|
152
|
+
label = key.to_s.ljust(max_key)
|
|
153
|
+
case value
|
|
154
|
+
when Array
|
|
155
|
+
puts " #{label} (#{value.length} items)"
|
|
156
|
+
value.first(5).each { |v| puts " #{format_list_value(v)}" }
|
|
157
|
+
puts " ... and #{value.length - 5} more" if value.length > 5
|
|
158
|
+
when Hash
|
|
159
|
+
puts " #{label} #{format_value(value)}"
|
|
160
|
+
else
|
|
161
|
+
puts " #{label} #{value}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.format_value(value)
|
|
167
|
+
case value
|
|
168
|
+
when Hash
|
|
169
|
+
value["title"] || value["name"] || value["description"] || value.to_json
|
|
170
|
+
else
|
|
171
|
+
value.to_s
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.format_list_value(value)
|
|
176
|
+
case value
|
|
177
|
+
when Hash
|
|
178
|
+
id = value["id"]
|
|
179
|
+
label = value["title"] || value["name"] || value["description"]
|
|
180
|
+
[id, label].compact.join(" ")
|
|
181
|
+
else
|
|
182
|
+
value.to_s
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- Markdown renderers ---
|
|
187
|
+
|
|
188
|
+
def self.render_markdown_table(items)
|
|
189
|
+
return puts("_No results_") if items.empty?
|
|
190
|
+
return items.each { |i| puts "- #{i}" } unless items.first.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
keys = items.first.keys.reject { |k| k.is_a?(String) && items.first[k].is_a?(Array) }
|
|
193
|
+
keys = keys.first(6) # Keep tables readable
|
|
194
|
+
|
|
195
|
+
# Header
|
|
196
|
+
puts "| #{keys.map { |k| k.to_s.gsub("_", " ").capitalize }.join(" | ")} |"
|
|
197
|
+
puts "| #{keys.map { |_| "---" }.join(" | ")} |"
|
|
198
|
+
|
|
199
|
+
# Rows
|
|
200
|
+
items.each do |item|
|
|
201
|
+
values = keys.map { |k| truncate(item[k].to_s, 40) }
|
|
202
|
+
puts "| #{values.join(" | ")} |"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.render_markdown_hash(data)
|
|
207
|
+
data.each do |key, value|
|
|
208
|
+
case value
|
|
209
|
+
when Array
|
|
210
|
+
puts "### #{key.to_s.gsub("_", " ").capitalize} (#{value.length})"
|
|
211
|
+
render_markdown_table(value)
|
|
212
|
+
puts
|
|
213
|
+
when Hash
|
|
214
|
+
puts "### #{key}"
|
|
215
|
+
render_markdown_hash(value)
|
|
216
|
+
else
|
|
217
|
+
puts "- **#{key}**: #{value}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.truncate(str, length)
|
|
223
|
+
str.length > length ? "#{str[0...length - 1]}…" : str
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private_class_method :render_json, :render_agent, :render_ids_only, :render_markdown, :render_styled,
|
|
227
|
+
:render_styled_list, :render_styled_list_item, :render_styled_hash,
|
|
228
|
+
:render_markdown_table, :render_markdown_hash, :format_value, :format_list_value, :truncate
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/shapeup_cli.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "digest"
|
|
9
|
+
require "base64"
|
|
10
|
+
require "socket"
|
|
11
|
+
|
|
12
|
+
require_relative "shapeup_cli/config"
|
|
13
|
+
require_relative "shapeup_cli/auth"
|
|
14
|
+
require_relative "shapeup_cli/client"
|
|
15
|
+
require_relative "shapeup_cli/output"
|
|
16
|
+
require_relative "shapeup_cli/commands"
|
|
17
|
+
|
|
18
|
+
module ShapeupCli
|
|
19
|
+
VERSION = "0.3.2"
|
|
20
|
+
DEFAULT_HOST = "https://shapeup.cc"
|
|
21
|
+
|
|
22
|
+
# Exit codes
|
|
23
|
+
EXIT_OK = 0
|
|
24
|
+
EXIT_USAGE = 1
|
|
25
|
+
EXIT_NOT_FOUND = 2
|
|
26
|
+
EXIT_AUTH = 3
|
|
27
|
+
EXIT_PERMISSION = 4
|
|
28
|
+
EXIT_API_ERROR = 5
|
|
29
|
+
EXIT_RATE_LIMIT = 6
|
|
30
|
+
EXIT_INTERRUPTED = 130
|
|
31
|
+
|
|
32
|
+
COMMAND_MAP = {
|
|
33
|
+
"orgs" => Commands::Orgs,
|
|
34
|
+
"pitches" => Commands::Pitches,
|
|
35
|
+
"cycle" => Commands::Cycle,
|
|
36
|
+
"scopes" => Commands::Scopes,
|
|
37
|
+
"tasks" => Commands::Tasks,
|
|
38
|
+
"issues" => Commands::Issues,
|
|
39
|
+
"my-work" => Commands::MyWork,
|
|
40
|
+
"search" => Commands::Search,
|
|
41
|
+
"auth" => Commands::Auth,
|
|
42
|
+
"config" => Commands::ConfigCmd,
|
|
43
|
+
"setup" => Commands::Setup,
|
|
44
|
+
"comments" => Commands::Comments
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def self.run(argv)
|
|
48
|
+
args = argv.dup
|
|
49
|
+
|
|
50
|
+
# Top-level: shapeup --agent --help
|
|
51
|
+
if args.include?("--agent") && args.include?("--help")
|
|
52
|
+
command = (args - [ "--agent", "--help" ]).first
|
|
53
|
+
if command && COMMAND_MAP[command]
|
|
54
|
+
puts JSON.pretty_generate(COMMAND_MAP[command].metadata)
|
|
55
|
+
else
|
|
56
|
+
puts JSON.pretty_generate(top_level_metadata)
|
|
57
|
+
end
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
command = args.shift
|
|
62
|
+
|
|
63
|
+
case command
|
|
64
|
+
when "login" then Commands::Login.run(args)
|
|
65
|
+
when "logout" then Commands::Logout.run(args)
|
|
66
|
+
when "auth" then Commands::Auth.run(args)
|
|
67
|
+
when "orgs" then Commands::Orgs.run(args)
|
|
68
|
+
when "pitches" then Commands::Pitches.run(args)
|
|
69
|
+
when "pitch" then Commands::Pitches.run(["show"] + args)
|
|
70
|
+
when "cycle" then Commands::Cycle.run(args)
|
|
71
|
+
when "cycles" then Commands::Cycle.run(["list"] + args)
|
|
72
|
+
when "scopes" then Commands::Scopes.run(args)
|
|
73
|
+
when "tasks" then Commands::Tasks.run(args)
|
|
74
|
+
when "todo" then Commands::Tasks.run(["create"] + args)
|
|
75
|
+
when "done" then Commands::Tasks.run(["complete"] + args)
|
|
76
|
+
when "issues" then Commands::Issues.run(args)
|
|
77
|
+
when "issue" then Commands::Issues.run(["show"] + args)
|
|
78
|
+
when "watching" then Commands::Issues.run(["watching"] + args)
|
|
79
|
+
when "comments" then Commands::Comments.run(args)
|
|
80
|
+
when "my-work", "me" then Commands::MyWork.run(args)
|
|
81
|
+
when "search" then Commands::Search.run(args)
|
|
82
|
+
when "config" then Commands::ConfigCmd.run(args)
|
|
83
|
+
when "setup" then Commands::Setup.run(args)
|
|
84
|
+
when "commands" then Commands.list_commands
|
|
85
|
+
when "version", "-v", "--version"
|
|
86
|
+
puts "shapeup #{VERSION}"
|
|
87
|
+
when "help", "-h", "--help", nil
|
|
88
|
+
Commands.help
|
|
89
|
+
else
|
|
90
|
+
$stderr.puts "Unknown command: #{command}"
|
|
91
|
+
$stderr.puts "Run 'shapeup help' for usage"
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
rescue Client::AuthError => e
|
|
95
|
+
$stderr.puts "Not authenticated. Run 'shapeup login' first."
|
|
96
|
+
exit EXIT_AUTH
|
|
97
|
+
rescue Client::NotFoundError => e
|
|
98
|
+
$stderr.puts "Not found: #{e.message}"
|
|
99
|
+
exit EXIT_NOT_FOUND
|
|
100
|
+
rescue Client::PermissionError => e
|
|
101
|
+
$stderr.puts "Access denied: #{e.message}"
|
|
102
|
+
exit EXIT_PERMISSION
|
|
103
|
+
rescue Client::RateLimitError => e
|
|
104
|
+
$stderr.puts "Rate limited — please wait and try again."
|
|
105
|
+
exit EXIT_RATE_LIMIT
|
|
106
|
+
rescue Client::ApiError => e
|
|
107
|
+
$stderr.puts "Error: #{e.message}"
|
|
108
|
+
exit EXIT_API_ERROR
|
|
109
|
+
rescue Interrupt
|
|
110
|
+
$stderr.puts "\nAborted."
|
|
111
|
+
exit EXIT_INTERRUPTED
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.top_level_metadata
|
|
115
|
+
{
|
|
116
|
+
command: "shapeup",
|
|
117
|
+
version: VERSION,
|
|
118
|
+
short: "Manage ShapeUp pitches, scopes, tasks, issues, and cycles from the terminal",
|
|
119
|
+
commands: COMMAND_MAP.map { |name, klass| { name: name, **klass.metadata.slice(:short, :path) } },
|
|
120
|
+
shortcuts: {
|
|
121
|
+
"pitch <id>" => "pitches show <id>",
|
|
122
|
+
"cycles" => "cycle list",
|
|
123
|
+
"todo \"...\"" => "tasks create \"...\"",
|
|
124
|
+
"done <id>" => "tasks complete <id>",
|
|
125
|
+
"issue <id>" => "issues show <id>",
|
|
126
|
+
"watching" => "issues watching",
|
|
127
|
+
"me" => "my-work"
|
|
128
|
+
},
|
|
129
|
+
inherited_flags: [
|
|
130
|
+
{ name: "org", type: "string", usage: "Organisation ID or name" },
|
|
131
|
+
{ name: "json", type: "bool", usage: "Full JSON envelope with breadcrumbs" },
|
|
132
|
+
{ name: "md", type: "bool", usage: "Markdown output" },
|
|
133
|
+
{ name: "agent", type: "bool", usage: "Raw JSON data only (for AI agents)" }
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
end
|