zad-cli 0.7.3

Command-line interface for zad — connects AI agents to external services (Discord, Slack, Google Calendar, Spotify, Telegram, YouTube Music, 1Password) via scoped service configurations.
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
//! Shared CLI plumbing for the staged-commit permissions workflow.
//!
//! Each service's clap tree pulls in [`StagingAction`] as an enum
//! variant and forwards matched subcommands to the generic dispatchers
//! here. The per-service CLI stays tiny — it knows its service type
//! ([`PermissionsService`] impl) and lets the shared code handle
//! mutation parsing, staging, signing, and JSON formatting.

use clap::{Args, Subcommand, ValueEnum};
use serde::Serialize;

use zad::error::{Result, ZadError};
use zad::permissions::mutation::{ListKind, Mutation};
use zad::permissions::service::{PermissionsService, global_path, local_path_current};
use zad::permissions::signing;
use zad::permissions::staging;

// ---------------------------------------------------------------------------
// clap types
// ---------------------------------------------------------------------------

/// Staging-workflow subcommands common to every permissions-bearing
/// service. Each service embeds this under its `permissions` subgroup.
#[derive(Debug, Subcommand)]
pub enum StagingAction {
    /// Print whether a pending policy exists at each scope.
    Status(ScopeArgs),
    /// Show the unified diff between live and pending (if any).
    Diff(ScopeArgs),
    /// Discard the pending policy without touching the live file.
    Discard(ScopeArgs),
    /// Promote the pending policy to live and upsert a trust-store
    /// entry signed with the keychain-held signing key.
    Commit(ScopeArgs),
    /// Sign the live file with the keychain-held signing key and
    /// upsert its entry in the per-machine trust store. Use this to
    /// trust a hand-edited file or a permissions file shipped from
    /// another machine. Requires `zad signing init` to have run.
    Sign(ScopeArgs),

    /// Queue an allow/deny pattern change. Writes to the pending file.
    #[command(alias = "add-pattern")]
    Add(PatternMutationArgs),
    /// Queue an allow/deny pattern removal.
    #[command(alias = "remove-pattern")]
    Remove(PatternMutationArgs),

    /// Queue a content `deny_words` / `deny_patterns` / `max_length`
    /// change.
    Content(ContentMutationArgs),
    /// Queue a time-window change.
    Time(TimeMutationArgs),
}

#[derive(Debug, Args)]
pub struct ScopeArgs {
    /// Operate on the project-local file instead of the global one.
    #[arg(long)]
    pub local: bool,
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Args)]
pub struct PatternMutationArgs {
    /// Function block to edit. Omit to edit the top-level defaults
    /// (only valid for services that expose top-level target lists).
    #[arg(long)]
    pub function: Option<String>,

    /// Target kind to edit. Must be one of the service's known target
    /// kinds (`channel`, `user`, `guild`, `chat`, `calendar`,
    /// `attendee`, `vault`, `item`, `tag`, `category`, `field`).
    #[arg(long)]
    pub target: String,

    /// Which list to edit.
    #[arg(long, value_enum)]
    pub list: CliListKind,

    /// Pattern value (exact match, glob, `re:<regex>`, or numeric ID).
    pub value: String,

    #[arg(long)]
    pub local: bool,
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Copy, Clone, ValueEnum)]
pub enum CliListKind {
    Allow,
    Deny,
}

impl From<CliListKind> for ListKind {
    fn from(v: CliListKind) -> ListKind {
        match v {
            CliListKind::Allow => ListKind::Allow,
            CliListKind::Deny => ListKind::Deny,
        }
    }
}

#[derive(Debug, Args)]
pub struct ContentMutationArgs {
    /// Scope the edit to one function's block. Omit for the top-level
    /// defaults.
    #[arg(long)]
    pub function: Option<String>,

    #[command(subcommand)]
    pub action: ContentAction,

    #[arg(long)]
    pub local: bool,
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Subcommand)]
pub enum ContentAction {
    /// Append a case-insensitive deny-word.
    AddDenyWord { word: String },
    /// Remove a deny-word.
    RemoveDenyWord { word: String },
    /// Append a deny regex.
    AddDenyRegex { pattern: String },
    /// Remove a deny regex.
    RemoveDenyRegex { pattern: String },
    /// Set the `max_length` codepoint cap. Pass `--clear` to remove.
    SetMaxLength {
        #[arg(long, conflicts_with = "clear")]
        value: Option<u32>,
        #[arg(long)]
        clear: bool,
    },
}

#[derive(Debug, Args)]
pub struct TimeMutationArgs {
    #[arg(long)]
    pub function: Option<String>,

    #[command(subcommand)]
    pub action: TimeAction,

    #[arg(long)]
    pub local: bool,
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Subcommand)]
pub enum TimeAction {
    /// Replace the weekday allow-list. Comma-separated: `mon,tue,wed`.
    SetDays {
        #[arg(long, value_delimiter = ',')]
        days: Vec<String>,
    },
    /// Replace the HH:MM-HH:MM window list. Comma-separated.
    SetWindows {
        #[arg(long, value_delimiter = ',')]
        windows: Vec<String>,
    },
}

// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------

/// Entry point a service's CLI module calls after matching the shared
/// subcommand.
pub fn run<S: PermissionsService>(action: StagingAction) -> Result<()> {
    match action {
        StagingAction::Status(a) => run_status::<S>(a),
        StagingAction::Diff(a) => run_diff::<S>(a),
        StagingAction::Discard(a) => run_discard::<S>(a),
        StagingAction::Commit(a) => run_commit::<S>(a),
        StagingAction::Sign(a) => run_sign::<S>(a),
        StagingAction::Add(a) => run_pattern_mutation::<S>(a, /* add */ true),
        StagingAction::Remove(a) => run_pattern_mutation::<S>(a, /* add */ false),
        StagingAction::Content(a) => run_content_mutation::<S>(a),
        StagingAction::Time(a) => run_time_mutation::<S>(a),
    }
}

fn scope_path<S: PermissionsService>(local: bool) -> Result<std::path::PathBuf> {
    if local {
        local_path_current::<S>()
    } else {
        global_path::<S>()
    }
}

#[derive(Debug, Serialize)]
struct StatusOut {
    command: String,
    scope: &'static str,
    live_path: String,
    live_exists: bool,
    pending_path: String,
    pending_exists: bool,
}

fn run_status<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
    let scope = if args.local { "local" } else { "global" };
    let live = scope_path::<S>(args.local)?;
    let pending = staging::pending_path_for(&live);
    let st = staging::status(&live);
    if args.json {
        let out = StatusOut {
            command: format!("{}.permissions.status", S::NAME),
            scope,
            live_path: live.display().to_string(),
            live_exists: st.live_exists,
            pending_path: pending.display().to_string(),
            pending_exists: st.pending_exists,
        };
        println!("{}", serde_json::to_string_pretty(&out).unwrap());
    } else {
        println!("# {} permissions ({scope})", S::NAME);
        println!(
            "  live    : {} ({})",
            live.display(),
            if st.live_exists { "present" } else { "absent" }
        );
        println!(
            "  pending : {} ({})",
            pending.display(),
            if st.pending_exists {
                "present"
            } else {
                "absent"
            }
        );
    }
    Ok(())
}

fn run_diff<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
    let live = scope_path::<S>(args.local)?;
    match staging::diff(&live)? {
        Some(body) if body.is_empty() => {
            println!("# no effective change (pending matches live)");
        }
        Some(body) => {
            print!("{body}");
        }
        None => {
            if args.json {
                println!(
                    "{{\"command\": \"{}.permissions.diff\", \"pending\": false}}",
                    S::NAME
                );
            } else {
                println!("# no pending changes at {}", live.display());
            }
        }
    }
    Ok(())
}

fn run_discard<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
    let live = scope_path::<S>(args.local)?;
    let removed = staging::discard(&live)?;
    if args.json {
        println!(
            "{{\"command\": \"{}.permissions.discard\", \"removed\": {removed}}}",
            S::NAME
        );
    } else if removed {
        println!(
            "Discarded pending changes at {}",
            staging::pending_path_for(&live).display()
        );
    } else {
        println!("No pending changes to discard.");
    }
    Ok(())
}

fn run_commit<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
    let live = scope_path::<S>(args.local)?;
    let key = signing::require_keychain_key()?;
    staging::commit::<S>(&live, &key)?;
    if args.json {
        println!(
            "{{\"command\": \"{}.permissions.commit\", \"signed_with\": \"{}\"}}",
            S::NAME,
            key.fingerprint()
        );
    } else {
        println!(
            "Committed and signed: {} (key {}).",
            live.display(),
            key.fingerprint()
        );
    }
    Ok(())
}

fn run_sign<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
    let live = scope_path::<S>(args.local)?;
    let key = signing::require_keychain_key()?;
    staging::sign_in_place::<S>(&live, &key)?;
    if args.json {
        println!(
            "{{\"command\": \"{}.permissions.sign\", \"signed_with\": \"{}\"}}",
            S::NAME,
            key.fingerprint()
        );
    } else {
        println!(
            "Signed and trusted: {} (key {}).",
            live.display(),
            key.fingerprint()
        );
    }
    Ok(())
}

fn run_pattern_mutation<S: PermissionsService>(args: PatternMutationArgs, add: bool) -> Result<()> {
    validate_function::<S>(args.function.as_deref())?;
    validate_target::<S>(&args.target)?;
    let mutation = if add {
        Mutation::AddPattern {
            function: args.function,
            target: args.target,
            list: args.list.into(),
            value: args.value,
        }
    } else {
        Mutation::RemovePattern {
            function: args.function,
            target: args.target,
            list: args.list.into(),
            value: args.value,
        }
    };
    queue_and_report::<S>(&mutation, args.local, args.json)
}

fn run_content_mutation<S: PermissionsService>(args: ContentMutationArgs) -> Result<()> {
    validate_function::<S>(args.function.as_deref())?;
    let mutation = match args.action {
        ContentAction::AddDenyWord { word } => Mutation::AddDenyWord {
            function: args.function,
            word,
        },
        ContentAction::RemoveDenyWord { word } => Mutation::RemoveDenyWord {
            function: args.function,
            word,
        },
        ContentAction::AddDenyRegex { pattern } => Mutation::AddDenyRegex {
            function: args.function,
            pattern,
        },
        ContentAction::RemoveDenyRegex { pattern } => Mutation::RemoveDenyRegex {
            function: args.function,
            pattern,
        },
        ContentAction::SetMaxLength { value, clear } => {
            if clear && value.is_some() {
                return Err(ZadError::Invalid(
                    "pass --value or --clear, not both".into(),
                ));
            }
            Mutation::SetMaxLength {
                function: args.function,
                value: if clear { None } else { value },
            }
        }
    };
    queue_and_report::<S>(&mutation, args.local, args.json)
}

fn run_time_mutation<S: PermissionsService>(args: TimeMutationArgs) -> Result<()> {
    validate_function::<S>(args.function.as_deref())?;
    let mutation = match args.action {
        TimeAction::SetDays { days } => Mutation::SetTimeDays {
            function: args.function,
            days,
        },
        TimeAction::SetWindows { windows } => Mutation::SetTimeWindows {
            function: args.function,
            windows,
        },
    };
    queue_and_report::<S>(&mutation, args.local, args.json)
}

fn queue_and_report<S: PermissionsService>(
    mutation: &Mutation,
    local: bool,
    json: bool,
) -> Result<()> {
    let live = scope_path::<S>(local)?;
    staging::mutate_pending::<S>(&live, mutation)?;
    let pending = staging::pending_path_for(&live);
    if json {
        println!(
            "{{\"command\": \"{}.permissions.queue\", \"mutation\": {}, \"pending\": \"{}\"}}",
            S::NAME,
            serde_json::to_string(mutation).unwrap(),
            pending.display()
        );
    } else {
        println!("Queued: {}", mutation.summary());
        println!("  pending: {}", pending.display());
        println!(
            "  review with `zad {} permissions diff{}`, commit with \
             `zad {} permissions commit{}`.",
            S::NAME,
            if local { " --local" } else { "" },
            S::NAME,
            if local { " --local" } else { "" }
        );
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// validation helpers
// ---------------------------------------------------------------------------

fn validate_function<S: PermissionsService>(function: Option<&str>) -> Result<()> {
    let Some(name) = function else {
        return Ok(());
    };
    if S::all_functions().contains(&name) {
        return Ok(());
    }
    Err(ZadError::Invalid(format!(
        "{}: unknown function `{name}`; expected one of {}",
        S::NAME,
        S::all_functions().join(", ")
    )))
}

fn validate_target<S: PermissionsService>(target: &str) -> Result<()> {
    if S::target_kinds().contains(&target) {
        return Ok(());
    }
    Err(ZadError::Invalid(format!(
        "{}: unknown target `{target}`; expected one of {}",
        S::NAME,
        S::target_kinds().join(", ")
    )))
}