luminarys-sdk 0.1.0

Rust SDK for building Luminarys WASM skills
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Host ABI — raw imports and safe Rust wrappers.
//!
//! The skill WASM module calls these to interact with the outside world.
//! The host provides them under the `env` module namespace.

use crate::types::*;
use serde::{Serialize, Deserialize};

// ── Raw ABI declarations ──────────────────────────────────────────────────────

#[cfg(target_arch = "wasm32")]
pub(crate) mod raw {
    #[link(wasm_import_module = "env")]
    extern "C" {
        // Core
        pub fn history_get(ptr: u32, len: u32) -> u64;
        pub fn prompt_complete(ptr: u32, len: u32) -> u64;
        pub fn env_get(ptr: u32, len: u32) -> u64;
        // File system
        pub fn fs_read(ptr: u32, len: u32) -> u64;
        pub fn fs_write(ptr: u32, len: u32) -> u64;
        pub fn fs_create(ptr: u32, len: u32) -> u64;
        pub fn fs_delete(ptr: u32, len: u32) -> u64;
        pub fn fs_mkdir(ptr: u32, len: u32) -> u64;
        pub fn fs_ls(ptr: u32, len: u32) -> u64;
        pub fn fs_chmod(ptr: u32, len: u32) -> u64;
        pub fn fs_read_lines(ptr: u32, len: u32) -> u64;
        pub fn fs_grep(ptr: u32, len: u32) -> u64;
        pub fn fs_glob(ptr: u32, len: u32) -> u64;
        // HTTP
        pub fn http_get(ptr: u32, len: u32) -> u64;
        pub fn http_post(ptr: u32, len: u32) -> u64;
        pub fn http_request(ptr: u32, len: u32) -> u64;
        // WebSocket
        pub fn ws_connect(ptr: u32, len: u32) -> u64;
        pub fn ws_send(ptr: u32, len: u32) -> u64;
        pub fn ws_close(ptr: u32, len: u32) -> u64;
        // TCP
        pub fn tcp_connect(ptr: u32, len: u32) -> u64;
        pub fn tcp_connect_tls(ptr: u32, len: u32) -> u64;
        pub fn tcp_set_callback(ptr: u32, len: u32) -> u64;
        pub fn tcp_write(ptr: u32, len: u32) -> u64;
        pub fn tcp_close(ptr: u32, len: u32) -> u64;
        pub fn tcp_request(ptr: u32, len: u32) -> u64;
        // Shell
        pub fn shell_exec(ptr: u32, len: u32) -> u64;
        // Logging
        pub fn log_write(ptr: u32, len: u32) -> u64;
        // System
        pub fn sys_info(ptr: u32, len: u32) -> u64;
        pub fn time_now(ptr: u32, len: u32) -> u64;
        pub fn disk_usage(ptr: u32, len: u32) -> u64;
        pub fn fs_allowed_dirs(ptr: u32, len: u32) -> u64;
        pub fn fs_copy(ptr: u32, len: u32) -> u64;
        // Archive
        pub fn archive_pack(ptr: u32, len: u32) -> u64;
        pub fn archive_unpack(ptr: u32, len: u32) -> u64;
        pub fn archive_list(ptr: u32, len: u32) -> u64;
        // Cluster
        pub fn file_transfer_send(ptr: u32, len: u32) -> u64;
        pub fn file_transfer_recv(ptr: u32, len: u32) -> u64;
        pub fn cluster_node_list(ptr: u32, len: u32) -> u64;
    }
}

// Stub implementations for non-wasm targets (tests / doc builds).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod raw {
    macro_rules! stub {
        ($($name:ident),* $(,)?) => {
            $(pub unsafe extern "C" fn $name(_ptr: u32, _len: u32) -> u64 {
                panic!(concat!(stringify!($name), " called outside WASM"))
            })*
        };
    }
    stub!(
        history_get, prompt_complete, env_get,
        fs_read, fs_write, fs_create, fs_delete, fs_mkdir,
        fs_ls, fs_chmod, fs_read_lines, fs_grep, fs_glob, fs_copy,
        fs_allowed_dirs,
        http_get, http_post, http_request,
        ws_connect, ws_send, ws_close,
        tcp_connect, tcp_connect_tls, tcp_set_callback, tcp_write, tcp_close, tcp_request,
        shell_exec,
        log_write,
        sys_info, time_now, disk_usage,
        archive_pack, archive_unpack, archive_list,
        file_transfer_send, file_transfer_recv, cluster_node_list,
    );
}

// ── ABI helpers ───────────────────────────────────────────────────────────────

/// Encode request, call host ABI function, decode the response as `R`.
///
/// Returns `Err(SkillError)` on host-reported errors.
pub(crate) fn call_host<S, R>(
    host_fn: unsafe extern "C" fn(u32, u32) -> u64,
    value: &S,
) -> Result<R, SkillError>
where
    S: serde::Serialize,
    R: serde::de::DeserializeOwned,
{
    let encoded = rmp_serde::to_vec_named(value)?;
    let result = unsafe { host_fn(encoded.as_ptr() as u32, encoded.len() as u32) };
    let bytes = unpack_result(result);

    // Check for error response before full decode.
    #[derive(serde::Deserialize)]
    struct ErrorCheck {
        #[serde(default)]
        error: String,
    }
    if let Ok(check) = rmp_serde::from_slice::<ErrorCheck>(&bytes) {
        if !check.error.is_empty() {
            return Err(SkillError(check.error));
        }
    }

    Ok(rmp_serde::from_slice(&bytes)?)
}

/// Decode a host ABI result.
fn unpack_result(packed: u64) -> Vec<u8> {
    let ptr = (packed >> 32) as u32;
    let len = (packed & 0xFFFF_FFFF) as u32;
    if len == 0 {
        return Vec::new();
    }
    // SAFETY: host guarantees the memory region [ptr, ptr+len) is valid.
    unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize).to_vec() }
}

// ── Public API ────────────────────────────────────────────────────────────────

/// Fetch dialogue history matching `filter` (e.g. `"last:10"`, `"role:user"`).
pub fn history_get(filter: &str) -> Result<Vec<HistoryMessage>, SkillError> {
    #[derive(serde::Serialize)]
    struct Req<'a> { filter: &'a str }
    call_host(raw::history_get, &Req { filter })
}

/// Send a prompt to the LLM via the host's model router.
pub fn prompt_complete(req: PromptRequest) -> Result<PromptResponse, SkillError> {
    let resp: PromptResponse = call_host(raw::prompt_complete, &req)?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp)
}

/// Read the named env value declared in the skill's manifest.
/// Returns `""` when not found. Never errors.
pub fn get_env(key: &str) -> String {
    #[derive(serde::Serialize)]
    struct Req<'a> { key: &'a str }
    #[derive(serde::Deserialize)]
    struct Resp { value: String }
    call_host::<_, Resp>(raw::env_get, &Req { key })
        .map(|r| r.value)
        .unwrap_or_default()
}

/// Decode a batch-callback payload into a [`BatchResult`].
pub fn unmarshal_batch_result(payload: &[u8]) -> Result<BatchResult, SkillError> {
    Ok(rmp_serde::from_slice(payload)?)
}

// ── File system ───────────────────────────────────────────────────────────────

/// Read a file inside the skill's sandbox.
pub fn fs_read(path: &str) -> Result<Vec<u8>, SkillError> {
    let resp: FsResponse = call_host(raw::fs_read, &FsRequest { path: path.into(), content: vec![] })?;
    check_fs_error(resp.error)?;
    Ok(resp.content)
}

/// Write (overwrite) a file inside the skill's sandbox.
pub fn fs_write(path: &str, content: Vec<u8>) -> Result<(), SkillError> {
    let resp: FsResponse = call_host(raw::fs_write, &FsRequest { path: path.into(), content })?;
    check_fs_error(resp.error)
}

/// Create a new file (fails if already exists).
pub fn fs_create(path: &str, content: Vec<u8>) -> Result<(), SkillError> {
    let resp: FsResponse = call_host(raw::fs_create, &FsRequest { path: path.into(), content })?;
    check_fs_error(resp.error)
}

/// Delete a file inside the skill's sandbox.
pub fn fs_delete(path: &str) -> Result<(), SkillError> {
    let resp: FsResponse = call_host(raw::fs_delete, &FsRequest { path: path.into(), content: vec![] })?;
    check_fs_error(resp.error)
}

/// Create one or more directories. Supports brace expansion when `recursive = true`.
pub fn fs_mkdir(path: &str, recursive: bool) -> Result<(), SkillError> {
    let resp: FsResponse = call_host(raw::fs_mkdir, &FsMkdirRequest { path: path.into(), recursive })?;
    check_fs_error(resp.error)
}

/// List directory contents.
///
/// When `long = true` every entry is stat-ed and `mod_time`, `mode`,
/// and `mode_str` are populated.
pub fn fs_ls(path: &str, long: bool) -> Result<Vec<DirEntry>, SkillError> {
    let resp: FsLsResponse = call_host(raw::fs_ls, &FsLsRequest { path: path.into(), long })?;
    check_fs_error(resp.error)?;
    Ok(resp.entries)
}

/// Change Unix permission bits.
pub fn fs_chmod(path: &str, mode: u32, recursive: bool) -> Result<(), SkillError> {
    let resp: FsResponse = call_host(raw::fs_chmod, &FsChmodRequest { path: path.into(), mode, recursive })?;
    check_fs_error(resp.error)
}

/// Read a slice of lines from a text file with optional pagination.
pub fn fs_read_lines(req: FsReadLinesRequest) -> Result<TextFileContent, SkillError> {
    let resp: TextFileContentResponse = call_host(raw::fs_read_lines, &req)?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(TextFileContent {
        lines: resp.lines,
        total_lines: resp.total_lines,
        offset: resp.offset,
        is_truncated: resp.is_truncated,
    })
}

/// Search files using a regex (or fixed string).
pub fn fs_grep(opts: GrepOptions) -> Result<Vec<GrepFileMatch>, SkillError> {
    let resp: GrepResponse = call_host(raw::fs_grep, &opts)?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp.matches)
}

/// Find files and directories matching glob patterns.
pub fn fs_glob(opts: GlobOptions) -> Result<Vec<GlobEntry>, SkillError> {
    let resp: GlobResponse = call_host(raw::fs_glob, &opts)?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp.matches)
}

// ── TCP request/response ──────────────────────────────────────────────────────

/// Synchronous TCP request: connect → send → read response → close.
///
/// Ideal for simple request/reply protocols (Redis PING, HTTP/1.0, etc.)
/// where a persistent connection is not needed.
pub fn tcp_request(opts: &TcpRequestOptions) -> Result<TcpRequestResult, SkillError> {
    let resp: TcpRequestResult = call_host(raw::tcp_request, opts)?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp)
}

// ── Shell ──────────────────────────────────────────────────────────────────────

/// Execute a shell command and return stdout, stderr, and exit code.
///
/// Requires `shell.enabled = true` in manifest.yaml.
/// If `shell.allowlist` is set, the command must match at least one pattern.
pub fn shell_exec(req: &ShellExecRequest) -> Result<ShellExecResult, SkillError> {
    let resp: ShellExecResult = call_host(raw::shell_exec, req)?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp)
}

// ── Logging ───────────────────────────────────────────────────────────────────

/// Write a structured log message to the host's logger.
/// The skill's instance ID is automatically included.
/// No permissions required.
///
/// ```rust,no_run
/// log_msg("info", "processing started", &[("items", "42")]);
/// log_msg("error", "connection failed", &[("addr", addr)]);
/// ```
pub fn log_msg(level: &str, message: &str, fields: &[(&str, &str)]) {
    use std::collections::HashMap;
    let mut f: HashMap<&str, &str> = HashMap::new();
    for (k, v) in fields {
        f.insert(k, v);
    }
    #[derive(serde::Serialize)]
    struct Req<'a> {
        level: &'a str,
        message: &'a str,
        fields: HashMap<&'a str, &'a str>,
    }
    #[derive(serde::Deserialize)]
    struct Empty {}
    let _ = call_host::<_, Empty>(raw::log_write, &Req { level, message, fields: f });
}

/// Shorthand: debug log.
pub fn log_debug(message: &str, fields: &[(&str, &str)]) { log_msg("debug", message, fields); }
/// Shorthand: info log.
pub fn log_info(message: &str, fields: &[(&str, &str)]) { log_msg("info", message, fields); }
/// Shorthand: warn log.
pub fn log_warn(message: &str, fields: &[(&str, &str)]) { log_msg("warn", message, fields); }
/// Shorthand: error log.
pub fn log_error(message: &str, fields: &[(&str, &str)]) { log_msg("error", message, fields); }

// ── System info ───────────────────────────────────────────────────────────────

/// Host OS and hardware info. No permissions required.
pub fn sys_info() -> Result<SysInfoResult, SkillError> {
    #[derive(serde::Serialize)]
    struct Empty {}
    call_host(raw::sys_info, &Empty {})
}

/// Current host time in multiple formats. No permissions required.
pub fn time_now() -> Result<TimeNowResult, SkillError> {
    #[derive(serde::Serialize)]
    struct Empty {}
    call_host(raw::time_now, &Empty {})
}

/// Disk space info for a path. Requires fs.enabled.
pub fn disk_usage(path: &str) -> Result<DiskUsageResult, SkillError> {
    #[derive(serde::Serialize)]
    struct Req<'a> { path: &'a str }
    let resp: DiskUsageResult = call_host(raw::disk_usage, &Req { path })?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp)
}

/// List of directories the skill is allowed to access. Requires fs.enabled.
pub fn fs_allowed_dirs() -> Result<Vec<AllowedDir>, SkillError> {
    #[derive(serde::Serialize)]
    struct Empty {}
    #[derive(serde::Deserialize)]
    struct Resp {
        #[serde(default)]
        dirs: Vec<AllowedDir>,
        #[serde(default)]
        error: String,
    }
    let resp: Resp = call_host(raw::fs_allowed_dirs, &Empty {})?;
    if !resp.error.is_empty() {
        return Err(SkillError(resp.error));
    }
    Ok(resp.dirs)
}

// ── File Copy ────────────────────────────────────────────────────────────────

/// Copy a file within allowed directories.
pub fn fs_copy(source: &str, dest: &str) -> Result<(), SkillError> {
    #[derive(Serialize)]
    struct Req { source: String, dest: String }
    let resp: FsResponse = call_host(raw::fs_copy, &Req {
        source: source.into(), dest: dest.into(),
    })?;
    check_fs_error(resp.error)
}

// ── Archive ──────────────────────────────────────────────────────────────────

/// Result of archive_pack.
#[derive(Deserialize, Default)]
pub struct ArchivePackResult {
    #[serde(default)]
    pub files_count: i64,
    #[serde(default)]
    pub format: String,
    #[serde(default)]
    pub error: String,
}

/// Create a tar.gz or zip archive from a directory.
pub fn archive_pack(source: &str, output: &str, format: &str, include: &str, exclude: &str) -> Result<ArchivePackResult, SkillError> {
    #[derive(Serialize)]
    struct Req { source: String, output: String, format: String, include: String, exclude: String }
    let resp: ArchivePackResult = call_host(raw::archive_pack, &Req {
        source: source.into(), output: output.into(), format: format.into(),
        include: include.into(), exclude: exclude.into(),
    })?;
    if !resp.error.is_empty() { return Err(SkillError(resp.error)); }
    Ok(resp)
}

/// Archive entry in listing.
#[derive(Deserialize, Default)]
pub struct ArchiveEntry {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub size: i64,
    #[serde(default)]
    pub is_dir: bool,
}

/// List contents of a tar.gz or zip archive.
pub fn archive_list(archive: &str, format: &str, include: &str, exclude: &str) -> Result<Vec<ArchiveEntry>, SkillError> {
    #[derive(Serialize)]
    struct Req { archive: String, format: String, include: String, exclude: String }
    #[derive(Deserialize, Default)]
    struct Resp { #[serde(default)] entries: Vec<ArchiveEntry>, #[serde(default)] error: String }
    let resp: Resp = call_host(raw::archive_list, &Req {
        archive: archive.into(), format: format.into(),
        include: include.into(), exclude: exclude.into(),
    })?;
    if !resp.error.is_empty() { return Err(SkillError(resp.error)); }
    Ok(resp.entries)
}

/// Extract a tar.gz or zip archive.
pub fn archive_unpack(archive: &str, dest: &str, format: &str, include: &str, exclude: &str, strip: i32) -> Result<i64, SkillError> {
    #[derive(Serialize)]
    struct Req { archive: String, dest: String, format: String, include: String, exclude: String, strip: i32 }
    #[derive(Deserialize, Default)]
    struct Resp { #[serde(default)] files_count: i64, #[serde(default)] error: String }
    let resp: Resp = call_host(raw::archive_unpack, &Req {
        archive: archive.into(), dest: dest.into(), format: format.into(),
        include: include.into(), exclude: exclude.into(), strip,
    })?;
    if !resp.error.is_empty() { return Err(SkillError(resp.error)); }
    Ok(resp.files_count)
}

// ── Cluster & File Transfer ──────────────────────────────────────────────────

/// Send a file to a remote cluster node.
pub fn file_transfer_send(target_node: &str, local_path: &str, remote_path: &str) -> Result<(), SkillError> {
    #[derive(Serialize)]
    struct Req { target_node: String, local_path: String, remote_path: String }
    #[derive(Deserialize, Default)]
    struct Resp { #[serde(default)] error: String }
    let resp: Resp = call_host(raw::file_transfer_send, &Req {
        target_node: target_node.into(), local_path: local_path.into(), remote_path: remote_path.into(),
    })?;
    if !resp.error.is_empty() { return Err(SkillError(resp.error)); }
    Ok(())
}

/// Request a file from a remote cluster node (pull mode).
pub fn file_transfer_recv(source_node: &str, remote_path: &str, local_path: &str) -> Result<(), SkillError> {
    #[derive(Serialize)]
    struct Req { source_node: String, remote_path: String, local_path: String }
    #[derive(Deserialize, Default)]
    struct Resp { #[serde(default)] error: String }
    let resp: Resp = call_host(raw::file_transfer_recv, &Req {
        source_node: source_node.into(), remote_path: remote_path.into(), local_path: local_path.into(),
    })?;
    if !resp.error.is_empty() { return Err(SkillError(resp.error)); }
    Ok(())
}

/// Cluster node information.
#[derive(Deserialize, Default)]
pub struct ClusterNodeInfo {
    #[serde(default)]
    pub node_id: String,
    #[serde(default)]
    pub role: String,
    #[serde(default)]
    pub skills: Vec<String>,
}

/// List known cluster nodes.
pub fn cluster_node_list() -> Result<(String, Vec<ClusterNodeInfo>), SkillError> {
    #[derive(Deserialize, Default)]
    struct Resp {
        #[serde(default)]
        current_node: String,
        #[serde(default)]
        nodes: Vec<ClusterNodeInfo>,
        #[serde(default)]
        error: String,
    }
    #[derive(Serialize)]
    struct Empty {}
    let resp: Resp = call_host(raw::cluster_node_list, &Empty {})?;
    if !resp.error.is_empty() { return Err(SkillError(resp.error)); }
    Ok((resp.current_node, resp.nodes))
}

// ── internal helpers ──────────────────────────────────────────────────────────

fn check_fs_error(err: String) -> Result<(), SkillError> {
    if err.is_empty() { Ok(()) } else { Err(SkillError(err)) }
}