skillc 0.2.1

A development kit for Agent Skills - the open format for extending AI agent capabilities
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
//! Access logging per [[RFC-0007:C-LOGGING]]

use crate::config::ensure_dir;
use chrono::Utc;
use rand::prelude::*;
use rusqlite::{Connection, ErrorCode};
use std::env;
use std::path::{Path, PathBuf};

/// Log entry for gateway command access
pub struct LogEntry {
    pub run_id: String,
    pub command: String,
    pub skill: String,
    pub skill_path: String,
    pub cwd: String,
    pub args: String,
    pub error: Option<String>,
}

/// Initialize the log database and return a connection.
///
/// Per [[RFC-0007:C-LOGGING]]:
/// - Creates runtime directory, .skillc-meta/ subdirectory, database, and schema if they don't exist
/// - Returns None if initialization fails (command should continue without logging)
///
/// Note: This does not test writability. Use `log_access_with_fallback` which handles
/// readonly errors at write time (EAFP pattern).
pub fn init_log_db(runtime_dir: &Path) -> Option<Connection> {
    try_init_db_at(runtime_dir)
}

/// Try to initialize log database at a specific directory.
/// Returns None on failure (does not print warnings).
fn try_init_db_at(dir: &Path) -> Option<Connection> {
    // Try to create directories (may fail silently if they exist but aren't writable)
    let _ = ensure_dir(dir);

    let meta_dir = dir.join(".skillc-meta");
    let _ = ensure_dir(&meta_dir);

    let db_path = meta_dir.join("logs.db");

    // Try to open/create database
    let conn = Connection::open(&db_path).ok()?;

    // Create schema if not exists (may fail on readonly, that's ok - we'll catch it on INSERT)
    let _ = conn.execute(
        "CREATE TABLE IF NOT EXISTS access_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL,
            run_id TEXT NOT NULL,
            command TEXT NOT NULL,
            skill TEXT NOT NULL,
            skill_path TEXT NOT NULL,
            cwd TEXT NOT NULL,
            args TEXT NOT NULL,
            error TEXT
        )",
        [],
    );

    // Migration: add cwd column if missing (for existing databases)
    let _ = conn.execute(
        "ALTER TABLE access_log ADD COLUMN cwd TEXT NOT NULL DEFAULT ''",
        [],
    );

    Some(conn)
}

/// Get the fallback log directory for a skill in the current working directory.
pub fn get_fallback_log_dir(skill_name: &str) -> Option<PathBuf> {
    env::current_dir()
        .ok()
        .map(|cwd| crate::util::project_skill_logs_dir(&cwd, skill_name))
}

/// List all skills with fallback logs in the current working directory.
pub fn list_fallback_skills() -> Vec<String> {
    let cwd = match env::current_dir() {
        Ok(c) => c,
        Err(_) => return Vec::new(),
    };

    let logs_dir = crate::util::project_logs_dir(&cwd);
    if !logs_dir.exists() {
        return Vec::new();
    }

    let mut skills = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&logs_dir) {
        for entry in entries.flatten() {
            if entry.path().is_dir() {
                let db_path = entry.path().join(".skillc-meta").join("logs.db");
                if db_path.exists()
                    && let Some(name) = entry.file_name().to_str()
                {
                    skills.push(name.to_string());
                }
            }
        }
    }

    skills.sort();
    skills
}

/// Log an access event to the database with automatic fallback.
///
/// Per [[RFC-0007:C-LOGGING]] (EAFP pattern):
/// 1. Try to write to the provided connection
/// 2. If it fails with SQLITE_READONLY, open fallback at `<cwd>/.skillc/logs/<skill>/`
/// 3. Retry write to fallback
/// 4. If both fail, warn but continue
///
/// Also checks for stale fallback logs and warns if found (per [[RFC-0007:C-LOGGING]]).
pub fn log_access_with_fallback(conn: Option<&Connection>, entry: &LogEntry) {
    // Check for stale fallback logs first (for all commands)
    check_stale_fallback_logs(&entry.skill);

    // Try primary connection first
    if let Some(c) = conn {
        match try_log_access(c, entry) {
            Ok(()) => return,
            Err(e) if is_readonly_error(&e) => {
                // Fall through to fallback
            }
            Err(e) => {
                eprintln!("warning: failed to log access: {}", e);
                return;
            }
        }
    }

    // Try fallback: <cwd>/.skillc/logs/<skill>/
    if let Ok(cwd) = env::current_dir() {
        let fallback_dir = crate::util::project_skill_logs_dir(&cwd, &entry.skill);
        if let Some(fallback_conn) = try_init_db_at(&fallback_dir) {
            if let Err(e) = try_log_access(&fallback_conn, entry) {
                eprintln!("warning: failed to log access to fallback: {}", e);
            }
            return;
        }
    }

    // Both failed (W002 per [[RFC-0005:C-CODES]])
    crate::error::SkillcWarning::LoggingDisabled.emit();
}

/// Check for stale fallback logs and emit warning if found.
///
/// Per [[RFC-0007:C-LOGGING]]: If fallback logs exist for the skill and the
/// database file's mtime is older than 1 hour, emit a warning.
/// The warning SHOULD be emitted at most once per command invocation.
fn check_stale_fallback_logs(skill: &str) {
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::time::{Duration, SystemTime};

    // Only warn once per process (command invocation)
    static WARNED: AtomicBool = AtomicBool::new(false);
    if WARNED.swap(true, Ordering::Relaxed) {
        return;
    }

    let Ok(cwd) = env::current_dir() else {
        return;
    };

    let fallback_db = cwd
        .join(".skillc")
        .join("logs")
        .join(skill)
        .join(".skillc-meta")
        .join("logs.db");

    if !fallback_db.exists() {
        return;
    }

    // Check mtime
    let Ok(metadata) = fallback_db.metadata() else {
        return;
    };

    let Ok(mtime) = metadata.modified() else {
        return;
    };

    let Ok(age) = SystemTime::now().duration_since(mtime) else {
        return;
    };

    // Warn if older than 1 hour (W003 per [[RFC-0005:C-CODES]])
    if age > Duration::from_secs(3600) {
        crate::error::SkillcWarning::StaleLogs(skill.to_string()).emit();
    }
}

/// Try to insert a log entry. Returns error on failure.
fn try_log_access(conn: &Connection, entry: &LogEntry) -> Result<(), rusqlite::Error> {
    let timestamp = Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO access_log (timestamp, run_id, command, skill, skill_path, cwd, args, error)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
        rusqlite::params![
            timestamp,
            entry.run_id,
            entry.command,
            entry.skill,
            entry.skill_path,
            entry.cwd,
            entry.args,
            entry.error.as_deref(),
        ],
    )?;
    Ok(())
}

/// Check if an error is a readonly database error.
fn is_readonly_error(e: &rusqlite::Error) -> bool {
    matches!(
        e,
        rusqlite::Error::SqliteFailure(
            rusqlite::ffi::Error {
                code: ErrorCode::ReadOnly,
                ..
            },
            _
        )
    )
}

/// Legacy function for compatibility. Prefer `log_access_with_fallback`.
pub fn log_access(conn: &Connection, entry: &LogEntry) {
    if let Err(e) = try_log_access(conn, entry) {
        eprintln!("warning: failed to log access: {}", e);
    }
}

/// Generate or retrieve run ID.
///
/// Per [[RFC-0007:C-LOGGING]]:
/// - If SKC_RUN_ID env var is set, use its value
/// - Otherwise, generate in format `YYYYMMDDTHHMMSSZ-{rand4}`
pub fn get_run_id() -> String {
    if let Ok(run_id) = env::var("SKC_RUN_ID") {
        return run_id;
    }

    let now = Utc::now();
    let mut rng = rand::rng();
    let rand_hex: String = (0..4)
        .map(|_| format!("{:x}", rng.random::<u8>() % 16))
        .collect();

    format!("{}Z-{}", now.format("%Y%m%dT%H%M%S"), rand_hex)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_init_log_db() {
        let temp = TempDir::new().expect("failed to create temp dir");
        let runtime_dir = temp.path().join("runtime");

        let conn = init_log_db(&runtime_dir).expect("should create db");

        // Verify schema exists
        let count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='access_log'",
                [],
                |row| row.get(0),
            )
            .expect("failed to query schema");
        assert_eq!(count, 1);
    }

    #[test]
    fn test_get_run_id_format() {
        let run_id = get_run_id();
        // Should be in format YYYYMMDDTHHMMSSZ-XXXX
        assert!(run_id.contains('T'));
        assert!(run_id.contains('Z'));
        assert!(run_id.contains('-'));
    }

    #[test]
    fn test_try_log_access() {
        let temp = TempDir::new().expect("create temp dir");
        let runtime_dir = temp.path().join("runtime");

        let conn = init_log_db(&runtime_dir).expect("create db");

        let entry = LogEntry {
            run_id: "test-run-123".to_string(),
            command: "outline".to_string(),
            skill: "test-skill".to_string(),
            skill_path: "/path/to/skill".to_string(),
            cwd: "/current/dir".to_string(),
            args: r#"{"section": "API"}"#.to_string(),
            error: None,
        };

        try_log_access(&conn, &entry).expect("log access should succeed");

        // Verify entry was inserted
        let count: i64 = conn
            .query_row("SELECT COUNT(*) FROM access_log", [], |row| row.get(0))
            .expect("count rows");
        assert_eq!(count, 1);
    }

    #[test]
    fn test_try_log_access_with_error() {
        let temp = TempDir::new().expect("create temp dir");
        let runtime_dir = temp.path().join("runtime");

        let conn = init_log_db(&runtime_dir).expect("create db");

        let entry = LogEntry {
            run_id: "error-run".to_string(),
            command: "show".to_string(),
            skill: "error-skill".to_string(),
            skill_path: "/path".to_string(),
            cwd: "/cwd".to_string(),
            args: "{}".to_string(),
            error: Some("E001: skill not found".to_string()),
        };

        try_log_access(&conn, &entry).expect("log access should succeed");

        // Verify error was stored
        let error: Option<String> = conn
            .query_row(
                "SELECT error FROM access_log WHERE run_id = 'error-run'",
                [],
                |row| row.get(0),
            )
            .expect("query error");
        assert_eq!(error, Some("E001: skill not found".to_string()));
    }

    #[test]
    fn test_log_access_legacy() {
        let temp = TempDir::new().expect("create temp dir");
        let runtime_dir = temp.path().join("runtime");

        let conn = init_log_db(&runtime_dir).expect("create db");

        let entry = LogEntry {
            run_id: "legacy-run".to_string(),
            command: "search".to_string(),
            skill: "legacy-skill".to_string(),
            skill_path: "/path".to_string(),
            cwd: "/cwd".to_string(),
            args: r#"{"query": "test"}"#.to_string(),
            error: None,
        };

        // Legacy function should not panic
        log_access(&conn, &entry);

        let count: i64 = conn
            .query_row("SELECT COUNT(*) FROM access_log", [], |row| row.get(0))
            .expect("count rows");
        assert_eq!(count, 1);
    }

    #[test]
    fn test_is_readonly_error() {
        // Create a non-readonly error
        let other_error = rusqlite::Error::InvalidQuery;
        assert!(!is_readonly_error(&other_error));
    }

    #[test]
    fn test_init_log_db_creates_directories() {
        let temp = TempDir::new().expect("create temp dir");
        let runtime_dir = temp.path().join("deep").join("nested").join("runtime");

        // Directory doesn't exist yet
        assert!(!runtime_dir.exists());

        let conn = init_log_db(&runtime_dir);
        assert!(conn.is_some(), "should create db even with nested dirs");

        // Verify meta dir was created
        let meta_dir = runtime_dir.join(".skillc-meta");
        assert!(meta_dir.exists(), "meta dir should exist");
        assert!(meta_dir.join("logs.db").exists(), "db file should exist");
    }

    #[test]
    fn test_log_access_with_fallback_primary_success() {
        let temp = TempDir::new().expect("create temp dir");
        let runtime_dir = temp.path().join("runtime");
        let conn = init_log_db(&runtime_dir).expect("create db");

        let entry = LogEntry {
            run_id: "test-fallback-run".to_string(),
            command: "outline".to_string(),
            skill: "test-skill".to_string(),
            skill_path: "/path/to/skill".to_string(),
            cwd: "/cwd".to_string(),
            args: "{}".to_string(),
            error: None,
        };

        // Should succeed with primary connection
        log_access_with_fallback(Some(&conn), &entry);

        let count: i64 = conn
            .query_row("SELECT COUNT(*) FROM access_log", [], |row| row.get(0))
            .expect("count");
        assert_eq!(count, 1, "entry should be logged to primary");
    }

    #[test]
    fn test_log_access_with_fallback_no_connection() {
        // When no connection provided, should try fallback (but won't crash)
        let entry = LogEntry {
            run_id: "no-conn-run".to_string(),
            command: "show".to_string(),
            skill: "test-skill".to_string(),
            skill_path: "/path".to_string(),
            cwd: "/cwd".to_string(),
            args: "{}".to_string(),
            error: None,
        };

        // Should not panic when no connection
        log_access_with_fallback(None, &entry);
    }

    #[test]
    fn test_get_fallback_log_dir() {
        // Should return Some path based on current directory
        let result = get_fallback_log_dir("test-skill");
        assert!(result.is_some(), "should return a path");
        let path = result.unwrap();
        assert!(
            path.to_string_lossy().contains("test-skill"),
            "path should contain skill name: {}",
            path.display()
        );
    }

    // Note: We don't test get_run_id with env var override here because
    // tests run in parallel and modifying env vars causes race conditions.
    // The env var logic is trivial and tested implicitly via integration tests.
}