openclacky 0.9.19 → 0.9.20

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b959a76ace0015ec6279b1572afa3e961d862d9c856c9fdecc98f72d9e190e59
4
- data.tar.gz: 53cc27fce3b47e83cb428955eb6591d53acc148cdab55721c7c32330b351fed0
3
+ metadata.gz: 803023a686f9005d8bbed56fa2604d6cb4a426ba2ff1c24ddf6e297242c7faed
4
+ data.tar.gz: ee8738600a36146e4f56b8605a917066746b31e370a41c3e245066ec4e4e961c
5
5
  SHA512:
6
- metadata.gz: 5b0d27875497a290060cabfc409c5b8a18b90b95c8bec82157f8d1f8e63a25fc78e3d52bff8bcbf3bd9969f8e65090137dc7ce7b394ac35779412a51534a166e
7
- data.tar.gz: 2b08d76c821e84c7196b4bc6364323879669259895b30cb803e01e70c49a59f44a40f5f607f8a4d3e10a96bfaa47167c03ed991fcebe6a5dcb44d099b18bdccd
6
+ metadata.gz: b5b27887365a698d193fee92d0672315c48963a9748509cae8a6b5426d0169f16b9fad709afa2232d80542999fc931ee48e09f2cee35100b39273b33c324b558
7
+ data.tar.gz: 5c5572d8a7b34af4eb03220dbf0e566ed0d2ab7b7278a89d8801f0c9ca58b100c90dd27fd17d1548016b21c20e1e458889a97a54983309c409479b863fc8b6ca
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.20] - 2026-03-30
11
+
12
+ ### Added
13
+ - **SSL error retry**: LLM API calls now automatically retry on SSL errors (same as other network failures — up to 10 retries with 5s delay)
14
+
15
+ ### Fixed
16
+ - **Brand wrapper not found under root**: the install script now places the brand command wrapper in the same directory as the `openclacky` binary, so it is always on PATH regardless of whether running as root or a normal user
17
+
18
+ ### Improved
19
+ - **Cron task management refactored to API**: cron task CRUD operations now go through the HTTP API instead of running ad-hoc Ruby scripts, making the scheduler more reliable and easier to maintain
20
+ - **UTF-8 encoding fix for browser tool on Windows**: browser command output with non-ASCII characters no longer causes encoding errors
21
+
22
+ ### More
23
+ - Installer no longer adds `~/.local/bin` to PATH (wrapper now colocated with gem binary, making the extra PATH entry unnecessary)
24
+ - Brand install tips in Windows PowerShell installer
25
+
10
26
  ## [0.9.19] - 2026-03-29
11
27
 
12
28
  ### Added
@@ -0,0 +1,64 @@
1
+ # C-End User Positioning
2
+
3
+ > Date: 2026-03-30
4
+
5
+ ---
6
+
7
+ ## Market Context
8
+
9
+ The "OpenClaw ecosystem" has exploded in 2026. Key players:
10
+
11
+ - **OpenClaw** — open-source, self-hosted, community Skills. Designed for technical users who configure everything themselves.
12
+ - **QClaw** — Tencent's fork. Bundled Kimi model, WeChat binding. Mass-market but Tencent-ecosystem only.
13
+ - **Others** (Wukong, etc.) — same lane.
14
+
15
+ OpenClaw has 5,700+ Skills, but almost all are open-source, free, and easily copied. The ecosystem lacks **expertise-backed, production-grade Skills worth paying for**.
16
+
17
+ ---
18
+
19
+ ## Who openclacky Is For
20
+
21
+ **Ordinary users, not technical geeks.**
22
+
23
+ The target user knows OpenClaw exists, has heard about "raising a lobster", but can't or doesn't want to:
24
+ - configure Docker / environment / webhooks
25
+ - manage their own API keys without knowing what they'll spend
26
+ - troubleshoot when a long task breaks halfway
27
+
28
+ They want to use a lobster built by an expert (a lawyer, a trader, an SEO specialist) — not build one themselves.
29
+
30
+ > Core insight: **OpenClaw is built for people who create Skills. openclacky is built for people who use them.**
31
+
32
+ ---
33
+
34
+ ## Why openclacky Over OpenClaw: 3 Core Reasons
35
+
36
+ ### 1. Zero-friction IM setup — the strongest differentiator
37
+
38
+ OpenClaw requires users to manually configure webhooks, tokens, and config files to connect WeChat / Feishu / WeCom. High technical barrier, most ordinary users give up.
39
+
40
+ openclacky uses **AI-automated channel setup**: one sentence, and the AI configures the IM connection for you — no plugins, no docs, no engineering knowledge required. This is a genuine technical moat.
41
+
42
+ ### 2. Built for China, natively
43
+
44
+ - No VPN required, no overseas credit card
45
+ - WeChat / Feishu / WeCom are the primary daily tools for Chinese users — openclacky treats them as first-class citizens
46
+ - Supports domestic models (DeepSeek, Kimi, etc.) out of the box
47
+ - QClaw is domestic too, but locked to Tencent's ecosystem and model choices
48
+
49
+ ### 3. Cost transparency and long-task reliability
50
+
51
+ - Real-time token cost tracking — users always know what they're spending
52
+ - Automatic compression (up to 90% savings via Insert-then-Compress + Prompt Caching)
53
+ - Long tasks don't break: sub-agent isolation + Time Machine architecture keeps context intact
54
+
55
+ ---
56
+
57
+ ## The User Progression
58
+
59
+ ```
60
+ Can use it → Dare to use it → Keep using it
61
+ (zero setup) (cost clarity) (tasks don't break)
62
+ ```
63
+
64
+ Each of the 3 reasons maps directly to one stage of this progression.
@@ -34,7 +34,7 @@ module Clacky
34
34
  max_tokens: @config.max_tokens,
35
35
  enable_caching: @config.enable_prompt_caching
36
36
  )
37
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
37
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
38
38
  @ui&.clear_progress
39
39
  retries += 1
40
40
  if retries <= max_retries
@@ -17,21 +17,14 @@ Storage:
17
17
  ~/.clacky/schedules.yml # All scheduled plans (YAML list)
18
18
  ~/.clacky/logger/clacky-*.log # Execution logs (daily rotation)
19
19
 
20
- WebUI Task Panel (Clacky Web Interface):
21
- - Automatically reads tasks/ + schedules.yml and renders the task list
22
- - Each row shows task name, cron expression, and content preview
23
- - Action buttons: Run (execute now), ✎ Edit (edit in session), ✕ (delete)
24
- - Run button triggers POST /api/tasks/run executes task in a new session
25
-
26
- API Endpoints (available when clacky server is running):
27
- GET /api/tasks list all tasks
28
- POST /api/tasks → create task {name, content}
29
- GET /api/tasks/:name → get task content
30
- DELETE /api/tasks/:name → delete task
31
- POST /api/tasks/run → run immediately {name}
32
- GET /api/schedules → list all schedules
33
- POST /api/schedules → create schedule {name, task, cron}
34
- DELETE /api/schedules/:name → delete schedule
20
+ API Base: http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}
21
+
22
+ Cron-Tasks API (unified manages task file + schedule together):
23
+ GET /api/cron-tasks → list all cron tasks with schedule info
24
+ POST /api/cron-tasks create task + schedule {name, content, cron, enabled?}
25
+ PATCH /api/cron-tasks/:name → update {content?, cron?, enabled?}
26
+ DELETE /api/cron-tasks/:name → delete task file + schedule
27
+ POST /api/cron-tasks/:name/run execute immediately (creates a new session)
35
28
  ```
36
29
 
37
30
  ## Cron Expression Quick Reference
@@ -54,10 +47,11 @@ Field order: `minute hour day-of-month month day-of-week`
54
47
 
55
48
  ### 1. LIST — Show all tasks
56
49
 
57
- When user asks "what tasks do I have", "list scheduled tasks", etc.:
50
+ ```bash
51
+ curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks
52
+ ```
58
53
 
59
- 1. Run `ruby ~/.clacky/skills/cron-task-creator/scripts/list_tasks.rb`
60
- 2. Display results: task name, cron schedule, enabled status, last run status
54
+ Display each task: name, cron schedule, enabled status, content preview.
61
55
 
62
56
  If no tasks exist, inform the user and offer to create one or show templates.
63
57
 
@@ -69,15 +63,14 @@ If no tasks exist, inform the user and offer to create one or show templates.
69
63
 
70
64
  **Step 1: Gather required info** (only ask for what's missing)
71
65
  - What should the task DO? (goal, behavior, output format)
72
- - How often should it run? (or is it manual-only?)
66
+ - How often should it run? (or is it manual-only without a schedule?)
73
67
  - Any specific parameters? (URLs, file paths, output location, language)
74
68
 
75
69
  **Step 2: Generate task name**
76
70
  - Rule: only `[a-z0-9_-]`, lowercase, no spaces
77
71
  - Examples: `daily_report`, `price_monitor`, `weekly_summary`
78
72
 
79
- **Step 3: Write the task prompt file**
80
- Path: `~/.clacky/tasks/<name>.md`
73
+ **Step 3: Write the task prompt**
81
74
 
82
75
  The prompt must be:
83
76
  - **Self-contained**: the agent running it has zero prior context — include everything needed
@@ -85,7 +78,7 @@ The prompt must be:
85
78
  - **Detailed**: include URLs, file paths, output format, language, expected output location
86
79
 
87
80
  Good task prompt example:
88
- ```markdown
81
+ ```
89
82
  You are a price monitoring assistant. Complete the following task:
90
83
 
91
84
  ## Goal
@@ -100,14 +93,17 @@ Check the current BTC price on CoinGecko, compare with yesterday's price, and lo
100
93
  Execute immediately.
101
94
  ```
102
95
 
103
- **Step 4: Write file and add schedule**
96
+ **Step 4: Create via API**
104
97
 
105
98
  ```bash
106
- # Write task file
107
- ruby ~/.clacky/skills/cron-task-creator/scripts/manage_task.rb create "<name>" "<content>"
108
-
109
- # Add schedule (if applicable)
110
- ruby ~/.clacky/skills/cron-task-creator/scripts/manage_schedule.rb add "<name>" "<task_name>" "<cron_expr>"
99
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks \
100
+ -H "Content-Type: application/json" \
101
+ -d '{
102
+ "name": "task_name",
103
+ "content": "task prompt content...",
104
+ "cron": "0 9 * * *",
105
+ "enabled": true
106
+ }'
111
107
  ```
112
108
 
113
109
  **Step 5: Confirm creation**
@@ -116,7 +112,6 @@ ruby ~/.clacky/skills/cron-task-creator/scripts/manage_schedule.rb add "<name>"
116
112
  ✅ Task created successfully!
117
113
 
118
114
  📋 Task name: daily_standup
119
- 📄 File: ~/.clacky/tasks/daily_standup.md
120
115
  ⏰ Schedule: Weekdays at 09:00 (cron: 0 9 * * 1-5)
121
116
 
122
117
  View and manage this task in the Clacky WebUI → Tasks panel. Click ▶ Run to execute immediately.
@@ -126,24 +121,27 @@ View and manage this task in the Clacky WebUI → Tasks panel. Click ▶ Run to
126
121
 
127
122
  ### 3. EDIT — Modify an existing task
128
123
 
129
- When the user wants to change task content, cron schedule, or rename:
124
+ **Step 1**: Identify the task (if unclear, LIST first and ask)
130
125
 
131
- **Step 1**: Identify the task to modify (if unclear, LIST first and ask)
126
+ **Step 2**: Show current state via LIST or ask user to confirm
132
127
 
133
- **Step 2**: Show current state (first 10 lines + cron expression)
134
-
135
- **Step 3**: Determine what to change
136
- - Prompt only → rewrite `~/.clacky/tasks/<name>.md`
137
- - Schedule only → update `cron` field in `schedules.yml`
138
- - Both → do both
139
- - Rename → rename .md file + update schedules.yml
128
+ **Step 3**: Update via API
140
129
 
141
130
  ```bash
142
- # Update task content
143
- ruby ~/.clacky/skills/cron-task-creator/scripts/manage_task.rb update "<name>" "<new_content>"
144
-
145
- # Update cron schedule
146
- ruby ~/.clacky/skills/cron-task-creator/scripts/manage_schedule.rb update "<schedule_name>" "<new_cron>"
131
+ # Update content only
132
+ curl -s -X PATCH http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name \
133
+ -H "Content-Type: application/json" \
134
+ -d '{"content": "new prompt content..."}'
135
+
136
+ # Update cron schedule only
137
+ curl -s -X PATCH http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name \
138
+ -H "Content-Type: application/json" \
139
+ -d '{"cron": "0 8 * * 1-5"}'
140
+
141
+ # Update both
142
+ curl -s -X PATCH http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name \
143
+ -H "Content-Type: application/json" \
144
+ -d '{"content": "...", "cron": "0 8 * * 1-5"}'
147
145
  ```
148
146
 
149
147
  **Step 4**: Confirm changes
@@ -151,7 +149,6 @@ ruby ~/.clacky/skills/cron-task-creator/scripts/manage_schedule.rb update "<sche
151
149
  ```
152
150
  ✅ Task updated!
153
151
  📋 daily_standup
154
- Prompt: updated ✓
155
152
  Schedule: 0 9 * * 1-5 → 0 8 * * 1-5 (now weekdays at 08:00)
156
153
  ```
157
154
 
@@ -160,7 +157,15 @@ ruby ~/.clacky/skills/cron-task-creator/scripts/manage_schedule.rb update "<sche
160
157
  ### 4. ENABLE / DISABLE — Toggle a task
161
158
 
162
159
  ```bash
163
- ruby ~/.clacky/skills/cron-task-creator/scripts/manage_schedule.rb toggle "<schedule_name>" true|false
160
+ # Disable
161
+ curl -s -X PATCH http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name \
162
+ -H "Content-Type: application/json" \
163
+ -d '{"enabled": false}'
164
+
165
+ # Enable
166
+ curl -s -X PATCH http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name \
167
+ -H "Content-Type: application/json" \
168
+ -d '{"enabled": true}'
164
169
  ```
165
170
 
166
171
  Confirm:
@@ -180,15 +185,22 @@ Always confirm before deleting (unless the user has explicitly said to delete):
180
185
  ```
181
186
 
182
187
  ```bash
183
- ruby ~/.clacky/skills/cron-task-creator/scripts/manage_task.rb delete "<name>"
188
+ curl -s -X DELETE http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name
184
189
  ```
185
190
 
186
191
  ---
187
192
 
188
193
  ### 6. HISTORY — View run history
189
194
 
195
+ Read the daily log files directly:
196
+
190
197
  ```bash
191
- ruby ~/.clacky/skills/cron-task-creator/scripts/task_history.rb "<task_name>"
198
+ grep "task_name" ~/.clacky/logger/clacky-$(date +%Y-%m-%d).log | tail -20
199
+ ```
200
+
201
+ Or search across recent days:
202
+ ```bash
203
+ grep -h "task_name" ~/.clacky/logger/clacky-*.log | tail -30
192
204
  ```
193
205
 
194
206
  Display format:
@@ -198,24 +210,21 @@ Display format:
198
210
  Mar 10 19:00 ❌ Failed — JSON::ParserError: unexpected end of input
199
211
  Mar 09 19:00 ✅ Success — took 1m 42s
200
212
  Mar 08 19:00 ✅ Success — took 2m 10s
201
- Mar 07 19:00 ⚠️ Skipped — (task was disabled)
202
-
203
- Recent error:
204
- JSON::ParserError at line 226
205
- Possible cause: API response was truncated or empty
206
213
  ```
207
214
 
208
215
  ---
209
216
 
210
217
  ### 7. RUN NOW — Execute immediately
211
218
 
219
+ ```bash
220
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/cron-tasks/task_name/run
212
221
  ```
213
- ▶️ Go to the Clacky WebUI → Tasks panel, find the task, and click ▶ Run.
214
222
 
215
- The task will execute in a new session. You can watch it run in real time via the WebUI.
223
+ This creates a new session. Tell the user:
224
+ ```
225
+ ▶️ Task started in a new session.
226
+ View it in the Clacky WebUI → Sessions panel.
216
227
  ```
217
-
218
- If the user wants to run the task in the current session, read the task file and execute the instructions directly.
219
228
 
220
229
  ---
221
230
 
@@ -243,8 +252,6 @@ Tell me which one interests you, or describe your own use case!
243
252
  ## Important Notes
244
253
 
245
254
  - Task names: only `[a-z0-9_-]`, no spaces, no uppercase
246
- - When modifying `schedules.yml`: always read → modify → write back the full file (never append directly)
247
255
  - Task prompt files must be **self-contained** — the executing agent has no prior memory
248
256
  - Clacky server must be running for cron to trigger automatically (checked every minute)
249
257
  - The WebUI Task Panel is the preferred interface for managing tasks — always remind the user to check it after changes
250
- - Path expansion: always use absolute paths, expand `~` to the actual home directory
@@ -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