openclacky 0.9.19 → 0.9.21

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.
@@ -315,11 +315,8 @@ module Clacky
315
315
  case [method, path]
316
316
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
317
317
  when ["POST", "/api/sessions"] then api_create_session(req, res)
318
- when ["GET", "/api/schedules"] then api_list_schedules(res)
319
- when ["POST", "/api/schedules"] then api_create_schedule(req, res)
320
- when ["GET", "/api/tasks"] then api_list_tasks(res)
321
- when ["POST", "/api/tasks"] then api_create_task(req, res)
322
- when ["POST", "/api/tasks/run"] then api_run_task(req, res)
318
+ when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
319
+ when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
323
320
  when ["GET", "/api/skills"] then api_list_skills(res)
324
321
  when ["GET", "/api/config"] then api_get_config(res)
325
322
  when ["POST", "/api/config"] then api_save_config(req, res)
@@ -365,15 +362,15 @@ module Clacky
365
362
  elsif method == "DELETE" && path.start_with?("/api/sessions/")
366
363
  session_id = path.sub("/api/sessions/", "")
367
364
  api_delete_session(session_id, res)
368
- elsif method == "DELETE" && path.start_with?("/api/schedules/")
369
- name = URI.decode_www_form_component(path.sub("/api/schedules/", ""))
370
- api_delete_schedule(name, res)
371
- elsif method == "GET" && path.start_with?("/api/tasks/")
372
- name = URI.decode_www_form_component(path.sub("/api/tasks/", ""))
373
- api_get_task(name, res)
374
- elsif method == "DELETE" && path.start_with?("/api/tasks/")
375
- name = URI.decode_www_form_component(path.sub("/api/tasks/", ""))
376
- api_delete_task(name, res)
365
+ elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
366
+ name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
367
+ api_run_cron_task(name, res)
368
+ elsif method == "PATCH" && path.match?(%r{^/api/cron-tasks/[^/]+$})
369
+ name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", ""))
370
+ api_update_cron_task(name, req, res)
371
+ elsif method == "DELETE" && path.match?(%r{^/api/cron-tasks/[^/]+$})
372
+ name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", ""))
373
+ api_delete_cron_task(name, res)
377
374
  elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
378
375
  name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
379
376
  api_toggle_skill(name, req, res)
@@ -1242,109 +1239,81 @@ module Clacky
1242
1239
  }
1243
1240
  end
1244
1241
 
1245
- # ── Schedules API ─────────────────────────────────────────────────────────
1246
1242
 
1247
- def api_list_schedules(res)
1248
- json_response(res, 200, { schedules: @scheduler.schedules })
1243
+ # ── Cron-Tasks API ───────────────────────────────────────────────────────
1244
+ # Unified API that manages task file + schedule as a single resource.
1245
+
1246
+ # GET /api/cron-tasks
1247
+ def api_list_cron_tasks(res)
1248
+ json_response(res, 200, { cron_tasks: @scheduler.list_cron_tasks })
1249
1249
  end
1250
1250
 
1251
- def api_create_schedule(req, res)
1252
- body = parse_json_body(req)
1253
- name = body["name"].to_s.strip
1254
- task = body["task"].to_s.strip
1255
- cron = body["cron"].to_s.strip
1251
+ # POST /api/cron-tasks — create task file + schedule in one step
1252
+ # Body: { name, content, cron, enabled? }
1253
+ def api_create_cron_task(req, res)
1254
+ body = parse_json_body(req)
1255
+ name = body["name"].to_s.strip
1256
+ content = body["content"].to_s
1257
+ cron = body["cron"].to_s.strip
1258
+ enabled = body.key?("enabled") ? body["enabled"] : true
1256
1259
 
1257
- if name.empty? || task.empty? || cron.empty?
1258
- json_response(res, 422, { error: "name, task, and cron are required" })
1259
- return
1260
- end
1260
+ return json_response(res, 422, { error: "name is required" }) if name.empty?
1261
+ return json_response(res, 422, { error: "content is required" }) if content.empty?
1262
+ return json_response(res, 422, { error: "cron is required" }) if cron.empty?
1261
1263
 
1262
- unless @scheduler.list_tasks.include?(task)
1263
- json_response(res, 422, { error: "Task not found: #{task}" })
1264
- return
1264
+ fields = cron.strip.split(/\s+/)
1265
+ unless fields.size == 5
1266
+ return json_response(res, 422, { error: "cron must have 5 fields (min hour dom month dow)" })
1265
1267
  end
1266
1268
 
1267
- @scheduler.add_schedule(name: name, task: task, cron: cron)
1269
+ @scheduler.create_cron_task(name: name, content: content, cron: cron, enabled: enabled)
1268
1270
  json_response(res, 201, { ok: true, name: name })
1269
1271
  end
1270
1272
 
1271
- def api_delete_schedule(name, res)
1272
- if @scheduler.remove_schedule(name)
1273
- json_response(res, 200, { ok: true })
1274
- else
1275
- json_response(res, 404, { error: "Schedule not found: #{name}" })
1276
- end
1277
- end
1278
-
1279
- # ── Tasks API ─────────────────────────────────────────────────────────────
1273
+ # PATCH /api/cron-tasks/:name — update content and/or cron/enabled
1274
+ # Body: { content?, cron?, enabled? }
1275
+ def api_update_cron_task(name, req, res)
1276
+ body = parse_json_body(req)
1277
+ content = body["content"]
1278
+ cron = body["cron"]&.to_s&.strip
1279
+ enabled = body["enabled"]
1280
1280
 
1281
- def api_list_tasks(res)
1282
- tasks = @scheduler.list_tasks.map do |name|
1283
- content = begin
1284
- @scheduler.read_task(name)
1285
- rescue StandardError
1286
- ""
1287
- end
1288
- { name: name, path: @scheduler.task_file_path(name), content: content }
1281
+ if cron && cron.split(/\s+/).size != 5
1282
+ return json_response(res, 422, { error: "cron must have 5 fields (min hour dom month dow)" })
1289
1283
  end
1290
- json_response(res, 200, { tasks: tasks })
1291
- end
1292
1284
 
1293
- def api_get_task(name, res)
1294
- content = @scheduler.read_task(name)
1295
- json_response(res, 200, { name: name, content: content })
1285
+ @scheduler.update_cron_task(name, content: content, cron: cron, enabled: enabled)
1286
+ json_response(res, 200, { ok: true, name: name })
1296
1287
  rescue => e
1297
1288
  json_response(res, 404, { error: e.message })
1298
1289
  end
1299
1290
 
1300
- def api_delete_task(name, res)
1301
- if @scheduler.delete_task(name)
1291
+ # DELETE /api/cron-tasks/:name — remove task file + schedule
1292
+ def api_delete_cron_task(name, res)
1293
+ if @scheduler.delete_cron_task(name)
1302
1294
  json_response(res, 200, { ok: true })
1303
1295
  else
1304
- json_response(res, 404, { error: "Task not found: #{name}" })
1296
+ json_response(res, 404, { error: "Cron task not found: #{name}" })
1305
1297
  end
1306
1298
  end
1307
1299
 
1308
- def api_create_task(req, res)
1309
- body = parse_json_body(req)
1310
- name = body["name"].to_s.strip
1311
- content = body["content"].to_s
1312
-
1313
- if name.empty?
1314
- json_response(res, 422, { error: "name is required" })
1315
- return
1316
- end
1317
-
1318
- @scheduler.write_task(name, content)
1319
- json_response(res, 201, { ok: true, name: name })
1320
- end
1321
-
1322
- def api_run_task(req, res)
1323
- body = parse_json_body(req)
1324
- name = body["name"].to_s.strip
1325
-
1326
- if name.empty?
1327
- json_response(res, 422, { error: "name is required" })
1328
- return
1300
+ # POST /api/cron-tasks/:name/run — execute immediately
1301
+ def api_run_cron_task(name, res)
1302
+ unless @scheduler.list_tasks.include?(name)
1303
+ return json_response(res, 404, { error: "Cron task not found: #{name}" })
1329
1304
  end
1330
1305
 
1331
- begin
1332
- prompt = @scheduler.read_task(name)
1333
- session_name = "▶ #{name} #{Time.now.strftime("%H:%M")}"
1334
- working_dir = File.expand_path("~/clacky_workspace")
1335
- FileUtils.mkdir_p(working_dir)
1336
-
1337
- # Tasks run unattended — use auto_approve so request_user_feedback doesn't block.
1338
- session_id = build_session(name: session_name, working_dir: working_dir, permission_mode: :auto_approve)
1306
+ prompt = @scheduler.read_task(name)
1307
+ session_name = "▶ #{name} #{Time.now.strftime("%H:%M")}"
1308
+ working_dir = File.expand_path("~/clacky_workspace")
1309
+ FileUtils.mkdir_p(working_dir)
1339
1310
 
1340
- # Store the pending task prompt so the WS "run_task" message can start it
1341
- # after the client has subscribed and is ready to receive broadcasts.
1342
- @registry.update(session_id, pending_task: prompt, pending_working_dir: working_dir)
1311
+ session_id = build_session(name: session_name, working_dir: working_dir, permission_mode: :auto_approve)
1312
+ @registry.update(session_id, pending_task: prompt, pending_working_dir: working_dir)
1343
1313
 
1344
- json_response(res, 202, { ok: true, session: @registry.session_summary(session_id) })
1345
- rescue => e
1346
- json_response(res, 422, { error: e.message })
1347
- end
1314
+ json_response(res, 202, { ok: true, session: @registry.session_summary(session_id) })
1315
+ rescue => e
1316
+ json_response(res, 422, { error: e.message })
1348
1317
  end
1349
1318
 
1350
1319
  # ── Skills API ────────────────────────────────────────────────────────────
@@ -85,6 +85,61 @@ module Clacky
85
85
  list.size < before_count
86
86
  end
87
87
 
88
+ # Update an existing schedule entry (cron and/or enabled).
89
+ # Returns false if the schedule does not exist.
90
+ def update_schedule(name, cron: nil, enabled: nil)
91
+ list = load_schedules
92
+ entry = list.find { |s| s["name"] == name }
93
+ return false unless entry
94
+
95
+ entry["cron"] = cron unless cron.nil?
96
+ entry["enabled"] = enabled unless enabled.nil?
97
+ save_schedules(list)
98
+ true
99
+ end
100
+
101
+ # ── Composite cron-task helpers ──────────────────────────────────────────
102
+
103
+ # Create a task file and its schedule in one step.
104
+ def create_cron_task(name:, content:, cron:, enabled: true)
105
+ write_task(name, content)
106
+ add_schedule(name: name, task: name, cron: cron, enabled: enabled)
107
+ end
108
+
109
+ # Update a cron-task: optionally update content and/or schedule fields.
110
+ def update_cron_task(name, content: nil, cron: nil, enabled: nil)
111
+ raise "Cron task not found: #{name}" unless list_tasks.include?(name)
112
+
113
+ write_task(name, content) unless content.nil?
114
+ update_schedule(name, cron: cron, enabled: enabled) if cron || !enabled.nil?
115
+ end
116
+
117
+ # Delete a cron-task: remove both the task file and its schedule.
118
+ def delete_cron_task(name)
119
+ removed_schedule = remove_schedule(name)
120
+ removed_task = delete_task(name)
121
+ removed_schedule || removed_task
122
+ end
123
+
124
+ # Return a merged list of cron-tasks (task content + schedule metadata).
125
+ def list_cron_tasks
126
+ schedule_map = load_schedules.each_with_object({}) do |s, h|
127
+ h[s["task"]] = s if s.is_a?(Hash)
128
+ end
129
+
130
+ list_tasks.map do |task_name|
131
+ content = begin; read_task(task_name); rescue StandardError; ""; end
132
+ schedule = schedule_map[task_name] || {}
133
+ {
134
+ "name" => task_name,
135
+ "content" => content,
136
+ "cron" => schedule["cron"],
137
+ "enabled" => schedule.fetch("enabled", true),
138
+ "scheduled" => !schedule.empty?
139
+ }
140
+ end
141
+ end
142
+
88
143
  # ── Task file helpers ────────────────────────────────────────────────────
89
144
 
90
145
  # Read the prompt content of a named task.
@@ -486,7 +486,7 @@ module Clacky
486
486
  # -----------------------------------------------------------------------
487
487
 
488
488
  private def find_node_binary
489
- path = `which node 2>/dev/null`.strip
489
+ path = Clacky::Utils::Encoding.cmd_to_utf8(`which node 2>/dev/null`, source_encoding: "UTF-8").strip
490
490
  return nil if path.empty? || !File.executable?(path)
491
491
  path
492
492
  end
@@ -494,7 +494,7 @@ module Clacky
494
494
  private def node_major_version
495
495
  node = find_node_binary
496
496
  return nil unless node
497
- `#{node} --version 2>/dev/null`.strip.gsub(/^v/, "").split(".").first.to_i
497
+ Clacky::Utils::Encoding.cmd_to_utf8(`#{node} --version 2>/dev/null`, source_encoding: "UTF-8").strip.gsub(/^v/, "").split(".").first.to_i
498
498
  end
499
499
 
500
500
  private def node_error
@@ -54,6 +54,27 @@ module Clacky
54
54
  str.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "")
55
55
  end
56
56
 
57
+ # Convert raw shell command output to valid UTF-8.
58
+ # Handles two common cases:
59
+ # - Windows commands (e.g. powershell.exe) that output GBK/CP936 bytes
60
+ # - Unix commands that output UTF-8 or ASCII bytes with ASCII-8BIT encoding
61
+ #
62
+ # Strategy: try GBK decode first (superset of ASCII, covers Chinese Windows);
63
+ # if that fails fall back to UTF-8 scrub.
64
+ #
65
+ # @param data [String, nil] raw bytes from backtick / IO.popen
66
+ # @param source_encoding [String] hint for source encoding (default: "GBK")
67
+ # @return [String] valid UTF-8 string
68
+ def self.cmd_to_utf8(data, source_encoding: "GBK")
69
+ return "" if data.nil? || data.empty?
70
+
71
+ data.dup
72
+ .force_encoding(source_encoding)
73
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
74
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
75
+ to_utf8(data)
76
+ end
77
+
57
78
  # Return an ASCII-safe UTF-8 copy of *str* suitable for security regex
58
79
  # pattern matching. Any byte that is not valid in the source encoding, or
59
80
  # that cannot be represented in UTF-8, is replaced with '?'. The
@@ -55,29 +55,32 @@ module Clacky
55
55
  end
56
56
 
57
57
  private_class_method def self.wsl_desktop_path
58
- if `which powershell.exe 2>/dev/null`.strip.empty?
58
+ if Utils::Encoding.cmd_to_utf8(`which powershell.exe 2>/dev/null`).strip.empty?
59
59
  return fallback_desktop_path
60
60
  end
61
61
 
62
- win_path = `powershell.exe -NoProfile -Command '[Environment]::GetFolderPath("Desktop")' 2>/dev/null`
63
- .strip.tr("\r\n", "")
62
+ # powershell.exe on Chinese Windows outputs GBK bytes; decode explicitly
63
+ win_path = Utils::Encoding.cmd_to_utf8(
64
+ `powershell.exe -NoProfile -Command '[Environment]::GetFolderPath("Desktop")' 2>/dev/null`
65
+ ).strip.tr("\r\n", "")
64
66
  return fallback_desktop_path if win_path.empty?
65
67
 
66
- linux_path = `wslpath '#{win_path}' 2>/dev/null`.strip
68
+ # wslpath output is UTF-8 (Linux side)
69
+ linux_path = Utils::Encoding.cmd_to_utf8(`wslpath '#{win_path}' 2>/dev/null`, source_encoding: "UTF-8").strip
67
70
  return linux_path if !linux_path.empty? && Dir.exist?(linux_path)
68
71
 
69
72
  fallback_desktop_path
70
73
  end
71
74
 
72
75
  private_class_method def self.linux_desktop_path
73
- path = `xdg-user-dir DESKTOP 2>/dev/null`.strip
76
+ path = Utils::Encoding.cmd_to_utf8(`xdg-user-dir DESKTOP 2>/dev/null`, source_encoding: "UTF-8").strip
74
77
  return path if !path.empty? && path != Dir.home && Dir.exist?(path)
75
78
 
76
79
  fallback_desktop_path
77
80
  end
78
81
 
79
82
  private_class_method def self.macos_desktop_path
80
- path = `osascript -e 'POSIX path of (path to desktop)' 2>/dev/null`.strip.chomp("/")
83
+ path = Utils::Encoding.cmd_to_utf8(`osascript -e 'POSIX path of (path to desktop)' 2>/dev/null`, source_encoding: "UTF-8").strip.chomp("/")
81
84
  return path if !path.empty? && Dir.exist?(path)
82
85
 
83
86
  fallback_desktop_path
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.19"
4
+ VERSION = "0.9.21"
5
5
  end
@@ -938,7 +938,7 @@ body {
938
938
  .task-btn-run { background: var(--color-accent-primary); color: #fff; }
939
939
  .task-btn-run:hover { background: var(--color-accent-hover); }
940
940
  .task-btn-edit { background: var(--color-border-primary); color: var(--color-text-primary); }
941
- .task-btn-edit:hover { background: #3d444d; }
941
+ .task-btn-edit:hover { background: var(--color-bg-hover); }
942
942
  .task-btn-del { background: var(--color-error-bg); color: var(--color-error); }
943
943
  .task-btn-del:hover { background: var(--color-error); color: #fff; }
944
944
 
@@ -14,27 +14,18 @@
14
14
 
15
15
  const Tasks = (() => {
16
16
  // ── Private state ──────────────────────────────────────────────────────
17
- let _tasks = []; // [{ name, path, content, schedules: Schedule[] }]
18
- let _schedules = []; // [{ name, task, cron, enabled }]
17
+ let _tasks = []; // [{ name, content, cron, enabled, scheduled }]
19
18
 
20
19
  // ── Private helpers ────────────────────────────────────────────────────
21
20
 
22
- /** Merge schedule info into task objects. Pure function. */
23
- function _attachSchedules(tasks, schedules) {
24
- return tasks.map(t => ({
25
- ...t,
26
- schedules: schedules.filter(s => s.task === t.name)
27
- }));
28
- }
29
-
30
21
  /** Render a single task row in the main panel table. */
31
22
  function _renderTaskRow(t) {
32
23
  const row = document.createElement("div");
33
24
  row.className = "task-table-row";
34
25
  row.dataset.name = t.name;
35
26
 
36
- const schedLabel = t.schedules.length > 0
37
- ? escapeHtml(t.schedules[0].cron)
27
+ const schedLabel = t.scheduled
28
+ ? escapeHtml(t.cron)
38
29
  : `<span class="sched-manual">${I18n.t("tasks.manual")}</span>`;
39
30
 
40
31
  const preview = (t.content || "")
@@ -99,17 +90,12 @@ const Tasks = (() => {
99
90
 
100
91
  // ── Data ─────────────────────────────────────────────────────────────
101
92
 
102
- /** Fetch tasks + schedules from server; re-render sidebar + panel if open. */
93
+ /** Fetch cron tasks from server; re-render sidebar + panel if open. */
103
94
  async load() {
104
95
  try {
105
- const [tr, sr] = await Promise.all([
106
- fetch("/api/tasks"),
107
- fetch("/api/schedules")
108
- ]);
109
- const td = await tr.json();
110
- const sd = await sr.json();
111
- _schedules = sd.schedules || [];
112
- _tasks = _attachSchedules(td.tasks || [], _schedules);
96
+ const res = await fetch("/api/cron-tasks");
97
+ const data = await res.json();
98
+ _tasks = data.cron_tasks || [];
113
99
  Tasks.renderSection();
114
100
  if (Router.current === "tasks") Tasks.renderTable();
115
101
  } catch (e) {
@@ -175,10 +161,8 @@ const Tasks = (() => {
175
161
  // ── CRUD ─────────────────────────────────────────────────────────────
176
162
 
177
163
  async run(name) {
178
- const res = await fetch("/api/tasks/run", {
179
- method: "POST",
180
- headers: { "Content-Type": "application/json" },
181
- body: JSON.stringify({ name })
164
+ const res = await fetch(`/api/cron-tasks/${encodeURIComponent(name)}/run`, {
165
+ method: "POST"
182
166
  });
183
167
  const data = await res.json();
184
168
  if (!res.ok) { alert(I18n.t("tasks.runError") + (data.error || "unknown")); return; }
@@ -255,7 +239,7 @@ const Tasks = (() => {
255
239
 
256
240
  async delete(name) {
257
241
  if (!confirm(I18n.t("tasks.confirmDelete", { name }))) return;
258
- const res = await fetch(`/api/tasks/${encodeURIComponent(name)}`, { method: "DELETE" });
242
+ const res = await fetch(`/api/cron-tasks/${encodeURIComponent(name)}`, { method: "DELETE" });
259
243
  if (!res.ok) { alert(I18n.t("tasks.deleteError")); return; }
260
244
 
261
245
  await Tasks.load();
data/scripts/install.ps1 CHANGED
@@ -17,7 +17,7 @@
17
17
  # After rebooting, run the same command again to complete installation.
18
18
  #
19
19
  # Development: .\install.ps1 -Local
20
- # Uses install_simple.sh from the same directory as this script instead of CDN.
20
+ # Uses install.sh from the same directory as this script instead of CDN.
21
21
 
22
22
  param(
23
23
  [switch]$Local,
@@ -33,7 +33,7 @@ $global:DisplayCmd = if ($CommandName) { $CommandName } else { "openclacky" }
33
33
 
34
34
  $CLACKY_CDN_BASE_URL = "https://oss.1024code.com"
35
35
  $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.ps1 | iex`""
36
- $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install_simple.sh"
36
+ $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.sh"
37
37
  $UBUNTU_WSL_AMD64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz"
38
38
  $UBUNTU_WSL_AMD64_SHA256_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz.sha256"
39
39
  $UBUNTU_WSL_ARM64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-arm64-ubuntu22.04lts.rootfs.tar.gz"
@@ -245,9 +245,9 @@ function Run-InstallInWsl {
245
245
  if ($Local) {
246
246
  # Convert Windows path to WSL path (e.g. C:\foo\bar -> /mnt/c/foo/bar)
247
247
  $scriptDir = Split-Path -Parent $MyInvocation.PSCommandPath
248
- $localScript = Join-Path $scriptDir "install_simple.sh"
248
+ $localScript = Join-Path $scriptDir "install.sh"
249
249
  if (-not (Test-Path $localScript)) {
250
- Write-Fail "Local mode: install_simple.sh not found at $localScript"
250
+ Write-Fail "Local mode: install.sh not found at $localScript"
251
251
  exit 1
252
252
  }
253
253
  $wslPath = ($localScript -replace '\', '/') -replace '^([A-Za-z]):', { '/mnt/' + $args[0].Groups[1].Value.ToLower() }
data/scripts/install.sh CHANGED
@@ -368,9 +368,19 @@ ensure_ruby() {
368
368
  return 0
369
369
  fi
370
370
 
371
- # No suitable Ruby install via mise
371
+ # Linux: try apt first (fast, Ubuntu 22.04 ships Ruby 3.0)
372
+ if [ "$OS" = "Linux" ] && ([ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]); then
373
+ print_info "Installing Ruby via apt..."
374
+ if sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby; then
375
+ return 0
376
+ fi
377
+ print_warning "apt Ruby install failed or version too old, falling back to mise..."
378
+ fi
379
+
380
+ # Fallback: install via mise (compiles from source)
372
381
  print_step "Installing Ruby via mise..."
373
382
  detect_shell
383
+ install_linux_build_deps
374
384
 
375
385
  if ! install_mise; then
376
386
  return 1
@@ -390,29 +400,37 @@ ensure_ruby() {
390
400
  }
391
401
 
392
402
  # --------------------------------------------------------------------------
393
- # Linux: install build deps before mise/Ruby (compile fallback)
403
+ # Linux: configure apt mirror + update (always runs before any apt install)
394
404
  # --------------------------------------------------------------------------
395
- install_linux_build_deps() {
396
- if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
397
- print_step "Installing build dependencies..."
398
-
399
- if [ "$USE_CN_MIRRORS" = true ]; then
400
- print_info "Configuring apt mirror (Aliyun)..."
401
- local codename="${VERSION_CODENAME:-jammy}"
402
- local mirror_base="https://mirrors.aliyun.com/ubuntu/"
403
- local components="main restricted universe multiverse"
404
- sudo tee /etc/apt/sources.list > /dev/null <<EOF
405
+ setup_apt_mirror() {
406
+ if [ "$DISTRO" != "ubuntu" ] && [ "$DISTRO" != "debian" ]; then return 0; fi
407
+
408
+ if [ "$USE_CN_MIRRORS" = true ]; then
409
+ print_info "Configuring apt mirror (Aliyun)..."
410
+ local codename="${VERSION_CODENAME:-jammy}"
411
+ local mirror_base="https://mirrors.aliyun.com/ubuntu/"
412
+ local components="main restricted universe multiverse"
413
+ sudo tee /etc/apt/sources.list > /dev/null <<EOF
405
414
  deb ${mirror_base} ${codename} ${components}
406
415
  deb ${mirror_base} ${codename}-updates ${components}
407
416
  deb ${mirror_base} ${codename}-backports ${components}
408
417
  deb ${mirror_base} ${codename}-security ${components}
409
418
  EOF
410
- fi
411
-
412
- sudo apt update
413
- sudo apt install -y build-essential libssl-dev libyaml-dev zlib1g-dev libgmp-dev git
414
- print_success "Build dependencies installed"
415
419
  fi
420
+
421
+ sudo apt-get update -qq
422
+ print_success "apt updated"
423
+ }
424
+
425
+ # --------------------------------------------------------------------------
426
+ # Linux: install build deps (only needed when compiling Ruby via mise)
427
+ # --------------------------------------------------------------------------
428
+ install_linux_build_deps() {
429
+ if [ "$DISTRO" != "ubuntu" ] && [ "$DISTRO" != "debian" ]; then return 0; fi
430
+
431
+ print_step "Installing build dependencies..."
432
+ sudo apt-get install -y build-essential libssl-dev libyaml-dev zlib1g-dev libgmp-dev git
433
+ print_success "Build dependencies installed"
416
434
  }
417
435
 
418
436
  # --------------------------------------------------------------------------
@@ -434,19 +452,18 @@ setup_gem_home() {
434
452
 
435
453
  export GEM_HOME="$HOME/.gem/ruby/${ruby_api}"
436
454
  export GEM_PATH="$HOME/.gem/ruby/${ruby_api}"
437
- export PATH="$HOME/.local/bin:$HOME/.gem/ruby/${ruby_api}/bin:$PATH"
455
+ export PATH="$HOME/.gem/ruby/${ruby_api}/bin:$PATH"
438
456
 
439
457
  print_info "System Ruby detected — gems will install to ~/.gem/ruby/${ruby_api}"
440
458
 
441
459
  # Persist to shell rc (use $HOME so the line is portable)
442
- # Also add ~/.local/bin so brand wrapper commands installed there are found
443
460
  if [ -n "$SHELL_RC" ] && ! grep -q "GEM_HOME" "$SHELL_RC" 2>/dev/null; then
444
461
  {
445
462
  echo ""
446
463
  echo "# Ruby user gem dir (added by openclacky installer)"
447
464
  echo "export GEM_HOME=\"\$HOME/.gem/ruby/${ruby_api}\""
448
465
  echo "export GEM_PATH=\"\$HOME/.gem/ruby/${ruby_api}\""
449
- echo "export PATH=\"\$HOME/.local/bin:\$HOME/.gem/ruby/${ruby_api}/bin:\$PATH\""
466
+ echo "export PATH=\"\$HOME/.gem/ruby/${ruby_api}/bin:\$PATH\""
450
467
  } >> "$SHELL_RC"
451
468
  print_info "GEM_HOME written to $SHELL_RC"
452
469
  fi
@@ -524,8 +541,17 @@ YAML
524
541
  print_success "Brand config written to $brand_file"
525
542
 
526
543
  if [ -n "$BRAND_COMMAND" ]; then
527
- local bin_dir="$HOME/.local/bin"
528
- mkdir -p "$bin_dir"
544
+ # Install wrapper in the same directory as the openclacky binary so it is
545
+ # always on PATH regardless of whether we are running as root or a normal user.
546
+ local clacky_bin bin_dir
547
+ clacky_bin=$(command -v openclacky 2>/dev/null || true)
548
+ if [ -n "$clacky_bin" ]; then
549
+ bin_dir=$(dirname "$clacky_bin")
550
+ else
551
+ print_warning "openclacky binary not found in PATH; skipping wrapper install"
552
+ return 0
553
+ fi
554
+
529
555
  local wrapper="$bin_dir/$BRAND_COMMAND"
530
556
  cat > "$wrapper" <<WRAPPER
531
557
  #!/bin/sh
@@ -533,13 +559,6 @@ exec openclacky "\$@"
533
559
  WRAPPER
534
560
  chmod +x "$wrapper"
535
561
  print_success "Wrapper installed: $wrapper"
536
-
537
- case ":$PATH:" in
538
- *":$bin_dir:"*) ;;
539
- *)
540
- print_warning "Add to your shell profile: export PATH=\"\$HOME/.local/bin:\$PATH\""
541
- ;;
542
- esac
543
562
  fi
544
563
  }
545
564
 
@@ -579,10 +598,10 @@ main() {
579
598
  detect_shell
580
599
  detect_network_region
581
600
 
582
- # Linux: install build deps first (needed if mise has to compile Ruby)
601
+ # Linux: configure apt mirror + update (always runs; build deps deferred to mise fallback)
583
602
  if [ "$OS" = "Linux" ]; then
584
603
  if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
585
- install_linux_build_deps
604
+ setup_apt_mirror
586
605
  else
587
606
  print_error "Unsupported Linux distribution: $DISTRO"
588
607
  print_info "Please install Ruby >= 2.6.0 manually and run: gem install openclacky"