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 +4 -4
- data/app/assets/stylesheets/cardinal.css +17 -1
- data/app/controllers/asana_controller.rb +31 -0
- data/app/services/asana.rb +73 -0
- data/app/views/asana/new_card.html.erb +59 -0
- data/app/views/cards/_card.html.erb +3 -1
- data/app/views/cards/_detail.html.erb +8 -6
- data/app/views/cards/new.html.erb +3 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260705225451_add_asana_url_to_cards.rb +5 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa2760a909ef2ee8cef012432e1a15311d1d3db2b1f37a5f2374e4322fc0c92
|
|
4
|
+
data.tar.gz: 6f995d0029b94d19dbb7f26fccf1dd01014ceab5973a766a91ef72aec043335d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
.
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<%= f.
|
|
81
|
-
|
|
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
|
data/lib/cardinal/version.rb
CHANGED
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.
|
|
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
|