plan_driven 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 +7 -0
- data/CHANGELOG.md +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +823 -0
- data/exe/plan-driven +7 -0
- data/lib/generators/plan_driven/install_generator.rb +37 -0
- data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
- data/lib/plan_driven/agent_prompt.rb +86 -0
- data/lib/plan_driven/cli/plan_commands.rb +158 -0
- data/lib/plan_driven/cli/setup_commands.rb +97 -0
- data/lib/plan_driven/cli/ticket_commands.rb +155 -0
- data/lib/plan_driven/cli/ui.rb +101 -0
- data/lib/plan_driven/cli.rb +150 -0
- data/lib/plan_driven/configuration.rb +114 -0
- data/lib/plan_driven/credentials.rb +90 -0
- data/lib/plan_driven/cursor_agents.rb +82 -0
- data/lib/plan_driven/cursor_llm.mjs +70 -0
- data/lib/plan_driven/cursor_llm.rb +79 -0
- data/lib/plan_driven/delivery.rb +362 -0
- data/lib/plan_driven/drafter.rb +122 -0
- data/lib/plan_driven/errors.rb +22 -0
- data/lib/plan_driven/evidence.rb +108 -0
- data/lib/plan_driven/gherkin.rb +42 -0
- data/lib/plan_driven/github.rb +118 -0
- data/lib/plan_driven/guards/migration_guard.rb +94 -0
- data/lib/plan_driven/guards/plan_guard.rb +84 -0
- data/lib/plan_driven/guards/pr_guard.rb +140 -0
- data/lib/plan_driven/guards/ticket_guard.rb +178 -0
- data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
- data/lib/plan_driven/guards.rb +57 -0
- data/lib/plan_driven/http.rb +60 -0
- data/lib/plan_driven/json_reply.rb +26 -0
- data/lib/plan_driven/llm.rb +80 -0
- data/lib/plan_driven/models/approval.rb +12 -0
- data/lib/plan_driven/models/event.rb +13 -0
- data/lib/plan_driven/models/evidence_run.rb +17 -0
- data/lib/plan_driven/models/plan.rb +98 -0
- data/lib/plan_driven/models/record.rb +8 -0
- data/lib/plan_driven/models/ticket.rb +58 -0
- data/lib/plan_driven/models.rb +8 -0
- data/lib/plan_driven/railtie.rb +9 -0
- data/lib/plan_driven/renderer/html.rb +106 -0
- data/lib/plan_driven/renderer/markdown.rb +145 -0
- data/lib/plan_driven/renderer/pdf.rb +39 -0
- data/lib/plan_driven/renderer/style.css +22 -0
- data/lib/plan_driven/renderer.rb +41 -0
- data/lib/plan_driven/repository.rb +35 -0
- data/lib/plan_driven/schema_context.rb +98 -0
- data/lib/plan_driven/template.rb +113 -0
- data/lib/plan_driven/ticket_generator.rb +98 -0
- data/lib/plan_driven/version.rb +5 -0
- data/lib/plan_driven/workflow.rb +59 -0
- data/lib/plan_driven.rb +72 -0
- metadata +146 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module PlanDriven
|
|
6
|
+
class CLI
|
|
7
|
+
# Terminal output and input. Colour only when writing to a terminal.
|
|
8
|
+
class UI
|
|
9
|
+
COLORS = { red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, gray: 90, bold: 1 }.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :input, :output
|
|
12
|
+
|
|
13
|
+
def initialize(input: $stdin, output: $stdout, assume_yes: false)
|
|
14
|
+
@input = input
|
|
15
|
+
@output = output
|
|
16
|
+
@assume_yes = assume_yes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def say(text = "")
|
|
20
|
+
output.puts(text)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def paint(text, color)
|
|
24
|
+
return text unless output.respond_to?(:tty?) && output.tty? && ENV["NO_COLOR"].nil?
|
|
25
|
+
|
|
26
|
+
"\e[#{COLORS.fetch(color)}m#{text}\e[0m"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def heading(text)
|
|
30
|
+
say
|
|
31
|
+
say paint(text, :bold)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def success(text) = say(paint("✓ #{text}", :green))
|
|
35
|
+
def warn(text) = say(paint("! #{text}", :yellow))
|
|
36
|
+
def error(text) = say(paint("✗ #{text}", :red))
|
|
37
|
+
def muted(text) = say(paint(text, :gray))
|
|
38
|
+
|
|
39
|
+
def ask(prompt, default: nil)
|
|
40
|
+
output.print(default ? "#{prompt} [#{default}] " : "#{prompt} ")
|
|
41
|
+
answer = read_line.strip
|
|
42
|
+
answer.empty? ? default.to_s : answer
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Several lines, finished by an empty line.
|
|
46
|
+
def ask_multiline(prompt)
|
|
47
|
+
say paint(prompt, :cyan)
|
|
48
|
+
muted " (finish with an empty line)"
|
|
49
|
+
lines = []
|
|
50
|
+
loop do
|
|
51
|
+
output.print " > "
|
|
52
|
+
line = read_line(nil)
|
|
53
|
+
break if line.nil? || line.strip.empty?
|
|
54
|
+
|
|
55
|
+
lines << line.rstrip
|
|
56
|
+
end
|
|
57
|
+
lines.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def secret(prompt)
|
|
61
|
+
output.print "#{prompt} "
|
|
62
|
+
value = input.respond_to?(:noecho) && input.tty? ? PlanDriven.utf8(input.noecho(&:gets)) : read_line
|
|
63
|
+
output.puts
|
|
64
|
+
value.to_s.strip
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def confirm?(prompt)
|
|
68
|
+
return true if @assume_yes
|
|
69
|
+
|
|
70
|
+
ask("#{prompt} [y/N]").match?(/\Ay(es)?\z/i)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def confirm_word?(prompt, word)
|
|
74
|
+
return true if @assume_yes
|
|
75
|
+
|
|
76
|
+
ask("#{prompt} Type #{paint(word, :bold)} to continue:") == word
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def report(report, ok_message: "All checks passed")
|
|
80
|
+
report.fixes.each { |fix| muted " fixed: #{fix}" }
|
|
81
|
+
report.errors.each { |message| error message }
|
|
82
|
+
report.warnings.each { |message| warn message }
|
|
83
|
+
success ok_message if report.errors.empty? && report.warnings.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def table(headers, rows)
|
|
87
|
+
widths = headers.each_index.map { |i| ([headers[i]] + rows.map { |row| row[i] }).map { |v| v.to_s.length }.max }
|
|
88
|
+
line = ->(cells) { cells.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ") }
|
|
89
|
+
say paint(line.call(headers), :bold)
|
|
90
|
+
rows.each { |row| say line.call(row) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def read_line(at_end = "")
|
|
96
|
+
line = input.gets
|
|
97
|
+
line.nil? ? at_end : PlanDriven.utf8(line)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
require_relative "cli/ui"
|
|
7
|
+
require_relative "cli/plan_commands"
|
|
8
|
+
require_relative "cli/ticket_commands"
|
|
9
|
+
require_relative "cli/setup_commands"
|
|
10
|
+
|
|
11
|
+
module PlanDriven
|
|
12
|
+
# bundle exec plan-driven <command> [arguments]
|
|
13
|
+
class CLI
|
|
14
|
+
include PlanCommands
|
|
15
|
+
include TicketCommands
|
|
16
|
+
include SetupCommands
|
|
17
|
+
|
|
18
|
+
COMMANDS = {
|
|
19
|
+
"new" => ["TITLE", "Interview in the terminal, then draft the plan from your answers and the schema"],
|
|
20
|
+
"list" => ["", "Every plan and its phase"],
|
|
21
|
+
"show" => ["PLAN", "Print the plan, or one section with --section"],
|
|
22
|
+
"edit" => ["PLAN SECTION", "Edit a section in $EDITOR"],
|
|
23
|
+
"redraft" => ["PLAN SECTION \"instruction\"", "Have the model rewrite one section"],
|
|
24
|
+
"check" => ["PLAN", "Run the plan guards"],
|
|
25
|
+
"submit" => ["PLAN", "Send the plan for approval (guards must pass)"],
|
|
26
|
+
"approve" => ["PLAN", "Approve the plan (--as ROLE, --note)"],
|
|
27
|
+
"reject" => ["PLAN", "Send the plan back with --note (--as ROLE)"],
|
|
28
|
+
"pdf" => ["PLAN", "Write the plan to docs/plans as Markdown, HTML and PDF"],
|
|
29
|
+
"tickets" => ["PLAN [\"instruction\"]", "Draft tickets from the approved plan, or redraft them"],
|
|
30
|
+
"approve-tickets" => ["PLAN", "Approve the tickets (--as ROLE); creates GitHub issues"],
|
|
31
|
+
"prompt" => ["PLAN/TICKET", "Show what the agent will be told"],
|
|
32
|
+
"develop" => ["PLAN [TICKET...]", "Hand ready tickets to Cursor cloud agents"],
|
|
33
|
+
"status" => ["PLAN", "Poll agents and pull requests, then show every ticket"],
|
|
34
|
+
"review" => ["PLAN/TICKET", "Run the pull request guards"],
|
|
35
|
+
"approve-pr" => ["PLAN/TICKET", "Approve the pull request (guards must pass)"],
|
|
36
|
+
"feedback" => ["PLAN/TICKET \"text\"", "Send review feedback to the ticket's agent"],
|
|
37
|
+
"merge" => ["PLAN/TICKET", "Merge an approved pull request"],
|
|
38
|
+
"evidence" => ["PLAN", "Run the plan's Cucumber scenarios and record the results (--from FILE)"],
|
|
39
|
+
"report" => ["PLAN", "Write the delivery report"],
|
|
40
|
+
"log" => ["PLAN", "The audit trail"],
|
|
41
|
+
"configure" => ["", "Store API keys in ~/.plan_driven/config"],
|
|
42
|
+
"doctor" => ["", "Check keys, repository and connections"]
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
NO_APP = %w[configure doctor help version].freeze
|
|
46
|
+
|
|
47
|
+
# Plans are UTF-8 whatever the terminal's locale says, so answers typed with č or ž under
|
|
48
|
+
# LANG=C are read as text, not bytes.
|
|
49
|
+
def self.start(argv, **options)
|
|
50
|
+
Encoding.default_external = Encoding::UTF_8
|
|
51
|
+
new(**options).run(argv.map { |arg| PlanDriven.utf8(arg) })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(input: $stdin, output: $stdout, delivery: nil, boot: true)
|
|
55
|
+
@input = input
|
|
56
|
+
@output = output
|
|
57
|
+
@delivery = delivery
|
|
58
|
+
@boot = boot
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run(argv)
|
|
62
|
+
options = parse_options(argv)
|
|
63
|
+
command = argv.shift || "help"
|
|
64
|
+
@ui = UI.new(input: @input, output: @output, assume_yes: options[:yes])
|
|
65
|
+
@options = options
|
|
66
|
+
return help if %w[help -h --help].include?(command)
|
|
67
|
+
return @ui.say(PlanDriven::VERSION) if %w[version -v --version].include?(command)
|
|
68
|
+
raise Error, "Unknown command `#{command}`. `plan-driven help` lists them." unless COMMANDS.key?(command)
|
|
69
|
+
|
|
70
|
+
boot_application unless NO_APP.include?(command)
|
|
71
|
+
public_send("cmd_#{command.tr("-", "_")}", *argv)
|
|
72
|
+
0
|
|
73
|
+
rescue GuardError => e
|
|
74
|
+
ui.error "Blocked by #{e.problems.size} problem(s):"
|
|
75
|
+
e.problems.each { |problem| ui.say " - #{problem}" }
|
|
76
|
+
1
|
|
77
|
+
rescue Error, ActiveRecord::RecordNotFound, ArgumentError => e
|
|
78
|
+
ui.error e.message
|
|
79
|
+
1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ui
|
|
83
|
+
@ui ||= UI.new(input: @input, output: @output)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def help
|
|
87
|
+
ui.say "plan-driven #{PlanDriven::VERSION}: from implementation plan to merged, tested pull requests"
|
|
88
|
+
ui.say
|
|
89
|
+
ui.say "Usage: bundle exec plan-driven COMMAND [ARGS] [--yes]"
|
|
90
|
+
ui.say
|
|
91
|
+
width = COMMANDS.map { |name, (args, _)| "#{name} #{args}".length }.max
|
|
92
|
+
COMMANDS.each { |name, (args, text)| ui.say " #{"#{name} #{args}".ljust(width)} #{text}" }
|
|
93
|
+
ui.say
|
|
94
|
+
ui.say "Phases: plan draft -> in review -> approved -> tickets -> tickets approved -> in development -> delivered"
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def delivery
|
|
101
|
+
@delivery ||= Delivery.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_options(argv)
|
|
105
|
+
options = {}
|
|
106
|
+
OptionParser.new do |parser|
|
|
107
|
+
parser.on("--as ROLE") { |value| options[:role] = value }
|
|
108
|
+
parser.on("--note TEXT") { |value| options[:note] = value }
|
|
109
|
+
parser.on("--section KEY") { |value| options[:section] = value }
|
|
110
|
+
parser.on("--from FILE") { |value| options[:from] = value }
|
|
111
|
+
parser.on("-y", "--yes") { options[:yes] = true }
|
|
112
|
+
end.parse!(argv)
|
|
113
|
+
options
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def boot_application
|
|
117
|
+
return unless @boot
|
|
118
|
+
return if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.initialized?
|
|
119
|
+
|
|
120
|
+
environment = File.expand_path("config/environment.rb", Dir.pwd)
|
|
121
|
+
unless File.exist?(environment)
|
|
122
|
+
raise Error,
|
|
123
|
+
"Run plan-driven from the root of a Rails application (no config/environment.rb here)."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
require environment
|
|
127
|
+
return if ActiveRecord::Base.connection.table_exists?("plan_driven_plans")
|
|
128
|
+
|
|
129
|
+
raise Error, "The plan_driven tables are missing. Run `bin/rails generate plan_driven:install` " \
|
|
130
|
+
"and `bin/rails db:migrate`."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_plan(reference)
|
|
134
|
+
raise ArgumentError, "Which plan? Pass its key, for example PD-1." if reference.to_s.empty?
|
|
135
|
+
|
|
136
|
+
Plan.find_by_reference!(reference.to_s.split("/").first)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def find_ticket(reference)
|
|
140
|
+
plan_key, ticket_key = reference.to_s.split("/")
|
|
141
|
+
raise ArgumentError, "Pass a ticket as PLAN/TICKET, for example PD-1/T2." unless ticket_key
|
|
142
|
+
|
|
143
|
+
find_plan(plan_key).ticket!(ticket_key)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def show_paths(paths)
|
|
147
|
+
paths.compact.each { |path| ui.muted " #{path.relative_path_from(PlanDriven.configuration.root_path)}" }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Everything has a default, so `plan-driven new` works in a fresh app with only keys set.
|
|
5
|
+
#
|
|
6
|
+
# PlanDriven.configure do |config|
|
|
7
|
+
# config.llm_provider = :anthropic
|
|
8
|
+
# config.llm_model = "claude-sonnet-4-5"
|
|
9
|
+
# config.plan_approvals = %w[review qa devops director]
|
|
10
|
+
# config.cucumber = true
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_MODELS = { openai: "gpt-4.1", anthropic: "claude-sonnet-4-5", cursor: "claude-opus-5-5" }.freeze
|
|
14
|
+
KEY_FOR_PROVIDER = { openai: :openai_api_key, anthropic: :anthropic_api_key, cursor: :cursor_api_key }.freeze
|
|
15
|
+
|
|
16
|
+
# Planning and ticket writing are the steps where a stronger model pays for itself.
|
|
17
|
+
attr_accessor :llm_provider, :llm_api_base, :temperature, :request_timeout, :max_repair_attempts
|
|
18
|
+
attr_writer :llm_model
|
|
19
|
+
|
|
20
|
+
# llm_provider :cursor runs the Cursor SDK under Node 22.13+.
|
|
21
|
+
attr_accessor :node_command, :cursor_sdk_path
|
|
22
|
+
|
|
23
|
+
# Who has to approve what before the next phase can start.
|
|
24
|
+
attr_accessor :plan_approvals, :ticket_approvals
|
|
25
|
+
|
|
26
|
+
# Where documentation is written, relative to the application root.
|
|
27
|
+
attr_accessor :docs_path, :root
|
|
28
|
+
|
|
29
|
+
# Cursor cloud agents.
|
|
30
|
+
attr_accessor :agent_model, :base_branch, :max_parallel_agents, :skip_reviewer_request
|
|
31
|
+
|
|
32
|
+
# GitHub.
|
|
33
|
+
attr_accessor :github_repository, :sync_issues, :merge_method, :issue_labels
|
|
34
|
+
|
|
35
|
+
# Guards.
|
|
36
|
+
attr_accessor :estimate_scale, :max_estimate, :max_pr_changed_lines, :require_specs_in_pr,
|
|
37
|
+
:spec_paths, :cucumber, :features_path
|
|
38
|
+
|
|
39
|
+
# Extra rules appended to every agent prompt: your team's conventions, in plain English.
|
|
40
|
+
attr_accessor :team_rules
|
|
41
|
+
|
|
42
|
+
# PDF rendering. A callable taking (html_path, pdf_path), or nil to use headless Chrome.
|
|
43
|
+
attr_accessor :pdf_renderer
|
|
44
|
+
|
|
45
|
+
# Anything the LLM should know that the schema doesn't show.
|
|
46
|
+
attr_accessor :extra_context
|
|
47
|
+
|
|
48
|
+
# The plan's sections. Template.default mirrors the usual Confluence implementation plan.
|
|
49
|
+
attr_writer :template
|
|
50
|
+
|
|
51
|
+
def template
|
|
52
|
+
@template ||= Template.default
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@llm_provider = :openai
|
|
57
|
+
@llm_model = nil
|
|
58
|
+
@llm_api_base = nil
|
|
59
|
+
@temperature = 0.2
|
|
60
|
+
@request_timeout = 180
|
|
61
|
+
@max_repair_attempts = 2
|
|
62
|
+
@node_command = ENV.fetch("PLAN_DRIVEN_NODE", "node")
|
|
63
|
+
@cursor_sdk_path = nil
|
|
64
|
+
|
|
65
|
+
@plan_approvals = %w[review]
|
|
66
|
+
@ticket_approvals = %w[review]
|
|
67
|
+
|
|
68
|
+
@docs_path = "docs/plans"
|
|
69
|
+
@root = nil
|
|
70
|
+
|
|
71
|
+
@agent_model = nil
|
|
72
|
+
@base_branch = "main"
|
|
73
|
+
@max_parallel_agents = 3
|
|
74
|
+
@skip_reviewer_request = false
|
|
75
|
+
|
|
76
|
+
@github_repository = nil
|
|
77
|
+
@sync_issues = true
|
|
78
|
+
@merge_method = "squash"
|
|
79
|
+
@issue_labels = %w[plan-driven]
|
|
80
|
+
|
|
81
|
+
@estimate_scale = [1, 2, 3, 5, 8]
|
|
82
|
+
@max_estimate = 5
|
|
83
|
+
@max_pr_changed_lines = 800
|
|
84
|
+
@require_specs_in_pr = true
|
|
85
|
+
@spec_paths = %w[spec/ test/ features/]
|
|
86
|
+
@cucumber = true
|
|
87
|
+
@features_path = "features"
|
|
88
|
+
|
|
89
|
+
@team_rules = []
|
|
90
|
+
@pdf_renderer = nil
|
|
91
|
+
@extra_context = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def llm_model
|
|
95
|
+
@llm_model || DEFAULT_MODELS.fetch(llm_provider.to_sym, DEFAULT_MODELS[:openai])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def llm_api_key
|
|
99
|
+
Credentials.fetch(llm_key_name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def llm_key_name
|
|
103
|
+
KEY_FOR_PROVIDER.fetch(llm_provider.to_sym, :openai_api_key)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def root_path
|
|
107
|
+
Pathname(root || (defined?(Rails) && Rails.respond_to?(:root) && Rails.root) || Dir.pwd)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def docs_root
|
|
111
|
+
root_path.join(docs_path)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module PlanDriven
|
|
8
|
+
# Keys live outside the application, in ~/.plan_driven/config (0600 inside a 0700 directory):
|
|
9
|
+
#
|
|
10
|
+
# openai_api_key: sk-...
|
|
11
|
+
# anthropic_api_key: sk-ant-...
|
|
12
|
+
# cursor_api_key: key_...
|
|
13
|
+
# github_token: ghp_...
|
|
14
|
+
#
|
|
15
|
+
# The environment always wins, and a key found there is never copied into the file.
|
|
16
|
+
module Credentials
|
|
17
|
+
KEYS = {
|
|
18
|
+
"openai_api_key" => "OPENAI_API_KEY",
|
|
19
|
+
"anthropic_api_key" => "ANTHROPIC_API_KEY",
|
|
20
|
+
"cursor_api_key" => "CURSOR_API_KEY",
|
|
21
|
+
"github_token" => "GITHUB_TOKEN"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
DIRECTORY = File.join(Dir.home, ".plan_driven")
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def path
|
|
28
|
+
ENV.fetch("PLAN_DRIVEN_CREDENTIALS", File.join(DIRECTORY, "config"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fetch(name)
|
|
32
|
+
name = name.to_s
|
|
33
|
+
env = KEYS.fetch(name)
|
|
34
|
+
value = ENV[env].to_s.strip
|
|
35
|
+
return value unless value.empty?
|
|
36
|
+
|
|
37
|
+
stored = read[name].to_s.strip
|
|
38
|
+
return stored unless stored.empty?
|
|
39
|
+
|
|
40
|
+
name == "github_token" ? gh_token : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def source(name)
|
|
44
|
+
name = name.to_s
|
|
45
|
+
return "environment (#{KEYS.fetch(name)})" unless ENV[KEYS.fetch(name)].to_s.strip.empty?
|
|
46
|
+
return path unless read[name].to_s.strip.empty?
|
|
47
|
+
return "gh auth token" if name == "github_token" && gh_token
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def store(name, value)
|
|
53
|
+
raise ArgumentError, "unknown credential #{name}" unless KEYS.key?(name.to_s)
|
|
54
|
+
|
|
55
|
+
data = read
|
|
56
|
+
data[name.to_s] = value.to_s.strip
|
|
57
|
+
write(data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read
|
|
61
|
+
return {} unless File.exist?(path)
|
|
62
|
+
|
|
63
|
+
data = YAML.safe_load_file(path) || {}
|
|
64
|
+
data.is_a?(Hash) ? data : {}
|
|
65
|
+
rescue StandardError
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def write(data)
|
|
72
|
+
directory = File.dirname(path)
|
|
73
|
+
FileUtils.mkdir_p(directory)
|
|
74
|
+
File.chmod(0o700, directory) if File.owned?(directory)
|
|
75
|
+
File.write(path, YAML.dump(data))
|
|
76
|
+
File.chmod(0o600, path)
|
|
77
|
+
path
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def gh_token
|
|
81
|
+
return @gh_token if defined?(@gh_token)
|
|
82
|
+
|
|
83
|
+
output, status = Open3.capture2("gh", "auth", "token", err: File::NULL)
|
|
84
|
+
@gh_token = status.success? && !output.strip.empty? ? output.strip : nil
|
|
85
|
+
rescue StandardError
|
|
86
|
+
@gh_token = nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module PlanDriven
|
|
6
|
+
# Cursor Cloud Agents API v1: one agent per ticket, running on a Cursor-hosted VM against a
|
|
7
|
+
# fresh clone, opening a pull request when it finishes.
|
|
8
|
+
# https://cursor.com/docs/cloud-agent/api/endpoints
|
|
9
|
+
class CursorAgents
|
|
10
|
+
BASE = "https://api.cursor.com/v1"
|
|
11
|
+
TERMINAL = %w[FINISHED ERROR CANCELLED EXPIRED].freeze
|
|
12
|
+
|
|
13
|
+
Run = Struct.new(:id, :agent_id, :status, :result, :branch, :pr_url, keyword_init: true) do
|
|
14
|
+
def terminal?
|
|
15
|
+
TERMINAL.include?(status)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def finished?
|
|
19
|
+
status == "FINISHED"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(api_key: Credentials.fetch(:cursor_api_key), base: BASE, config: PlanDriven.configuration)
|
|
24
|
+
@api_key = api_key
|
|
25
|
+
@base = base
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def launch(prompt:, repo_url:, name:)
|
|
30
|
+
body = {
|
|
31
|
+
prompt: { text: prompt },
|
|
32
|
+
name: name[0, 100],
|
|
33
|
+
repos: [{ url: repo_url, startingRef: @config.base_branch }],
|
|
34
|
+
autoCreatePR: true,
|
|
35
|
+
skipReviewerRequest: @config.skip_reviewer_request
|
|
36
|
+
}
|
|
37
|
+
body[:model] = { id: @config.agent_model } if @config.agent_model
|
|
38
|
+
response = request(:post, "/agents", body)
|
|
39
|
+
agent = response.fetch("agent")
|
|
40
|
+
[agent, to_run(response.fetch("run"))]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run(agent_id, run_id)
|
|
44
|
+
to_run(request(:get, "/agents/#{agent_id}/runs/#{run_id}"))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def follow_up(agent_id, text)
|
|
48
|
+
to_run(request(:post, "/agents/#{agent_id}/runs", { prompt: { text: text } }).fetch("run"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def me
|
|
52
|
+
request(:get, "/me")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# IDs and aliases of the models this key can start agents with.
|
|
56
|
+
def model_ids
|
|
57
|
+
Array(request(:get, "/models")["items"]).flat_map { |item| [item["id"], *Array(item["aliases"])] }.compact.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def to_run(data)
|
|
63
|
+
branch = Array(data.dig("git", "branches")).first || {}
|
|
64
|
+
Run.new(id: data["id"], agent_id: data["agentId"], status: data["status"], result: data["result"],
|
|
65
|
+
branch: branch["branch"], pr_url: branch["prUrl"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request(method, path, body = nil)
|
|
69
|
+
raise ConfigurationError, "No Cursor API key. Run `plan-driven configure` or set CURSOR_API_KEY." unless @api_key
|
|
70
|
+
|
|
71
|
+
response = HTTP.request(method, "#{@base}#{path}", body: body, timeout: 60, headers: {
|
|
72
|
+
"Authorization" => "Basic #{Base64.strict_encode64("#{@api_key}:")}",
|
|
73
|
+
"Content-Type" => "application/json"
|
|
74
|
+
})
|
|
75
|
+
return response.json if response.success?
|
|
76
|
+
|
|
77
|
+
error = response.json["error"]
|
|
78
|
+
message = error.is_a?(Hash) ? error["message"] || error["code"] : error
|
|
79
|
+
raise ProviderError, "Cursor API returned #{response.status}: #{message || response.body.to_s[0, 200]}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Runs one read-only Cursor agent turn for plan_driven and prints {"text", "usage"} as JSON.
|
|
2
|
+
// Input on stdin: {"model", "prompt", "cwd", "sdkPaths"}. The key comes from CURSOR_API_KEY.
|
|
3
|
+
// `--check` only resolves the SDK, for `plan-driven doctor`.
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const READ_ONLY_TOOLS = ["read", "grep", "glob", "ls"];
|
|
10
|
+
|
|
11
|
+
function globalRoot() {
|
|
12
|
+
try {
|
|
13
|
+
return execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadSdk(dirs) {
|
|
20
|
+
const candidates = [...dirs, path.join(os.homedir(), ".plan_driven", "node"), globalRoot()].filter(Boolean);
|
|
21
|
+
for (const dir of candidates) {
|
|
22
|
+
try {
|
|
23
|
+
return createRequire(path.join(dir, "package.json"))("@cursor/sdk");
|
|
24
|
+
} catch {
|
|
25
|
+
// try the next place
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error(
|
|
29
|
+
"@cursor/sdk not found. Install it with `npm install --prefix ~/.plan_driven/node @cursor/sdk` (Node 22.13+)."
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readStdin() {
|
|
34
|
+
let data = "";
|
|
35
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
36
|
+
return JSON.parse(data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fail(message) {
|
|
40
|
+
process.stdout.write(JSON.stringify({ error: message }));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (process.argv.includes("--check")) {
|
|
46
|
+
const dirs = process.argv.slice(3);
|
|
47
|
+
loadSdk(dirs);
|
|
48
|
+
process.stdout.write(JSON.stringify({ ok: true, node: process.version }));
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const input = await readStdin();
|
|
53
|
+
const { Agent } = loadSdk([input.cwd, ...(input.sdkPaths || [])]);
|
|
54
|
+
const result = await Agent.prompt(input.prompt, {
|
|
55
|
+
apiKey: process.env.CURSOR_API_KEY,
|
|
56
|
+
model: { id: input.model },
|
|
57
|
+
tools: READ_ONLY_TOOLS,
|
|
58
|
+
local: { cwd: input.cwd },
|
|
59
|
+
});
|
|
60
|
+
if (result.status !== "finished") fail(result.error?.message || `agent run ${result.status}`);
|
|
61
|
+
|
|
62
|
+
const usage = result.usage || {};
|
|
63
|
+
process.stdout.write(JSON.stringify({
|
|
64
|
+
text: result.result || "",
|
|
65
|
+
usage: { input: usage.inputTokens, output: usage.outputTokens },
|
|
66
|
+
}));
|
|
67
|
+
process.exit(0);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
fail(error?.message || String(error));
|
|
70
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module PlanDriven
|
|
8
|
+
# Drafting through a Cursor agent (Claude Opus, GPT, Composer...) on the user's Cursor account.
|
|
9
|
+
# The agent runs locally with read-only tools, so it can read the application's code while it
|
|
10
|
+
# writes, and can't change a file. It goes through the Cursor SDK, which is Node, via
|
|
11
|
+
# cursor_llm.mjs.
|
|
12
|
+
class CursorLLM
|
|
13
|
+
BRIDGE = File.expand_path("cursor_llm.mjs", __dir__)
|
|
14
|
+
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def chat(system:, messages:)
|
|
20
|
+
data = run_bridge(JSON.generate(model: @config.llm_model, prompt: prompt(system, messages),
|
|
21
|
+
cwd: @config.root_path.to_s, sdkPaths: Array(@config.cursor_sdk_path)))
|
|
22
|
+
LLM::Reply.new(text: data["text"].to_s, input_tokens: data.dig("usage", "input"),
|
|
23
|
+
output_tokens: data.dig("usage", "output"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# [ok, detail] for doctor: whether Node can load the SDK.
|
|
27
|
+
def check
|
|
28
|
+
out, status = Open3.capture2e(@config.node_command, BRIDGE, "--check", @config.root_path.to_s,
|
|
29
|
+
*Array(@config.cursor_sdk_path))
|
|
30
|
+
data = parse(out)
|
|
31
|
+
[status.success?, data["error"] || "Node #{data["node"]}, @cursor/sdk found"]
|
|
32
|
+
rescue Errno::ENOENT
|
|
33
|
+
[false, "#{@config.node_command} not found; the Cursor SDK needs Node 22.13+"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def prompt(system, messages)
|
|
39
|
+
turns = messages.map do |message|
|
|
40
|
+
message = message.to_h.stringify_keys
|
|
41
|
+
"## #{message["role"]}\n\n#{message["content"]}"
|
|
42
|
+
end
|
|
43
|
+
<<~PROMPT
|
|
44
|
+
#{system}
|
|
45
|
+
|
|
46
|
+
You are inside the application's repository and may read any file to ground your answer in
|
|
47
|
+
the real code. Don't try to change files. Reply with the JSON object only, no prose around it.
|
|
48
|
+
|
|
49
|
+
# Conversation
|
|
50
|
+
|
|
51
|
+
#{turns.join("\n\n")}
|
|
52
|
+
PROMPT
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run_bridge(input)
|
|
56
|
+
key = @config.llm_api_key or
|
|
57
|
+
raise ConfigurationError, "No Cursor API key. Run `plan-driven configure` or set CURSOR_API_KEY."
|
|
58
|
+
|
|
59
|
+
out, err, status = Timeout.timeout(@config.request_timeout) do
|
|
60
|
+
Open3.capture3({ "CURSOR_API_KEY" => key }, @config.node_command, BRIDGE, stdin_data: input)
|
|
61
|
+
end
|
|
62
|
+
data = parse(out)
|
|
63
|
+
return data if status.success? && !data.key?("error")
|
|
64
|
+
|
|
65
|
+
raise ProviderError, "cursor: #{data["error"] || err.to_s.strip.last(300).presence || "agent failed"}"
|
|
66
|
+
rescue Timeout::Error
|
|
67
|
+
raise ProviderError, "cursor: no answer within #{@config.request_timeout}s (config.request_timeout)"
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
raise ConfigurationError, "#{@config.node_command} not found; the Cursor SDK needs Node 22.13+ " \
|
|
70
|
+
"(set config.node_command or PLAN_DRIVEN_NODE)"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse(out)
|
|
74
|
+
JSON.parse(out.to_s.strip.lines.last.to_s)
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|