tickrb 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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +15 -0
  4. data/Gemfile +17 -0
  5. data/Gemfile.lock +143 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +112 -0
  8. data/Rakefile +53 -0
  9. data/lib/tickrb/auth.rb +106 -0
  10. data/lib/tickrb/client.rb +144 -0
  11. data/lib/tickrb/mcp_server.rb +445 -0
  12. data/lib/tickrb/token_store.rb +29 -0
  13. data/lib/tickrb/version.rb +6 -0
  14. data/lib/tickrb.rb +37 -0
  15. data/sorbet/config +4 -0
  16. data/sorbet/rbi/gems/.gitattributes +1 -0
  17. data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
  18. data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
  19. data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
  20. data/sorbet/rbi/gems/dotenv@3.1.8.rbi +295 -0
  21. data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
  22. data/sorbet/rbi/gems/json@2.12.2.rbi +2051 -0
  23. data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +14244 -0
  24. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +240 -0
  25. data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
  26. data/sorbet/rbi/gems/net-http@0.6.0.rbi +4247 -0
  27. data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
  28. data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
  29. data/sorbet/rbi/gems/parser@3.3.8.0.rbi +5535 -0
  30. data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
  31. data/sorbet/rbi/gems/racc@1.8.1.rbi +164 -0
  32. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
  33. data/sorbet/rbi/gems/rake@13.3.0.rbi +3031 -0
  34. data/sorbet/rbi/gems/rbi@0.3.3.rbi +6742 -0
  35. data/sorbet/rbi/gems/rbs@3.9.4.rbi +6976 -0
  36. data/sorbet/rbi/gems/regexp_parser@2.10.0.rbi +3795 -0
  37. data/sorbet/rbi/gems/rexml@3.4.1.rbi +5243 -0
  38. data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11238 -0
  39. data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
  40. data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
  41. data/sorbet/rbi/gems/rspec-sorbet-types@0.3.0.rbi +130 -0
  42. data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
  43. data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
  44. data/sorbet/rbi/gems/rubocop-ast@1.45.0.rbi +7721 -0
  45. data/sorbet/rbi/gems/rubocop-performance@1.25.0.rbi +9 -0
  46. data/sorbet/rbi/gems/rubocop@1.75.8.rbi +62104 -0
  47. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +1318 -0
  48. data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
  49. data/sorbet/rbi/gems/standard-custom@1.0.2.rbi +9 -0
  50. data/sorbet/rbi/gems/standard-performance@1.8.0.rbi +9 -0
  51. data/sorbet/rbi/gems/standard@1.50.0.rbi +1146 -0
  52. data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
  53. data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
  54. data/sorbet/rbi/gems/unicode-display_width@3.1.4.rbi +132 -0
  55. data/sorbet/rbi/gems/unicode-emoji@4.0.4.rbi +251 -0
  56. data/sorbet/rbi/gems/uri@1.0.3.rbi +2325 -0
  57. data/sorbet/rbi/gems/webrick@1.9.1.rbi +2856 -0
  58. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
  59. data/sorbet/rbi/gems/yard@0.9.37.rbi +18445 -0
  60. data/sorbet/rbi/tickrb.rbi +9 -0
  61. data/sorbet/tapioca.yml +10 -0
  62. data/test_api_direct.rb +98 -0
  63. data/test_mcp_server.rb +157 -0
  64. data/tickrb.gemspec +47 -0
  65. metadata +278 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1259ef1c25eaefaeb97799c08b14263216e146fcbbdbcb2ca9402df91efb437e
4
+ data.tar.gz: 8bb1528dd50101fa6516b8b386e1762125d082f3ac46865d3d50095e1c5107eb
5
+ SHA512:
6
+ metadata.gz: 69fb625733958656d9fd280ad6096f994008c9d634af87711388c01da7bbb14d4eeb11f86467fe27b82a89ae2c62a70b11190c80d35573c4b9716ca46b735fb1
7
+ data.tar.gz: 1992e85fa8c9dbdb29c0311ded01ef7a02c6b4cab3785fbd516d8a837bcf95c2f992a269b012e255341f19e4a8e09dc2eb7331897661c14a4f00769ceb2d0855
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,15 @@
1
+ require:
2
+ - standard
3
+
4
+ plugins:
5
+ - standard-custom
6
+ - standard-performance
7
+ - rubocop-performance
8
+
9
+ inherit_gem:
10
+ standard: config/base.yml
11
+ standard-custom: config/base.yml
12
+ standard-performance: config/base.yml
13
+
14
+ AllCops:
15
+ SuggestExtensions: false
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in tickrb.gemspec
4
+ gemspec
5
+
6
+ gem "rake", "~> 13.0"
7
+ gem "rspec", "~> 3.0"
8
+ gem "sorbet-runtime"
9
+ gem "webrick"
10
+ gem "net-http"
11
+ gem "uri"
12
+
13
+ gem "sorbet", "~> 0.5", group: :development
14
+ gem "tapioca", require: false, group: %i[development test]
15
+ gem "rspec-sorbet-types", group: :development
16
+ gem "standard", group: :development
17
+ gem "dotenv", groups: [:development, :test]
data/Gemfile.lock ADDED
@@ -0,0 +1,143 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ tickrb (0.1.0)
5
+ net-http
6
+ sorbet-runtime
7
+ uri
8
+ webrick
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ ast (2.4.3)
14
+ benchmark (0.4.1)
15
+ diff-lcs (1.6.2)
16
+ dotenv (3.1.8)
17
+ erubi (1.13.1)
18
+ json (2.12.2)
19
+ language_server-protocol (3.17.0.5)
20
+ lint_roller (1.1.0)
21
+ logger (1.7.0)
22
+ net-http (0.6.0)
23
+ uri
24
+ netrc (0.11.0)
25
+ parallel (1.27.0)
26
+ parser (3.3.8.0)
27
+ ast (~> 2.4.1)
28
+ racc
29
+ prism (1.4.0)
30
+ racc (1.8.1)
31
+ rainbow (3.1.1)
32
+ rake (13.3.0)
33
+ rbi (0.3.3)
34
+ prism (~> 1.0)
35
+ rbs (>= 3.4.4)
36
+ sorbet-runtime (>= 0.5.9204)
37
+ rbs (3.9.4)
38
+ logger
39
+ regexp_parser (2.10.0)
40
+ rexml (3.4.1)
41
+ rspec (3.13.1)
42
+ rspec-core (~> 3.13.0)
43
+ rspec-expectations (~> 3.13.0)
44
+ rspec-mocks (~> 3.13.0)
45
+ rspec-core (3.13.4)
46
+ rspec-support (~> 3.13.0)
47
+ rspec-expectations (3.13.5)
48
+ diff-lcs (>= 1.2.0, < 2.0)
49
+ rspec-support (~> 3.13.0)
50
+ rspec-mocks (3.13.5)
51
+ diff-lcs (>= 1.2.0, < 2.0)
52
+ rspec-support (~> 3.13.0)
53
+ rspec-sorbet-types (0.3.0)
54
+ rspec (~> 3.0)
55
+ sorbet-runtime
56
+ tapioca (>= 0.16)
57
+ rspec-support (3.13.4)
58
+ rubocop (1.75.8)
59
+ json (~> 2.3)
60
+ language_server-protocol (~> 3.17.0.2)
61
+ lint_roller (~> 1.1.0)
62
+ parallel (~> 1.10)
63
+ parser (>= 3.3.0.2)
64
+ rainbow (>= 2.2.2, < 4.0)
65
+ regexp_parser (>= 2.9.3, < 3.0)
66
+ rubocop-ast (>= 1.44.0, < 2.0)
67
+ ruby-progressbar (~> 1.7)
68
+ unicode-display_width (>= 2.4.0, < 4.0)
69
+ rubocop-ast (1.45.0)
70
+ parser (>= 3.3.7.2)
71
+ prism (~> 1.4)
72
+ rubocop-performance (1.25.0)
73
+ lint_roller (~> 1.1)
74
+ rubocop (>= 1.75.0, < 2.0)
75
+ rubocop-ast (>= 1.38.0, < 2.0)
76
+ ruby-progressbar (1.13.0)
77
+ sorbet (0.5.12149)
78
+ sorbet-static (= 0.5.12149)
79
+ sorbet-runtime (0.5.12149)
80
+ sorbet-static (0.5.12149-universal-darwin)
81
+ sorbet-static-and-runtime (0.5.12149)
82
+ sorbet (= 0.5.12149)
83
+ sorbet-runtime (= 0.5.12149)
84
+ spoom (1.6.3)
85
+ erubi (>= 1.10.0)
86
+ prism (>= 0.28.0)
87
+ rbi (>= 0.3.3)
88
+ rexml (>= 3.2.6)
89
+ sorbet-static-and-runtime (>= 0.5.10187)
90
+ thor (>= 0.19.2)
91
+ standard (1.50.0)
92
+ language_server-protocol (~> 3.17.0.2)
93
+ lint_roller (~> 1.0)
94
+ rubocop (~> 1.75.5)
95
+ standard-custom (~> 1.0.0)
96
+ standard-performance (~> 1.8)
97
+ standard-custom (1.0.2)
98
+ lint_roller (~> 1.0)
99
+ rubocop (~> 1.50)
100
+ standard-performance (1.8.0)
101
+ lint_roller (~> 1.1)
102
+ rubocop-performance (~> 1.25.0)
103
+ tapioca (0.16.11)
104
+ benchmark
105
+ bundler (>= 2.2.25)
106
+ netrc (>= 0.11.0)
107
+ parallel (>= 1.21.0)
108
+ rbi (~> 0.2)
109
+ sorbet-static-and-runtime (>= 0.5.11087)
110
+ spoom (>= 1.2.0)
111
+ thor (>= 1.2.0)
112
+ yard-sorbet
113
+ thor (1.3.2)
114
+ unicode-display_width (3.1.4)
115
+ unicode-emoji (~> 4.0, >= 4.0.4)
116
+ unicode-emoji (4.0.4)
117
+ uri (1.0.3)
118
+ webrick (1.9.1)
119
+ yard (0.9.37)
120
+ yard-sorbet (0.9.0)
121
+ sorbet-runtime
122
+ yard
123
+
124
+ PLATFORMS
125
+ x86_64-darwin-22
126
+
127
+ DEPENDENCIES
128
+ dotenv
129
+ net-http
130
+ rake (~> 13.0)
131
+ rspec (~> 3.0)
132
+ rspec-sorbet-types
133
+ rubocop (~> 1.21)
134
+ sorbet (~> 0.5)
135
+ sorbet-runtime
136
+ standard
137
+ tapioca
138
+ tickrb!
139
+ uri
140
+ webrick
141
+
142
+ BUNDLED WITH
143
+ 2.4.9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Graham Turner
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,112 @@
1
+ # TickRb
2
+
3
+ A Ruby gem that provides TickTick integration through a Model Context Protocol (MCP) server, enabling Claude and other AI assistants to manage your TickTick tasks seamlessly.
4
+
5
+ ## Quick Start
6
+
7
+ 1. **Install the gem**: `gem install tickrb`
8
+ 2. **Get TickTick credentials** from [TickTick Developer Console](https://developer.ticktick.com/)
9
+ 3. **Configure Claude** with your credentials in MCP config
10
+ 4. **Authenticate**: The first time Claude starts TickRb MCP server, it will prompt for OAuth.
11
+ 5. **Start chatting** with Claude about your tasks!
12
+
13
+ ## Installation
14
+
15
+ $ gem install tickrb
16
+
17
+ ## Setup
18
+
19
+ Before using TickRb, you need to create a TickTick application to get OAuth credentials:
20
+
21
+ 1. Go to [TickTick Developer Console](https://developer.ticktick.com/)
22
+ 2. Create a new application
23
+ 3. Note your `Client ID` and `Client Secret`
24
+ 4. Set the redirect URI to `http://localhost:8080/callback`
25
+
26
+ ## Usage
27
+
28
+ After installing the MCP server, the first usage will open a browser window to authenticate with ticktick.
29
+
30
+ ### Claude Desktop Configuration
31
+
32
+ For Claude Desktop, add this to your MCP settings file:
33
+
34
+ **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "tickrb": {
40
+ "command": "tickrb-mcp-server",
41
+ "args": [
42
+ "--client-id", "your_ticktick_client_id",
43
+ "--client-secret", "your_ticktick_client_secret"
44
+ ]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ### Claude Code (CLI) Configuration
51
+
52
+ For Claude Code, add the MCP server to your configuration:
53
+
54
+ ```bash
55
+ # Add the MCP server with credentials
56
+ claude-code mcp install tickrb tickrb-mcp-server --client-id your_ticktick_client_id --client-secret your_ticktick_client_secret
57
+ ```
58
+
59
+ ### Available MCP Tools
60
+
61
+ Once connected to Claude, you can use these natural language commands:
62
+
63
+ - **"List my tasks"** - Shows all your TickTick tasks
64
+ - **"Create a task called 'Buy groceries'"** - Creates a new task
65
+ - **"Complete the task with ID xyz"** - Marks a task as complete
66
+ - **"Delete the task with ID xyz"** - Removes a task
67
+ - **"Show my projects"** - Lists all your TickTick projects
68
+
69
+ ## Configuration
70
+
71
+ ### Token Storage
72
+
73
+ Authentication tokens are stored in `~/.config/tickrb/token.json`.
74
+
75
+ ### Command Line Help
76
+
77
+ To see all available options:
78
+
79
+ ```bash
80
+ tickrb-mcp-server --help
81
+ ```
82
+
83
+ ## Development
84
+
85
+ After checking out the repo, run `bin/setup` to install dependencies.
86
+
87
+ ### Running Tests
88
+
89
+ ```bash
90
+ # Run all tests
91
+ bundle exec rake spec
92
+
93
+ # Run complete pipeline (tests + linting + type checking)
94
+ bundle exec rake ci
95
+
96
+ # Run individual quality checks
97
+ bundle exec rake test # Tests only
98
+ bundle exec rake lint # Linting only
99
+ bundle exec rake typecheck # Type checking only
100
+ bundle exec rake fix # Auto-fix linting issues
101
+ ```
102
+
103
+ ### Type Checking
104
+
105
+ This gem uses [Sorbet](https://sorbet.org/) for static type checking. Common Sorbet commands:
106
+
107
+ - `bundle exec rake typecheck` - Run type checker
108
+ - `bundle exec rake rbi` - Update RBI files
109
+
110
+ ## License
111
+
112
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) do |t|
7
+ t.rspec_opts = "--format progress"
8
+ t.verbose = false
9
+ end
10
+
11
+ require "rubocop/rake_task"
12
+
13
+ RuboCop::RakeTask.new
14
+
15
+ begin
16
+ require "tapioca/internal"
17
+ rescue LoadError
18
+ # tapioca tasks won't be available
19
+ end
20
+
21
+ task default: %i[spec rubocop typecheck]
22
+
23
+ desc "Run all tests"
24
+ task test: :spec
25
+
26
+ desc "Run linting"
27
+ task lint: :rubocop
28
+
29
+ desc "Run type checking"
30
+ task typecheck: "sorbet:tc"
31
+
32
+ desc "Run type checking"
33
+ task rbi: "sorbet:rbi"
34
+
35
+ desc "Run all quality checks (tests, lint, typecheck)"
36
+ task ci: [:spec, :rubocop, :rbi, :typecheck]
37
+
38
+ desc "Auto-fix linting issues"
39
+ task :fix do
40
+ sh "bundle exec rubocop --auto-correct-all"
41
+ end
42
+
43
+ namespace :sorbet do
44
+ desc "Run Sorbet type checker"
45
+ task :tc do
46
+ sh "bundle exec srb tc"
47
+ end
48
+
49
+ desc "Update RBI files"
50
+ task :rbi do
51
+ sh "bundle exec tapioca gems"
52
+ end
53
+ end
@@ -0,0 +1,106 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "webrick"
5
+ require "net/http"
6
+ require "uri"
7
+ require "base64"
8
+ require "json"
9
+
10
+ require_relative "token_store"
11
+
12
+ AUTH_URL = "https://ticktick.com/oauth/authorize?"
13
+
14
+ module Tickrb
15
+ class Auth
16
+ class << self
17
+ def run(client_id: nil, client_secret: nil, redirect_uri: nil)
18
+ auth_client = new(
19
+ client_id: client_id,
20
+ client_secret: client_secret,
21
+ redirect_uri: redirect_uri
22
+ )
23
+ auth_client.run_oauth_flow
24
+ end
25
+ end
26
+
27
+ def initialize(client_id: nil, client_secret: nil, redirect_uri: nil, token_store: nil)
28
+ @client_id = client_id || ENV["CLIENT_ID"]
29
+ @client_secret = client_secret || ENV["CLIENT_SECRET"]
30
+ @redirect_uri = redirect_uri || ENV["REDIRECT_URI"]
31
+ @token_store = token_store || TokenStore
32
+ end
33
+
34
+ def run_oauth_flow
35
+ # Start local server to receive callback
36
+ server = WEBrick::HTTPServer.new(Port: 8080, Logger: WEBrick::Log.new(File::NULL))
37
+
38
+ # TODO: Generate a random state parameter for CSRF protection
39
+ auth_params = {
40
+ client_id: client_id,
41
+ scope: scope,
42
+ redirect_uri: redirect_uri,
43
+ response_type: "code"
44
+ }
45
+
46
+ auth_url_params = build_auth_url_params(auth_params)
47
+ auth_url = AUTH_URL + auth_url_params
48
+
49
+ system("open \"#{auth_url}\"") # macOS, use 'xdg-open' on Linux
50
+
51
+ # Handle callback
52
+ server.mount_proc "/callback", ->(req, res) do
53
+ code = req.query["code"]
54
+ # Exchange code for token
55
+ token_info = exchange_code_for_token(code)
56
+ token_store.store_token(token_info)
57
+
58
+ res.body = "Authentication successful! You can close this window."
59
+ server.shutdown
60
+ end
61
+
62
+ server.start
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :client_id, :client_secret, :redirect_uri, :token_store
68
+
69
+ def exchange_code_for_token(code)
70
+ uri = URI("https://ticktick.com/oauth/token")
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.use_ssl = true
73
+
74
+ request = Net::HTTP::Post.new(uri)
75
+ token_data = {
76
+ grant_type: "authorization_code",
77
+ code: code,
78
+ redirect_uri: redirect_uri,
79
+ scope: scope
80
+ }
81
+
82
+ request.body = URI.encode_www_form(token_data)
83
+
84
+ request["Content-Type"] = "application/x-www-form-urlencoded"
85
+ request["Authorization"] = "Basic #{basic_auth}".delete("\n")
86
+ request["User-Agent"] = "curl/8.7.1"
87
+ request["Accept-Encoding"] = nil
88
+
89
+ response = http.request(request)
90
+
91
+ JSON.parse(response.body).slice("access_token", "expires_in")
92
+ end
93
+
94
+ def scope
95
+ "tasks:write tasks:read"
96
+ end
97
+
98
+ def basic_auth
99
+ Base64.encode64("#{client_id}:#{client_secret}")
100
+ end
101
+
102
+ def build_auth_url_params(params)
103
+ URI.encode_www_form(params)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,144 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "uri"
6
+ require "json"
7
+ require "sorbet-runtime"
8
+
9
+ module Tickrb
10
+ class Client
11
+ extend T::Sig
12
+
13
+ BASE_URL = "https://api.ticktick.com/open/v1"
14
+
15
+ sig { params(token: T.nilable(String)).void }
16
+ def initialize(token: nil)
17
+ @token = token || TokenStore.load_token
18
+ raise Error, "No authentication token available. Please run authentication first." unless @token
19
+ @projects_cache = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]]))
20
+ @tasks_cache = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]]))
21
+ @cache_timestamp = T.let(nil, T.nilable(Time))
22
+ end
23
+
24
+ sig { returns(T::Array[T::Hash[String, T.untyped]]) }
25
+ def get_tasks
26
+ return @tasks_cache if cache_valid? && @tasks_cache
27
+
28
+ all_tasks = []
29
+ projects = get_projects
30
+
31
+ projects.each do |project|
32
+ project_tasks = get_tasks_for_project(project["id"])
33
+ project_tasks.each { |task| task["projectId"] = project["id"] }
34
+ all_tasks.concat(project_tasks)
35
+ end
36
+
37
+ @tasks_cache = all_tasks
38
+ @cache_timestamp = Time.now.utc
39
+ all_tasks
40
+ end
41
+
42
+ sig { params(project_id: String).returns(T::Array[T::Hash[String, T.untyped]]) }
43
+ def get_tasks_for_project(project_id)
44
+ response = make_request("GET", "/project/#{project_id}/data")
45
+ if response.is_a?(Hash) && response["tasks"]
46
+ response["tasks"]
47
+ else
48
+ []
49
+ end
50
+ end
51
+
52
+ sig { params(title: String, content: T.nilable(String), project_id: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
53
+ def create_task(title:, content: nil, project_id: nil)
54
+ task_data = {
55
+ title: title,
56
+ content: content,
57
+ projectId: project_id
58
+ }.compact
59
+
60
+ result = T.cast(make_request("POST", "/task", task_data), T::Hash[String, T.untyped])
61
+ invalidate_cache
62
+ result
63
+ end
64
+
65
+ sig { params(task_id: String, project_id: String).returns(T::Hash[String, T.untyped]) }
66
+ def complete_task(task_id, project_id)
67
+ result = T.cast(make_request("POST", "/project/#{project_id}/task/#{task_id}/complete"), T::Hash[String, T.untyped])
68
+ invalidate_cache
69
+ result
70
+ end
71
+
72
+ sig { params(task_id: String, project_id: String).returns(T::Hash[String, T.untyped]) }
73
+ def delete_task(task_id, project_id)
74
+ result = T.cast(make_request("DELETE", "/project/#{project_id}/task/#{task_id}"), T::Hash[String, T.untyped])
75
+ invalidate_cache
76
+ result
77
+ end
78
+
79
+ sig { returns(T::Array[T::Hash[String, T.untyped]]) }
80
+ def get_projects
81
+ return @projects_cache if cache_valid? && @projects_cache
82
+
83
+ response = make_request("GET", "/project")
84
+ @projects_cache = response.is_a?(Array) ? response : []
85
+ @cache_timestamp = Time.now.utc
86
+ @projects_cache
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :token
92
+
93
+ sig { returns(T::Boolean) }
94
+ def cache_valid?
95
+ return false unless @cache_timestamp
96
+ Time.now.utc - @cache_timestamp < 100
97
+ end
98
+
99
+ sig { void }
100
+ def invalidate_cache
101
+ @projects_cache = nil
102
+ @tasks_cache = nil
103
+ @cache_timestamp = nil
104
+ end
105
+
106
+ sig { params(method: String, endpoint: String, data: T.nilable(T::Hash[String, T.untyped])).returns(T.any(T::Hash[String, T.untyped], T::Array[T::Hash[String, T.untyped]])) }
107
+ def make_request(method, endpoint, data = nil)
108
+ uri = URI("#{BASE_URL}#{endpoint}")
109
+ http = Net::HTTP.new(uri.host, uri.port)
110
+ http.use_ssl = true
111
+
112
+ case method.upcase
113
+ when "GET"
114
+ request = Net::HTTP::Get.new(uri)
115
+ when "POST"
116
+ request = Net::HTTP::Post.new(uri)
117
+ if data
118
+ request.body = data.to_json
119
+ request["Content-Type"] = "application/json"
120
+ end
121
+ when "DELETE"
122
+ request = Net::HTTP::Delete.new(uri)
123
+ else
124
+ raise Error, "Unsupported HTTP method: #{method}"
125
+ end
126
+
127
+ request["Authorization"] = "Bearer #{token}"
128
+ request["User-Agent"] = "TickRb/1.0.0"
129
+
130
+ response = http.request(request)
131
+
132
+ case response.code.to_i
133
+ when 200..299
134
+ response.body.empty? ? {} : JSON.parse(response.body)
135
+ when 401
136
+ raise Error, "Authentication failed. Token may be expired."
137
+ when 404
138
+ raise Error, "Resource not found"
139
+ else
140
+ raise Error, "API request failed: #{response.code} #{response.message}"
141
+ end
142
+ end
143
+ end
144
+ end