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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Login
6
+ def self.run(args)
7
+ host = nil
8
+ profile_name = nil
9
+ args.each_with_index do |arg, i|
10
+ case arg
11
+ when "--host" then host = args[i + 1]
12
+ when /\A--host=(.+)\z/ then host = $1
13
+ when "--profile" then profile_name = args[i + 1]
14
+ when /\A--profile=(.+)\z/ then profile_name = $1
15
+ end
16
+ end
17
+
18
+ host ||= Config.host
19
+ token_response = ShapeupCli::Auth.login(host: host)
20
+ token = token_response["access_token"]
21
+
22
+ # Fetch orgs to let user pick one for this profile
23
+ client = Client.new(host: host, token: token)
24
+ result = client.call_tool("list_organisations")
25
+ data = Output.extract_data(result)
26
+ orgs = data.is_a?(Hash) ? (data["organisations"] || []) : []
27
+
28
+ if orgs.empty?
29
+ puts "No organisations found."
30
+ return
31
+ end
32
+
33
+ # If only one org, use it automatically
34
+ org = if orgs.length == 1
35
+ orgs.first
36
+ else
37
+ puts "\nChoose an organisation for this profile:\n"
38
+ orgs.each_with_index do |o, i|
39
+ puts " #{i + 1}) #{o["name"]}"
40
+ end
41
+ print "\nEnter number (1-#{orgs.length}): "
42
+ choice = $stdin.gets&.strip&.to_i
43
+ orgs[choice - 1] if choice&.between?(1, orgs.length)
44
+ end
45
+
46
+ unless org
47
+ puts "No organisation selected."
48
+ return
49
+ end
50
+
51
+ # Generate profile name from org name (slug it)
52
+ profile_name ||= org["name"].downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
53
+
54
+ Config.save_profile(
55
+ profile_name,
56
+ token: token,
57
+ host: host,
58
+ organisation_id: org["id"],
59
+ display_name: org["name"]
60
+ )
61
+
62
+ # Also set as default
63
+ Config.switch_profile(profile_name)
64
+
65
+ puts "\nProfile '#{profile_name}' created and set as default."
66
+ puts " org: #{org["name"]} (#{org["id"]})"
67
+ puts " host: #{host}"
68
+ puts "\nTo add another profile, run 'shapeup login' again."
69
+ puts "To switch: 'shapeup auth switch <name>'"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Logout
6
+ def self.run(_args)
7
+ Config.clear_credentials
8
+ puts "Logged out."
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class MyWork < Base
6
+ def self.metadata
7
+ {
8
+ command: "my-work",
9
+ path: "shapeup me",
10
+ short: "Show all pitches, scopes, and tasks assigned to you",
11
+ aliases: { "me" => "my-work" },
12
+ flags: [
13
+ { name: "user", type: "string", usage: "User ID to show work for (default: me)" }
14
+ ],
15
+ examples: [
16
+ "shapeup me",
17
+ "shapeup me --json",
18
+ "shapeup my-work --user 5"
19
+ ]
20
+ }
21
+ end
22
+
23
+ def execute
24
+ assignee = extract_option("--user") || "me"
25
+
26
+ result = call_tool("show_my_work", assignee: assignee)
27
+
28
+ render result,
29
+ summary: assignee == "me" ? "My Work" : "Work for #{assignee}",
30
+ breadcrumbs: [
31
+ { cmd: "shapeup pitch <id>", description: "View pitch details" },
32
+ { cmd: "shapeup done <id>", description: "Complete a task" },
33
+ { cmd: "shapeup tasks list --assignee me", description: "List just my tasks" }
34
+ ]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Orgs < Base
6
+ def self.metadata
7
+ {
8
+ command: "orgs",
9
+ path: "shapeup orgs",
10
+ short: "List organisations you have access to",
11
+ flags: [],
12
+ examples: [
13
+ "shapeup orgs",
14
+ "shapeup orgs --json"
15
+ ]
16
+ }
17
+ end
18
+
19
+ def execute
20
+ result = client.call_tool("list_organisations")
21
+
22
+ render result,
23
+ summary: "Organisations",
24
+ breadcrumbs: [
25
+ { cmd: "shapeup pitches list --org <id>", description: "List pitches for an org" },
26
+ { cmd: "shapeup cycles --org <id>", description: "List cycles for an org" }
27
+ ]
28
+ end
29
+
30
+ private
31
+ # Override: orgs doesn't need an org_id
32
+ def org_id = nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Pitches < Base
6
+ def self.metadata
7
+ {
8
+ command: "pitches",
9
+ path: "shapeup pitches",
10
+ short: "List and show pitches (packages)",
11
+ subcommands: [
12
+ { name: "list", short: "List pitches (default)", path: "shapeup pitches list" },
13
+ { name: "show", short: "Show pitch details with scopes and tasks", path: "shapeup pitches show <id>" },
14
+ { name: "create", short: "Create a new pitch", path: "shapeup pitches create \"Title\" --stream \"Name\"" },
15
+ { name: "help", short: "Show usage", path: "shapeup pitches help" }
16
+ ],
17
+ flags: [
18
+ { name: "status", type: "string", usage: "Filter by status: idea, framed, shaped" },
19
+ { name: "cycle", type: "string", usage: "Filter by cycle ID" },
20
+ { name: "limit", type: "integer", usage: "Limit number of results" },
21
+ { name: "stream", type: "string", usage: "Stream name or ID (for create)" },
22
+ { name: "appetite", type: "string", usage: "Appetite: unknown, small_batch, big_batch (for create, default: big_batch)" },
23
+ { name: "cycle-id", type: "string", usage: "Assign to cycle ID (for create)" }
24
+ ],
25
+ examples: [
26
+ "shapeup pitches list",
27
+ "shapeup pitches list --status shaped",
28
+ "shapeup pitches list --cycle 5",
29
+ "shapeup pitch 42",
30
+ "shapeup pitch 42 --json",
31
+ "shapeup pitches create \"Redesign Search\" --stream \"Platform\"",
32
+ "shapeup pitches create \"Auth Overhaul\" --stream \"Platform\" --appetite small_batch"
33
+ ]
34
+ }
35
+ end
36
+
37
+ def execute
38
+ subcommand = positional_arg(0)
39
+
40
+ case subcommand
41
+ when "show" then show
42
+ when "create" then create
43
+ when "list", nil then list
44
+ when "help" then help
45
+ else
46
+ subcommand.match?(/\A\d+\z/) ? show(subcommand) : list
47
+ end
48
+ end
49
+
50
+ private
51
+ def list
52
+ cycle_id = extract_option("--cycle")
53
+ status = extract_option("--status")
54
+ limit = extract_option("--limit")&.to_i
55
+ args = {}
56
+ args[:cycle] = cycle_id if cycle_id
57
+
58
+ result = call_tool("list_packages", **args)
59
+ data = Output.extract_data(result)
60
+ packages = data.is_a?(Hash) ? (data["packages"] || []) : Array(data)
61
+
62
+ # Client-side filtering
63
+ packages = packages.select { |p| p["status"] == status } if status
64
+ packages = packages.first(limit) if limit
65
+
66
+ summary = "Pitches"
67
+ summary += " (#{status})" if status
68
+ summary += " in cycle #{cycle_id}" if cycle_id
69
+ summary += " — #{packages.length} results"
70
+
71
+ render_list(packages, summary)
72
+ end
73
+
74
+ def show(id = nil)
75
+ id ||= positional_arg(1) || abort("Usage: shapeup pitches show <id>")
76
+
77
+ result = call_tool("show_package", package: id.to_s)
78
+
79
+ render result,
80
+ summary: "Pitch ##{id}",
81
+ breadcrumbs: [
82
+ { cmd: "shapeup scopes list --pitch #{id}", description: "List scopes" },
83
+ { cmd: "shapeup scopes create --pitch #{id} \"Title\"", description: "Add a scope" },
84
+ { cmd: "shapeup todo \"Task\" --pitch #{id}", description: "Add a task" },
85
+ { cmd: "shapeup tasks list --pitch #{id}", description: "List all tasks" }
86
+ ]
87
+ end
88
+
89
+ def render_list(packages, summary)
90
+ # Build a simplified list for display
91
+ items = packages.map do |p|
92
+ {
93
+ "id" => p["id"],
94
+ "title" => p["title"],
95
+ "status" => p["status"],
96
+ "appetite" => p["appetite"],
97
+ "cycle" => p["cycle"]
98
+ }
99
+ end
100
+
101
+ Output.render(
102
+ { "content" => [ { "type" => "text", "text" => JSON.generate(items) } ] },
103
+ breadcrumbs: [
104
+ { cmd: "shapeup pitch <id>", description: "View pitch details" },
105
+ { cmd: "shapeup pitches list --status shaped", description: "Show shaped pitches" },
106
+ { cmd: "shapeup pitches list --cycle <id>", description: "Filter by cycle" },
107
+ { cmd: "shapeup pitches help", description: "Show usage" }
108
+ ],
109
+ mode: @mode,
110
+ summary: summary
111
+ )
112
+ end
113
+
114
+ def create
115
+ title = positional_arg(1) || abort("Usage: shapeup pitches create \"Title\" --stream \"Name\"")
116
+ stream = extract_option("--stream") || abort("Usage: shapeup pitches create \"Title\" --stream \"Name\"")
117
+ appetite = extract_option("--appetite")
118
+ cycle_id = extract_option("--cycle-id")
119
+
120
+ args = { title: title, stream: stream }
121
+ args[:appetite] = appetite if appetite
122
+ args[:cycle] = cycle_id if cycle_id
123
+
124
+ result = call_tool("create_package", **args)
125
+
126
+ render result,
127
+ summary: "Pitch created",
128
+ breadcrumbs: [
129
+ { cmd: "shapeup scopes create --pitch <id> \"Title\"", description: "Add a scope" },
130
+ { cmd: "shapeup todo \"Task\" --pitch <id>", description: "Add a task" },
131
+ { cmd: "shapeup pitch <id>", description: "View pitch details" }
132
+ ]
133
+ end
134
+
135
+ def help
136
+ puts <<~HELP
137
+ Usage: shapeup pitches <subcommand> [options]
138
+
139
+ Subcommands:
140
+ list List pitches (default)
141
+ show <id> Show pitch details
142
+ create "Title" Create a new pitch
143
+ help This help
144
+
145
+ Filters (list):
146
+ --status <s> Filter by status: idea, framed, shaped
147
+ --cycle <id> Filter by cycle
148
+ --limit <n> Limit results
149
+
150
+ Options (create):
151
+ --stream <name> Stream name or ID (required)
152
+ --appetite <a> unknown, small_batch, big_batch (default: big_batch)
153
+ --cycle-id <id> Assign to a cycle
154
+
155
+ Output:
156
+ --json JSON envelope with breadcrumbs
157
+ --md Markdown table
158
+ --agent Raw data only
159
+
160
+ Examples:
161
+ shapeup pitches list
162
+ shapeup pitches list --status shaped
163
+ shapeup pitch 42
164
+ shapeup pitches create "Redesign Search" --stream "Platform"
165
+ shapeup pitches create "Auth Overhaul" --stream "Platform" --appetite small_batch
166
+ HELP
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Scopes < Base
6
+ def self.metadata
7
+ {
8
+ command: "scopes",
9
+ path: "shapeup scopes",
10
+ short: "Manage scopes within a pitch",
11
+ subcommands: [
12
+ { name: "list", short: "List scopes for a pitch", path: "shapeup scopes list --pitch <id>" },
13
+ { name: "create", short: "Create a new scope", path: "shapeup scopes create --pitch <id> \"Title\"" },
14
+ { name: "update", short: "Update scope title or color", path: "shapeup scopes update <id> --title \"New\"" },
15
+ { name: "position", short: "Update hill chart position (0-100)", path: "shapeup scopes position <id> <position>" }
16
+ ],
17
+ flags: [
18
+ { name: "pitch", type: "string", usage: "Pitch ID (required for list and create)" },
19
+ { name: "title", type: "string", usage: "Scope title (for create/update)" },
20
+ { name: "color", type: "string", usage: "Hex color code (for update)" }
21
+ ],
22
+ examples: [
23
+ "shapeup scopes list --pitch 42",
24
+ "shapeup scopes create --pitch 42 \"User onboarding\"",
25
+ "shapeup scopes update 7 --title \"Revised onboarding\"",
26
+ "shapeup scopes position 7 50"
27
+ ]
28
+ }
29
+ end
30
+
31
+ def execute
32
+ subcommand = positional_arg(0)
33
+
34
+ case subcommand
35
+ when "create" then create
36
+ when "update" then update
37
+ when "position" then position
38
+ when "list", nil then list
39
+ else list
40
+ end
41
+ end
42
+
43
+ private
44
+ def list
45
+ pitch_id = extract_option("--pitch") || abort("Usage: shapeup scopes list --pitch <id>")
46
+
47
+ result = call_tool("show_package", package: pitch_id.to_s)
48
+
49
+ render result,
50
+ summary: "Scopes for Pitch ##{pitch_id}",
51
+ breadcrumbs: [
52
+ { cmd: "shapeup scopes create --pitch #{pitch_id} \"Title\"", description: "Add a scope" },
53
+ { cmd: "shapeup tasks list --pitch #{pitch_id}", description: "List all tasks" }
54
+ ]
55
+ end
56
+
57
+ def create
58
+ pitch_id = extract_option("--pitch") || abort("Usage: shapeup scopes create --pitch <id> \"Title\"")
59
+ title = positional_arg(1) || abort("Usage: shapeup scopes create --pitch <id> \"Title\"")
60
+
61
+ result = call_tool("create_scope", package: pitch_id.to_s, title: title)
62
+
63
+ render result,
64
+ summary: "Scope created",
65
+ breadcrumbs: [
66
+ { cmd: "shapeup tasks list --scope <id>", description: "List tasks in this scope" },
67
+ { cmd: "shapeup todo \"Task\" --pitch #{pitch_id} --scope <id>", description: "Add a task" }
68
+ ]
69
+ end
70
+
71
+ def position
72
+ scope_id = positional_arg(1) || abort("Usage: shapeup scopes position <id> <position>")
73
+ pos = positional_arg(2) || abort("Usage: shapeup scopes position <id> <position>")
74
+
75
+ result = call_tool("update_scope_position", scope: scope_id.to_s, position: pos.to_f)
76
+
77
+ render result,
78
+ summary: "Scope ##{scope_id} moved to position #{pos}",
79
+ breadcrumbs: [
80
+ { cmd: "shapeup scopes list --pitch <id>", description: "List scopes" }
81
+ ]
82
+ end
83
+
84
+ def update
85
+ scope_id = positional_arg(1) || abort("Usage: shapeup scopes update <id> [--title \"New\"] [--color #hex]")
86
+ args = { scope: scope_id.to_s }
87
+ args[:title] = extract_option("--title") if @remaining.include?("--title")
88
+ args[:color] = extract_option("--color") if @remaining.include?("--color")
89
+
90
+ result = call_tool("update_scope", **args)
91
+
92
+ render result, summary: "Scope ##{scope_id} updated"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Search < Base
6
+ def self.metadata
7
+ {
8
+ command: "search",
9
+ path: "shapeup search",
10
+ short: "Search across pitches, scopes, tasks, tickets, and users",
11
+ flags: [],
12
+ examples: [
13
+ "shapeup search \"onboarding\"",
14
+ "shapeup search \"login bug\" --json"
15
+ ]
16
+ }
17
+ end
18
+
19
+ def execute
20
+ query = positional_arg(0) || abort("Usage: shapeup search \"query\"")
21
+
22
+ result = call_tool("search", query: query)
23
+
24
+ render result,
25
+ summary: "Search: #{query}",
26
+ breadcrumbs: [
27
+ { cmd: "shapeup pitch <id>", description: "View a pitch" },
28
+ { cmd: "shapeup cycle show <id>", description: "View a cycle" }
29
+ ]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Setup < Base
6
+ SKILL_SOURCE = File.expand_path("../../../skills/shapeup/SKILL.md", __dir__)
7
+
8
+ def self.metadata
9
+ {
10
+ command: "setup",
11
+ path: "shapeup setup",
12
+ short: "Install ShapeUp skills into an AI agent",
13
+ subcommands: [
14
+ { name: "claude", short: "Install skill into Claude Code (~/.claude/skills/)", path: "shapeup setup claude" },
15
+ { name: "cursor", short: "Install skill into Cursor (.cursor/skills/)", path: "shapeup setup cursor" },
16
+ { name: "project", short: "Install skill into current project (.claude/skills/)", path: "shapeup setup project" }
17
+ ],
18
+ flags: [],
19
+ examples: [
20
+ "shapeup setup claude",
21
+ "shapeup setup project",
22
+ "shapeup setup cursor"
23
+ ]
24
+ }
25
+ end
26
+
27
+ def execute
28
+ target = positional_arg(0)
29
+
30
+ case target
31
+ when "claude" then install_global("claude")
32
+ when "cursor" then install_global("cursor")
33
+ when "project" then install_project
34
+ else
35
+ puts <<~HELP
36
+ Usage: shapeup setup <target>
37
+
38
+ Targets:
39
+ claude Install skill globally into ~/.claude/skills/
40
+ cursor Install skill globally into ~/.cursor/skills/
41
+ project Install skill into .claude/skills/ (current directory)
42
+
43
+ This copies the ShapeUp SKILL.md so your AI agent knows how to
44
+ use the ShapeUp CLI when triggered by relevant phrases.
45
+ HELP
46
+ end
47
+ end
48
+
49
+ private
50
+ def install_global(agent)
51
+ dir = File.join(Dir.home, ".#{agent}", "skills", "shapeup")
52
+ install_skill(dir, "~/.#{agent}/skills/shapeup/")
53
+ end
54
+
55
+ def install_project
56
+ dir = File.join(".claude", "skills", "shapeup")
57
+ install_skill(dir, ".claude/skills/shapeup/")
58
+ end
59
+
60
+ def install_skill(dir, display_path)
61
+ unless File.exist?(SKILL_SOURCE)
62
+ abort "SKILL.md not found at #{SKILL_SOURCE}. Is the CLI installed correctly?"
63
+ end
64
+
65
+ FileUtils.mkdir_p(dir)
66
+ dest = File.join(dir, "SKILL.md")
67
+
68
+ if File.exist?(dest)
69
+ puts "Updating #{display_path}SKILL.md"
70
+ else
71
+ puts "Installing #{display_path}SKILL.md"
72
+ end
73
+
74
+ FileUtils.cp(SKILL_SOURCE, dest)
75
+
76
+ puts "Done! Restart your agent session to pick up the skill."
77
+ puts
78
+ puts "The skill triggers on phrases like:"
79
+ puts " 'my tasks', 'list pitches', 'cycle progress', 'shapeup', etc."
80
+ puts
81
+ puts "Your agent will use the ShapeUp CLI to handle these requests."
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShapeupCli
4
+ module Commands
5
+ class Tasks < Base
6
+ def self.metadata
7
+ {
8
+ command: "tasks",
9
+ path: "shapeup tasks",
10
+ short: "Manage tasks within scopes and pitches",
11
+ aliases: { "todo" => "tasks create", "done" => "tasks complete" },
12
+ subcommands: [
13
+ { name: "list", short: "List tasks", path: "shapeup tasks list" },
14
+ { name: "create", short: "Create a task", path: "shapeup todo \"Description\" --pitch <id>" },
15
+ { name: "complete", short: "Mark task(s) as complete", path: "shapeup done <id> [<id>...]" }
16
+ ],
17
+ flags: [
18
+ { name: "pitch", type: "string", usage: "Pitch ID (required for create)" },
19
+ { name: "scope", type: "string", usage: "Scope ID (optional filter or target)" },
20
+ { name: "assignee", type: "string", usage: "User ID or 'me' (for list)" }
21
+ ],
22
+ examples: [
23
+ "shapeup tasks list --pitch 42",
24
+ "shapeup tasks list --assignee me",
25
+ "shapeup todo \"Fix login bug\" --pitch 42 --scope 7",
26
+ "shapeup done 123",
27
+ "shapeup done 123 124 125"
28
+ ]
29
+ }
30
+ end
31
+
32
+ def execute
33
+ subcommand = positional_arg(0)
34
+
35
+ case subcommand
36
+ when "create" then create
37
+ when "complete" then complete
38
+ when "list", nil then list
39
+ else list
40
+ end
41
+ end
42
+
43
+ private
44
+ def list
45
+ scope_id = extract_option("--scope")
46
+ pitch_id = extract_option("--pitch")
47
+ assignee = extract_option("--assignee")
48
+
49
+ args = {}
50
+ args[:scope] = scope_id if scope_id
51
+ args[:package] = pitch_id if pitch_id
52
+ args[:assignee] = assignee if assignee
53
+
54
+ result = call_tool("list_tasks", **args)
55
+
56
+ render result,
57
+ summary: "Tasks",
58
+ breadcrumbs: [
59
+ { cmd: "shapeup todo \"Description\" --pitch <id>", description: "Create a task" },
60
+ { cmd: "shapeup done <id>", description: "Complete a task" }
61
+ ]
62
+ end
63
+
64
+ def create
65
+ pitch_id = extract_option("--pitch") || abort("Usage: shapeup todo \"Description\" --pitch <id> [--scope <id>]")
66
+ scope_id = extract_option("--scope")
67
+ description = positional_arg(1) || abort("Usage: shapeup todo \"Description\" --pitch <id>")
68
+
69
+ args = { package: pitch_id.to_s, description: description }
70
+ args[:scope] = scope_id if scope_id
71
+
72
+ result = call_tool("create_task", **args)
73
+
74
+ render result,
75
+ summary: "Task created",
76
+ breadcrumbs: [
77
+ { cmd: "shapeup done <id>", description: "Mark as complete" },
78
+ { cmd: "shapeup tasks list --pitch #{pitch_id}", description: "List all tasks" }
79
+ ]
80
+ end
81
+
82
+ def complete
83
+ ids = positional_args.drop(1) # drop "complete" subcommand
84
+ ids = positional_args if ids.empty? # handle shortcut: shapeup done <id>
85
+
86
+ abort("Usage: shapeup done <id> [<id>...]") if ids.empty?
87
+
88
+ ids.each do |id|
89
+ result = call_tool("complete_task", task: id.to_s)
90
+
91
+ render result,
92
+ summary: "Task ##{id} completed",
93
+ breadcrumbs: [
94
+ { cmd: "shapeup me", description: "Show remaining work" }
95
+ ]
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end