openclacky 1.0.0 → 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -22,16 +22,23 @@ module Clacky
22
22
  # 1. Block hard-dangerous commands: sudo, pkill clacky, eval, exec,
23
23
  # `...`, $(...), | sh, | bash,
24
24
  # redirect to /etc /usr /bin.
25
- # 2. Rewrite `rm` → `mv <file> <trash>` so the file is recoverable.
26
- # 3. Rewrite `curl ... | bash` → save script to a file for manual
25
+ # 2. Rewrite `curl ... | bash` → save script to a file for manual
27
26
  # review instead of exec.
28
- # 4. Protect credential/secret files: .env, .ssh/, .aws/ — block
27
+ # 3. Protect credential/secret files: .env, .ssh/, .aws/ — block
29
28
  # writes to these only. Other
30
29
  # "project" files (Gemfile,
31
30
  # README.md, package.json, …)
32
31
  # are NOT protected — editing
33
32
  # them is a normal dev task.
34
33
  #
34
+ # Note on `rm`:
35
+ # `rm` is NOT rewritten here — it's intercepted at runtime by a shell
36
+ # function installed in each PTY session (see Terminal::SAFE_RM_BASH
37
+ # and Terminal#install_marker). This lets the shell's own parser
38
+ # handle heredocs / multi-line / globs / variables correctly. A
39
+ # static Ruby-side rewrite cannot — it would mis-parse heredoc
40
+ # bodies and destroy legitimate commands.
41
+ #
35
42
  # Notes:
36
43
  # - `cp`, `mv`, `mkdir`, `touch`, `echo` are allowed to touch ANY path
37
44
  # (including outside the project root). The source of a `cp` is
@@ -96,7 +103,6 @@ module Clacky
96
103
  @project_root = File.expand_path(project_root)
97
104
 
98
105
  trash_directory = Clacky::TrashDirectory.new(@project_root)
99
- @trash_dir = trash_directory.trash_dir
100
106
  @backup_dir = trash_directory.backup_dir
101
107
 
102
108
  @project_hash = trash_directory.generate_project_hash(@project_root)
@@ -114,12 +120,16 @@ module Clacky
114
120
  @safe_check_command = Clacky::Utils::Encoding.safe_check(command)
115
121
 
116
122
  case @safe_check_command
117
- when /pkill.*clacky|killall.*clacky|kill\s+.*\bclacky\b/i
118
- raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
119
- when /clacky\s+server/
120
- raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
121
- when /^rm\s+/
122
- replace_rm_command(command)
123
+ # Block attempts to terminate the clacky server process.
124
+ # IMPORTANT: each verb is anchored with \b so substrings like
125
+ # "Skill" (contains "kill") or "Bill Killalina" don't trigger
126
+ # false positives. We also require `clacky` to appear as a whole
127
+ # word AND within a reasonable distance (same logical command,
128
+ # not hundreds of chars later in an unrelated echo string).
129
+ when /\bpkill\b[^\n;|&]{0,80}\bclacky\b|\bkillall\b[^\n;|&]{0,80}\bclacky\b|\bkill\s+(?:-\S+\s+)*[^\n;|&]{0,40}\bclacky\b/i
130
+ raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: #{restart_hint}"
131
+ when /\bclacky\s+server\b/
132
+ raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: #{restart_hint}"
123
133
  when /^chmod\s+x/
124
134
  replace_chmod_command(command)
125
135
  when /^curl.*\|\s*(sh|bash)/
@@ -136,27 +146,6 @@ module Clacky
136
146
  end
137
147
  end
138
148
 
139
- def replace_rm_command(command)
140
- files = parse_rm_files(command)
141
- raise SecurityError, "No files specified for deletion" if files.empty?
142
-
143
- commands = files.map do |file|
144
- validate_file_path(file)
145
-
146
- timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%N")
147
- safe_name = "#{File.basename(file)}_deleted_#{timestamp}"
148
- trash_path = File.join(@trash_dir, safe_name)
149
-
150
- create_delete_metadata(file, trash_path) if File.exist?(file)
151
-
152
- "mv #{Shellwords.escape(file)} #{Shellwords.escape(trash_path)}"
153
- end
154
-
155
- result = commands.join(' && ')
156
- log_replacement("rm", result, "Files moved to trash instead of permanent deletion")
157
- result
158
- end
159
-
160
149
  def replace_chmod_command(command)
161
150
  begin
162
151
  parts = Shellwords.split(command)
@@ -194,6 +183,22 @@ module Clacky
194
183
  command
195
184
  end
196
185
 
186
+ # Build a copy-pasteable "how to restart clacky server" hint.
187
+ # When running inside a clacky server worker, `CLACKY_MASTER_PID` is
188
+ # injected by ServerMaster (see server_master.rb). We keep the
189
+ # variable name in the hint (so the AI / user learns the standard
190
+ # convention) AND append the resolved PID in parentheses so it's
191
+ # immediately actionable. When the variable isn't set (e.g. one-shot
192
+ # CLI invocation), we just show the variable name.
193
+ def restart_hint
194
+ pid = ENV["CLACKY_MASTER_PID"].to_s
195
+ if pid =~ /\A\d+\z/
196
+ "kill -USR1 $CLACKY_MASTER_PID (current master PID: #{pid})"
197
+ else
198
+ "kill -USR1 $CLACKY_MASTER_PID"
199
+ end
200
+ end
201
+
197
202
  # Relaxed validator for mv / cp / mkdir / touch / echo.
198
203
  #
199
204
  # Historical behavior was to forbid any path outside @project_root,
@@ -258,16 +263,6 @@ module Clacky
258
263
  command
259
264
  end
260
265
 
261
- def parse_rm_files(command)
262
- begin
263
- parts = Shellwords.split(command)
264
- rescue ArgumentError
265
- parts = command.split(/\s+/)
266
- end
267
-
268
- parts.drop(1).reject { |part| part.start_with?('-') }
269
- end
270
-
271
266
  # Block writes that would clobber credentials / secrets.
272
267
  # These are the only paths truly dangerous to write to by accident:
273
268
  # - ~/.ssh/* (SSH private keys)
@@ -298,30 +293,12 @@ module Clacky
298
293
  end
299
294
  end
300
295
 
301
- # Kept for `rm` handler which rewrites rm → mv-to-trash. We still
302
- # want to prevent accidentally trashing a secret file.
296
+ # Alias retained for readabilitychmod handler validates that
297
+ # the target is not a credential/secret file.
303
298
  def validate_file_path(path)
304
299
  validate_secret_write(path)
305
300
  end
306
301
 
307
- def create_delete_metadata(original_path, trash_path)
308
- metadata = {
309
- original_path: File.expand_path(original_path),
310
- project_root: @project_root,
311
- trash_directory: File.dirname(trash_path),
312
- deleted_at: Time.now.iso8601,
313
- deleted_by: 'AI_Terminal',
314
- file_size: File.size(original_path),
315
- file_type: File.extname(original_path),
316
- file_mode: File.stat(original_path).mode.to_s(8)
317
- }
318
-
319
- metadata_file = "#{trash_path}.metadata.json"
320
- File.write(metadata_file, JSON.pretty_generate(metadata))
321
- rescue StandardError => e
322
- log_warning("Failed to create metadata for #{original_path}: #{e.message}")
323
- end
324
-
325
302
  def log_replacement(original, replacement, reason)
326
303
  write_log(
327
304
  action: 'command_replacement',
@@ -342,12 +319,13 @@ module Clacky
342
319
  # Logging must never break main functionality.
343
320
  end
344
321
 
345
- private :replace_rm_command, :replace_chmod_command,
322
+ private :replace_chmod_command,
346
323
  :replace_curl_pipe_command, :block_sudo_command,
347
324
  :allow_dev_null_redirect, :validate_and_allow,
348
- :validate_general_command, :parse_rm_files,
325
+ :validate_general_command,
349
326
  :validate_file_path, :validate_secret_write,
350
- :create_delete_metadata, :log_replacement,
327
+ :restart_hint,
328
+ :log_replacement,
351
329
  :log_warning, :write_log
352
330
  end
353
331
  end
@@ -125,15 +125,16 @@ module Clacky
125
125
  end
126
126
 
127
127
  # Put a session back into the persistent slot after a successful
128
- # command. If the slot is already filled (concurrent call built
129
- # another one), we just discard the extra to avoid leaks.
128
+ # command. Returns true if stored (caller keeps the session),
129
+ # false if the slot was already filled or the session is unhealthy
130
+ # (caller MUST clean up the session — fds and process — itself).
130
131
  def release(session)
131
132
  @mutex.synchronize do
132
133
  if @session.nil? && session_healthy?(session)
133
134
  @session = session
135
+ true
134
136
  else
135
- # Either we already have one, or this one looks unhealthy.
136
- # Let the caller's cleanup_session path handle teardown.
137
+ false
137
138
  end
138
139
  end
139
140
  end
@@ -156,6 +157,7 @@ module Clacky
156
157
  rescue StandardError
157
158
  # ignore
158
159
  end
160
+ close_fds(sess)
159
161
  end
160
162
  end
161
163
 
@@ -168,11 +170,20 @@ module Clacky
168
170
  rescue StandardError
169
171
  # ignore
170
172
  end
173
+ close_fds(sess)
171
174
  SessionManager.forget(sess.id)
172
175
  end
173
176
 
174
177
  private :drop_locked
175
178
 
179
+ # Close all open file descriptors on a session struct. Safe to call
180
+ # multiple times (all closes are rescue-wrapped).
181
+ private def close_fds(session)
182
+ session.log_io&.close rescue nil
183
+ session.writer&.close rescue nil
184
+ session.reader&.close rescue nil
185
+ end
186
+
176
187
  def session_healthy?(session)
177
188
  return false unless session
178
189
  return false if %w[exited killed].include?(session.status.to_s)
@@ -0,0 +1,106 @@
1
+ # Safe rm shell function — sourced by Clacky::Tools::Terminal at the top
2
+ # of every interactive PTY session. See terminal.rb (SAFE_RM_PATH /
3
+ # install_marker) for rationale.
4
+ #
5
+ # Defines a `rm` function that moves files to $CLACKY_TRASH_DIR instead
6
+ # of deleting them, so deletions can be recovered via `trash_manager`.
7
+ # The metadata sidecar schema matches
8
+ # Clacky::Tools::Security::Replacer#create_delete_metadata so
9
+ # `trash_manager list/restore` keeps working unchanged.
10
+ #
11
+ # Covers: direct `rm ...` calls in the interactive shell, including
12
+ # multi-line commands, heredocs (heredoc bodies no longer trigger
13
+ # the rewriter), and shell glob expansion.
14
+ # Does NOT cover: `command rm`, `/bin/rm` (absolute path), `xargs rm`,
15
+ # `find -exec rm`, and child scripts — these bypass shell functions
16
+ # by design. This is the same coverage the old static Ruby rewriter
17
+ # had; it could not see inside those either.
18
+
19
+ rm() {
20
+ # Parse args: respect `--`, collect flag-like and path-like args.
21
+ local __dd=0
22
+ local -a __paths=() __flags=()
23
+ local __a
24
+ for __a in "$@"; do
25
+ if [ "$__dd" = "1" ]; then
26
+ __paths+=("$__a")
27
+ elif [ "$__a" = "--" ]; then
28
+ __dd=1
29
+ elif [ "${__a:0:1}" = "-" ] && [ -n "${__a:1}" ]; then
30
+ __flags+=("$__a")
31
+ else
32
+ __paths+=("$__a")
33
+ fi
34
+ done
35
+
36
+ # Trash dir is provisioned by the Ruby side via env.
37
+ local __trash="${CLACKY_TRASH_DIR:-}"
38
+ if [ -z "$__trash" ]; then
39
+ echo "[clacky-rm] CLACKY_TRASH_DIR not set; refusing to rm" >&2
40
+ return 1
41
+ fi
42
+ mkdir -p "$__trash" 2>/dev/null || true
43
+
44
+ # Safety: refuse catastrophic targets (pre-expansion by the shell).
45
+ local __p __norm
46
+ for __p in ${__paths[@]+"${__paths[@]}"}; do
47
+ __norm="${__p%/}"
48
+ [ -z "$__norm" ] && __norm="/"
49
+ case "$__norm" in
50
+ /|/root|/etc|/usr|/bin|/sbin|/var)
51
+ echo "[clacky-rm] refused dangerous target: $__p" >&2
52
+ return 1
53
+ ;;
54
+ esac
55
+ if [ "$__norm" = "$HOME" ] || [ "$__p" = "~" ]; then
56
+ echo "[clacky-rm] refused dangerous target: $__p" >&2
57
+ return 1
58
+ fi
59
+ done
60
+
61
+ # `-f` semantics: suppress "no such file" errors.
62
+ local __has_f=0 __f
63
+ for __f in ${__flags[@]+"${__flags[@]}"}; do
64
+ case "$__f" in *f*) __has_f=1 ;; esac
65
+ done
66
+
67
+ local __rc=0 __base __ts __dest __abs __size __mode __ext __now
68
+ for __p in ${__paths[@]+"${__paths[@]}"}; do
69
+ if [ ! -e "$__p" ] && [ ! -L "$__p" ]; then
70
+ if [ "$__has_f" = "0" ]; then
71
+ echo "rm: $__p: No such file or directory" >&2
72
+ __rc=1
73
+ fi
74
+ continue
75
+ fi
76
+ __base="$(basename -- "$__p")"
77
+ __ts="$(date +%Y%m%d_%H%M%S_%N 2>/dev/null || date +%Y%m%d_%H%M%S)"
78
+ __dest="$__trash/${__base}_deleted_${__ts}"
79
+ # Resolve absolute path for metadata BEFORE mv (path won't exist after).
80
+ if [ -d "$__p" ]; then
81
+ __abs="$(cd "$__p" 2>/dev/null && pwd)" || __abs="$__p"
82
+ else
83
+ __abs="$(cd "$(dirname -- "$__p")" 2>/dev/null && pwd)/$(basename -- "$__p")" || __abs="$__p"
84
+ fi
85
+ # Size / mode best-effort; ignore for dirs or on failure.
86
+ __size="$(stat -f%z "$__p" 2>/dev/null || stat -c%s "$__p" 2>/dev/null || echo 0)"
87
+ __mode="$(stat -f%Lp "$__p" 2>/dev/null || stat -c%a "$__p" 2>/dev/null || echo 644)"
88
+ case "$__base" in
89
+ *.*) __ext=".${__base##*.}" ;;
90
+ *) __ext="" ;;
91
+ esac
92
+ __now="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%s)"
93
+ if command mv -- "$__p" "$__dest" 2>/dev/null; then
94
+ # Metadata sidecar — schema matches
95
+ # Clacky::Tools::Security::Replacer#create_delete_metadata so
96
+ # `trash_manager list/restore` continue to work.
97
+ printf '{"original_path":"%s","trash_directory":"%s","deleted_at":"%s","deleted_by":"clacky_rm_shell","file_size":%s,"file_type":"%s","file_mode":"%s"}\n' \
98
+ "$__abs" "$__trash" "$__now" "${__size:-0}" "$__ext" "${__mode:-644}" \
99
+ > "$__dest.metadata.json" 2>/dev/null || true
100
+ else
101
+ echo "rm: failed to move $__p to trash" >&2
102
+ __rc=1
103
+ fi
104
+ done
105
+ return $__rc
106
+ }
@@ -175,12 +175,17 @@ module Clacky
175
175
  end
176
176
  end
177
177
 
178
- # Kill every live session. Called from at_exit.
178
+ # Kill every live session and close any open fds. Called from at_exit.
179
179
  def kill_all!
180
180
  (@sessions.values rescue []).each do |s|
181
- next if s.status == "exited" || s.status == "killed"
182
- Process.kill("KILL", s.pid) rescue nil
181
+ begin
182
+ Process.kill("KILL", s.pid) unless %w[exited killed].include?(s.status.to_s)
183
+ rescue StandardError
184
+ # ignore
185
+ end
183
186
  s.log_io&.close rescue nil
187
+ s.writer&.close rescue nil
188
+ s.reader&.close rescue nil
184
189
  end
185
190
  end
186
191