basecamp-cli 0.1.0

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: 6ad45dc6324b786c253b79d9c76d4751e32d90a62382e90091b160a14c6a5e96
4
+ data.tar.gz: 3532827c6e377a67c344d9a1d4b284344c7604e304a2b65bafe4430294ca0429
5
+ SHA512:
6
+ metadata.gz: 1ba797440b833e8c8c1b62aad1cc4d674d784f420bc4a079d228c5ba18117f5c4cc3dbea4d3bce5e5ad8ac2270a0b73ca23a164ff409d11940e2ba68a2e5d81e
7
+ data.tar.gz: da8dfe2cbbe8774d2058b8eef6fe242c8b907b278f370f80449182a31f0f73a31f425ac16550b56ab47900744aa3fad6c3005696b0532a0a8e76a09c0431200b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # Basecamp CLI
2
+
3
+ A simple command-line interface for Basecamp. List projects, browse card tables (Kanban boards), view cards, and move cards between columns.
4
+
5
+ ## Features
6
+
7
+ - **List projects** - See all projects in your Basecamp account
8
+ - **Browse boards** - View card tables and their columns within a project
9
+ - **List cards** - See all cards in a board, optionally filtered by column
10
+ - **View card details** - See full card info including description and comments
11
+ - **Move cards** - Move cards between columns
12
+
13
+ ## Installation
14
+
15
+ ### From RubyGems
16
+
17
+ ```bash
18
+ gem install basecamp-cli
19
+ ```
20
+
21
+ ### From source
22
+
23
+ ```bash
24
+ git clone https://github.com/robzolkos/ruby-basecamp-cli.git
25
+ cd ruby-basecamp-cli
26
+ bundle install
27
+ ```
28
+
29
+ Then either:
30
+ - Run directly: `./bin/basecamp`
31
+ - Add to PATH: `export PATH="$PATH:/path/to/basecamp-cli/bin"`
32
+ - Install as gem: `rake install`
33
+
34
+ ### Requirements
35
+
36
+ - Ruby 3.0+
37
+
38
+ ## Setup
39
+
40
+ ### 1. Register your application with Basecamp
41
+
42
+ Go to [launchpad.37signals.com/integrations](https://launchpad.37signals.com/integrations) and register a new application.
43
+
44
+ You'll need to provide:
45
+ - **Name**: e.g., "Basecamp CLI"
46
+ - **Redirect URI**: `http://localhost:3002/callback`
47
+
48
+ After registering, you'll receive:
49
+ - **Client ID**
50
+ - **Client Secret**
51
+
52
+ You'll also need your **Account ID**, which is the number in your Basecamp URL:
53
+ ```
54
+ https://3.basecamp.com/YOUR_ACCOUNT_ID/...
55
+ ```
56
+
57
+ ### 2. Configure the CLI
58
+
59
+ Run the init command and enter your credentials:
60
+
61
+ ```bash
62
+ ./bin/basecamp init
63
+ ```
64
+
65
+ This saves your configuration to `~/.basecamp.json`.
66
+
67
+ ### 3. Authenticate
68
+
69
+ ```bash
70
+ ./bin/basecamp auth
71
+ ```
72
+
73
+ This opens your browser for OAuth authorization. After approving, the token is saved to `~/.basecamp_token.json`.
74
+
75
+ ## Commands
76
+
77
+ ### `basecamp projects`
78
+
79
+ List all projects in your account.
80
+
81
+ ```
82
+ $ basecamp projects
83
+ Projects
84
+ ============================================================
85
+ [*] 12345678 Website Redesign
86
+ Main company website overhaul
87
+ [*] 23456789 Mobile App
88
+ iOS and Android development
89
+ [ ] 34567890 Old Project
90
+ Archived project
91
+
92
+ [*] = active
93
+ ```
94
+
95
+ **Output:**
96
+ - `[*]` indicates active projects, `[ ]` indicates inactive
97
+ - Project ID and name on each line
98
+ - Description (if set) indented below
99
+
100
+ ---
101
+
102
+ ### `basecamp boards <project_id>`
103
+
104
+ List card tables (Kanban boards) in a project, with column summary.
105
+
106
+ ```
107
+ $ basecamp boards 12345678
108
+ Card Tables in: Website Redesign
109
+ ============================================================
110
+ 87654321 Development Tasks
111
+
112
+ Columns:
113
+ - Backlog (12 cards)
114
+ - In Progress (3 cards)
115
+ - Review (2 cards)
116
+ - Done (45 cards)
117
+ ```
118
+
119
+ **Output:**
120
+ - Board ID and title
121
+ - List of columns with card counts
122
+
123
+ ---
124
+
125
+ ### `basecamp cards <project_id> <board_id> [--column <name>]`
126
+
127
+ List cards in a board. Use `--column` to filter by column name (partial match).
128
+
129
+ ```
130
+ $ basecamp cards 12345678 87654321
131
+ Cards: Development Tasks
132
+ ======================================================================
133
+
134
+ Backlog (12)
135
+ ----------------------------------------
136
+ 11111111 Fix login validation
137
+ by Jane Smith
138
+ 22222222 Add password reset flow
139
+ by John Doe
140
+ 33333333 Update API documentation
141
+ by Jane Smith
142
+
143
+ In Progress (3)
144
+ ----------------------------------------
145
+ 44444444 Implement dark mode
146
+ by John Doe
147
+ ```
148
+
149
+ With column filter:
150
+
151
+ ```
152
+ $ basecamp cards 12345678 87654321 --column "Progress"
153
+ Cards: Development Tasks
154
+ ======================================================================
155
+
156
+ In Progress (3)
157
+ ----------------------------------------
158
+ 44444444 Implement dark mode
159
+ by John Doe
160
+ 55555555 Refactor authentication
161
+ by Jane Smith
162
+ 66666666 Add unit tests
163
+ by John Doe
164
+ ```
165
+
166
+ **Output:**
167
+ - Cards grouped by column
168
+ - Card ID and title
169
+ - Creator name
170
+
171
+ ---
172
+
173
+ ### `basecamp card <project_id> <card_id> [--comments]`
174
+
175
+ View full details of a single card. Use `--comments` to include comments.
176
+
177
+ ```
178
+ $ basecamp card 12345678 44444444 --comments
179
+ Card: Implement dark mode
180
+ ======================================================================
181
+
182
+ ID: 44444444
183
+ Creator: John Doe
184
+ Created: 2025-01-15T09:30:00.000Z
185
+ Updated: 2025-01-20T14:22:00.000Z
186
+ URL: https://3.basecamp.com/12345678/buckets/.../cards/44444444
187
+ Assigned: Jane Smith, Bob Wilson
188
+
189
+ Description:
190
+ ----------------------------------------
191
+ Add dark mode support to the application. Should respect system
192
+ preferences and allow manual toggle. See design specs in Figma.
193
+
194
+ Comments (2):
195
+ ----------------------------------------
196
+
197
+ Jane Smith (2025-01-16T10:00:00.000Z):
198
+ I've started on the color palette. Will share preview tomorrow.
199
+
200
+ John Doe (2025-01-17T09:15:00.000Z):
201
+ Looks great! Let's also add a toggle in the settings menu.
202
+ ```
203
+
204
+ **Output:**
205
+ - Card metadata (ID, creator, timestamps, URL, assignees)
206
+ - Full description text (HTML stripped)
207
+ - Comments with author and timestamp (when `--comments` flag used)
208
+
209
+ ---
210
+
211
+ ### `basecamp move <project_id> <board_id> <card_id> --to <column>`
212
+
213
+ Move a card to a different column.
214
+
215
+ ```
216
+ $ basecamp move 12345678 87654321 44444444 --to "Review"
217
+ Card 44444444 moved to 'Review'
218
+ ```
219
+
220
+ **Output:**
221
+ - Confirmation message with card ID and target column
222
+
223
+ ---
224
+
225
+ ## Configuration Files
226
+
227
+ | File | Purpose |
228
+ |------|---------|
229
+ | `~/.basecamp.json` | Client credentials and account ID |
230
+ | `~/.basecamp_token.json` | OAuth access token (auto-generated) |
231
+
232
+ ### `~/.basecamp.json` format
233
+
234
+ ```json
235
+ {
236
+ "client_id": "your_client_id",
237
+ "client_secret": "your_client_secret",
238
+ "account_id": "12345678",
239
+ "redirect_uri": "http://localhost:3002/callback"
240
+ }
241
+ ```
242
+
243
+ ## API Coverage
244
+
245
+ Progress towards full [Basecamp API](https://github.com/basecamp/bc3-api) implementation.
246
+
247
+ ### Projects & Structure
248
+ - [x] Projects - list
249
+ - [ ] Projects - create, update, delete
250
+ - [ ] Basecamps (workspaces)
251
+ - [ ] Templates
252
+
253
+ ### Card Tables (Kanban)
254
+ - [x] Card tables - list, get
255
+ - [x] Card table cards - list, get, move
256
+ - [ ] Card table cards - create, update, delete
257
+ - [ ] Card table columns - list, create, update, delete
258
+ - [ ] Card table steps
259
+
260
+ ### To-dos
261
+ - [ ] Todosets
262
+ - [ ] Todolists
263
+ - [ ] Todolist groups
264
+ - [ ] Todos - list, create, update, complete, delete
265
+
266
+ ### Communication
267
+ - [ ] Message boards
268
+ - [ ] Messages
269
+ - [ ] Message types
270
+ - [x] Comments - list (on cards)
271
+ - [ ] Comments - create, update, delete
272
+ - [ ] Campfires (chat)
273
+ - [ ] Chatbots
274
+
275
+ ### Documents & Files
276
+ - [ ] Vaults (folders)
277
+ - [ ] Documents
278
+ - [ ] Uploads
279
+ - [ ] Attachments
280
+
281
+ ### Schedule
282
+ - [ ] Schedules
283
+ - [ ] Schedule entries
284
+
285
+ ### Check-ins
286
+ - [ ] Questionnaires
287
+ - [ ] Questions
288
+ - [ ] Question answers
289
+
290
+ ### Email
291
+ - [ ] Inboxes
292
+ - [ ] Inbox replies
293
+ - [ ] Forwards
294
+
295
+ ### Client Portal
296
+ - [ ] Client visibility
297
+ - [ ] Client approvals
298
+ - [ ] Client correspondences
299
+ - [ ] Client replies
300
+
301
+ ### People & Permissions
302
+ - [ ] People - list, get
303
+ - [ ] Subscriptions
304
+
305
+ ### Other
306
+ - [ ] Events
307
+ - [ ] Recordings
308
+ - [ ] Reports
309
+ - [ ] Search
310
+ - [ ] Timeline
311
+ - [ ] Lineup markers
312
+ - [ ] Rich text
313
+ - [ ] Webhooks
314
+
315
+ ## License
316
+
317
+ MIT
data/bin/basecamp ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/basecamp/cli'
5
+
6
+ Basecamp::CLI.new.run(ARGV)
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'version'
5
+ require_relative 'config'
6
+ require_relative 'client'
7
+ require_relative 'commands/init'
8
+ require_relative 'commands/auth'
9
+ require_relative 'commands/projects'
10
+ require_relative 'commands/boards'
11
+ require_relative 'commands/cards'
12
+ require_relative 'commands/card'
13
+ require_relative 'commands/move'
14
+
15
+ module Basecamp
16
+ class CLI
17
+ HELP = <<~HELP
18
+ Usage: basecamp <command> [options]
19
+
20
+ Commands:
21
+ init Configure the CLI (client_id, secret, account)
22
+ auth Authenticate with Basecamp (OAuth)
23
+ projects List all projects
24
+ boards <project_id> List card tables in a project
25
+ cards <project_id> <board_id> List cards (--column <name> to filter)
26
+ card <project_id> <card_id> Show card details (--comments for comments)
27
+ move <project_id> <board_id> <card_id> --to <column> Move a card
28
+ version Show version
29
+
30
+ Examples:
31
+ basecamp projects
32
+ basecamp boards 12345678
33
+ basecamp cards 12345678 87654321 --column "Doing"
34
+ basecamp card 12345678 11111111 --comments
35
+ basecamp move 12345678 87654321 11111111 --to "Done"
36
+ HELP
37
+
38
+ def run(args)
39
+ if args.empty? || args.first == 'help' || args.first == '--help' || args.first == '-h'
40
+ puts HELP
41
+ return
42
+ end
43
+
44
+ command = args.shift
45
+
46
+ case command
47
+ when 'version', '--version', '-v'
48
+ puts "basecamp-cli #{VERSION}"
49
+ return
50
+ when 'init'
51
+ Commands::Init.new.run
52
+ when 'auth'
53
+ Commands::Auth.new.run
54
+ when 'projects'
55
+ Commands::Projects.new.run
56
+ when 'boards'
57
+ project_id = args.shift or raise "Usage: basecamp boards <project_id>"
58
+ Commands::Boards.new.run(project_id)
59
+ when 'cards'
60
+ project_id = args.shift or raise "Usage: basecamp cards <project_id> <board_id>"
61
+ board_id = args.shift or raise "Usage: basecamp cards <project_id> <board_id>"
62
+ column = extract_option(args, '--column')
63
+ Commands::Cards.new.run(project_id, board_id, column: column)
64
+ when 'card'
65
+ project_id = args.shift or raise "Usage: basecamp card <project_id> <card_id>"
66
+ card_id = args.shift or raise "Usage: basecamp card <project_id> <card_id>"
67
+ comments = args.include?('--comments')
68
+ Commands::Card.new.run(project_id, card_id, comments: comments)
69
+ when 'move'
70
+ project_id = args.shift or raise "Usage: basecamp move <project_id> <board_id> <card_id> --to <column>"
71
+ board_id = args.shift or raise "Usage: basecamp move <project_id> <board_id> <card_id> --to <column>"
72
+ card_id = args.shift or raise "Usage: basecamp move <project_id> <board_id> <card_id> --to <column>"
73
+ to = extract_option(args, '--to') or raise "Usage: basecamp move ... --to <column>"
74
+ Commands::Move.new.run(project_id, board_id, card_id, to: to)
75
+ else
76
+ puts "Unknown command: #{command}"
77
+ puts HELP
78
+ exit 1
79
+ end
80
+ rescue => e
81
+ $stderr.puts "Error: #{e.message}"
82
+ exit 1
83
+ end
84
+
85
+ private
86
+
87
+ def extract_option(args, flag)
88
+ idx = args.index(flag)
89
+ return nil unless idx
90
+
91
+ args.delete_at(idx)
92
+ args.delete_at(idx)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ module Basecamp
9
+ class Client
10
+ USER_AGENT = 'Basecamp CLI (https://github.com/rzolkos/basecamp-cli)'
11
+
12
+ def initialize
13
+ @token = Config.token
14
+ end
15
+
16
+ def get(path)
17
+ url = path.start_with?('http') ? path : "#{Config.api_base_url}#{path}"
18
+ request(:get, url)
19
+ end
20
+
21
+ def post(path, data = {})
22
+ url = path.start_with?('http') ? path : "#{Config.api_base_url}#{path}"
23
+ request(:post, url, data)
24
+ end
25
+
26
+ def put(path, data = {})
27
+ url = path.start_with?('http') ? path : "#{Config.api_base_url}#{path}"
28
+ request(:put, url, data)
29
+ end
30
+
31
+ # Fetch all pages of a paginated endpoint
32
+ def get_all(path)
33
+ results = []
34
+ url = path.start_with?('http') ? path : "#{Config.api_base_url}#{path}"
35
+
36
+ loop do
37
+ response = request_raw(:get, url)
38
+ results.concat(JSON.parse(response.body))
39
+
40
+ # Check for next page
41
+ link_header = response['Link']
42
+ break unless link_header
43
+
44
+ next_link = link_header.split(',').find { |l| l.include?('rel="next"') }
45
+ break unless next_link
46
+
47
+ url = next_link.match(/<([^>]+)>/)[1]
48
+ end
49
+
50
+ results
51
+ end
52
+
53
+ private
54
+
55
+ def request(method, url, data = nil)
56
+ response = request_raw(method, url, data)
57
+ return nil if response.body.nil? || response.body.empty?
58
+ JSON.parse(response.body)
59
+ end
60
+
61
+ def request_raw(method, url, data = nil)
62
+ uri = URI(url)
63
+ http = Net::HTTP.new(uri.host, uri.port)
64
+ http.use_ssl = true
65
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
66
+
67
+ request = case method
68
+ when :get then Net::HTTP::Get.new(uri.request_uri)
69
+ when :post then Net::HTTP::Post.new(uri.request_uri)
70
+ when :put then Net::HTTP::Put.new(uri.request_uri)
71
+ end
72
+
73
+ request['Authorization'] = "Bearer #{@token}"
74
+ request['User-Agent'] = USER_AGENT
75
+ request['Content-Type'] = 'application/json' if data
76
+
77
+ request.body = data.to_json if data
78
+
79
+ response = http.request(request)
80
+
81
+ unless response.is_a?(Net::HTTPSuccess)
82
+ raise "API error: #{response.code} #{response.message}\n#{response.body}"
83
+ end
84
+
85
+ response
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webrick'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'openssl'
8
+
9
+ module Basecamp
10
+ module Commands
11
+ class Auth
12
+ def run
13
+ puts "Basecamp OAuth Authentication"
14
+ puts "=" * 40
15
+
16
+ @auth_code = nil
17
+ start_server
18
+ open_authorization_url
19
+ wait_for_callback
20
+ exchange_code_for_token
21
+
22
+ puts "\nAuthentication successful!"
23
+ puts "Token saved to: #{Config::TOKEN_FILE}"
24
+ end
25
+
26
+ private
27
+
28
+ def start_server
29
+ port = URI(Config.redirect_uri).port || 3002
30
+ puts "\nStarting callback server on port #{port}..."
31
+
32
+ @server = WEBrick::HTTPServer.new(
33
+ Port: port,
34
+ Logger: WEBrick::Log.new('/dev/null'),
35
+ AccessLog: []
36
+ )
37
+
38
+ @server.mount_proc '/callback' do |req, res|
39
+ @auth_code = req.query['code']
40
+
41
+ res.status = 200
42
+ res.content_type = 'text/html'
43
+ res.body = if @auth_code
44
+ '<html><body style="font-family:sans-serif;text-align:center;padding:50px;">' \
45
+ '<h1>Authentication Successful!</h1><p>You can close this window.</p></body></html>'
46
+ else
47
+ '<html><body style="font-family:sans-serif;text-align:center;padding:50px;">' \
48
+ '<h1>Authentication Failed</h1><p>No authorization code received.</p></body></html>'
49
+ end
50
+ end
51
+
52
+ Thread.new { @server.start }
53
+ sleep 0.5
54
+ end
55
+
56
+ def open_authorization_url
57
+ params = URI.encode_www_form(
58
+ type: 'web_server',
59
+ client_id: Config.client_id,
60
+ redirect_uri: Config.redirect_uri
61
+ )
62
+ auth_url = "#{Config::AUTHORIZATION_URL}?#{params}"
63
+
64
+ puts "\nOpening browser for authorization..."
65
+ puts "URL: #{auth_url}"
66
+ puts "\nIf browser doesn't open, copy the URL above."
67
+
68
+ system("xdg-open '#{auth_url}' 2>/dev/null || open '#{auth_url}' 2>/dev/null || true")
69
+ end
70
+
71
+ def wait_for_callback
72
+ puts "\nWaiting for authorization..."
73
+
74
+ timeout = 120
75
+ start = Time.now
76
+
77
+ until @auth_code
78
+ sleep 0.5
79
+ if Time.now - start > timeout
80
+ @server.shutdown
81
+ raise 'Timeout waiting for authorization'
82
+ end
83
+ end
84
+
85
+ @server.shutdown
86
+ puts "Authorization code received"
87
+ end
88
+
89
+ def exchange_code_for_token
90
+ puts "\nExchanging code for token..."
91
+
92
+ uri = URI(Config::TOKEN_URL)
93
+ http = Net::HTTP.new(uri.host, uri.port)
94
+ http.use_ssl = true
95
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
96
+
97
+ request = Net::HTTP::Post.new(uri.path)
98
+ request.set_form_data(
99
+ type: 'web_server',
100
+ client_id: Config.client_id,
101
+ client_secret: Config.client_secret,
102
+ redirect_uri: Config.redirect_uri,
103
+ code: @auth_code
104
+ )
105
+
106
+ response = http.request(request)
107
+
108
+ unless response.is_a?(Net::HTTPSuccess)
109
+ raise "Token exchange failed: #{response.code} #{response.message}\n#{response.body}"
110
+ end
111
+
112
+ token_data = JSON.parse(response.body, symbolize_names: true)
113
+ Config.save_token(token_data)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Commands
5
+ class Boards
6
+ def run(project_id)
7
+ client = Client.new
8
+
9
+ # Get project details to find the card table dock
10
+ project = client.get("/projects/#{project_id}.json")
11
+
12
+ puts "Card Tables in: #{project['name']}"
13
+ puts "=" * 60
14
+
15
+ # Find card tables in the dock
16
+ dock = project['dock'] || []
17
+ card_table_dock = dock.find { |d| d['name'] == 'kanban_board' }
18
+
19
+ unless card_table_dock
20
+ puts "No card table found in this project."
21
+ return
22
+ end
23
+
24
+ # Get the card table
25
+ card_table_url = card_table_dock['url']
26
+ card_table = client.get(card_table_url)
27
+
28
+ puts "#{card_table['id']} #{card_table['title']}"
29
+
30
+ # Show columns summary
31
+ lists = card_table['lists'] || []
32
+ if lists.any?
33
+ puts ""
34
+ puts "Columns:"
35
+ lists.each do |list|
36
+ puts " - #{list['title']} (#{list['cards_count']} cards)"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Commands
5
+ class Card
6
+ def run(project_id, card_id, comments: false)
7
+ client = Client.new
8
+
9
+ card = client.get("/buckets/#{project_id}/card_tables/cards/#{card_id}.json")
10
+
11
+ puts "Card: #{card['title']}"
12
+ puts "=" * 70
13
+ puts ""
14
+ puts "ID: #{card['id']}"
15
+ puts "Creator: #{card.dig('creator', 'name')}"
16
+ puts "Created: #{card['created_at']}"
17
+ puts "Updated: #{card['updated_at']}"
18
+ puts "URL: #{card['app_url']}"
19
+
20
+ if card['assignees'] && !card['assignees'].empty?
21
+ names = card['assignees'].map { |a| a['name'] }.join(', ')
22
+ puts "Assigned: #{names}"
23
+ end
24
+
25
+ puts ""
26
+ puts "Description:"
27
+ puts "-" * 40
28
+ description = strip_html(card['content'] || card['description'] || 'No description')
29
+ puts description
30
+ puts ""
31
+
32
+ if comments && card['comments_count']&.positive?
33
+ puts "Comments (#{card['comments_count']}):"
34
+ puts "-" * 40
35
+
36
+ all_comments = client.get_all(card['comments_url'])
37
+
38
+ all_comments.each do |comment|
39
+ author = comment.dig('creator', 'name') || 'Unknown'
40
+ content = strip_html(comment['content'] || '')
41
+ created = comment['created_at']
42
+
43
+ puts ""
44
+ puts "#{author} (#{created}):"
45
+ puts content
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def strip_html(html)
53
+ return '' unless html
54
+ html.gsub(/<[^>]*>/, ' ').gsub(/\s+/, ' ').strip
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Commands
5
+ class Cards
6
+ def run(project_id, board_id, column: nil)
7
+ client = Client.new
8
+
9
+ # Get the card table
10
+ card_table = client.get("/buckets/#{project_id}/card_tables/#{board_id}.json")
11
+
12
+ puts "Cards: #{card_table['title']}"
13
+ puts "=" * 70
14
+
15
+ lists = card_table['lists'] || []
16
+
17
+ if lists.empty?
18
+ puts "No columns found."
19
+ return
20
+ end
21
+
22
+ lists.each do |list|
23
+ column_name = list['title']
24
+
25
+ # Filter by column if specified
26
+ if column
27
+ next unless column_name.downcase.include?(column.downcase)
28
+ end
29
+
30
+ next if list['cards_count'].zero?
31
+
32
+ puts ""
33
+ puts "#{column_name} (#{list['cards_count']})"
34
+ puts "-" * 40
35
+
36
+ # Fetch cards from this column
37
+ cards = client.get(list['cards_url'])
38
+
39
+ cards.each do |card|
40
+ creator = card.dig('creator', 'name') || 'Unknown'
41
+ puts " #{card['id']} #{card['title']}"
42
+ puts " by #{creator}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Commands
5
+ class Init
6
+ def run(client_id: nil, client_secret: nil, account_id: nil, redirect_uri: nil)
7
+ puts "Basecamp CLI Configuration"
8
+ puts "=" * 40
9
+
10
+ config = {}
11
+
12
+ config[:client_id] = client_id || prompt("Client ID")
13
+ config[:client_secret] = client_secret || prompt("Client Secret")
14
+ config[:account_id] = account_id || prompt("Account ID")
15
+ config[:redirect_uri] = redirect_uri || prompt("Redirect URI", default: "http://localhost:3002/callback")
16
+
17
+ Config.save(config)
18
+
19
+ puts "\nConfiguration saved to: #{Config::CONFIG_FILE}"
20
+ puts "Run 'basecamp auth' to authenticate."
21
+ end
22
+
23
+ private
24
+
25
+ def prompt(label, default: nil)
26
+ if default
27
+ print "#{label} [#{default}]: "
28
+ else
29
+ print "#{label}: "
30
+ end
31
+
32
+ value = $stdin.gets.chomp
33
+ value.empty? ? default : value
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Commands
5
+ class Move
6
+ def run(project_id, board_id, card_id, to:)
7
+ client = Client.new
8
+
9
+ # Get the card table to find the target column
10
+ card_table = client.get("/buckets/#{project_id}/card_tables/#{board_id}.json")
11
+
12
+ lists = card_table['lists'] || []
13
+ target_column = lists.find { |l| l['title'].downcase == to.downcase }
14
+
15
+ unless target_column
16
+ puts "Column '#{to}' not found."
17
+ puts "Available columns: #{lists.map { |l| l['title'] }.join(', ')}"
18
+ return
19
+ end
20
+
21
+ # Move the card
22
+ client.post("/buckets/#{project_id}/card_tables/cards/#{card_id}/moves.json", {
23
+ column_id: target_column['id']
24
+ })
25
+
26
+ puts "Card #{card_id} moved to '#{target_column['title']}'"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Commands
5
+ class Projects
6
+ def run
7
+ client = Client.new
8
+ projects = client.get('/projects.json')
9
+
10
+ if projects.empty?
11
+ puts "No projects found."
12
+ return
13
+ end
14
+
15
+ puts "Projects"
16
+ puts "=" * 60
17
+
18
+ projects.each do |project|
19
+ status = project['status']
20
+ status_icon = status == 'active' ? '*' : ' '
21
+
22
+ puts "[#{status_icon}] #{project['id']} #{project['name']}"
23
+ puts " #{project['description']}" if project['description'] && !project['description'].empty?
24
+ end
25
+
26
+ puts ""
27
+ puts "[*] = active"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ class Config
5
+ CONFIG_FILE = File.expand_path('~/.basecamp.json')
6
+ TOKEN_FILE = File.expand_path('~/.basecamp_token.json')
7
+
8
+ # OAuth endpoints
9
+ AUTHORIZATION_URL = 'https://launchpad.37signals.com/authorization/new'
10
+ TOKEN_URL = 'https://launchpad.37signals.com/authorization/token'
11
+
12
+ class << self
13
+ def load
14
+ return @config if @config
15
+
16
+ unless File.exist?(CONFIG_FILE)
17
+ raise "Config file not found: #{CONFIG_FILE}\nRun 'basecamp init' to create one."
18
+ end
19
+
20
+ @config = JSON.parse(File.read(CONFIG_FILE), symbolize_names: true)
21
+ end
22
+
23
+ def save(config)
24
+ File.write(CONFIG_FILE, JSON.pretty_generate(config))
25
+ @config = config
26
+ end
27
+
28
+ def client_id
29
+ load[:client_id]
30
+ end
31
+
32
+ def client_secret
33
+ load[:client_secret]
34
+ end
35
+
36
+ def account_id
37
+ load[:account_id]
38
+ end
39
+
40
+ def redirect_uri
41
+ load[:redirect_uri] || 'http://localhost:3002/callback'
42
+ end
43
+
44
+ def api_base_url
45
+ "https://3.basecampapi.com/#{account_id}"
46
+ end
47
+
48
+ def token
49
+ return @token if @token
50
+
51
+ unless File.exist?(TOKEN_FILE)
52
+ raise "Not authenticated. Run 'basecamp auth' first."
53
+ end
54
+
55
+ token_data = JSON.parse(File.read(TOKEN_FILE), symbolize_names: true)
56
+
57
+ if token_data[:expires_at] && Time.now.to_i > token_data[:expires_at]
58
+ raise "Token expired. Run 'basecamp auth' to refresh."
59
+ end
60
+
61
+ @token = token_data[:access_token]
62
+ end
63
+
64
+ def save_token(token_data)
65
+ token_data[:expires_at] = Time.now.to_i + token_data[:expires_in] if token_data[:expires_in]
66
+ File.write(TOKEN_FILE, JSON.pretty_generate(token_data))
67
+ @token = token_data[:access_token]
68
+ end
69
+
70
+ def clear_token_cache
71
+ @token = nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ VERSION = '0.1.0'
5
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: basecamp-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rob Zolkos
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: webrick
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
27
+ description: A simple CLI for Basecamp. List projects, browse card tables, view cards,
28
+ and move cards between columns.
29
+ email:
30
+ - rob@zolkos.com
31
+ executables:
32
+ - basecamp
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - bin/basecamp
39
+ - lib/basecamp/cli.rb
40
+ - lib/basecamp/client.rb
41
+ - lib/basecamp/commands/auth.rb
42
+ - lib/basecamp/commands/boards.rb
43
+ - lib/basecamp/commands/card.rb
44
+ - lib/basecamp/commands/cards.rb
45
+ - lib/basecamp/commands/init.rb
46
+ - lib/basecamp/commands/move.rb
47
+ - lib/basecamp/commands/projects.rb
48
+ - lib/basecamp/config.rb
49
+ - lib/basecamp/version.rb
50
+ homepage: https://github.com/robzolkos/ruby-basecamp-cli
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/robzolkos/ruby-basecamp-cli
55
+ source_code_uri: https://github.com/robzolkos/ruby-basecamp-cli
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.0.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Command-line interface for Basecamp
75
+ test_files: []