zad-cli 0.9.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.
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
//! Discord's plug-in to the generic service lifecycle.
//!
//! Everything in this file is Discord-specific — scope names, prompts
//! for application ID and default guild, the shape of the Discord
//! credential (`DiscordSecrets` = one bot token), and the call that
//! validates the token against the Discord API. The generic plumbing
//! (flag parsing, path resolution, JSON envelopes, human banners,
//! keychain I/O sequencing) lives in `src/cli/lifecycle.rs` and is
//! shared with every other service.
//!
//! See `docs/services.md#adding-a-new-service` for the recipe a new
//! service would follow. This file is the first — and, until
//! Telegram/Slack/etc. land, only — implementation of that recipe.

use crate::cli::DialoguerExt;
use async_trait::async_trait;
use clap::Args;
use dialoguer::{Input, Password, theme::ColorfulTheme};

use crate::cli::lifecycle::{
    BotTokenArgs, CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg,
    SecretRef, resolve_scopes,
};
use zad::config::{DiscordServiceCfg, ProjectConfig};
use zad::error::{Result, ZadError};
use zad::secrets::{self, Scope};
use zad::service::discord::DiscordHttp;

const DEFAULT_SCOPES: &[&str] = &["guilds", "messages.read", "messages.send"];
const ALL_SCOPES: &[&str] = &[
    "guilds",
    "messages.read",
    "messages.send",
    "channels.manage",
    "gateway.listen",
];

// ---------------------------------------------------------------------------
// Discord's credential shape
// ---------------------------------------------------------------------------

/// Discord only uses one secret — the long-lived bot token — so
/// `Secrets` wraps it in a named struct rather than `String` for
/// parity with services that need richer shapes.
pub struct DiscordSecrets {
    pub bot_token: String,
}

// ---------------------------------------------------------------------------
// Discord's `zad service create discord` args
// ---------------------------------------------------------------------------

#[derive(Debug, Args)]
pub struct CreateArgs {
    #[command(flatten)]
    pub base: CreateArgsBase,
    #[command(flatten)]
    pub token: BotTokenArgs,
    #[command(flatten)]
    pub scopes: ScopesArg,
    /// Discord application (bot) ID.
    #[arg(long)]
    pub application_id: Option<String>,
    /// Optional default guild (server) ID.
    #[arg(long)]
    pub default_guild: Option<String>,
    /// Numeric Discord user ID for the human user this bot belongs to.
    /// Resolved from the literal `@me` in later send targets. Obtain
    /// from Discord: Settings → Advanced → enable Developer Mode, then
    /// right-click yourself → "Copy User ID". Leave unset in
    /// non-interactive mode to skip; fill later via `zad discord self
    /// set <id>`.
    #[arg(long)]
    pub self_user: Option<String>,
}

impl CreateArgsLike for CreateArgs {
    fn base(&self) -> &CreateArgsBase {
        &self.base
    }
}

// ---------------------------------------------------------------------------
// The trait impl — this is the entire Discord-specific surface
// ---------------------------------------------------------------------------

pub struct DiscordLifecycle;

#[async_trait]
impl LifecycleService for DiscordLifecycle {
    const NAME: &'static str = "discord";
    const DISPLAY: &'static str = "Discord";
    type Cfg = DiscordServiceCfg;
    type Secrets = DiscordSecrets;

    fn enable_in_project(cfg: &mut ProjectConfig) {
        cfg.enable_discord();
    }

    fn disable_in_project(cfg: &mut ProjectConfig) {
        cfg.disable_discord();
    }

    async fn validate(_cfg: &DiscordServiceCfg, creds: &mut DiscordSecrets) -> Result<String> {
        DiscordHttp::unscoped(&creds.bot_token)
            .validate_token()
            .await
            .map_err(|e| ZadError::Service {
                name: Self::NAME,
                message: format!("token validation failed: {e}"),
            })
    }

    fn store_secrets(creds: &DiscordSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        secrets::store(&account, &creds.bot_token)?;
        Ok(vec![SecretRef {
            label: "token",
            account,
            present: true,
        }])
    }

    fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        secrets::delete(&account)?;
        Ok(vec![SecretRef {
            label: "token",
            account,
            present: false,
        }])
    }

    fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        let present = secrets::load(&account)?.is_some();
        Ok(vec![SecretRef {
            label: "token",
            account,
            present,
        }])
    }

    fn load_secrets(scope: Scope<'_>) -> Result<Option<DiscordSecrets>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        Ok(secrets::load(&account)?.map(|bot_token| DiscordSecrets { bot_token }))
    }

    fn cfg_human(cfg: &DiscordServiceCfg) -> Vec<(&'static str, String)> {
        let mut out = vec![("app id", cfg.application_id.clone())];
        if let Some(g) = &cfg.default_guild {
            out.push(("guild", g.clone()));
        }
        if let Some(u) = &cfg.self_user_id {
            out.push(("self", u.clone()));
        }
        out
    }

    fn cfg_json(cfg: &DiscordServiceCfg) -> serde_json::Value {
        serde_json::json!({
            "application_id": cfg.application_id,
            "default_guild": cfg.default_guild,
            "self_user_id": cfg.self_user_id,
        })
    }

    fn scopes_of(cfg: &DiscordServiceCfg) -> &[String] {
        &cfg.scopes
    }

    fn post_create_hint(cfg: &DiscordServiceCfg) -> Option<String> {
        Some(install_url(&cfg.application_id))
    }
}

#[async_trait]
impl CliLifecycle for DiscordLifecycle {
    type CreateArgs = CreateArgs;

    async fn resolve(
        args: &CreateArgs,
        non_interactive: bool,
    ) -> Result<(DiscordServiceCfg, DiscordSecrets)> {
        let open_browser = !args.base.no_browser;
        let application_id = resolve_application_id(
            args.application_id.as_deref(),
            open_browser,
            non_interactive,
        )?;
        let default_guild = resolve_default_guild(args.default_guild.as_deref(), non_interactive)?;
        let scopes = resolve_scopes(
            args.scopes.scopes.as_deref(),
            DEFAULT_SCOPES,
            ALL_SCOPES,
            non_interactive,
        )?;
        let bot_token = resolve_discord_bot_token(
            args.token.bot_token.as_deref(),
            args.token.bot_token_env.as_deref(),
            &application_id,
            open_browser,
            non_interactive,
        )?;
        let self_user_id =
            resolve_self_user_id(args.self_user.as_deref(), &bot_token, non_interactive).await?;
        Ok((
            DiscordServiceCfg {
                application_id,
                scopes,
                default_guild,
                self_user_id,
            },
            DiscordSecrets { bot_token },
        ))
    }
}

// ---------------------------------------------------------------------------
// Discord-specific prompt helpers
// ---------------------------------------------------------------------------

fn theme() -> ColorfulTheme {
    ColorfulTheme::default()
}

fn resolve_application_id(
    flag: Option<&str>,
    open_browser: bool,
    non_interactive: bool,
) -> Result<String> {
    if let Some(v) = flag {
        return validate_numeric(v, "application-id").map(|_| v.to_string());
    }
    if non_interactive {
        return Err(ZadError::MissingRequired("--application-id"));
    }

    let url = PORTAL_APPS_URL;
    println!();
    println!("Your Discord applications live at:");
    println!("  {url}");
    println!("Create one (or open an existing app) and copy its Application ID.");
    if open_browser {
        let _ = open::that(url);
    }

    let v: String = Input::with_theme(&theme())
        .with_prompt("Discord application ID")
        .validate_with(|s: &String| validate_numeric(s, "application-id").map(|_| ()))
        .interact_text()
        .into_zad()?;
    Ok(v)
}

fn resolve_default_guild(flag: Option<&str>, non_interactive: bool) -> Result<Option<String>> {
    if let Some(v) = flag {
        validate_numeric(v, "default-guild")?;
        return Ok(Some(v.to_string()));
    }
    if non_interactive {
        return Ok(None);
    }

    println!();
    println!("To find a guild (server) ID in Discord:");
    println!("  Settings → Advanced → enable Developer Mode, then");
    println!("  right-click the server icon → \"Copy Server ID\".");
    println!("Leave blank to skip — you can set a default guild later.");

    let v: String = Input::with_theme(&theme())
        .with_prompt("Default guild ID (leave blank for none)")
        .allow_empty(true)
        .interact_text()
        .into_zad()?;
    if v.trim().is_empty() {
        Ok(None)
    } else {
        validate_numeric(&v, "default-guild").map(|_| Some(v))
    }
}

fn validate_numeric(v: &str, field: &'static str) -> Result<()> {
    if v.chars().all(|c| c.is_ascii_digit()) && !v.is_empty() {
        Ok(())
    } else {
        Err(ZadError::Invalid(format!(
            "{field} must be a numeric Discord snowflake, got `{v}`"
        )))
    }
}

/// Discord-specific bot-token prompt: same flag/env contract as
/// the generic `resolve_bot_token`, but the interactive path also
/// surfaces (and optionally opens) the developer-portal URL where
/// the token is actually generated. Discord doesn't issue bot
/// tokens via OAuth — the portal is the only source — so the best
/// "easy setup" we can offer is dropping the user on the right
/// page and asking them to paste once.
fn resolve_discord_bot_token(
    flag: Option<&str>,
    env_flag: Option<&str>,
    application_id: &str,
    open_browser: bool,
    non_interactive: bool,
) -> Result<String> {
    if let Some(env) = env_flag {
        return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
    }
    if let Some(v) = flag {
        return Ok(v.to_string());
    }
    if non_interactive {
        return Err(ZadError::MissingRequired("--bot-token or --bot-token-env"));
    }

    let url = portal_bot_url(application_id);
    println!();
    println!("Your Discord bot token lives at:");
    println!("  {url}");
    println!("Click \"Reset Token\" → \"Copy\", then paste it below.");
    if open_browser {
        let _ = open::that(&url);
    }

    let v = Password::with_theme(&theme())
        .with_prompt("Discord bot token")
        .interact()
        .into_zad()?;
    Ok(v)
}

const PORTAL_APPS_URL: &str = "https://discord.com/developers/applications";

fn portal_bot_url(application_id: &str) -> String {
    format!("https://discord.com/developers/applications/{application_id}/bot")
}

fn install_url(application_id: &str) -> String {
    format!(
        "https://discord.com/api/oauth2/authorize?client_id={application_id}&scope=bot&permissions=0"
    )
}

// ---------------------------------------------------------------------------
// Self-user capture
// ---------------------------------------------------------------------------

/// Resolve `self_user_id` for `zad service create discord`. The flag
/// path is non-interactive: validate numeric, validate against the
/// Discord API, persist. The interactive path prints the Developer
/// Mode recipe, prompts, and validates.
async fn resolve_self_user_id(
    flag: Option<&str>,
    bot_token: &str,
    non_interactive: bool,
) -> Result<Option<String>> {
    if let Some(raw) = flag {
        return validate_self_user(bot_token, raw).await.map(Some);
    }
    if non_interactive {
        return Ok(None);
    }

    println!();
    println!("Optional: configure `@me` so commands like");
    println!("  zad discord send --user @me \"hello\"");
    println!("resolve to your own Discord user.");
    println!("Find your user ID: Settings → Advanced → enable Developer Mode,");
    println!("then right-click yourself → \"Copy User ID\".");

    let raw: String = Input::with_theme(&theme())
        .with_prompt("Your Discord user ID (leave blank to skip)")
        .allow_empty(true)
        .interact_text()
        .into_zad()?;
    if raw.trim().is_empty() {
        return Ok(None);
    }
    validate_self_user(bot_token, raw.trim()).await.map(Some)
}

/// Validate a Discord user-ID string: numeric snowflake that resolves
/// via `GET /users/{id}`. Shared between the create-time path and
/// `zad discord self set`. Returns the canonical string form (not the
/// parsed `u64`) because the config field is already a `String`.
pub async fn validate_self_user(bot_token: &str, raw: &str) -> Result<String> {
    validate_numeric(raw, "self-user")?;
    let id: u64 = raw.parse().map_err(|_| {
        ZadError::Invalid(format!(
            "self-user `{raw}` doesn't fit in a 64-bit unsigned integer"
        ))
    })?;
    let name = DiscordHttp::unscoped(bot_token)
        .get_user(id)
        .await
        .map_err(|e| ZadError::Service {
            name: "discord",
            message: format!("user-id validation failed: {e}"),
        })?;
    println!("  ✓ resolved `{raw}` as `{name}`");
    Ok(raw.to_string())
}