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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +124 -114
- data/lib/gitflash/branch.rb +10 -5
- data/lib/gitflash/branch_activity.rb +47 -0
- data/lib/gitflash/cleanup.rb +42 -1
- data/lib/gitflash/cli.rb +32 -0
- data/lib/gitflash/commands/clean.rb +14 -4
- data/lib/gitflash/commands/skill_install.rb +118 -0
- data/lib/gitflash/hook/branch_marker.rb +17 -11
- data/lib/gitflash/hook/command_parser.rb +13 -6
- data/lib/gitflash/hook/heredocs.rb +120 -0
- data/lib/gitflash/hook/shell_text.rb +52 -19
- data/lib/gitflash/mcp/confirmation.rb +61 -0
- data/lib/gitflash/mcp/elicitation.rb +122 -0
- data/lib/gitflash/mcp/plan_change.rb +53 -0
- data/lib/gitflash/mcp/runner.rb +41 -0
- data/lib/gitflash/mcp/server.rb +148 -0
- data/lib/gitflash/mcp/tools.rb +195 -0
- data/lib/gitflash/repo.rb +4 -4
- data/lib/gitflash/reused_snapshot.rb +11 -0
- data/lib/gitflash/skill_cli.rb +46 -0
- data/lib/gitflash/snapshot.rb +3 -0
- data/lib/gitflash/snapshots.rb +3 -2
- data/lib/gitflash/ui.rb +8 -2
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +74 -0
- data/skills/gitflash/SKILL.md +95 -0
- metadata +32 -4
|
@@ -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',
|
|
78
|
-
"#{Repo::HEADS}#{
|
|
84
|
+
'git', '-C', dir, 'log', '-g', '--date=unix', '--format=%gd%x1f%H%x1f%gs',
|
|
85
|
+
"#{Repo::HEADS}#{branch}", '--'
|
|
79
86
|
)
|
|
80
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
# [
|
|
30
|
+
# [commands, scripts] in one pass over the line
|
|
26
31
|
def scan(line)
|
|
27
|
-
|
|
28
|
-
scripts = []
|
|
32
|
+
state = Scan.new([+''], [], [], {})
|
|
29
33
|
index = 0
|
|
30
34
|
quote = nil
|
|
31
|
-
index, quote = step(line, index, quote,
|
|
32
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|