gitflash 0.7.0 → 0.8.0.beta1

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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Gitflash
6
+ module Commands
7
+ # Copies the gitflash Agent Skill that ships in the gem (skills/gitflash/SKILL.md) into an
8
+ # agent's skills directory:
9
+ # user ~/.claude/skills/gitflash/SKILL.md, for every Claude Code session (default)
10
+ # project .claude/skills/gitflash/SKILL.md at the repository top level, shared with the team
11
+ # --path DIR/gitflash/SKILL.md, for any agent that reads skills from DIR
12
+ # A file that differs from the shipped one may hold the user's own edits, which no snapshot
13
+ # saves, so replacing it needs --confirm TOKEN instead of --yes.
14
+ class SkillInstall < Base
15
+ SOURCE = File.expand_path('../../../skills/gitflash/SKILL.md', __dir__)
16
+ SCOPES = %w[user project].freeze
17
+ NAME = 'gitflash'
18
+
19
+ def call
20
+ plan = build_plan
21
+ existing = read_existing(plan[:file])
22
+ return already_installed(plan) if existing == skill
23
+ return replace(plan, existing) if existing
24
+ return planned(plan, "Would install #{where(plan)}") if ui.dry_run?
25
+
26
+ install(plan, 'install')
27
+ end
28
+
29
+ private
30
+
31
+ def replace(plan, existing)
32
+ # In the plan, so the --confirm token belongs to these file contents
33
+ plan[:previous_sha256] = Digest::SHA256.hexdigest(existing)
34
+ return planned(plan, "Would update #{where(plan)}") if ui.dry_run?
35
+ return cancelled(plan) unless confirm_replace?(plan)
36
+
37
+ install(plan, 'update')
38
+ end
39
+
40
+ def install(plan, verb)
41
+ write(plan[:file])
42
+ ui.report(status: 'done', plan: plan, result: plan,
43
+ text: "#{verb.capitalize}d #{where(plan)}")
44
+ end
45
+
46
+ def where(plan) = "the gitflash skill at #{shown(plan[:file])}"
47
+
48
+ def build_plan
49
+ scope, directory = target
50
+ { scope: scope, directory: directory, file: File.join(directory, NAME, 'SKILL.md'),
51
+ version: VERSION }
52
+ end
53
+
54
+ # The scope and the skills directory the skill goes into
55
+ def target
56
+ return path_target if options[:path]
57
+
58
+ scope = options[:scope] || 'user'
59
+ unless SCOPES.include?(scope)
60
+ usage_error!('invalid_options', "Unknown scope '#{scope}' (use #{SCOPES.join(', ')})")
61
+ end
62
+ return ['user', File.join(Dir.home, '.claude', 'skills')] if scope == 'user'
63
+
64
+ directory = File.join(repo.toplevel, '.claude', 'skills')
65
+ refuse_symlinks!(directory)
66
+ ['project', directory]
67
+ end
68
+
69
+ def path_target
70
+ usage_error!('invalid_options', 'Pass --scope or --path, not both') if options[:scope]
71
+
72
+ directory = File.expand_path(options[:path])
73
+ return ['path', directory] if File.directory?(directory)
74
+
75
+ usage_error!('invalid_options', "#{shown(directory)} is not a directory")
76
+ end
77
+
78
+ # The repository decides what .claude holds, and a link there could point at the user's own
79
+ # skills; writing through it would change files outside the repository
80
+ def refuse_symlinks!(directory)
81
+ paths = [File.dirname(directory), directory, File.join(directory, NAME),
82
+ File.join(directory, NAME, 'SKILL.md')]
83
+ linked = paths.find { |path| File.symlink?(path) }
84
+ return unless linked
85
+
86
+ raise Error, "#{shown(linked)} is a symbolic link; gitflash will not write the skill " \
87
+ 'through it'
88
+ end
89
+
90
+ def read_existing(file)
91
+ File.exist?(file) ? File.read(file) : nil
92
+ rescue SystemCallError => e
93
+ raise Error, "Could not read #{shown(file)}: #{e.message}"
94
+ end
95
+
96
+ def confirm_replace?(plan)
97
+ summary = "#{shown(plan[:file])} differs from the gitflash #{VERSION} skill; " \
98
+ 'replacing it loses any edits made to it'
99
+ ui.confirm?(summary, plan: plan, irreversible: true)
100
+ end
101
+
102
+ def write(file)
103
+ AtomicFile.write(file, skill)
104
+ rescue SystemCallError, IOError => e
105
+ raise Error, "Could not write #{shown(file)}: #{e.message}"
106
+ end
107
+
108
+ def already_installed(plan)
109
+ ui.report(status: 'noop', plan: plan, result: plan,
110
+ text: "The gitflash #{VERSION} skill is already at #{shown(plan[:file])}")
111
+ end
112
+
113
+ def skill
114
+ @skill ||= File.read(SOURCE)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -73,11 +73,20 @@ module Gitflash
73
73
  def stamp(creation)
74
74
  return '' unless Dir.exist?(creation[:dir])
75
75
 
76
+ creation_entry(creation[:dir], creation[:branch])&.join(' ').to_s
77
+ end
78
+
79
+ # [time, commit, message] of the oldest reflog entry of a branch, or nil. The time is the
80
+ # reflog entry's own (`@{UNIX}` with --date=unix), not the commit's: a branch created now
81
+ # from an old commit is new.
82
+ def creation_entry(dir, branch)
76
83
  stdout, _stderr, success = Git::BashCommand.capture(
77
- 'git', '-C', creation[:dir], 'log', '-g', '--format=%ct %H %gs',
78
- "#{Repo::HEADS}#{creation[:branch]}", '--'
84
+ 'git', '-C', dir, 'log', '-g', '--date=unix', '--format=%gd%x1f%H%x1f%gs',
85
+ "#{Repo::HEADS}#{branch}", '--'
79
86
  )
80
- success ? stdout.lines(chomp: true).last.to_s : ''
87
+ selector, sha, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 3)
88
+ time = selector.to_s[/@\{(\d+)\}\z/, 1]
89
+ success && time ? [time.to_i, sha, subject.to_s] : nil
81
90
  end
82
91
 
83
92
  # One file per tool call, named from a hash so the id can never form a path
@@ -133,7 +142,7 @@ module Gitflash
133
142
 
134
143
  Dir.chdir(dir) do
135
144
  ownership = Ownership.new
136
- next false unless created_recently?(branch) && !ownership.all.key?(branch)
145
+ next false unless created_recently?(dir, branch) && !ownership.all.key?(branch)
137
146
 
138
147
  ownership.mark(branch, 'agent')
139
148
  true
@@ -141,14 +150,11 @@ module Gitflash
141
150
  end
142
151
 
143
152
  # The oldest reflog entry of the branch is its creation, and it is recent
144
- def created_recently?(branch)
145
- stdout, _stderr, success = Git::BashCommand.capture(
146
- 'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
147
- )
148
- return false unless success
153
+ def created_recently?(dir, branch)
154
+ time, _sha, subject = creation_entry(dir, branch)
155
+ return false unless time
149
156
 
150
- created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
151
- subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
157
+ subject.start_with?(CREATED) && @clock.call.to_i - time <= RECENT_SECONDS
152
158
  end
153
159
  end
154
160
  end
@@ -32,6 +32,11 @@ module Gitflash
32
32
 
33
33
  SHELLS = %w[bash sh zsh dash ksh].freeze
34
34
  MAX_NESTING = 3
35
+
36
+ # The option that makes a shell run its next argument as a script: `-c`, `-lc`. The `c` is
37
+ # looked for apart from the pattern, which `-ccc...c` would otherwise make backtrack.
38
+ def self.script_flag?(word) = word.match?(/\A-[a-z]+\z/) && word.include?('c')
39
+
35
40
  # Subcommands git never lets an alias replace and that have no rule, so their aliases need
36
41
  # not be read
37
42
  BUILTINS = (Rules::RULES.keys + %w[status log diff show add commit push pull fetch rev-parse
@@ -78,13 +83,14 @@ module Gitflash
78
83
 
79
84
  def git_commands(command_line, dir = @cwd, depth = 0)
80
85
  stack = []
81
- ShellText.segments(command_line).flat_map do |segment|
86
+ ShellText.commands(command_line).flat_map do |segment, heredocs|
82
87
  words = CommandWords.new(segment)
83
88
  if DIR_COMMANDS.include?(words.words.first)
84
89
  dir = dir_after(words.words, dir, stack)
85
90
  next substituted(segment, dir, depth)
86
91
  end
87
- substituted(segment, dir, depth) + in_segment(words, dir, segment.strip, depth)
92
+ scripts = Heredocs.scripts(heredocs, words.words)
93
+ substituted(segment, dir, depth, scripts) + in_segment(words, dir, segment.strip, depth)
88
94
  end
89
95
  end
90
96
 
@@ -104,9 +110,10 @@ module Gitflash
104
110
  commands_in(words.words, dir, segment, depth, words.env)
105
111
  end
106
112
 
107
- # Git commands inside `$(...)`, `<(...)` and backticks, which the shell runs first
108
- def substituted(command_line, dir, depth)
109
- ShellText.substitutions(command_line).flat_map do |script|
113
+ # Git commands inside `$(...)`, `<(...)` and backticks, which the shell runs first, and in
114
+ # the scripts its heredocs run (Heredocs.scripts)
115
+ def substituted(command_line, dir, depth, scripts = [])
116
+ (ShellText.substitutions(command_line) + scripts).flat_map do |script|
110
117
  nested(script, dir, depth)
111
118
  end
112
119
  end
@@ -136,7 +143,7 @@ module Gitflash
136
143
  return words.drop(1).join(' ') if words.first == 'eval'
137
144
  return nil unless SHELLS.include?(File.basename(words.first))
138
145
 
139
- index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
146
+ index = words.index { |word| CommandParser.script_flag?(word) }
140
147
  index && words[index + 1]
141
148
  end
142
149
 
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Here-documents in a shell command line (see ShellText). `cat <<'EOF'` takes the lines after
6
+ # the command, up to a line that is only `EOF`, as its input. Those lines are text, not
7
+ # commands: read as commands, an apostrophe in them would open a quote that hides the real
8
+ # commands after `EOF`, and a git command quoted in them would count as one that runs.
9
+ module Heredocs
10
+ # body: the lines up to the delimiter. quoted: the delimiter was quoted (`<<'EOF'`,
11
+ # `<<"EOF"`, `<<\EOF`), so nothing in the body runs; unquoted, `$(...)` and backticks in it
12
+ # run as they do inside double quotes.
13
+ Heredoc = Data.define(:body, :quoted)
14
+ # A heredoc whose body starts after the current line; part is the segment it belongs to
15
+ Pending = Data.define(:delimiter, :quoted, :strip_tabs, :part)
16
+ WORD_END = /[\s;&|<>()]/
17
+ BLANKS = [' ', "\t"].freeze
18
+
19
+ module_function
20
+
21
+ # [Pending, index after the delimiter] for a `<<` at index, or nil. `<<<` is a here-string,
22
+ # whose word is on the same line.
23
+ def open(line, index, part)
24
+ return nil unless line[index, 2] == '<<' && line[index + 2] != '<'
25
+
26
+ from = index + 2
27
+ strip_tabs = line[from] == '-'
28
+ from += 1 if strip_tabs
29
+ from += 1 while BLANKS.include?(line[from])
30
+ delimiter, quoted, to = word(line, from)
31
+ return nil if delimiter.empty?
32
+
33
+ [Pending.new(delimiter: delimiter, quoted: quoted, strip_tabs: strip_tabs, part: part), to]
34
+ end
35
+
36
+ # [the word at index without its quotes, whether it had quotes or backslashes, index after]
37
+ def word(line, index)
38
+ text = +''
39
+ quoted = false
40
+ until index >= line.length || line[index].match?(WORD_END)
41
+ piece, index, marked = word_piece(line, index)
42
+ text << piece
43
+ quoted ||= marked
44
+ end
45
+ [text, quoted, index]
46
+ end
47
+
48
+ # [text, index after it, whether it was quoted] for one quoted string, escape or character
49
+ def word_piece(line, index)
50
+ char = line[index]
51
+ return [line[index + 1].to_s, index + 2, true] if char == '\\'
52
+ return [char, index + 1, false] unless ["'", '"'].include?(char)
53
+
54
+ close = line.index(char, index + 1) || line.length
55
+ [line[(index + 1)...close].to_s, close + 1, true]
56
+ end
57
+
58
+ # The bodies of the pending heredocs, in order, read from index (the start of the next line).
59
+ # Returns [[part, Heredoc]...] and the index after the last body.
60
+ def bodies(line, index, pending)
61
+ found = pending.map do |doc|
62
+ body, index = body(line, index, doc)
63
+ [doc.part, Heredoc.new(body: body, quoted: doc.quoted)]
64
+ end
65
+ [found, index]
66
+ end
67
+
68
+ # Lines up to one that is only the delimiter (`<<-` strips leading tabs first), or to the end
69
+ # of the text when none is
70
+ def body(line, index, doc)
71
+ body = +''
72
+ while index < line.length
73
+ stop = line.index("\n", index) || line.length
74
+ text = line[index...stop]
75
+ index = stop + 1
76
+ return [body, index] if (doc.strip_tabs ? text.sub(/\A\t+/, '') : text) == doc.delimiter
77
+
78
+ body << text << "\n"
79
+ end
80
+ [body, index]
81
+ end
82
+
83
+ # The scripts an unquoted body runs: `$(...)` and backticks, read as inside double quotes,
84
+ # where a quote is a plain character
85
+ def substitutions(body)
86
+ scripts = []
87
+ index = 0
88
+ while index < body.length
89
+ script = ShellText.substitution(body, index, '"')
90
+ scripts << body[script.first...script.last] if script
91
+ index = script ? script.last + 1 : after(body, index)
92
+ end
93
+ scripts
94
+ end
95
+
96
+ def after(body, index)
97
+ body[index] == '\\' ? index + 2 : index + 1
98
+ end
99
+
100
+ # The scripts the heredocs of a command run, given the command's words: the whole body when
101
+ # a shell without `-c` reads it as its script (`bash <<'EOF'`, `sh -s`), else the
102
+ # substitutions of an unquoted body. A shell that runs a script file reads the heredoc as
103
+ # data instead; reading it as a script then costs at most an extra snapshot.
104
+ def scripts(heredocs, words)
105
+ reads_script = stdin_script?(words)
106
+ heredocs.flat_map do |heredoc|
107
+ next [heredoc.body] if reads_script
108
+ next [] if heredoc.quoted
109
+
110
+ substitutions(heredoc.body)
111
+ end
112
+ end
113
+
114
+ def stdin_script?(words)
115
+ CommandParser::SHELLS.include?(File.basename(words.first.to_s)) &&
116
+ words.none? { |word| CommandParser.script_flag?(word) }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -5,14 +5,19 @@ module Gitflash
5
5
  # Reads a shell command line as text, without running it: where commands end, and which
6
6
  # commands run inside `$(...)`, `<(...)` and backticks. Quotes are followed as the shell
7
7
  # does: nothing runs inside single quotes, `$(...)` and backticks run inside double quotes,
8
- # and a backslash keeps the next character (outside single quotes).
8
+ # and a backslash keeps the next character (outside single quotes). Heredoc bodies are taken
9
+ # out of the line and handed back with the command that reads them (see Heredocs).
9
10
  module ShellText
10
11
  SEPARATORS = ";&|\n"
11
12
 
13
+ # What one pass over a line collects. heredocs: { segment index => [Heredoc] }
14
+ Scan = Struct.new(:parts, :scripts, :pending, :heredocs)
15
+
12
16
  module_function
13
17
 
14
- # The command line split on ; & | and newlines outside quotes and substitutions
15
- def segments(line)
18
+ # The command line split on ; & | and newlines outside quotes and substitutions, as
19
+ # [segment, heredocs] pairs: the heredocs are the ones that segment reads
20
+ def commands(line)
16
21
  scan(line).first
17
22
  end
18
23
 
@@ -22,33 +27,49 @@ module Gitflash
22
27
  scan(line).last
23
28
  end
24
29
 
25
- # [segments, scripts] in one pass over the line
30
+ # [commands, scripts] in one pass over the line
26
31
  def scan(line)
27
- parts = [+'']
28
- scripts = []
32
+ state = Scan.new([+''], [], [], {})
29
33
  index = 0
30
34
  quote = nil
31
- index, quote = step(line, index, quote, parts, scripts) while index < line.length
32
- [parts.reject { |part| part.strip.empty? }, scripts]
35
+ index, quote = step(line, index, quote, state) while index < line.length
36
+ commands = state.parts.each_with_index.filter_map do |part, at|
37
+ [part, state.heredocs.fetch(at, [])] unless part.strip.empty?
38
+ end
39
+ [commands, state.scripts]
33
40
  end
34
41
 
35
42
  # Reads what starts at index and returns where to go on, with the quote then open
36
- def step(line, index, quote, parts, scripts)
43
+ def step(line, index, quote, state)
37
44
  char = line[index]
38
- return [index + 1, close_single(char, parts)] if quote == "'"
39
- return take(line, index, index + 2, parts) + [quote] if char == '\\'
45
+ return [index + 1, close_single(char, state.parts)] if quote == "'"
46
+ return take(line, index, index + 2, state.parts) + [quote] if char == '\\'
40
47
  return [comment_end(line, index), nil] if quote.nil? && comment?(line, index)
41
48
 
42
- substituted(line, index, quote, parts, scripts) || plain(char, index, quote, parts)
49
+ opened(line, index, quote, state) || plain(line, index, quote, state)
50
+ end
51
+
52
+ # Takes a heredoc or a substitution that opens at index, or returns nil
53
+ def opened(line, index, quote, state)
54
+ (quote.nil? && heredoc(line, index, state)) || substituted(line, index, quote, state)
43
55
  end
44
56
 
45
57
  # Takes a substitution that opens at index, or returns nil
46
- def substituted(line, index, quote, parts, scripts)
58
+ def substituted(line, index, quote, state)
47
59
  script = substitution(line, index, quote)
48
60
  return nil unless script
49
61
 
50
- scripts << line[script.first...script.last]
51
- take(line, index, script.last + 1, parts) + [quote]
62
+ state.scripts << line[script.first...script.last]
63
+ take(line, index, script.last + 1, state.parts) + [quote]
64
+ end
65
+
66
+ # Takes `<<WORD` at index, whose body is read after the end of this line, or returns nil
67
+ def heredoc(line, index, state)
68
+ pending, to = Heredocs.open(line, index, state.parts.length - 1)
69
+ return nil unless pending
70
+
71
+ state.pending << pending
72
+ take(line, index, to, state.parts) + [nil]
52
73
  end
53
74
 
54
75
  # A `#` that starts a word outside quotes starts a comment, which runs to the end of the
@@ -66,15 +87,27 @@ module Gitflash
66
87
  char == "'" ? nil : "'"
67
88
  end
68
89
 
69
- def plain(char, index, quote, parts)
90
+ def plain(line, index, quote, state)
91
+ char = line[index]
70
92
  if !quote && SEPARATORS.include?(char)
71
- parts << +''
72
- return [index + 1, nil]
93
+ state.parts << +''
94
+ return [index + 1, nil] unless char == "\n" && state.pending.any?
95
+
96
+ return [bodies(line, index + 1, state), nil]
73
97
  end
74
- parts.last << char
98
+ state.parts.last << char
75
99
  [index + 1, next_quote(char, quote)]
76
100
  end
77
101
 
102
+ # Reads the bodies of the heredocs opened on the line that just ended; returns the index
103
+ # after them
104
+ def bodies(line, index, state)
105
+ found, index = Heredocs.bodies(line, index, state.pending)
106
+ found.each { |part, heredoc| (state.heredocs[part] ||= []) << heredoc }
107
+ state.pending.clear
108
+ index
109
+ end
110
+
78
111
  def next_quote(char, quote)
79
112
  return (char == '"' ? nil : quote) if quote
80
113
  return char if ["'", '"'].include?(char)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'securerandom'
6
+ require 'time'
7
+
8
+ module Gitflash
9
+ module Mcp
10
+ # The token a destructive tool hands out with its plan, and checks on the second call.
11
+ # It is an HMAC, under a secret that lives only in this server process, of the tool, its
12
+ # arguments, the plan and an expiry. So a token only confirms the exact change it was issued
13
+ # for, only while the repository would still get the same plan, and only for TTL seconds.
14
+ class Confirmation
15
+ TTL = 300
16
+
17
+ def initialize(secret: SecureRandom.bytes(32), clock: -> { Time.now.to_i })
18
+ @secret = secret
19
+ @clock = clock
20
+ end
21
+
22
+ def issue(tool, arguments, plan)
23
+ expires = @clock.call + TTL
24
+ "#{expires}.#{sign(tool, arguments, plan, expires)}"
25
+ end
26
+
27
+ def valid?(token, tool, arguments, plan)
28
+ expires, mac = token.to_s.split('.', 2)
29
+ return false unless expires&.match?(/\A\d+\z/) && mac
30
+ return false if expires.to_i < @clock.call
31
+
32
+ OpenSSL.fixed_length_secure_compare(mac, sign(tool, arguments, plan, expires.to_i))
33
+ rescue ArgumentError
34
+ false
35
+ end
36
+
37
+ # The envelope of a plan that waits for confirmation: the dry run's, with a new token.
38
+ # `token` is the one the call brought, if any, which did not fit.
39
+ def required(tool, arguments, preview, token)
40
+ note = token ? 'The confirm_token has expired or does not match this plan. ' : ''
41
+ new_token = issue(tool.name, arguments, preview['plan'])
42
+ message = "#{note}Review the plan; to make this change, call #{tool.name} with the " \
43
+ "same arguments and confirm_token #{new_token} (valid until " \
44
+ "#{self.class.expires_at(new_token)})."
45
+ preview.merge('ok' => false, 'status' => 'confirmation_required', 'dry_run' => false,
46
+ 'error' => { 'code' => 'confirmation_required', 'message' => message,
47
+ 'exit_code' => 2, 'confirm_token' => new_token })
48
+ end
49
+
50
+ def self.expires_at(token)
51
+ Time.at(token.split('.', 2).first.to_i).utc.iso8601
52
+ end
53
+
54
+ private
55
+
56
+ def sign(tool, arguments, plan, expires)
57
+ OpenSSL::HMAC.hexdigest('SHA256', @secret, JSON.generate([tool, arguments, plan, expires]))
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'mcp'
5
+ require 'timeout'
6
+
7
+ module Gitflash
8
+ module Mcp
9
+ # Asks the user to approve a destructive tool's plan through the MCP client (a form with one
10
+ # checkbox), when the client declared the `elicitation` capability. The answer comes from the
11
+ # user, not the agent, so an approved plan runs without a second call.
12
+ #
13
+ # How depends on the connection. A legacy one (`initialize`) sends `elicitation/create` and
14
+ # waits up to TIMEOUT seconds; some clients never answer. The 2026-07-28 lifecycle forbids
15
+ # requests from the server, so there `ask` returns an `input_required` result holding the
16
+ # form, and the client calls the tool again with the answer (`answer`) and the state.
17
+ #
18
+ # Whenever the user cannot be asked, `ask` returns nil and the tool falls back to the
19
+ # confirm_token flow, and the reason goes to stderr.
20
+ class Elicitation
21
+ TIMEOUT = 120
22
+ # Accept is the approval. The box is optional and defaults to ticked: a required one made
23
+ # Claude Code refuse Accept until the user ticked it as well
24
+ SCHEMA = {
25
+ type: 'object',
26
+ properties: {
27
+ confirm: { type: 'boolean', title: 'Make this change', default: true,
28
+ description: 'gitflash saves a snapshot first' }
29
+ }
30
+ }.freeze
31
+ MAX_MESSAGE = 4000
32
+ FORM = 'confirm'
33
+
34
+ LOG = ->(text) { warn "gitflash mcp: #{text}" }
35
+
36
+ def initialize(enabled: true, timeout: TIMEOUT, log: LOG)
37
+ @enabled = enabled
38
+ @timeout = timeout
39
+ @log = log
40
+ end
41
+
42
+ # :accepted, :declined, an `input_required` result (2026-07-28 lifecycle), or nil when the
43
+ # user cannot be asked. `state` gives the request state the client echoes on its retry.
44
+ # Without a request context (a call from Ruby, not from a client) there is nobody to ask
45
+ # and nothing is logged.
46
+ def ask(context, tool, plan, state:)
47
+ return unless context
48
+
49
+ reason = unavailable(context)
50
+ return skipped(tool, reason) if reason
51
+ return form(context, tool, plan, state.call) if context.modern?
52
+
53
+ direct(context, tool, plan)
54
+ end
55
+
56
+ # The user's answer on the retry of an `input_required` round. `fits` says whether the state
57
+ # the client echoed still confirms the plan the repository gives now.
58
+ def answer(context, tool, fits:)
59
+ return skipped(tool, 'the plan changed or the form expired') unless fits
60
+
61
+ response = context.input_response(FORM)
62
+ decision(response) || skipped(tool, "unknown answer #{response.inspect[0, 200]}")
63
+ end
64
+
65
+ private
66
+
67
+ def direct(context, tool, plan)
68
+ answer = Timeout.timeout(@timeout) do
69
+ context.create_form_elicitation(message: message(tool, plan), requested_schema: SCHEMA)
70
+ end
71
+ decision(answer) || skipped(tool, "unknown answer #{answer.inspect[0, 200]}")
72
+ rescue Timeout::Error
73
+ skipped(tool, "no answer within #{@timeout} seconds")
74
+ rescue StandardError => e
75
+ skipped(tool, "asking failed: #{e.class}: #{e.message}")
76
+ end
77
+
78
+ def form(context, tool, plan, state)
79
+ params = { mode: 'form', message: message(tool, plan), requestedSchema: SCHEMA }
80
+ result = MCP::Server::InputRequiredResult.new(
81
+ input_requests: { FORM => { method: 'elicitation/create', params: params } },
82
+ request_state: state
83
+ )
84
+ missing = result.missing_client_capabilities(context.client_capabilities)
85
+ missing.empty? ? result : skipped(tool, "the client cannot show forms (#{missing.to_json})")
86
+ end
87
+
88
+ def unavailable(context)
89
+ return 'started with --no-elicitation' unless @enabled
90
+
91
+ capabilities = context.client_capabilities
92
+ return if capabilities.is_a?(Hash) && (capabilities.key?(:elicitation) ||
93
+ capabilities.key?('elicitation'))
94
+
95
+ "the client did not declare the elicitation capability (#{capabilities.inspect[0, 200]})"
96
+ end
97
+
98
+ def skipped(tool, reason)
99
+ @log.call("#{tool.name}: not asking the user, #{reason}; using the confirm_token")
100
+ nil
101
+ end
102
+
103
+ # Accept is a yes unless the user cleared the box; decline and cancel are a no
104
+ def decision(answer)
105
+ answer = JSON.parse(JSON.generate(answer))
106
+ return unless answer.is_a?(Hash) && %w[accept decline cancel].include?(answer['action'])
107
+
108
+ approved = answer['action'] == 'accept' && answer.dig('content', 'confirm') != false
109
+ approved ? :accepted : :declined
110
+ end
111
+
112
+ # Branch names, paths and commit subjects come from the repository, so control characters
113
+ # are shown as escapes and cannot rewrite what the user reads
114
+ def message(tool, plan)
115
+ text = "gitflash #{tool.name} plans this change. A snapshot is saved first; the plan " \
116
+ "lists anything gitflash undo cannot bring back.\n\n" \
117
+ "#{TerminalText.safe(JSON.generate(plan))}"
118
+ text.length > MAX_MESSAGE ? "#{text[0, MAX_MESSAGE]}\n..." : text
119
+ end
120
+ end
121
+ end
122
+ end