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

dcdpr / jp / 25437138375

06 May 2026 01:07PM UTC coverage: 66.045% (-0.001%) from 66.046%
25437138375

push

github

web-flow
fix(cli, init): Prefer global git identity for name default (#606)

`detect_default_user_name` previously ran `git config --get user.name`
without a scope flag, meaning a repo-local override could be picked up
and written into the user-global JP config, where it would then be
inherited by every future workspace — leaking an unrelated identity.

The cascade now tries `git config --global --get user.name` first, then
falls back to the unscoped lookup for users who only have a repo-local
identity, and finally falls through to the `$USER`/`$USERNAME`
environment variable as before.

The name prompt also switches from `with_default()` to
`with_initial_value()`, so the detected name is pre-filled and editable
rather than silently substituted on empty submission. This lets users
clear the field and submit empty to skip attribution, which the previous
behaviour made impossible without typing a space first.

---------

Signed-off-by: Jean Mertz <git@jeanmertz.com>

0 of 14 new or added lines in 1 file covered. (0.0%)

48 existing lines in 3 files now uncovered.

25519 of 38639 relevant lines covered (66.04%)

238.46 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.
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.
96
    fn maybe_ask_and_persist_user_name(
×
97
        writer: &mut dyn io::Write,
×
98
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
×
99
        let home = env::home_dir().and_then(|p| Utf8PathBuf::try_from(p).ok());
×
100
        let Some(global_dir) = user_global_config_dir(home.as_deref()) else {
×
101
            return Ok(());
×
102
        };
103

104
        let loader = ConfigLoader {
×
105
            file_stem: "config".into(),
×
106
            create_if_missing: Some(Format::Toml),
×
107
            ..Default::default()
×
108
        };
×
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.
113
        if let Ok(existing) = toml::from_str::<PartialAppConfig>(&config_file.content)
×
114
            && existing.user.name.as_deref().is_some_and(|s| !s.is_empty())
×
115
        {
116
            return Ok(());
×
117
        }
×
118

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

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

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

132
        Ok(())
×
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.
138
    ///
139
    /// The default is set as an *initial value* (editable), not as an
140
    /// `inquire` default (substituted on empty submission), so the user can
141
    /// clear the field and submit empty to skip attribution.
142
    fn ask_user_name(
×
143
        writer: &mut dyn io::Write,
×
144
    ) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
×
145
        let prompt = Text::new("Your name for conversations:").with_help_message(
×
NEW
146
            "Stamped onto each message you send. Press Enter to accept the pre-filled value, or \
×
NEW
147
             clear the field for the generic 'user' label.",
×
148
        );
149

NEW
150
        let initial = detect_default_user_name();
×
NEW
151
        let answer = match initial.as_deref() {
×
NEW
152
            Some(v) => prompt.with_initial_value(v).prompt_with_writer(writer)?,
×
UNCOV
153
            None => prompt.prompt_with_writer(writer)?,
×
154
        };
155

156
        let trimmed = answer.trim();
×
157
        Ok((!trimmed.is_empty()).then(|| trimmed.to_owned()))
×
158
    }
×
159

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

169
        if help {
×
170
            options.push("Help…".to_owned());
×
171
        }
×
172

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

181
        if answer == "Help…" {
×
182
            let _err = indoc::writedoc!(
×
183
                writer,
×
184
                r"
185

186
                    # Recommended Configuration
187

188
                    Yes (confirm before running tools)
189

190
                    # Summary
191

192
                    The assistant runs tools on your local machine, these
193
                    can perform destructive actions and should therefore
194
                    be run with a human-in-the-loop confirmation.
195

196
                    # Details
197

198
                    When using JP, the assistant needs to run tools on
199
                    your local machine to perform certain tasks such as
200
                    modifying files, running CLI tools, etc.
201

202
                    Most of these tools are safe to run, but some can
203
                    be potentially dangerous, depending on the
204
                    arguments provided to them.
205

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

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

217
                    To avoid this, you should configure the assistant to
218
                    run these tools with a human-in-the-loop confirmation.
219
                    This will ensure that the assistant only runs tools
220
                    that you explicitly allow it to run.
221

222
                    You can also configure the assistant to run tools
223
                    automatically, which means it will run tools without
224
                    asking you first.
225

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

232
                "
233
            );
234
            writer.flush()?;
×
235

236
            return Self::ask_run_mode(writer, false);
×
237
        }
×
238

239
        Ok(if answer.starts_with("Yes") {
×
240
            RunMode::Unattended
×
241
        } else {
242
            RunMode::Ask
×
243
        })
244
    }
×
245

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

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

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

258
        if ans == "Other (enter manually)" {
×
259
            let providers = ProviderId::variants();
×
260
            let provider_strs: Vec<String> = providers.iter().map(ToString::to_string).collect();
×
261

262
            let provider_str =
×
263
                Select::new("Select a provider:", provider_strs).prompt_with_writer(writer)?;
×
264

265
            let provider =
×
266
                ProviderId::from_str(&provider_str).map_err(|e| io::Error::other(e.to_string()))?;
×
267

268
            let name = Text::new("Enter the model name:")
×
269
                .with_placeholder("e.g. gpt-4o")
×
270
                .prompt_with_writer(writer)?;
×
271

272
            Ok((provider, Name(name)))
×
273
        } else {
274
            let m = models.iter().find(|m| m.to_string() == ans).unwrap();
×
275
            Ok((m.provider, m.name.clone()))
×
276
        }
277
    }
×
278

279
    fn detect_models() -> Vec<ModelIdConfig> {
×
280
        let mut models = Vec::new();
×
281

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

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

304
                if name.is_empty() {
×
305
                    continue;
×
306
                }
×
307

308
                let name = name.split(':').next().unwrap_or(name);
×
309
                models.push(ModelIdConfig {
×
310
                    provider: ProviderId::Ollama,
×
311
                    name: Name(name.to_owned()),
×
312
                });
×
313
            }
314
        }
×
315

316
        models.sort();
×
317
        models.dedup();
×
318
        models
×
319
    }
×
320
}
321

322
/// Best-effort detection of a sensible default display name.
323
///
324
/// Cascades through:
325
///
326
/// 1. `git config --global --get user.name` — the user's stable global
327
///    identity. Preferred because the picked-up value is persisted to
328
///    user-global JP config and inherited by every future workspace; a
329
///    repo-local override would otherwise leak into unrelated workspaces.
330
/// 2. `git config --get user.name` (no scope) — falls back to whatever
331
///    git resolves in the current directory, for users who only have a
332
///    repo-local identity.
333
/// 3. `$USER` (Unix) or `$USERNAME` (Windows) — the system login name,
334
///    last-resort fallback.
335
///
336
/// Returns `None` when nothing is available so the prompt renders without
337
/// a pre-filled value.
338
fn detect_default_user_name() -> Option<String> {
×
339
    // 1. git config (global), 2. git config (any scope).
NEW
340
    for args in [
×
NEW
341
        ["config", "--global", "--get", "user.name"].as_slice(),
×
NEW
342
        ["config", "--get", "user.name"].as_slice(),
×
NEW
343
    ] {
×
NEW
344
        if let Ok(out) = cmd("git", args).stderr_null().read() {
×
NEW
345
            let trimmed = out.trim();
×
NEW
346
            if !trimmed.is_empty() {
×
NEW
347
                return Some(trimmed.to_owned());
×
NEW
348
            }
×
349
        }
×
350
    }
351

352
    // 3. system login name ($USER on Unix, $USERNAME on Windows).
353
    env::var("USER")
×
354
        .or_else(|_| env::var("USERNAME"))
×
355
        .ok()
×
356
        .map(|s| s.trim().to_owned())
×
357
        .filter(|s| !s.is_empty())
×
358
}
×
359

360
fn has_anthropic() -> bool {
×
361
    env::var("ANTHROPIC_API_KEY").is_ok()
×
362
}
×
363

364
fn has_openai() -> bool {
×
365
    env::var("OPENAI_API_KEY").is_ok()
×
366
}
×
367

368
fn has_google() -> bool {
×
369
    env::var("GOOGLE_API_KEY").is_ok()
×
370
}
×
371

372
fn default_model_id_for(provider: ProviderId) -> Option<ModelIdConfig> {
×
373
    let name = match provider {
×
374
        ProviderId::Anthropic => Name("claude-sonnet-4-6".into()),
×
375
        ProviderId::Google => Name("gemini-3.1-pro-preview".into()),
×
376
        ProviderId::Openai => Name("gpt-5.2".into()),
×
377
        _ => return None,
×
378
    };
379

380
    Some(ModelIdConfig { provider, name })
×
381
}
×
382

383
impl IntoPartialAppConfig for Init {
384
    fn apply_cli_config(
×
385
        &self,
×
386
        _workspace: Option<&Workspace>,
×
387
        partial: PartialAppConfig,
×
388
        _: Option<&PartialAppConfig>,
×
389
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
390
        Ok(partial)
×
391
    }
×
392
}
393

394
#[cfg(test)]
395
#[path = "init_tests.rs"]
396
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