yatfa 1.0.78 → 1.0.80

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.
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Yatfa Claw Agent Setup - Loaded by setup-agent.rb when AGENT_TYPE == "claw"
5
+ #
6
+ # This file contains the completely separate setup flow for claw agents:
7
+ # - Validates claw-specific environment variables
8
+ # - Installs OpenClaw gateway via npm
9
+ # - Configures OpenClaw for Telegram + LLM provider
10
+ # - Installs yatfa-tools native OpenClaw plugin
11
+ # - Starts the gateway in tmux
12
+ #
13
+ # Dependencies:
14
+ # - fetch_llm_credentials! is defined by the parent setup-agent.rb
15
+ # - Standard library requires (json, fileutils, pathname, etc.) are loaded
16
+ # by setup-agent.rb before this file is required
17
+
18
+ def check_env_claw!
19
+ required = %w[OPENCLAW_TELEGRAM_BOT_TOKEN]
20
+ missing = required.select { |var| ENV[var].to_s.empty? }
21
+
22
+ unless missing.empty?
23
+ puts "❌ Missing environment variables for claw agent: #{missing.join(', ')}"
24
+ puts ""
25
+ puts "Required:"
26
+ puts " OPENCLAW_TELEGRAM_BOT_TOKEN - Telegram bot token for the Pi agent"
27
+ puts ""
28
+ puts "Optional:"
29
+ puts " OPENCLAW_PORT - Gateway port (default: 18789)"
30
+ puts " OPENCLAW_MODEL - Model to use (default: from openclaw config)"
31
+ puts " OPENCLAW_API_KEY - API key for the model provider"
32
+ puts " OPENCLAW_BASE_URL - Base URL for the model provider"
33
+ puts ""
34
+ puts "Yatfa Integration (optional - enables Pi to interact with Yatfa team):"
35
+ puts " YATFA_API_URL - Yatfa API URL (derived from YATFA_URL)"
36
+ puts " YATFA_API_KEY - API key for yatfa-mcp tools"
37
+ exit 1
38
+ end
39
+
40
+ # Derive YATFA_API_URL from YATFA_URL if set (optional for claw)
41
+ if ENV["YATFA_URL"] && !ENV["YATFA_API_URL"]
42
+ ENV["YATFA_API_URL"] = "#{ENV['YATFA_URL']}/api/v1"
43
+ end
44
+
45
+ puts "✅ Claw environment validated"
46
+ puts " Telegram Bot Token: #{ENV['OPENCLAW_TELEGRAM_BOT_TOKEN'][0..10]}..."
47
+ puts " Gateway Port: #{ENV['OPENCLAW_PORT'] || 18789}"
48
+
49
+ if ENV["YATFA_API_URL"] && ENV["YATFA_API_KEY"]
50
+ puts " Yatfa Integration: enabled (#{ENV['YATFA_API_URL']})"
51
+ else
52
+ puts " Yatfa Integration: disabled (set YATFA_API_URL and YATFA_API_KEY to enable)"
53
+ end
54
+ end
55
+
56
+ def install_openclaw!
57
+ puts "📦 Installing OpenClaw gateway..."
58
+
59
+ # Ensure Node.js 22+ is available (required by openclaw)
60
+ node_version = `node --version 2>/dev/null`.strip
61
+ if node_version.empty?
62
+ puts "❌ Node.js is not installed. OpenClaw requires Node.js 22+."
63
+ exit 1
64
+ end
65
+
66
+ node_major = node_version.gsub(/^v/, "").split(".").first.to_i
67
+ if node_major < 22
68
+ puts "❌ Node.js #{node_version} is too old. OpenClaw requires Node.js >= 22.14.0."
69
+ exit 1
70
+ end
71
+ puts " Node.js version: #{node_version}"
72
+
73
+ # Install OpenClaw globally (sudo needed in container for global npm dir)
74
+ install_cmd = "npm install -g openclaw@latest 2>&1"
75
+ install_cmd = "sudo #{install_cmd}" unless Process.uid == 0
76
+
77
+ output = `#{install_cmd} 2>&1`
78
+ unless $?.success?
79
+ puts "❌ Failed to install openclaw via npm:"
80
+ puts output
81
+ exit 1
82
+ end
83
+
84
+ # Verify installation
85
+ unless system("which openclaw > /dev/null 2>&1")
86
+ puts "❌ openclaw command not found after installation"
87
+ exit 1
88
+ end
89
+
90
+ openclaw_version = `openclaw --version 2>/dev/null`.strip
91
+ puts "✅ OpenClaw installed (version: #{openclaw_version.empty? ? 'unknown' : openclaw_version})"
92
+
93
+ # Fix ownership of ~/.openclaw if sudo created it as root
94
+ openclaw_dir = File.expand_path("~/.openclaw")
95
+ if File.exist?(openclaw_dir) && Process.uid != 0
96
+ system("sudo chown -R #{Process.uid}:#{Process.gid} #{openclaw_dir} 2>/dev/null")
97
+ end
98
+ end
99
+
100
+ def setup_openclaw_config!(llm_credentials = nil)
101
+ config_dir = File.expand_path("~/.openclaw")
102
+ FileUtils.mkdir_p(config_dir)
103
+
104
+ # Ensure config dir is owned by current user (sudo npm install may create it as root)
105
+ File.chmod(0700, config_dir) if File.exist?(config_dir)
106
+
107
+ config_path = File.join(config_dir, "openclaw.json")
108
+
109
+ # Build config with correct OpenClaw schema:
110
+ # models.providers.<id>.apiKey / baseUrl — provider credentials
111
+ # agents.defaults.model — "provider/model" format
112
+ config = {
113
+ "gateway" => { "mode" => "local" },
114
+ "models" => { "providers" => {} },
115
+ "agents" => { "defaults" => {} },
116
+ "channels" => {
117
+ "telegram" => {
118
+ "botToken" => ENV["OPENCLAW_TELEGRAM_BOT_TOKEN"]
119
+ }
120
+ },
121
+ "plugins" => { "enabled" => true }
122
+ }
123
+
124
+ # Resolve API key, base URL, and model — from backend or env vars
125
+ api_key = ENV["OPENCLAW_API_KEY"]
126
+ base_url = ENV["OPENCLAW_BASE_URL"]
127
+ model = ENV["OPENCLAW_MODEL"]
128
+
129
+ if llm_credentials
130
+ api_key ||= llm_credentials["api_key"]
131
+ base_url ||= llm_credentials["base_url"] if llm_credentials["base_url"] && !llm_credentials["base_url"].empty?
132
+ model ||= llm_credentials["model_sonnet"] || llm_credentials["model_opus"] || llm_credentials["model_haiku"]
133
+ end
134
+
135
+ if api_key
136
+ # Detect API format from base URL (anthropic-messages vs openai-completions)
137
+ api_format = "openai-completions"
138
+ if base_url && (base_url.include?("anthropic") || base_url.include?("claude"))
139
+ api_format = "anthropic-messages"
140
+ end
141
+
142
+ # Register provider with id "yatfa" — requires baseUrl and models array
143
+ provider_entry = {
144
+ "apiKey" => api_key,
145
+ "baseUrl" => base_url || "https://api.anthropic.com",
146
+ "models" => [
147
+ {
148
+ "id" => model || "default",
149
+ "name" => model || "default",
150
+ "api" => api_format
151
+ }
152
+ ]
153
+ }
154
+ config["models"]["providers"]["yatfa"] = provider_entry
155
+
156
+ # Prefix model with provider if not already prefixed
157
+ if model && !model.empty?
158
+ config["agents"]["defaults"]["model"] = model.include?("/") ? model : "yatfa/#{model}"
159
+ end
160
+
161
+ puts " Provider: yatfa (#{api_format})"
162
+ puts " Backend model: #{model || 'default'}"
163
+ puts " Backend base URL: #{base_url || 'default'}"
164
+ else
165
+ # No credentials — just set model if provided (uses built-in providers)
166
+ if model && !model.empty?
167
+ config["agents"]["defaults"]["model"] = model
168
+ end
169
+ puts " No API key configured — using openclaw defaults"
170
+ end
171
+
172
+ # Remove empty sections
173
+ config["models"].delete("providers") if config["models"]["providers"].empty?
174
+ config.delete("models") if config["models"].empty?
175
+ config["agents"].delete("defaults") if config["agents"]["defaults"].empty?
176
+ config.delete("agents") if config["agents"].empty?
177
+
178
+ # Write config (do not overwrite if file exists and has custom config)
179
+ if File.exist?(config_path)
180
+ begin
181
+ existing = JSON.parse(File.read(config_path))
182
+ # Backend (new) values win for models/agents; existing wins for everything else
183
+ config = existing.merge(config) { |key, old_val, new_val|
184
+ if key == "models" || key == "agents"
185
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
186
+ old_val.merge(new_val) # new (backend) overrides old
187
+ else
188
+ new_val
189
+ end
190
+ elsif old_val.is_a?(Hash) && new_val.is_a?(Hash)
191
+ new_val.merge(old_val) # old (user) overrides new
192
+ else
193
+ old_val
194
+ end
195
+ }
196
+ puts "📝 Merging with existing OpenClaw config"
197
+ rescue JSON::ParserError
198
+ puts "⚠️ Existing openclaw.json is invalid, overwriting"
199
+ end
200
+ end
201
+
202
+ File.write(config_path, JSON.pretty_generate(config))
203
+ puts "✅ OpenClaw config written to #{config_path}"
204
+ end
205
+
206
+ def setup_yatfa_tools_for_claw!
207
+ # Installs the yatfa-tools native OpenClaw plugin so Pi's main agent session
208
+ # can use yatfa tools directly (tickets, memories, knowledge, etc.)
209
+ # Also installs the yatfa-bridge skill for context on how to use them.
210
+ yatfa_api_url = ENV["YATFA_API_URL"]
211
+ yatfa_api_key = ENV["YATFA_API_KEY"]
212
+
213
+ unless yatfa_api_url && !yatfa_api_url.empty? && yatfa_api_key && !yatfa_api_key.empty?
214
+ puts "ℹ️ Yatfa integration disabled (YATFA_API_URL or YATFA_API_KEY not set)"
215
+ return
216
+ end
217
+
218
+ puts "🔗 Setting up Yatfa integration for Pi agent..."
219
+
220
+ # Install native OpenClaw plugin to ~/.openclaw/extensions/yatfa-tools/
221
+ ext_dir = File.expand_path("~/.openclaw/extensions/yatfa-tools")
222
+ FileUtils.mkdir_p(ext_dir)
223
+
224
+ plugin_js = File.join(ext_dir, "index.js")
225
+ plugin_manifest = File.join(ext_dir, "openclaw.plugin.json")
226
+ plugin_pkg = File.join(ext_dir, "package.json")
227
+
228
+ # Resolve path to the openclaw dist directory for the definePluginEntry import
229
+ openclaw_dist = nil
230
+ plugin_entry_file = nil
231
+ ["/usr/lib/node_modules/openclaw/dist", "/usr/local/lib/node_modules/openclaw/dist"].each do |candidate|
232
+ next unless File.directory?(candidate)
233
+ matches = Dir.glob(File.join(candidate, "plugin-entry-*.js"))
234
+ if matches.any?
235
+ openclaw_dist = candidate
236
+ plugin_entry_file = File.basename(matches.first)
237
+ break
238
+ end
239
+ end
240
+
241
+ unless openclaw_dist && plugin_entry_file
242
+ puts "⚠️ Cannot find OpenClaw dist directory — skipping plugin install"
243
+ return
244
+ end
245
+
246
+ # Compute relative path from ext_dir to openclaw dist for the import
247
+ rel_path = Pathname.new(openclaw_dist).relative_path_from(Pathname.new(ext_dir))
248
+
249
+ # Fetch tool definitions from Rails now (during setup) so the plugin can
250
+ # register them synchronously — register() must NOT be async.
251
+ tool_defs_json = File.join(ext_dir, "tools.json")
252
+ begin
253
+ uri = URI("#{yatfa_api_url}/mcp/tools")
254
+ req = Net::HTTP::Get.new(uri)
255
+ req["Content-Type"] = "application/json"
256
+ req["X-API-Key"] = yatfa_api_key
257
+ req["Accept"] = "application/json"
258
+ http = Net::HTTP.new(uri.host, uri.port)
259
+ http.use_ssl = uri.scheme == "https"
260
+ resp = http.request(req)
261
+ tools_data = JSON.parse(resp.body)
262
+ File.write(tool_defs_json, JSON.pretty_generate(tools_data["tools"] || []))
263
+ puts "✅ Fetched #{(tools_data["tools"] || []).length} tool definitions from Yatfa API"
264
+ rescue => e
265
+ puts "⚠️ Failed to fetch tool definitions: #{e.message}"
266
+ File.write(tool_defs_json, "[]")
267
+ end
268
+
269
+ File.write(plugin_js, <<~JS)
270
+ import { t as definePluginEntry } from "#{rel_path}/#{plugin_entry_file}";
271
+ import { readFileSync } from "node:fs";
272
+ import { fileURLToPath } from "node:url";
273
+ import { dirname, join } from "node:path";
274
+
275
+ const __filename = fileURLToPath(import.meta.url);
276
+ const __dirname = dirname(__filename);
277
+
278
+ const API_BASE_URL = #{yatfa_api_url.inspect};
279
+ const API_KEY = #{yatfa_api_key.inspect};
280
+
281
+ const toolDefs = JSON.parse(readFileSync(join(__dirname, "tools.json"), "utf8"));
282
+
283
+ export default definePluginEntry({
284
+ id: "yatfa-tools",
285
+ name: "Yatfa Tools",
286
+ description: "Yatfa team management tools — tickets, memories, knowledge, agents, and more.",
287
+
288
+ register(api) {
289
+ for (const def of toolDefs) {
290
+ api.registerTool({
291
+ name: def.name,
292
+ description: def.description,
293
+ parameters: def.inputSchema || { type: "object", properties: {} },
294
+ async execute(_id, params) {
295
+ try {
296
+ const url = `${API_BASE_URL}/mcp/execute`;
297
+ const res = await fetch(url, {
298
+ method: "POST",
299
+ headers: {
300
+ "Content-Type": "application/json",
301
+ "X-API-Key": API_KEY,
302
+ Accept: "application/json",
303
+ },
304
+ body: JSON.stringify({ tool: def.name, params }),
305
+ });
306
+ if (!res.ok) {
307
+ const body = await res.text();
308
+ return { content: [{ type: "text", text: `API Error ${res.status}: ${body}` }], isError: true };
309
+ }
310
+ const data = await res.json();
311
+ return { content: [{ type: "text", text: JSON.stringify(data.result, null, 2) }] };
312
+ } catch (err) {
313
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
314
+ }
315
+ },
316
+ });
317
+ }
318
+ },
319
+ });
320
+ JS
321
+
322
+ File.write(plugin_manifest, <<~JSON)
323
+ {
324
+ "id": "yatfa-tools",
325
+ "name": "Yatfa Tools",
326
+ "description": "Yatfa team management tools — tickets, memories, knowledge, agents, and more.",
327
+ "configSchema": {
328
+ "type": "object",
329
+ "additionalProperties": false,
330
+ "properties": {}
331
+ }
332
+ }
333
+ JSON
334
+
335
+ File.write(plugin_pkg, <<~JSON)
336
+ {
337
+ "name": "@yatfa/openclaw-plugin",
338
+ "version": "1.0.0",
339
+ "type": "module",
340
+ "openclaw": {
341
+ "extensions": ["./index.js"],
342
+ "compat": { "pluginApi": ">=2026.4.10" }
343
+ }
344
+ }
345
+ JSON
346
+
347
+ puts "✅ yatfa-tools plugin installed to #{ext_dir}"
348
+
349
+ # Install yatfa-bridge skill
350
+ skills_dir = File.expand_path("~/.openclaw/skills/yatfa-bridge")
351
+ FileUtils.mkdir_p(skills_dir)
352
+
353
+ skill_content = <<~SKILL
354
+ # Yatfa Bridge - Pi Agent Integration
355
+
356
+ You have access to the Yatfa team's task management system. You are the human's **front desk** to the Yatfa agent team.
357
+
358
+ ## What You Can Do
359
+
360
+ Use the yatfa tools to:
361
+
362
+ - **Check status**: `get_status` — see project overview, ticket counts, agent availability
363
+ - **View tickets**: `list_tickets` — see all tickets with optional filters (status, priority)
364
+ - **Get ticket details**: `get_ticket` — full details of a specific ticket
365
+ - **Create tickets**: `create_ticket` — create new tasks or bugs
366
+ - **Add comments**: `add_comment` — add notes, questions, or decisions to tickets
367
+ - **Search knowledge**: `search_knowledge_articles` — find project documentation
368
+ - **Read knowledge**: `get_knowledge_article` — read a specific article
369
+ - **Search tickets**: `search_tickets` — semantically search for related tickets
370
+ - **Search memories**: `search_memory` — find relevant agent memories
371
+ - **Store memories**: `store_memory` — persist learnings or context for future reference
372
+
373
+ ## How to Help the Human
374
+
375
+ When the human messages you on Telegram:
376
+
377
+ 1. **Status requests**: "What's the team working on?" → Use `get_status` and `list_tickets`
378
+ 2. **Ticket creation**: "Create a task to fix the login bug" → Use `create_ticket`
379
+ 3. **Ticket updates**: "What's the status of TINK-42?" → Use `get_ticket`
380
+ 4. **Questions**: "Why was this rejected?" → Use `get_ticket` and `list_comments`
381
+ 5. **Knowledge**: "What's the deployment process?" → Use `search_knowledge_articles`
382
+
383
+ ## Important Guidelines
384
+
385
+ - Always confirm with the human before creating tickets (show title and description first)
386
+ - Use human-friendly language — translate ticket statuses and technical terms
387
+ - Don't modify or transition tickets — that's the agents' job
388
+ - If unsure, ask the human for clarification
389
+ - Keep responses concise for Telegram (avoid overly long messages)
390
+
391
+ ## Ticket Statuses
392
+
393
+ - **draft/backlog/todo**: Not yet started
394
+ - **in_progress**: Currently being worked on by a team member
395
+ - **pending_audit**: Awaiting code review
396
+ - **pending_approval**: Ready for final approval
397
+ - **done**: Completed
398
+ - **blocked**: Something is preventing progress
399
+ SKILL
400
+
401
+ File.write(File.join(skills_dir, "SKILL.md"), skill_content)
402
+ puts "✅ yatfa-bridge skill installed to #{skills_dir}"
403
+ end
404
+
405
+ def run_openclaw_gateway!
406
+ port = ENV["OPENCLAW_PORT"] || "18789"
407
+
408
+ # Validate port is numeric to prevent command injection
409
+ unless port =~ /\A\d+\z/
410
+ puts "❌ Invalid OPENCLAW_PORT: must be numeric, got '#{port}'"
411
+ exit 1
412
+ end
413
+
414
+ puts ""
415
+ puts "🐾 Starting OpenClaw Gateway..."
416
+ puts " Port: #{port}"
417
+ puts " Press Ctrl+B then D to detach from tmux"
418
+ puts ""
419
+
420
+ # Use tmux for persistent session with status bar
421
+ # Array form of system() prevents shell injection
422
+ system("tmux", "new-session", "-d", "-s", "agent", "-x", "220", "-y", "50",
423
+ "openclaw gateway --port #{port} --verbose",
424
+ err: File::NULL)
425
+
426
+ # Enable mouse mode (scroll, pane selection)
427
+ system("tmux set-option -t agent mouse on 2>/dev/null")
428
+
429
+ # Set up status bar
430
+ system("tmux set-option -t agent status-position bottom 2>/dev/null")
431
+ system("tmux set-option -t agent status-style 'bg=#1a1a2e,fg=#e0e0e0' 2>/dev/null")
432
+ system("tmux set-option -t agent status-left-length 30 2>/dev/null")
433
+ system("tmux set-option -t agent status-left ' 🐾 OpenClaw Gateway ' 2>/dev/null")
434
+ system("tmux set-option -t agent status-right ' %Y-%m-%d %H:%M ' 2>/dev/null")
435
+
436
+ # Wait for gateway to start, then verify the port is listening
437
+ sleep 2
438
+
439
+ gateway_running = false
440
+ 5.times do |i|
441
+ begin
442
+ socket = TCPSocket.new("localhost", port.to_i)
443
+ socket.close
444
+ gateway_running = true
445
+ break
446
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET
447
+ sleep 1
448
+ end
449
+ end
450
+
451
+ if gateway_running
452
+ puts "✅ OpenClaw gateway started in tmux session 'agent'"
453
+ puts " Health check: curl http://localhost:#{port}/healthz"
454
+ else
455
+ puts "⚠️ Gateway port not responding after 7 seconds"
456
+ puts " Check logs: docker exec <container> tmux capture-pane -t agent -p"
457
+ # Don't exit - gateway may still be initializing
458
+ end
459
+
460
+ # Block: keep the script alive so the container doesn't restart
461
+ # The gateway runs inside tmux — periodically check it's still running
462
+ puts " Keeping container alive (gateway runs in tmux)..."
463
+ loop do
464
+ sleep 60
465
+ # If tmux session died, exit so the container can restart
466
+ unless system("tmux has-session -t agent 2>/dev/null")
467
+ puts "⚠️ tmux session 'agent' died, exiting..."
468
+ break
469
+ end
470
+ end
471
+ end
472
+
473
+ def setup_claw_agent!
474
+ puts ""
475
+ puts "🐾 Yatfa Claw Agent Setup"
476
+ puts "=========================="
477
+ puts ""
478
+
479
+ check_env_claw!
480
+ install_openclaw!
481
+ llm_credentials = fetch_llm_credentials!
482
+ if llm_credentials
483
+ puts "✅ Fetched LLM credentials from backend (source: #{llm_credentials['source']})"
484
+ else
485
+ puts "⚠️ No LLM credentials from backend — relying on OPENCLAW_* env vars"
486
+ end
487
+ setup_openclaw_config!(llm_credentials)
488
+ setup_yatfa_tools_for_claw!
489
+ run_openclaw_gateway!
490
+ end
491
+
492
+ # ─── Main ─────────────────────────────────────────────────────────────────
493
+
494
+ setup_claw_agent!