openclacky 1.3.2 → 1.3.3

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: extend-openclacky
3
- description: Customize, fix, override or extend openclacky itself — e.g. change a built-in tool's behavior, intercept/audit/block tool calls with shell scripts, or plug in a new IM channel (Slack, in-house IM, etc.). Trigger on phrases like "patch clacky", "patch openclacky", "change WebSearch behavior", "block dangerous commands", "audit tool use", "add Slack channel", " openclacky 内置", "改 clacky 内置", "monkey patch openclacky", "拦截工具调用". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
3
+ description: Customize, fix, override or extend openclacky itself — change a built-in tool's behavior, intercept/audit/block tool calls, plug in a new IM channel (Slack, in-house IM…), or add UI to the Web UI (panel, button, settings tab). Trigger on "patch openclacky", "block dangerous commands", "audit tool use", "add Slack channel", "extend the web ui", "改 openclacky 内置", "拦截工具调用", "扩展 web 界面". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
4
4
  ---
5
5
 
6
6
  # Extending Openclacky
7
7
 
8
- Openclacky ships three official extension mechanisms that survive `gem update` and never require editing the gem source.
8
+ Openclacky ships four official extension mechanisms that survive `gem update` and never require editing the gem source.
9
9
  **Never tell the user to `bundle show openclacky` and edit the gem — always use one of these.**
10
10
 
11
11
  ## Pick the right mechanism
@@ -15,6 +15,7 @@ Openclacky ships three official extension mechanisms that survive `gem update` a
15
15
  | Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
16
16
  | **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
17
17
  | Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
18
+ | Add UI to the **Web UI** (custom panel, header button, settings tab, visualize data) | **Web UI Extension** | drop a `.js` file in `~/.clacky/webui_ext/` | reload page; `Clacky.ext.slots()` in console; `?pure=true` to escape |
18
19
 
19
20
  ## Authoritative documentation
20
21
 
@@ -23,14 +24,15 @@ Each mechanism has a full reference doc — read the relevant one with `web_fetc
23
24
  - Patches → https://www.openclacky.com/docs/extend-patches
24
25
  - Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
25
26
  - Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
27
+ - Web UI Extensions → https://www.openclacky.com/docs/extend-webui
26
28
 
27
29
  ## Execution playbook
28
30
 
29
31
  1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
30
32
  2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
31
- 3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta.
33
+ 3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta. *(Web UI Extensions have no scaffold — just create a `.js` file under `~/.clacky/webui_ext/`; the doc shows the `Clacky.ext` contract.)*
32
34
  4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
33
- 5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim.
35
+ 5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim. *(Web UI Extensions have no verify command — reload the page and confirm the slot rendered; if anything breaks, `?pure=true` disables all extensions instantly.)*
34
36
 
35
37
  ## When NOT to use this skill
36
38
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: media-gen
3
- description: 'Generate images, videos, or audio (text-to-speech) in the current task. Use whenever the user asks to create/generate/produce a picture / image / illustration / cover / poster / icon / artwork, a video / clip / animation, or speech / voiceover / narration / TTS — e.g. 生成图片, 画一张, 做封面, 配图, generate image, make a picture, draw, design a cover, 生成视频, 做个视频, text-to-video, 朗读, 配音, 旁白, 文字转语音, generate speech, voiceover. Also use when a document (slides, poster, README hero) needs an inline image.'
3
+ description: 'Generate or edit images, videos, or audio (text-to-speech) in the current task. Use whenever the user asks to create/generate/produce or edit/modify a picture / image / illustration / cover / poster / icon / artwork, a video / clip / animation, or speech / voiceover / narration / TTS — e.g. generate image, draw, design a cover, edit this image, change the background, text-to-video, generate speech; 画一张, 配图, 编辑图片, 改图, 换背景, 做个视频, 配音, 文字转语音. Also use when a document (slides, poster, README hero) needs an inline image.'
4
4
  disable-model-invocation: false
5
5
  user-invocable: true
6
6
  always-show: true
@@ -8,7 +8,7 @@ always-show: true
8
8
 
9
9
  # media-gen
10
10
 
11
- Generate images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings).
11
+ Generate **and edit** images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings). Editing (image-in → image-out) works with any image model that accepts image input — most current ones do.
12
12
 
13
13
  ## Endpoint
14
14
 
@@ -73,7 +73,32 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
73
73
  |----------------|----------|-------------------------------------|-------|
74
74
  | `prompt` | yes | string | Be detailed and concrete. See prompt tips below. |
75
75
  | `aspect_ratio` | no | `landscape` / `square` / `portrait` | Defaults to `landscape`. |
76
- | `output_dir` | no | absolute path | Defaults to the current working directory. The image is saved under `<output_dir>/assets/generated/`. |
76
+ | `output_dir` | no | absolute path | Per-call override. When omitted, falls back to the user's `media_output_dir` setting (Settings → Models → Media Output Directory), then to the working directory. The image is always saved into the `assets/generated/` subdirectory of whichever path is used. |
77
+ | `image` | no | file path / base64 / data URL | A single input image to **edit**. Triggers image-edit mode (see below). |
78
+ | `images` | no | array of the above | Multiple input images for a multi-image edit. Takes precedence over `image`. |
79
+
80
+ ### Editing an existing image
81
+
82
+ To edit instead of generate from scratch, pass the existing image as `image`
83
+ (a local file path is easiest — the skill reads and encodes it for you) plus a
84
+ `prompt` describing the change. The configured image model receives the
85
+ image alongside the prompt and returns an edited result.
86
+
87
+ ```bash
88
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image \
89
+ -H "Content-Type: application/json" \
90
+ -d '{
91
+ "prompt": "change the background to a starry night sky, keep the cat unchanged",
92
+ "image": "/abs/path/to/input.png"
93
+ }'
94
+ ```
95
+
96
+ - The result is a **new** edited image saved under `assets/generated/` — the
97
+ original file is never modified in place.
98
+ - For combining several inputs (e.g. "put the product from image 1 onto the
99
+ background from image 2"), pass them as `images: ["/path/a.png", "/path/b.png"]`
100
+ and describe the composition in the prompt.
101
+ - Same speed/concurrency rules apply: editing is as slow as generation, one at a time.
77
102
 
78
103
  ### Response shape (success)
79
104
 
@@ -81,7 +106,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
81
106
  {
82
107
  "success": true,
83
108
  "image": "/abs/path/to/working_dir/assets/generated/img_20260525_011820_a1b2c3d4.png",
84
- "model": "or-gemini-3-pro-image",
109
+ "model": "<the configured image model>",
85
110
  "provider": "openclacky",
86
111
  "prompt": "A clean, modern hero illustration ...",
87
112
  "aspect_ratio": "landscape",
@@ -145,7 +170,6 @@ When the user gives a vague request like "给我配张图", ask one clarifying q
145
170
 
146
171
  ## When NOT to use this skill
147
172
 
148
- - The user asks to **edit** an existing image (this skill is text-to-image only today)
149
173
  - The user wants a **diagram / chart** with specific data — use a charting library (matplotlib, mermaid, etc.) instead; image gen is for illustrations, not data viz
150
174
  - The user asks for **screenshots** of real software — use the browser tool
151
175
 
@@ -190,7 +214,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
190
214
  | `aspect_ratio` | no | `landscape` / `portrait` | Defaults to `landscape` (16:9). |
191
215
  | `duration_seconds` | no | 4–8 | Defaults to 8. |
192
216
  | `image` | no | `{ "b64_json": "...", "mime_type": "image/png" }` | Optional first frame for image-to-video. |
193
- | `output_dir` | no | absolute path | MP4 saved under `<output_dir>/assets/generated/`. |
217
+ | `output_dir` | no | absolute path | Per-call override; same fallback chain as `image` (user setting → cwd). MP4 saved into the `assets/generated/` subdirectory of whichever path is used. |
194
218
 
195
219
  ### Response (success)
196
220
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "base64"
5
6
  require_relative "base"
6
7
 
7
8
  module Clacky
@@ -28,7 +29,7 @@ module Clacky
28
29
  VIDEO_ASPECTS = %w[landscape portrait].freeze
29
30
  DEFAULT_VIDEO_DURATION = 8
30
31
 
31
- def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, **_kwargs)
32
+ def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, image: nil, images: nil, **_kwargs)
32
33
  provider_id = Clacky::Providers.find_by_base_url(@base_url) || "custom"
33
34
  aspect = ASPECT_TO_SIZE.key?(aspect_ratio) ? aspect_ratio : DEFAULT_ASPECT
34
35
  size = ASPECT_TO_SIZE[aspect]
@@ -52,6 +53,18 @@ module Clacky
52
53
  )
53
54
  end
54
55
 
56
+ begin
57
+ input_images = normalize_input_images(image, images)
58
+ rescue ArgumentError => e
59
+ return error_response(
60
+ error: e.message,
61
+ error_type: "invalid_argument",
62
+ provider: provider_id,
63
+ prompt: prompt,
64
+ aspect_ratio: aspect
65
+ )
66
+ end
67
+
55
68
  payload = { model: @model, n: n }
56
69
  if gemini_family?(@model)
57
70
  # Gemini image models (routed via openclacky / openrouter gateway)
@@ -64,6 +77,11 @@ module Clacky
64
77
  payload[:size] = size
65
78
  end
66
79
 
80
+ # With input image(s) this becomes an edit: the gateway forwards them
81
+ # to the model alongside the prompt. Sent as `images` (array) so
82
+ # multi-image edits work; the gateway also accepts a single `image`.
83
+ payload[:images] = input_images unless input_images.empty?
84
+
67
85
  begin
68
86
  response = connection.post("images/generations") do |req|
69
87
  req.headers["Content-Type"] = "application/json"
@@ -306,6 +324,51 @@ module Clacky
306
324
  model_name.to_s.match?(/gemini|imagen/i)
307
325
  end
308
326
 
327
+ # Normalise the optional image/edit inputs into an array of data URLs
328
+ # ("data:<mime>;base64,<payload>") the gateway understands. Each input
329
+ # may be a local file path, a data URL, or a bare base64 string.
330
+ # `images` (array or single) takes precedence over `image`.
331
+ # Raises ArgumentError on a missing file or undecodable input.
332
+ private def normalize_input_images(image, images)
333
+ raw = images.nil? ? image : images
334
+ return [] if raw.nil?
335
+
336
+ list = raw.is_a?(Array) ? raw : [raw]
337
+ list.filter_map do |item|
338
+ s = item.to_s.strip
339
+ next if s.empty?
340
+ to_data_url(s)
341
+ end
342
+ end
343
+
344
+ private def to_data_url(input)
345
+ return input if input.start_with?("data:")
346
+
347
+ # A filesystem path → read and encode.
348
+ if File.file?(input)
349
+ bytes = File.binread(input)
350
+ mime = mime_for_path(input)
351
+ return "data:#{mime};base64,#{Base64.strict_encode64(bytes)}"
352
+ end
353
+
354
+ # Otherwise treat as a bare base64 payload; validate it decodes.
355
+ begin
356
+ Base64.strict_decode64(input)
357
+ rescue ArgumentError
358
+ raise ArgumentError, "input image is neither an existing file path nor valid base64"
359
+ end
360
+ "data:image/png;base64,#{input}"
361
+ end
362
+
363
+ private def mime_for_path(path)
364
+ case File.extname(path).downcase
365
+ when ".jpg", ".jpeg" then "image/jpeg"
366
+ when ".webp" then "image/webp"
367
+ when ".gif" then "image/gif"
368
+ else "image/png"
369
+ end
370
+ end
371
+
309
372
  # base_url is taken verbatim from PRESETS (each provider already
310
373
  # includes the API version segment when needed). We only ensure a
311
374
  # trailing slash so Faraday's relative-path join behaves.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Media
5
+ # Resolves the on-disk root for generated media files (images, videos,
6
+ # audio) according to a fixed precedence:
7
+ #
8
+ # 1. `param` — explicit `output_dir` from the API caller.
9
+ # Highest priority; lets a single call land
10
+ # somewhere specific (e.g. a doc's project root).
11
+ # 2. `configured` — user setting from AgentConfig#media_output_dir.
12
+ # Set via Settings → Models → Media Output Directory.
13
+ # 3. `fallback` — process default; preserves legacy behavior for
14
+ # configs that have neither key set.
15
+ #
16
+ # Pure function on purpose: callers (HTTP handlers) read the configured
17
+ # value off AgentConfig and inject it here. Keeps this helper trivially
18
+ # unit-testable and free of global state.
19
+ #
20
+ # The final on-disk path is `<resolved>/assets/generated/<file>` —
21
+ # the `assets/generated/` suffix is appended by Media::Base#save_*
22
+ # for stable relative-path semantics across markdown / slide outputs,
23
+ # and is intentionally not configurable here.
24
+ module OutputDir
25
+ # @param param [String, nil] explicit per-call override
26
+ # @param configured [String, nil] user-configured default
27
+ # @param fallback [String] last-resort default (defaults to Dir.pwd)
28
+ # @return [String] absolute or `~`-prefixed path; the
29
+ # caller's File.join with "assets/generated/" handles `~` via the
30
+ # surrounding FileUtils.mkdir_p call only when expanded — for safety
31
+ # we expand `~` here so downstream sees an absolute path.
32
+ def self.resolve(param:, configured:, fallback: Dir.pwd)
33
+ chosen = first_present(param, configured) || fallback
34
+ File.expand_path(chosen.to_s)
35
+ end
36
+
37
+ # @api private
38
+ def self.first_present(*candidates)
39
+ candidates.find { |c| c.is_a?(String) && !c.strip.empty? }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -85,6 +85,15 @@ module Clacky
85
85
  self
86
86
  end
87
87
 
88
+ # Truncate history starting from the user message with the given created_at timestamp.
89
+ # Removes that message and everything after it. Returns self.
90
+ def truncate_from_created_at(created_at)
91
+ idx = @messages.index { |m| m[:role] == "user" && m[:created_at].to_s == created_at.to_s }
92
+ return self unless idx
93
+
94
+ truncate_from(idx)
95
+ end
96
+
88
97
  # Roll back the history to just before the given message object.
89
98
  # Removes the message and anything appended after it.
90
99
  # Used to undo a failed speculative append (e.g. compression message that errored).
@@ -43,6 +43,8 @@ module Clacky
43
43
  @running = false
44
44
  @mutex = Mutex.new
45
45
  @session_counters = Hash.new(0)
46
+ @dedup_mutex = Mutex.new
47
+ @last_message = {} # channel_key => [digest, monotonic_time]
46
48
  end
47
49
 
48
50
  # Start all enabled adapters in background threads. Non-blocking.
@@ -242,6 +244,16 @@ module Clacky
242
244
  files = event[:files] || []
243
245
  return if (text.nil? || text.empty?) && files.empty?
244
246
 
247
+ # Safety net against adapter-side message storms: drop an identical message
248
+ # repeated on the same channel within a short window. A real user never
249
+ # sends the exact same text+files twice within DEDUP_WINDOW seconds, but a
250
+ # misbehaving adapter (or noisy IM system messages) can, which would
251
+ # otherwise spin the interrupt→restart loop below indefinitely.
252
+ if duplicate_message?(event, text, files)
253
+ Clacky::Logger.info("[ChannelManager] Dropping duplicate message on #{channel_key(event)} within #{DEDUP_WINDOW}s")
254
+ return
255
+ end
256
+
245
257
  # Handle built-in commands
246
258
  if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
247
259
  handle_command(adapter, event, text)
@@ -749,6 +761,20 @@ module Clacky
749
761
  adapter.send_text(chat_id, "Recent sessions:\n#{lines.join("\n")}\n\nUse `/bind <n>` to switch.")
750
762
  end
751
763
 
764
+ DEDUP_WINDOW = 2.0 # seconds; identical messages on the same channel within this window are dropped
765
+
766
+ private def duplicate_message?(event, text, files)
767
+ key = channel_key(event)
768
+ digest = [text, files.map { |f| f.is_a?(Hash) ? f[:name] : f.to_s }].hash
769
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
770
+
771
+ @dedup_mutex.synchronize do
772
+ prev_digest, prev_time = @last_message[key]
773
+ @last_message[key] = [digest, now]
774
+ !prev_digest.nil? && prev_digest == digest && (now - prev_time) < DEDUP_WINDOW
775
+ end
776
+ end
777
+
752
778
  def channel_key(event)
753
779
  platform = event[:platform].to_s
754
780
  case @binding_mode
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Clacky
6
+ module Server
7
+ # Read-mostly git operations scoped to a session's working directory, backing
8
+ # the official "git" WebUI panel. Commands run with explicit argv (no shell),
9
+ # so user-supplied values (paths, messages) cannot inject. Write operations
10
+ # are limited to a guarded `commit`; history-rewriting / remote-mutating
11
+ # commands are never exposed here.
12
+ module GitPanel
13
+ module_function
14
+
15
+ # Run a git subcommand in `dir` with argv-style args (no shell). Returns
16
+ # [stdout, stderr, success_bool]. Never raises on git failure.
17
+ def git(dir, *args)
18
+ out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
19
+ [out, err, status.success?]
20
+ rescue StandardError => e
21
+ ["", e.message, false]
22
+ end
23
+
24
+ # Whether `dir` is inside a git work tree.
25
+ def repo?(dir)
26
+ out, _err, ok = git(dir, "rev-parse", "--is-inside-work-tree")
27
+ ok && out.strip == "true"
28
+ end
29
+
30
+ # { branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] }
31
+ # Parsed from `git status --porcelain=v2 --branch`.
32
+ def status(dir)
33
+ out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
34
+ return { branch: nil, files: [] } unless ok
35
+
36
+ branch = nil
37
+ ahead = behind = 0
38
+ files = []
39
+ out.each_line do |line|
40
+ line = line.chomp
41
+ if line.start_with?("# branch.head ")
42
+ branch = line.sub("# branch.head ", "")
43
+ elsif line.start_with?("# branch.ab ")
44
+ m = line.match(/\+(\d+) -(\d+)/)
45
+ ahead, behind = m[1].to_i, m[2].to_i if m
46
+ elsif line.start_with?("1 ", "2 ")
47
+ xy = line.split(" ")[1]
48
+ path = line.split(" ", 9).last
49
+ files << { path: path, x: xy[0], y: xy[1],
50
+ staged: xy[0] != ".", untracked: false }
51
+ elsif line.start_with?("? ")
52
+ files << { path: line.sub("? ", ""), x: "?", y: "?",
53
+ staged: false, untracked: true }
54
+ end
55
+ end
56
+ { branch: branch, ahead: ahead, behind: behind, files: files }
57
+ end
58
+
59
+ # Unified diff. `file` (optional, relative) limits to one path; omitted =
60
+ # whole working tree (tracked changes). `--` guards path from being read
61
+ # as an option.
62
+ def diff(dir, file: nil)
63
+ args = ["diff"]
64
+ args += ["--", file] if file && !file.empty?
65
+ out, _err, _ok = git(dir, *args)
66
+ out
67
+ end
68
+
69
+ # Recent commits: [{ hash:, short:, author:, date:, subject: }].
70
+ def log(dir, limit: 50)
71
+ limit = limit.to_i.clamp(1, 200)
72
+ fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
73
+ out, _err, ok = git(dir, "log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}")
74
+ return [] unless ok
75
+
76
+ out.each_line.filter_map do |line|
77
+ h, short, author, date, subject = line.chomp.split("\x1f")
78
+ next unless h
79
+ { hash: h, short: short, author: author, date: date, subject: subject }
80
+ end
81
+ end
82
+
83
+ # [{ name:, current: bool }] from `git branch`.
84
+ def branches(dir)
85
+ out, _err, ok = git(dir, "branch", "--format=%(refname:short)%00%(HEAD)")
86
+ return [] unless ok
87
+
88
+ out.each_line.filter_map do |line|
89
+ name, head = line.chomp.split("\x00")
90
+ next unless name && !name.empty?
91
+ { name: name, current: head == "*" }
92
+ end
93
+ end
94
+
95
+ # Stage `files` (relative paths) and commit with `message`. Returns
96
+ # { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
97
+ # argv so paths/message cannot inject; no --no-verify, no amend.
98
+ def commit(dir, message:, files:)
99
+ msg = message.to_s.strip
100
+ paths = Array(files).map(&:to_s).reject(&:empty?)
101
+ return { ok: false, error: "commit message is required" } if msg.empty?
102
+ return { ok: false, error: "no files selected" } if paths.empty?
103
+
104
+ _out, add_err, add_ok = git(dir, "add", "--", *paths)
105
+ return { ok: false, error: "git add failed: #{add_err.strip}" } unless add_ok
106
+
107
+ _out, c_err, c_ok = git(dir, "commit", "-m", msg, "--", *paths)
108
+ return { ok: false, error: "git commit failed: #{c_err.strip}" } unless c_ok
109
+
110
+ head, _err, _ok = git(dir, "rev-parse", "--short", "HEAD")
111
+ { ok: true, hash: head.strip }
112
+ end
113
+ end
114
+ end
115
+ end