cardinal-ai 0.2.16 → 0.2.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84bab289a5a9655c54a7e2cbd4587c21275a122903d08892f264c20fc2c0a52d
4
- data.tar.gz: fdf187aa387793ddb12e7e5ccb6d9cb649a3214d894859490217be45585d690b
3
+ metadata.gz: aaa2760a909ef2ee8cef012432e1a15311d1d3db2b1f37a5f2374e4322fc0c92
4
+ data.tar.gz: 6f995d0029b94d19dbb7f26fccf1dd01014ceab5973a766a91ef72aec043335d
5
5
  SHA512:
6
- metadata.gz: b128883b680056de0b221bba6df4d8db8c8ea299ef93feec45c30cf3c7df238982b983cc6ffb50db17ed52c7781d71ee2442c4091c1fd1c922e03209ca2af7ea
7
- data.tar.gz: de0ae6c696aa5b3fa490f5f985a9acf81bf45d468660ff47522ec835c9871061b039eee9f5d872570667d8c109dd1b08ca2b71fe861cbea7a775f2ccef70d61e
6
+ metadata.gz: 58d4a91169f2b8ab305e86b49d4d839cb16b275904d672525352258845af6ab70b6c47777d6a9b56dfc45b1db583b19fbb5e7bcce3d63cd8ec578e2d5a1e06ae
7
+ data.tar.gz: bd8de891a41f0a7841d371d342173639b7548988b8c25377804207971da1735f6e9ea9b2a8e6e17a736a6487d0d394b0160a5aa18ce60638e51b78b1bce5c693
@@ -127,7 +127,13 @@ a { color: var(--blue); text-decoration: none; }
127
127
  .archive-meta { display: block; font-size: 11px; color: var(--text-dim); margin-top: 3px; }
128
128
  .restore-btn { white-space: nowrap; }
129
129
 
130
- .note-toggle { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; cursor: pointer; }
130
+ .composer-box { flex: 1; display: flex; flex-direction: column; gap: 3px; }
131
+ .composer-box textarea { width: 100%; }
132
+ .note-toggle {
133
+ font-size: 11px; color: var(--text-dim); cursor: pointer; align-self: flex-start;
134
+ display: inline-flex; align-items: center; gap: 4px;
135
+ }
136
+ .note-toggle input[type="checkbox"] { margin: 0; }
131
137
  .note-toggle input { accent-color: var(--amber); }
132
138
  .note-flag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 8px; padding: 0 6px; margin-right: 4px; }
133
139
 
@@ -152,6 +158,16 @@ a { color: var(--blue); text-decoration: none; }
152
158
  .archive-rails .inline-check { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; cursor: pointer; }
153
159
  .archive-rails .hint { display: block; margin-top: 6px; }
154
160
 
161
+ .asana-entry {
162
+ margin-right: auto; display: inline-flex; align-items: center;
163
+ background: transparent; color: var(--text-dim); border: 1px solid var(--border);
164
+ border-radius: 6px; padding: 6px 12px; font-weight: 600; font-size: inherit;
165
+ }
166
+ .asana-entry:hover { color: var(--text); border-color: var(--text-dim); }
167
+ .asana-steps { font-size: 13px; margin: 8px 0 14px; padding-left: 20px; }
168
+ .asana-steps li { margin: 4px 0; }
169
+ .asana-connected { color: var(--green); }
170
+
155
171
  .pull-form { display: inline; }
156
172
  #repo-pull-status { font-size: 0.85rem; }
157
173
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -0,0 +1,31 @@
1
+ # Asana import (card #7). First visit is a connect wizard (PAT walkthrough);
2
+ # once connected it's a one-field modal: paste the task URL, get a card.
3
+ class AsanaController < ApplicationController
4
+ def new_card
5
+ @board = Board.first!
6
+ redirect_to root_path and return unless turbo_frame_request?
7
+ @connected = Asana.connected?
8
+ @error = params[:error]
9
+ @just_connected = params[:connected]
10
+ end
11
+
12
+ def connect
13
+ name = Asana.verify!(params.require(:token))
14
+ Asana.save_token!(params[:token])
15
+ redirect_to asana_new_card_path(connected: name)
16
+ rescue Asana::Error => e
17
+ redirect_to asana_new_card_path(error: e.message)
18
+ end
19
+
20
+ def import
21
+ card = Asana.import!(Board.first!, params.require(:url))
22
+ redirect_to card_path(card)
23
+ rescue Asana::Error => e
24
+ redirect_to asana_new_card_path(error: e.message)
25
+ end
26
+
27
+ def disconnect
28
+ Asana.disconnect!
29
+ redirect_to asana_new_card_path
30
+ end
31
+ end
@@ -0,0 +1,73 @@
1
+ require "net/http"
2
+
3
+ # Asana import (card #7): paste a task URL, get a card. First use walks
4
+ # through connecting a Personal Access Token, which lives as a 0600 file in
5
+ # .cardinal/ (like the Claude token) — never in the database, never in git.
6
+ module Asana
7
+ API = "https://app.asana.com/api/1.0".freeze
8
+
9
+ class Error < StandardError; end
10
+
11
+ def self.token_path
12
+ Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("asana-token")
13
+ end
14
+
15
+ def self.connected? = !!File.size?(token_path)
16
+ def self.token = File.read(token_path).strip
17
+
18
+ def self.save_token!(value)
19
+ FileUtils.mkdir_p(File.dirname(token_path))
20
+ File.write(token_path, value.strip)
21
+ File.chmod(0o600, token_path)
22
+ end
23
+
24
+ def self.disconnect! = FileUtils.rm_f(token_path)
25
+
26
+ # Cheapest possible "does this token work" — also gives us a name to show.
27
+ def self.verify!(candidate)
28
+ data = request("/users/me", candidate)
29
+ data["name"].presence || data["email"].presence || "connected"
30
+ end
31
+
32
+ # Task URLs come in several vintages (/0/<project>/<task>, /0/.../f,
33
+ # /1/<ws>/project/<p>/task/<t>) — the task gid is the last long digit run.
34
+ def self.task_gid(url)
35
+ url.to_s.scan(/\d{6,}/).last or raise Error, "That doesn't look like an Asana task URL"
36
+ end
37
+
38
+ def self.import!(board, url)
39
+ data = request("/tasks/#{task_gid(url)}?opt_fields=name,notes,permalink_url,tags.name", token)
40
+ permalink = data["permalink_url"].presence || url
41
+ if (existing = board.cards.find_by(asana_url: permalink))
42
+ return existing
43
+ end
44
+
45
+ inbox = board.columns.inbox.order(:position).first
46
+ card = board.cards.create!(
47
+ column: inbox,
48
+ title: data["name"].presence || "Asana task",
49
+ asana_url: permalink,
50
+ tags: Array(data["tags"]).filter_map { |t| t["name"] }.first(5),
51
+ description: "#{data["notes"].presence || "(no description on the Asana task)"}\n\n---\n_Imported from Asana: #{permalink}_"
52
+ )
53
+ card.log!("status_change", actor: "user", text: "Imported from Asana: #{permalink}")
54
+ card
55
+ end
56
+
57
+ def self.request(path, auth)
58
+ uri = URI("#{API}#{path}")
59
+ http = Net::HTTP.new(uri.host, uri.port)
60
+ http.use_ssl = true
61
+ http.open_timeout = 10
62
+ http.read_timeout = 15
63
+ response = http.get(uri.request_uri, { "Authorization" => "Bearer #{auth}" })
64
+ unless response.code.to_i == 200
65
+ raise Error, "Asana said no (HTTP #{response.code}) — check the token, and that it can see this task"
66
+ end
67
+ JSON.parse(response.body)["data"]
68
+ rescue JSON::ParserError
69
+ raise Error, "Asana returned something unreadable"
70
+ rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
71
+ raise Error, "Couldn't reach Asana (#{e.class.name.demodulize}) — check your connection"
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
3
+ <div class="modal modal-sm">
4
+ <header class="modal-header">
5
+ <h1>◯ New from Asana</h1>
6
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
7
+ </header>
8
+ <div class="modal-body">
9
+ <% if @error.present? %><p class="form-error"><%= @error %></p><% end %>
10
+
11
+ <% if @connected %>
12
+ <% if @just_connected.present? %>
13
+ <p class="hint asana-connected">✅ Connected as <strong><%= @just_connected %></strong> — you won't need to do that again.</p>
14
+ <% end %>
15
+ <p class="hint">Paste the Asana task's URL (open the task, copy the address bar or
16
+ use "Copy task link"). Cardinal pulls the title, description, and tags into a new
17
+ Tasks card that links back to Asana.</p>
18
+
19
+ <%= form_with url: asana_import_path, class: "card-edit",
20
+ data: { turbo_frame: "_top" } do |f| %>
21
+ <label>Asana task URL</label>
22
+ <%= f.text_field :url, required: true, autofocus: true,
23
+ placeholder: "https://app.asana.com/0/1200000000000000/1205000000000000" %>
24
+ <div class="card-edit-actions">
25
+ <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
26
+ <%= f.submit "Import task" %>
27
+ </div>
28
+ <% end %>
29
+ <details class="advanced-rules">
30
+ <summary>Connection</summary>
31
+ <p class="hint">Your Asana token lives in <code>.cardinal/asana-token</code> on this machine only.</p>
32
+ <%= button_to "Unlink Asana", asana_disconnect_path, class: "btn-cancel",
33
+ form: { data: { turbo_frame: "modal" } } %>
34
+ </details>
35
+
36
+ <% else %>
37
+ <p class="hint"><strong>One-time setup:</strong> Cardinal talks to Asana with a
38
+ Personal Access Token that stays on this machine
39
+ (<code>.cardinal/asana-token</code>, never committed, never in the database).</p>
40
+ <ol class="asana-steps">
41
+ <li>Open <a href="https://app.asana.com/0/my-apps" target="_blank" rel="noopener">app.asana.com/0/my-apps</a> (Asana → Settings → Apps → Developer console)</li>
42
+ <li>Click <strong>Create new token</strong>, name it something like <code>cardinal</code></li>
43
+ <li>Copy the token and paste it below</li>
44
+ </ol>
45
+
46
+ <%= form_with url: asana_connect_path, class: "card-edit" do |f| %>
47
+ <label>Personal Access Token</label>
48
+ <%= f.password_field :token, required: true, autofocus: true,
49
+ placeholder: "1/1234567890:abcdef…", autocomplete: "off" %>
50
+ <div class="card-edit-actions">
51
+ <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
52
+ <%= f.submit "Connect Asana" %>
53
+ </div>
54
+ <% end %>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <% end %>
@@ -34,10 +34,12 @@
34
34
  </div>
35
35
  <% end %>
36
36
  <% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
37
- <% if has_cost || card.pr_url.present? %>
37
+ <% if has_cost || card.pr_url.present? || card.asana_url.present? %>
38
38
  <div class="card-footer">
39
39
  <% if card.pr_url.present? %>
40
40
  <a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</a>
41
+ <% elsif card.asana_url.present? %>
42
+ <a class="footer-pr" href="<%= card.asana_url %>" target="_blank" rel="noopener" title="Open the source task on Asana">◯ Asana ↗</a>
41
43
  <% else %>
42
44
  <span class="footer-pr"></span>
43
45
  <% end %>
@@ -73,12 +73,14 @@
73
73
  data-action="scroll#jump">↓ New messages</button>
74
74
 
75
75
  <%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
76
- <%= f.text_area "message[text]", rows: 2, required: true,
77
- data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
78
- placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
79
- <label class="note-toggle" title="Record this message without asking any AI to respond. Future agents (the next column's assistant or worker) still read it as context — perfect for a parting instruction right before you drag the card onward.">
80
- <%= f.check_box "message[note]", {}, "1", "0" %> 📝 note only — no AI reply
81
- </label>
76
+ <div class="composer-box">
77
+ <label class="note-toggle" title="Record this message without asking any AI to respond. Future agents (the next column's assistant or worker) still read it as context — perfect for a parting instruction right before you drag the card onward.">
78
+ <%= f.check_box "message[note]", {}, "1", "0" %> 📝 note only no AI reply
79
+ </label>
80
+ <%= f.text_area "message[text]", rows: 2, required: true,
81
+ data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
82
+ placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
83
+ </div>
82
84
  <% end %>
83
85
  <% end %>
84
86
  </section>
@@ -25,6 +25,9 @@
25
25
  <%= f.text_field "card[pr_url]", placeholder: "https://github.com/owner/repo/pull/123" %>
26
26
  <p class="hint">Leave blank and the agent picks a branch. Set either to point work at an existing branch or PR.</p>
27
27
  <div class="card-edit-actions">
28
+ <%= link_to "◯ New from Asana", asana_new_card_path, class: "asana-entry",
29
+ data: { turbo_frame: "modal" },
30
+ title: "Import an Asana task as a card — first use walks you through connecting" %>
28
31
  <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
29
32
  <%= f.submit "Save" %>
30
33
  </div>
data/config/routes.rb CHANGED
@@ -20,6 +20,11 @@ Rails.application.routes.draw do
20
20
  end
21
21
  resources :messages, only: [:create]
22
22
  end
23
+ get "asana/new" => "asana#new_card", as: :asana_new_card
24
+ post "asana/connect" => "asana#connect", as: :asana_connect
25
+ post "asana/import" => "asana#import", as: :asana_import
26
+ post "asana/disconnect" => "asana#disconnect", as: :asana_disconnect
27
+
23
28
  resources :columns, only: [:create, :edit, :update, :destroy]
24
29
  resources :runs, only: [] do
25
30
  member do
@@ -0,0 +1,5 @@
1
+ class AddAsanaUrlToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :asana_url, :string
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.16"
2
+ VERSION = "0.2.17"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.16
4
+ version: 0.2.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -163,6 +163,7 @@ files:
163
163
  - app/assets/stylesheets/application.css
164
164
  - app/assets/stylesheets/cardinal.css
165
165
  - app/controllers/application_controller.rb
166
+ - app/controllers/asana_controller.rb
166
167
  - app/controllers/boards_controller.rb
167
168
  - app/controllers/cards_controller.rb
168
169
  - app/controllers/columns_controller.rb
@@ -209,12 +210,14 @@ files:
209
210
  - app/models/run.rb
210
211
  - app/services/agent/runner.rb
211
212
  - app/services/agent/workspace.rb
213
+ - app/services/asana.rb
212
214
  - app/services/card_transition.rb
213
215
  - app/services/claude_cli.rb
214
216
  - app/services/github_issues.rb
215
217
  - app/services/rules.rb
216
218
  - app/services/rules/compiler.rb
217
219
  - app/services/run_sweeper.rb
220
+ - app/views/asana/new_card.html.erb
218
221
  - app/views/boards/archive.html.erb
219
222
  - app/views/boards/brief.html.erb
220
223
  - app/views/boards/edit.html.erb
@@ -276,6 +279,7 @@ files:
276
279
  - db/migrate/20260705120000_create_ai_calls.rb
277
280
  - db/migrate/20260705120001_add_issue_number_to_cards.rb
278
281
  - db/migrate/20260705193935_add_settings_to_boards.rb
282
+ - db/migrate/20260705225451_add_asana_url_to_cards.rb
279
283
  - db/queue_schema.rb
280
284
  - db/seeds.rb
281
285
  - docker/agent/Dockerfile