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.
@@ -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
@@ -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