• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 25391169006

05 May 2026 05:15PM UTC coverage: 64.354% (-0.08%) from 64.43%
25391169006

Pull #598

github

web-flow
Merge 8e37e2827 into 76d733523
Pull Request #598: feat(config, conversation, cli): Add `user.name` attribution to turns

28 of 89 new or added lines in 8 files covered. (31.46%)

3 existing lines in 3 files now uncovered.

24641 of 38290 relevant lines covered (64.35%)

181.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/jp_cli/src/cmd/init.rs
1
use std::{env, fs, io, str::FromStr as _, sync::Arc};
2

3
use camino::{FromPathBufError, Utf8PathBuf};
4
use clean_path::Clean as _;
5
use crossterm::style::Stylize as _;
6
use duct::cmd;
7
use inquire::{Select, Text};
8
use jp_config::{
9
    PartialAppConfig,
10
    conversation::tool::RunMode,
11
    fs::{ConfigLoader, Format, user_global_config_dir},
12
    model::id::{ModelIdConfig, Name, ProviderId},
13
};
14
use jp_printer::Printer;
15
use jp_storage::backend::FsStorageBackend;
16
use jp_workspace::Workspace;
17
use schematic::ConfigEnum as _;
18

19
use crate::{DEFAULT_STORAGE_DIR, cmd::Output, ctx::IntoPartialAppConfig};
20

21
#[derive(Debug, clap::Args)]
22
pub(crate) struct Init {
23
    /// Path to initialize the workspace at. Defaults to the current directory.
24
    path: Option<Utf8PathBuf>,
25
}
26

27
impl Init {
28
    pub(crate) fn run(&self, printer: &Printer) -> Output {
×
29
        let cwd: Utf8PathBuf = std::env::current_dir()?
×
30
            .try_into()
×
31
            .map_err(FromPathBufError::into_io_error)?;
×
32

33
        let mut root: Utf8PathBuf = self
×
34
            .path
×
35
            .clone()
×
36
            .unwrap_or_else(|| Utf8PathBuf::from("."))
×
37
            .into_std_path_buf()
×
38
            .clean()
×
39
            .try_into()
×
40
            .map_err(FromPathBufError::into_io_error)?;
×
41

42
        if !root.is_absolute() {
×
43
            root = cwd.join(root);
×
44
        }
×
45

46
        fs::create_dir_all(&root)?;
×
47

48
        let storage = root.join(DEFAULT_STORAGE_DIR);
×
49
        let id = jp_workspace::Id::new();
×
50

51
        let fs = Arc::new(FsStorageBackend::new(&storage)?);
×
52
        let _workspace = Workspace::new_with_id(root.clone(), id.clone()).with_backend(fs);
×
53

54
        id.store(&storage)?;
×
55

56
        // Interactive configuration
57
        let run_mode = Self::ask_run_mode(&mut printer.out_writer(), true)?;
×
58
        let (provider, name) = Self::ask_model(&mut printer.out_writer())?;
×
59

60
        // Ask for the user's display name (skipped if already configured
61
        // in user-global config). Writes to the user-global config so the
62
        // setting is shared across all workspaces.
NEW
63
        Self::maybe_ask_and_persist_user_name(&mut printer.out_writer())?;
×
64

65
        // Write workspace config
66
        fs::write(
×
67
            storage.join("config.toml"),
×
68
            indoc::formatdoc!(
×
69
                r#"
70
                [assistant.model.id]
71
                provider = "{provider}"
72
                name = "{name}"
73

74
                [conversation.tools.'*']
75
                run = "{run_mode}"
76
            "#
77
            ),
78
        )?;
×
79

80
        let loc = if root == cwd {
×
81
            "current directory".to_owned()
×
82
        } else {
83
            root.to_string().bold().to_string()
×
84
        };
85

86
        printer.println(format!("Initialized workspace at {loc}"));
×
87
        Ok(())
×
88
    }
×
89

90
    /// Ask for and persist `user.name` to user-global config, unless it is
91
    /// already set there.
92
    ///
93
    /// Skipped silently when no user-global config directory can be
94
    /// determined (e.g. `$HOME` is unset). Empty input also skips,
95
    /// leaving transcripts to fall back to the generic `user` label.
NEW
96
    fn maybe_ask_and_persist_user_name(
×
NEW
97
        writer: &mut dyn io::Write,
×
NEW
98
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
×
NEW
99
        let home = env::home_dir().and_then(|p| Utf8PathBuf::try_from(p).ok());
×
NEW
100
        let Some(global_dir) = user_global_config_dir(home.as_deref()) else {
×
NEW
101
            return Ok(());
×
102
        };
103

NEW
104
        let loader = ConfigLoader {
×
NEW
105
            file_stem: "config".into(),
×
NEW
106
            create_if_missing: Some(Format::Toml),
×
NEW
107
            ..Default::default()
×
NEW
108
        };
×
NEW
109
        let mut config_file = loader.load(&global_dir)?;
×
110

111
        // If the user has already set their name in user-global config,
112
        // don't pester them again.
NEW
113
        if let Ok(existing) = toml::from_str::<PartialAppConfig>(&config_file.content)
×
NEW
114
            && existing.user.name.as_deref().is_some_and(|s| !s.is_empty())
×
115
        {
NEW
116
            return Ok(());
×
NEW
117
        }
×
118

NEW
119
        let Some(name) = Self::ask_user_name(writer)? else {
×
NEW
120
            return Ok(());
×
121
        };
122

NEW
123
        let mut partial = PartialAppConfig::empty();
×
NEW
124
        partial.user.name = Some(name);
×
NEW
125
        config_file.merge_delta(&partial)?;
×
126

NEW
127
        if let Some(parent) = config_file.path.parent() {
×
NEW
128
            fs::create_dir_all(parent)?;
×
NEW
129
        }
×
NEW
130
        fs::write(&config_file.path, &config_file.content)?;
×
131

NEW
132
        Ok(())
×
NEW
133
    }
×
134

135
    /// Prompt the user for their display name, pre-filled with the best
136
    /// available default (see [`detect_default_user_name`]). Returns `None`
137
    /// when the user submits an empty value.
NEW
138
    fn ask_user_name(
×
NEW
139
        writer: &mut dyn io::Write,
×
NEW
140
    ) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
×
NEW
141
        let prompt = Text::new("Your name for conversations:").with_help_message(
×
NEW
142
            "Stamped onto each message you send. Press Enter to accept the default, or leave \
×
NEW
143
             blank for the generic 'user' label.",
×
144
        );
145

NEW
146
        let default = detect_default_user_name();
×
NEW
147
        let answer = match default.as_deref() {
×
NEW
148
            Some(d) => prompt.with_default(d).prompt_with_writer(writer)?,
×
NEW
149
            None => prompt.prompt_with_writer(writer)?,
×
150
        };
151

NEW
152
        let trimmed = answer.trim();
×
NEW
153
        Ok((!trimmed.is_empty()).then(|| trimmed.to_owned()))
×
NEW
154
    }
×
155

156
    fn ask_run_mode(
×
157
        writer: &mut dyn io::Write,
×
158
        help: bool,
×
159
    ) -> Result<RunMode, Box<dyn std::error::Error + Send + Sync>> {
×
160
        let mut options = vec![
×
161
            format!("Yes {}", "(safest option)".green()),
×
162
            format!("No  {}", "(potentially dangerous)".red()),
×
163
        ];
164

165
        if help {
×
166
            options.push("Help…".to_owned());
×
167
        }
×
168

169
        let answer = Select::new("Confirm before running tools?", options)
×
170
            .with_help_message(
×
171
                "You can always configure individual tools you deem safe to run without \
×
172
                 confirmation.",
×
173
            )
174
            .with_starting_cursor(0)
×
175
            .prompt_with_writer(writer)?;
×
176

177
        if answer == "Help…" {
×
178
            let _err = indoc::writedoc!(
×
179
                writer,
×
180
                r"
181

182
                    # Recommended Configuration
183

184
                    Yes (confirm before running tools)
185

186
                    # Summary
187

188
                    The assistant runs tools on your local machine, these
189
                    can perform destructive actions and should therefore
190
                    be run with a human-in-the-loop confirmation.
191

192
                    # Details
193

194
                    When using JP, the assistant needs to run tools on
195
                    your local machine to perform certain tasks such as
196
                    modifying files, running CLI tools, etc.
197

198
                    Most of these tools are safe to run, but some can
199
                    be potentially dangerous, depending on the
200
                    arguments provided to them.
201

202
                    While all of JP's built-in tools are confined to the
203
                    workspace root, externally supplied tools cannot be
204
                    restricted in the same way, and can potentially run
205
                    any command on your system.
206

207
                    For example, a potentially external tool `rm` could
208
                    take an argument `file`, which could be an absolute
209
                    path to a file outside of your workspace root,
210
                    deleting files from your system that you don't want
211
                    to delete.
212

213
                    To avoid this, you should configure the assistant to
214
                    run these tools with a human-in-the-loop confirmation.
215
                    This will ensure that the assistant only runs tools
216
                    that you explicitly allow it to run.
217

218
                    You can also configure the assistant to run tools
219
                    automatically, which means it will run tools without
220
                    asking you first.
221

222
                    The answer to this question will be used as the default
223
                    for all tools that are run by the assistant, but each
224
                    tool can also be configured to run with a different
225
                    mode, by editing your config file after the workspace
226
                    is initialized.
227

228
                "
229
            );
230
            writer.flush()?;
×
231

232
            return Self::ask_run_mode(writer, false);
×
233
        }
×
234

235
        Ok(if answer.starts_with("Yes") {
×
236
            RunMode::Unattended
×
237
        } else {
238
            RunMode::Ask
×
239
        })
240
    }
×
241

242
    fn ask_model(
×
243
        writer: &mut dyn io::Write,
×
244
    ) -> Result<(ProviderId, Name), Box<dyn std::error::Error + Send + Sync>> {
×
245
        let models = Self::detect_models();
×
246

247
        let mut options: Vec<String> = models.iter().map(ToString::to_string).collect();
×
248
        options.push("Other (enter manually)".to_string());
×
249

250
        let ans = Select::new("Select an AI model to use:", options.clone())
×
251
            .with_help_message("We detected these models based on your environment.")
×
252
            .prompt_with_writer(writer)?;
×
253

254
        if ans == "Other (enter manually)" {
×
255
            let providers = ProviderId::variants();
×
256
            let provider_strs: Vec<String> = providers.iter().map(ToString::to_string).collect();
×
257

258
            let provider_str =
×
259
                Select::new("Select a provider:", provider_strs).prompt_with_writer(writer)?;
×
260

261
            let provider =
×
262
                ProviderId::from_str(&provider_str).map_err(|e| io::Error::other(e.to_string()))?;
×
263

264
            let name = Text::new("Enter the model name:")
×
265
                .with_placeholder("e.g. gpt-4o")
×
266
                .prompt_with_writer(writer)?;
×
267

268
            Ok((provider, Name(name)))
×
269
        } else {
270
            let m = models.iter().find(|m| m.to_string() == ans).unwrap();
×
271
            Ok((m.provider, m.name.clone()))
×
272
        }
273
    }
×
274

275
    fn detect_models() -> Vec<ModelIdConfig> {
×
276
        let mut models = Vec::new();
×
277

278
        if has_anthropic()
×
279
            && let Some(m) = default_model_id_for(ProviderId::Anthropic)
×
280
        {
×
281
            models.push(m);
×
282
        }
×
283
        if has_openai()
×
284
            && let Some(m) = default_model_id_for(ProviderId::Openai)
×
285
        {
×
286
            models.push(m);
×
287
        }
×
288
        if has_google()
×
289
            && let Some(m) = default_model_id_for(ProviderId::Google)
×
290
        {
×
291
            models.push(m);
×
292
        }
×
293

294
        if let Ok(output) = cmd!("ollama", "list").read() {
×
295
            for line in output.lines().skip(1) {
×
296
                let Some(name) = line.split_whitespace().next() else {
×
297
                    continue;
×
298
                };
299

300
                if name.is_empty() {
×
301
                    continue;
×
302
                }
×
303

304
                let name = name.split(':').next().unwrap_or(name);
×
305
                models.push(ModelIdConfig {
×
306
                    provider: ProviderId::Ollama,
×
307
                    name: Name(name.to_owned()),
×
308
                });
×
309
            }
310
        }
×
311

312
        models.sort();
×
313
        models.dedup();
×
314
        models
×
315
    }
×
316
}
317

318
/// Best-effort detection of a sensible default display name.
319
///
320
/// Cascades through:
321
///
322
/// 1. `git config --get user.name` — typically the user's real name, set
323
///    once and reused across tools.
324
/// 2. `$USER` (Unix) or `$USERNAME` (Windows) — the system login name,
325
///    last-resort fallback.
326
///
327
/// Returns `None` when nothing is available so the prompt renders without
328
/// a pre-filled value.
NEW
329
fn detect_default_user_name() -> Option<String> {
×
330
    // 1. git config
NEW
331
    if let Ok(out) = cmd!("git", "config", "--get", "user.name")
×
NEW
332
        .stderr_null()
×
NEW
333
        .read()
×
334
    {
NEW
335
        let trimmed = out.trim();
×
NEW
336
        if !trimmed.is_empty() {
×
NEW
337
            return Some(trimmed.to_owned());
×
NEW
338
        }
×
NEW
339
    }
×
340

341
    // 2. system login name ($USER on Unix, $USERNAME on Windows).
NEW
342
    env::var("USER")
×
NEW
343
        .or_else(|_| env::var("USERNAME"))
×
NEW
344
        .ok()
×
NEW
345
        .map(|s| s.trim().to_owned())
×
NEW
346
        .filter(|s| !s.is_empty())
×
NEW
347
}
×
348

349
fn has_anthropic() -> bool {
×
350
    env::var("ANTHROPIC_API_KEY").is_ok()
×
351
}
×
352

353
fn has_openai() -> bool {
×
354
    env::var("OPENAI_API_KEY").is_ok()
×
355
}
×
356

357
fn has_google() -> bool {
×
358
    env::var("GOOGLE_API_KEY").is_ok()
×
359
}
×
360

361
fn default_model_id_for(provider: ProviderId) -> Option<ModelIdConfig> {
×
362
    let name = match provider {
×
363
        ProviderId::Anthropic => Name("claude-sonnet-4-6".into()),
×
364
        ProviderId::Google => Name("gemini-3.1-pro-preview".into()),
×
365
        ProviderId::Openai => Name("gpt-5.2".into()),
×
366
        _ => return None,
×
367
    };
368

369
    Some(ModelIdConfig { provider, name })
×
370
}
×
371

372
impl IntoPartialAppConfig for Init {
373
    fn apply_cli_config(
×
374
        &self,
×
375
        _workspace: Option<&Workspace>,
×
376
        partial: PartialAppConfig,
×
377
        _: Option<&PartialAppConfig>,
×
378
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
379
        Ok(partial)
×
380
    }
×
381
}
382

383
#[cfg(test)]
384
#[path = "init_tests.rs"]
385
mod tests;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc