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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +76 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +823 -0
  5. data/exe/plan-driven +7 -0
  6. data/lib/generators/plan_driven/install_generator.rb +37 -0
  7. data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
  8. data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
  9. data/lib/plan_driven/agent_prompt.rb +86 -0
  10. data/lib/plan_driven/cli/plan_commands.rb +158 -0
  11. data/lib/plan_driven/cli/setup_commands.rb +97 -0
  12. data/lib/plan_driven/cli/ticket_commands.rb +155 -0
  13. data/lib/plan_driven/cli/ui.rb +101 -0
  14. data/lib/plan_driven/cli.rb +150 -0
  15. data/lib/plan_driven/configuration.rb +114 -0
  16. data/lib/plan_driven/credentials.rb +90 -0
  17. data/lib/plan_driven/cursor_agents.rb +82 -0
  18. data/lib/plan_driven/cursor_llm.mjs +70 -0
  19. data/lib/plan_driven/cursor_llm.rb +79 -0
  20. data/lib/plan_driven/delivery.rb +362 -0
  21. data/lib/plan_driven/drafter.rb +122 -0
  22. data/lib/plan_driven/errors.rb +22 -0
  23. data/lib/plan_driven/evidence.rb +108 -0
  24. data/lib/plan_driven/gherkin.rb +42 -0
  25. data/lib/plan_driven/github.rb +118 -0
  26. data/lib/plan_driven/guards/migration_guard.rb +94 -0
  27. data/lib/plan_driven/guards/plan_guard.rb +84 -0
  28. data/lib/plan_driven/guards/pr_guard.rb +140 -0
  29. data/lib/plan_driven/guards/ticket_guard.rb +178 -0
  30. data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
  31. data/lib/plan_driven/guards.rb +57 -0
  32. data/lib/plan_driven/http.rb +60 -0
  33. data/lib/plan_driven/json_reply.rb +26 -0
  34. data/lib/plan_driven/llm.rb +80 -0
  35. data/lib/plan_driven/models/approval.rb +12 -0
  36. data/lib/plan_driven/models/event.rb +13 -0
  37. data/lib/plan_driven/models/evidence_run.rb +17 -0
  38. data/lib/plan_driven/models/plan.rb +98 -0
  39. data/lib/plan_driven/models/record.rb +8 -0
  40. data/lib/plan_driven/models/ticket.rb +58 -0
  41. data/lib/plan_driven/models.rb +8 -0
  42. data/lib/plan_driven/railtie.rb +9 -0
  43. data/lib/plan_driven/renderer/html.rb +106 -0
  44. data/lib/plan_driven/renderer/markdown.rb +145 -0
  45. data/lib/plan_driven/renderer/pdf.rb +39 -0
  46. data/lib/plan_driven/renderer/style.css +22 -0
  47. data/lib/plan_driven/renderer.rb +41 -0
  48. data/lib/plan_driven/repository.rb +35 -0
  49. data/lib/plan_driven/schema_context.rb +98 -0
  50. data/lib/plan_driven/template.rb +113 -0
  51. data/lib/plan_driven/ticket_generator.rb +98 -0
  52. data/lib/plan_driven/version.rb +5 -0
  53. data/lib/plan_driven/workflow.rb +59 -0
  54. data/lib/plan_driven.rb +72 -0
  55. 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