use std::path::Path;
use anyhow::{Context, Result};
use tokio::fs;
pub const TEMPLATE_HACKERNEWS: &str = r#"#!/usr/bin/env node
/**
* HackerNews top stories scraper
* Usage: node hackernews-scrape.ts [--limit 10]
*/
const HN_API = 'https://hacker-news.firebaseio.com/v0/topstories.json';
async function main() {
const args = process.argv.slice(2);
const limit = parseInt(args.find(a => a === '--limit') ? args[args.indexOf('--limit') + 1] : '10');
const ids = await fetch(HN_API).then(r => r.json());
const top = ids.slice(0, limit);
const stories = await Promise.all(top.map(async (id) => {
const s = await fetch(`${HN_API.replace('topstories', `item/${id}`)}`).then(r => r.json()).catch(() => null);
if (!s) return null;
return { title: s.title, url: s.url || `https://news.ycombinator.com/item?id=${id}`, score: s.score, by: s.by, comments: s.descendants || 0 };
}));
const valid = stories.filter(Boolean);
console.log(JSON.stringify({ stories: valid, count: valid.length }));
}
main().catch(e => { console.error(JSON.stringify({error: e.message})); process.exit(1); });
"#;
pub const TEMPLATE_TRANSLATE: &str = r#"#!/usr/bin/env node
/**
* Translation tool — uses LLM API to translate text.
* Input: PREV_STEP_OUTPUT env var or --text argument.
* Output: Translated text to stdout.
*/
const API_URL = process.env.LLM_API_URL || 'http://localhost:8080/v1/chat/completions';
const API_KEY = process.env.LLM_API_KEY || '';
const MODEL = process.env.LLM_MODEL || 'glm-4-air';
async function translate(text, fromLang = 'en', toLang = 'ko') {
if (!text || text.trim() === '') {
console.log(JSON.stringify({ error: 'No input text' }));
return;
}
const body = {
model: MODEL,
messages: [{
role: 'user',
content: `Translate the following from ${fromLang} to ${toLang}. Keep the format. Just translate:\n\n${text.slice(0, 4000)}`
}],
max_tokens: 4000,
temperature: 0.3,
};
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(API_KEY ? { 'Authorization': `Bearer ${API_KEY}` } : {}) },
body: JSON.stringify(body),
});
const data = await res.json();
const translated = data.choices?.[0]?.message?.content || data.error?.message || '';
console.log(translated);
} catch(e) {
console.error(JSON.stringify({ error: e.message }));
process.exit(1);
}
}
const text = process.env.PREV_STEP_OUTPUT
|| (process.argv.find(a => a === '--text') ? process.argv[process.argv.indexOf('--text') + 1] : '');
const from = process.argv.find(a => a === '--from') ? process.argv[process.argv.indexOf('--from') + 1] : 'en';
const to = process.argv.find(a => a === '--to') ? process.argv[process.argv.indexOf('--to') + 1] : 'ko';
translate(text, from, to);
"#;
pub const TEMPLATE_EMAIL: &str = r#"#!/usr/bin/env node
/**
* Email sender via HTTP API (Resend, SendGrid, etc.)
* Usage: node send-email.ts --to <email> --subject <subject>
* Input: PREV_STEP_OUTPUT env var contains the email body.
*/
const API_KEY = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY || '';
const FROM = process.env.EMAIL_FROM || 'cron@garden.local';
const API_URL = process.env.EMAIL_API_URL || '';
async function send(to, subject, body) {
if (!API_URL || !API_KEY) {
console.log(JSON.stringify({ status: 'noop', reason: 'No email API configured. Set EMAIL_API_URL and EMAIL_API_KEY.' }));
return;
}
const payload = { from: FROM, to, subject, html: `<pre>${body}</pre>` };
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
console.log(JSON.stringify(data));
}
const to = process.argv.find(a => a === '--to') ? process.argv[process.argv.indexOf('--to') + 1] : '';
const subject = process.argv.find(a => a === '--subject') ? process.argv[process.argv.indexOf('--subject') + 1] : 'ClawGarden Cron Report';
const body = process.env.PREV_STEP_OUTPUT || '';
send(to, subject, body).catch(e => { console.error(e.message); process.exit(1); });
"#;
pub const TEMPLATE_WRITE_FILE: &str = r#"#!/usr/bin/env node
/**
* Write content to a file (Obsidian vault, logs, etc.)
* Usage: node write-file.ts --path <path>
* Input: PREV_STEP_OUTPUT env var contains the file content.
*/
const fs = require('fs');
const path = require('path');
const filePath = process.argv.find(a => a === '--path')
? process.argv[process.argv.indexOf('--path') + 1]
: process.env.FILE_PATH || '';
const content = process.env.PREV_STEP_OUTPUT
|| (process.argv.find(a => a === '--content') ? process.argv[process.argv.indexOf('--content') + 1] : '');
if (!filePath) {
console.error(JSON.stringify({ error: 'No path specified. Use --path or FILE_PATH env var.' }));
process.exit(1);
}
const dir = path.dirname(filePath);
if (dir && dir !== '.') {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, content, 'utf8');
console.log(JSON.stringify({ written: filePath, bytes: content.length }));
"#;
pub const TEMPLATE_GITHUB: &str = r#"#!/usr/bin/env node
/**
* GitHub API: fetch trending repositories or user's starred repos.
* Usage: node github-api.ts --endpoint trending [--language javascript]
*/
const API_KEY = process.env.GITHUB_TOKEN || '';
const BASE = 'https://api.github.com';
async function gh(endpoint, params = {}) {
const qs = Object.entries(params).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&');
const url = `${BASE}${endpoint}${qs ? '?' + qs : ''}`;
const headers = { 'Accept': 'application/vnd.github.v3+json' };
if (API_KEY) headers['Authorization'] = `token ${API_KEY}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
return res.json();
}
async function main() {
const args = process.argv.slice(2);
const endpoint = args.find(a => a === '--endpoint') ? args[args.indexOf('--endpoint') + 1] : 'trending';
const lang = args.find(a => a === '--language') ? args[args.indexOf('--language') + 1] : '';
let data;
if (endpoint === 'trending') {
// GitHub doesn't have a native trending API — use search instead
const since = 'daily';
const q = lang ? `language:${lang} created:>${since}` : `created:>${since}`;
data = await gh('/search/repositories', { q, sort: 'stars', order: 'desc', per_page: '10' });
data = { repos: (data.items || []).map(r => ({ name: r.full_name, desc: r.description, stars: r.stargazers_count, url: r.html_url, lang: r.language })) };
} else if (endpoint === 'user') {
const user = args.find(a => a === '--user') ? args[args.indexOf('--user') + 1] : '';
data = await gh(`/users/${user}/starred`);
data = { repos: data.map(r => ({ name: r.full_name, desc: r.description, stars: r.stargazers_count })) };
} else {
data = { error: `Unknown endpoint: ${endpoint}` };
}
console.log(JSON.stringify(data));
}
main().catch(e => { console.error(JSON.stringify({ error: e.message })); process.exit(1); });
"#;
pub const TEMPLATE_SCRAPE: &str = r#"#!/usr/bin/env node
/**
* Generic HTTP fetch + HTML scraper.
* Usage: node scrape.ts --url <url> [--selector .title] [--json]
*/
async function main() {
const args = process.argv.slice(2);
const url = args.find(a => a === '--url') ? args[args.indexOf('--url') + 1] : '';
const selector = args.find(a => a === '--selector') ? args[args.indexOf('--selector') + 1] : '';
if (!url) {
console.error(JSON.stringify({ error: 'No URL specified. Use --url <url>' }));
process.exit(1);
}
const res = await fetch(url);
const html = await res.text();
if (args.includes('--json')) {
// Try to parse as JSON
try {
const json = JSON.parse(html);
console.log(JSON.stringify(json));
} catch {
console.log(JSON.stringify({ error: 'Not JSON', status: res.status, length: html.length }));
}
} else {
// Return raw text or selected portion
if (selector) {
// Simple extraction: look for text between tags
const match = html.match(new RegExp(`<[^>]*${selector}[^>]*>([^<]+)</`, 'i'));
const text = match ? match[1].trim() : html.slice(0, 2000);
console.log(JSON.stringify({ url, extracted: text }));
} else {
console.log(JSON.stringify({ url, status: res.status, preview: html.slice(0, 500) }));
}
}
}
main().catch(e => { console.error(JSON.stringify({ error: e.message })); process.exit(1); });
"#;
pub fn template_for_action(action: &str) -> (&'static str, &'static str, &'static str) {
match action.to_lowercase().as_str() {
"hackernews" | "hn" | "news" | "scrape" => (
"hackernews-scrape",
TEMPLATE_HACKERNEWS,
"Fetch top stories from Hacker News API",
),
"translate" | "번역" => (
"translate-ko",
TEMPLATE_TRANSLATE,
"Translate text using LLM API",
),
"email" | "mail" | "이메일" | "메일" => (
"send-email",
TEMPLATE_EMAIL,
"Send email via HTTP API (Resend, etc.)",
),
"write_file" | "obsidian" | "save" | "노트" | "기록" => (
"write-file",
TEMPLATE_WRITE_FILE,
"Write content to a file path",
),
"github" | "trending" | "repo" | "레포" | "깃허브" => (
"github-api",
TEMPLATE_GITHUB,
"GitHub API: trending repos, starred repos",
),
"fetch" | "http" | "web" => (
"scrape",
TEMPLATE_SCRAPE,
"Generic HTTP fetch and HTML scraper",
),
_ => (
"custom-tool",
r#"#!/usr/bin/env node
console.log(process.env.PREV_STEP_OUTPUT || '');
"#,
"Custom tool",
),
}
}
pub async fn ensure_tool(
workspace_dir: &Path,
action: &str,
_params: &str,
) -> Result<String> {
let (tool_name, content, description) = template_for_action(action);
let tool_path = workspace_dir.join("tools").join(format!("{}.ts", tool_name));
fs::create_dir_all(workspace_dir.join("tools")).await.context("create tools dir")?;
if !tool_path.exists() {
fs::write(&tool_path, content).await.context("write tool file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::metadata(&tool_path).await?.permissions();
let mut p = perms;
p.set_mode(0o755);
fs::set_permissions(&tool_path, p).await?;
}
}
update_registry(workspace_dir, tool_name, description).await?;
Ok(tool_name.to_string())
}
pub async fn ensure_tools(
workspace_dir: &Path,
steps: &[crate::cron_nlp::parser::StepSpec],
) -> Result<Vec<String>> {
let mut tool_names = Vec::new();
for step in steps {
let name = ensure_tool(workspace_dir, &step.action, &step.params).await?;
tool_names.push(name);
}
Ok(tool_names)
}
async fn update_registry(workspace_dir: &Path, tool_name: &str, description: &str) -> Result<()> {
let registry_path = workspace_dir.join("skills").join("registry.json");
let content = match fs::read_to_string(®istry_path).await {
Ok(c) => c,
Err(_) => r#"{"version":1,"skills":[]}"#.to_string(),
};
let mut registry: serde_json::Value = serde_json::from_str(&content)
.unwrap_or_else(|_| serde_json::json!({"version": 1, "skills": [] }));
if let Some(skills) = registry.get_mut("skills").and_then(|s| s.as_array_mut()) {
if skills.iter().any(|s| {
s.get("name").and_then(|n| n.as_str()) == Some(tool_name)
}) {
return Ok(()); }
skills.push(serde_json::json!({
"name": tool_name,
"version": "1",
"description": description,
"runtime": "shell",
"script_path": format!("tools/{}.ts", tool_name),
"side_effect_level": "read_only",
"timeout_ms": 30000,
"requires_auth": false,
"visibility": ["global"],
"tags": ["cron", "workflow", "auto-generated"]
}));
}
let new_content = serde_json::to_string_pretty(®istry)
.context("serialize registry")?;
fs::write(®istry_path, new_content)
.await
.context("write registry")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_template_for_action() {
let (name, _, desc) = template_for_action("hackernews");
assert_eq!(name, "hackernews-scrape");
assert!(desc.contains("Hacker News"));
let (name2, _, _) = template_for_action("email");
assert_eq!(name2, "send-email");
let (name3, _, _) = template_for_action("translate");
assert_eq!(name3, "translate-ko");
}
}