aristo-cli 0.6.1

Aristo CLI binary (the `aristo` command).
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
//! `aristo init` — bootstrap a project for Aristo.
//!
//! Creates the project state (`aristo.toml`, `.aristo/specs/`, `.aristo/doc/`),
//! seeds a local `.aristo/index.toml` cache, and gitignores the runtime/per-user
//! `.aristo/` artifacts (cache, sessions, queues, nudge state, critiques,
//! archive). The pre-commit hook is deprecated and opt-in (`--hook`), never
//! auto-installed. CI workflows are opt-in: `--ci` writes the lite PR gate,
//! `--ci-verify` also writes the nightly verify workflow.
//!
//! The index is a regenerable local cache (index-as-local-cache / Option B):
//! read commands regenerate it from source + `.aristo/proofs/` on demand, so
//! they do NOT require `aristo stamp` to have run or the file to exist.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use aristo_core::config::ConfigFile;
use aristo_core::index::{IndexFile, Meta};

use crate::{CliError, CliResult};

/// The pre-commit hook (DEPRECATED; opt-in only via `aristo init --hook`).
///
/// Runs three steps in order:
///
/// 1. `aristo stamp` — refresh `.aristo/index.toml` against
///    currently-staged source.
/// 2. `aristo doc` — regenerate `.aristo/doc/<id>.md` files so they
///    stay in sync with the index (CI's `doc --check` gate fails
///    without this step on every annotation-text edit).
/// 3. `aristo lint --check` — fail-fast on annotation quality
///    issues. Mirrors J6's default `pre_commit = "check"` mode.
///
/// This hook is an OPTIONAL convenience, not the enforcement boundary: the
/// index is a gitignored local cache, so freshness is enforced in CI by
/// `aristo verify --audit`, not here. If `aristo` is not on `PATH` (e.g. a
/// fork checkout where the contributor never ran `cargo install aristo-cli`),
/// the hook skips silently (`command -v` guard) rather than aborting every
/// commit with `command not found`.
///
/// When the binary IS present, the `-e` shell option propagates any non-zero
/// exit from `doc` or `lint --check` as the hook's exit code.
const PRE_COMMIT_HOOK: &str = "\
#!/usr/bin/env bash
# Aristo pre-commit hook (DEPRECATED; opt-in, installed only by `aristo init --hook`).
# The index is a gitignored local cache, so this hook is NOT an enforcement
# boundary — CI (`aristo verify --audit`) is. It refreshes the local cache and
# the committed .aristo/doc/<id>.md artifacts, then lints annotation prose.
# Skips silently when the aristo binary is absent (e.g. a fork checkout).
command -v aristo >/dev/null 2>&1 || exit 0
set -e

echo \"-> aristo stamp (refresh local cache)\" >&2
aristo stamp >&2

echo \"-> aristo doc\" >&2
aristo doc >&2

echo \"-> aristo lint --check\" >&2
aristo lint --check >&2
";

const GH_WORKFLOW_STARTER: &str = "\
# Aristo lite PR gate (starter; edit freely). Generated by `aristo init --ci`.
#
# Runs the cheap, local annotation gate — audit/lint/doc — via the shared
# aristo-action. No token needed; runs entirely on the runner. `audit` maps to
# `aristo verify --audit --strict`: it regenerates the index from source +
# .aristo/proofs/ (the index is a gitignored cache, never committed) and reds
# on any stale (code drifted from its proof), refuted, or orphan proof. A fresh
# repo has no such debt, so --strict is safe here.
#
# Heavy canon verification (`aristo verify`) is a separate workflow: run
# `aristo init --ci-verify` to also generate aristo-verify.yml.
name: aristo
on: [push, pull_request]
jobs:
  aristo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aretta-ai/aristo-action@v1
        with:
          checks: audit, lint, doc
";

const GH_VERIFY_WORKFLOW: &str = "\
# Aristo heavy verification (starter; edit freely).
# Generated by `aristo init --ci-verify`.
#
# Runs `aristo verify` (server-side canon conformance) for the bound
# annotations in source (the index is regenerated from source on demand).
# Manual + nightly.
#
# Setup:
#   1. Add a repository secret ARETTA_TOKEN (your arta_* token; paid tier).
#   2. Hosted org (your server is not code.aretta.ai)? Point the data plane at
#      your org's server so the token is spent there — a bare token otherwise
#      resolves to the code.aretta.ai default. Simplest: commit
#      `[instance] url = \"https://<org>.aretta.ai\"` in aristo.toml (committed,
#      and it fixes local `aristo verify` too). Alternatively add a repository
#      Variable ARETTA_API_URL = your org's URL and wire it on the verify job
#      (`env: { ARETTA_API_URL: ${{ vars.ARETTA_API_URL }} }`) — an unset
#      Variable expands to empty, which aristo treats as unset (falls back to
#      [instance] url or the default), so it's safe to leave off for prod.
# NOT on pull_request — verify needs the checked-out commit pushed to origin.
name: aristo verify
on:
  workflow_dispatch:
  schedule:
    - cron: '0 7 * * *' # nightly at 07:00 UTC
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aretta-ai/aristo-action@v1
        with:
          checks: verify
          aretta-token: ${{ secrets.ARETTA_TOKEN }}
";

#[aristo::intent(
    "A second invocation never errors and never overwrites existing \
     files. Each pre-existing artifact is noted; only missing ones get \
     created.",
    verify = "test",
    id = "init_is_idempotent"
)]
pub(crate) fn run(_force: bool, ci: bool, ci_verify: bool, hook: bool) -> CliResult<()> {
    let cwd = std::env::current_dir()?;
    let mut any_change = false;

    // 1. aristo.toml at the workspace root.
    create_or_note_file(
        &cwd.join("aristo.toml"),
        "aristo.toml",
        serialize_default_config,
        &mut any_change,
    )?;

    // 2. .aristo/ directory must exist before its children.
    let aristo_dir = cwd.join(".aristo");
    fs::create_dir_all(&aristo_dir)?;

    // 3. .aristo/index.toml — seed the local cache (gitignored below). Readers
    //    regenerate it from source + proofs, so this is just a fast-path seed.
    create_or_note_file_custom_msg(
        &aristo_dir.join("index.toml"),
        "ok: created .aristo/index.toml (local cache; gitignored)",
        "note: .aristo/index.toml already exists — leaving as-is.",
        serialize_initial_index,
        &mut any_change,
    )?;

    // 3b. Gitignore the runtime/per-user `.aristo/` artifacts (the regenerable
    //     index cache, review sessions, in-flight queues, nudge state,
    //     critiques, and the orphan-proof archive). Durable state (proofs, doc,
    //     specs, feedback, expectations, canon-matches) stays tracked.
    ensure_gitignored(&cwd, &mut any_change)?;

    // 4. .aristo/specs/
    create_or_note_dir(&aristo_dir.join("specs"), ".aristo/specs/", &mut any_change)?;

    // 5. .aristo/doc/
    create_or_note_dir(&aristo_dir.join("doc"), ".aristo/doc/", &mut any_change)?;

    // 6. .git/hooks/pre-commit — DEPRECATED and OPT-IN only (`--hook`). Never
    //    auto-installed: the index is a gitignored cache, so CI
    //    (`aristo verify --audit`) is the enforcement point, not a local hook.
    if hook {
        eprintln!(
            "note: the pre-commit hook is deprecated. CI (`aristo verify --audit`) is the \
             enforcement point; the hook only runs `aristo doc` + lint locally."
        );
        let hook_dir = cwd.join(".git").join("hooks");
        if hook_dir.is_dir() {
            let hook_path = hook_dir.join("pre-commit");
            if hook_path.exists() {
                println!("note: pre-commit hook already installed.");
            } else {
                fs::write(&hook_path, PRE_COMMIT_HOOK)?;
                make_executable(&hook_path)?;
                println!("ok: installed pre-commit hook (.git/hooks/pre-commit)");
                any_change = true;
            }
        } else {
            println!("note: --hook given, but no .git/hooks/ directory here; skipping.");
        }
    }

    // 7. CI workflows — opt-in. `--ci` writes the lite PR gate; `--ci-verify`
    //    additionally writes the nightly/manual verify workflow (and implies
    //    the lite gate). Default `init` writes nothing under .github/.
    let workflows_dir = cwd.join(".github").join("workflows");
    if ci || ci_verify {
        create_or_note_file_custom_msg(
            &workflows_dir.join("aristo.yml"),
            "ok: wrote .github/workflows/aristo.yml (lite PR gate; edit freely)",
            "note: .github/workflows/aristo.yml already exists.",
            || Ok(GH_WORKFLOW_STARTER.to_string()),
            &mut any_change,
        )?;
    }
    if ci_verify {
        create_or_note_file_custom_msg(
            &workflows_dir.join("aristo-verify.yml"),
            "ok: wrote .github/workflows/aristo-verify.yml (heavy verify; needs ARETTA_TOKEN secret; edit freely)",
            "note: .github/workflows/aristo-verify.yml already exists.",
            || Ok(GH_VERIFY_WORKFLOW.to_string()),
            &mut any_change,
        )?;
        print_verify_token_help(&cwd);
    }

    if !any_change {
        println!("ok: nothing to do.");
    }

    Ok(())
}

/// Runtime, per-user, and regenerable `.aristo/` paths that must NOT be
/// committed. Durable state (`.aristo/proofs/*.proof`, `.aristo/doc/`,
/// `.aristo/specs/`, `.aristo/feedback/`, `.aristo/expectations.toml`,
/// `.aristo/canon-matches.toml`, `aristo.toml`) is deliberately absent here so
/// it stays tracked and propagates to CI and fresh clones.
const ARISTO_GITIGNORE_ENTRIES: &[&str] = &[
    ".aristo/index.toml",
    ".aristo/catalogue.json",
    ".aristo/sessions/",
    ".aristo/nudge-state.toml",
    ".aristo/verify-queue/",
    ".aristo/critique-queue/",
    ".aristo/critiques/",
    ".aristo/proofs/*.proof.bak",
    ".aristo/archive/",
];

/// Ensure every runtime/cache `.aristo/` path is ignored in the repo's
/// `.gitignore`, creating the file if absent and appending to an existing one.
/// Incremental and idempotent: only entries not already present are appended,
/// so it is safe to re-run and safe over a hand-edited `.gitignore`. Inline `#`
/// comments are intentionally not placed on entry lines (git does not treat a
/// trailing `#` as a comment).
fn ensure_gitignored(cwd: &Path, any_change: &mut bool) -> CliResult<()> {
    let path = cwd.join(".gitignore");
    let existing = fs::read_to_string(&path).unwrap_or_default();
    let present: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
    let missing: Vec<&str> = ARISTO_GITIGNORE_ENTRIES
        .iter()
        .copied()
        .filter(|entry| !present.contains(entry))
        .collect();
    if missing.is_empty() {
        return Ok(());
    }
    let mut out = existing;
    if !out.is_empty() && !out.ends_with('\n') {
        out.push('\n');
    }
    out.push_str(
        "\n# Aristo runtime, cache, and per-user state: never commit these.\n\
         # Durable state (.aristo/proofs/*.proof, .aristo/doc/, .aristo/specs/,\n\
         # .aristo/feedback/, .aristo/expectations.toml, .aristo/canon-matches.toml,\n\
         # aristo.toml) is committed and intentionally not listed here.\n",
    );
    for entry in &missing {
        out.push_str(entry);
        out.push('\n');
    }
    fs::write(&path, out).map_err(crate::CliError::Io)?;
    for entry in &missing {
        println!("ok: added `{entry}` to .gitignore");
    }
    *any_change = true;
    Ok(())
}

fn create_or_note_file<F>(
    path: &Path,
    label: &str,
    content_fn: F,
    any_change: &mut bool,
) -> CliResult<()>
where
    F: FnOnce() -> CliResult<String>,
{
    create_or_note_file_custom_msg(
        path,
        &format!("ok: created {label}"),
        &format!("note: {label} already exists — leaving as-is."),
        content_fn,
        any_change,
    )
}

fn create_or_note_file_custom_msg<F>(
    path: &Path,
    created_msg: &str,
    exists_msg: &str,
    content_fn: F,
    any_change: &mut bool,
) -> CliResult<()>
where
    F: FnOnce() -> CliResult<String>,
{
    if path.exists() {
        println!("{exists_msg}");
    } else {
        let content = content_fn()?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, content)?;
        println!("{created_msg}");
        *any_change = true;
    }
    Ok(())
}

fn create_or_note_dir(path: &Path, label: &str, any_change: &mut bool) -> CliResult<()> {
    if path.exists() {
        println!("note: {label} already exists.");
    } else {
        fs::create_dir(path)?;
        println!("ok: created {label}");
        *any_change = true;
    }
    Ok(())
}

/// After `--ci-verify` writes the verify workflow, point the user at the exact
/// GitHub secrets page + how to grab their token. The workflow is inert until
/// `ARETTA_TOKEN` is set, so this guidance is the difference between "wrote a
/// file" and "actually set up".
fn print_verify_token_help(cwd: &Path) {
    println!();
    println!("Next — the verify workflow needs an ARETTA_TOKEN secret on this repo:");
    match aristo_core::auth::derive_repo_full_name(cwd) {
        Ok(repo) => println!(
            "  1. Add the secret: https://github.com/{repo}/settings/secrets/actions/new  (name: ARETTA_TOKEN)"
        ),
        Err(_) => println!(
            "  1. Add the secret under Settings -> Secrets and variables -> Actions -> New repository secret  (name: ARETTA_TOKEN)"
        ),
    }
    println!(
        "  2. Token value:    `aristo auth token` prints yours — pipe to your clipboard, e.g. `aristo auth token | pbcopy`"
    );
    println!("                     ...or `aristo auth login` to mint a new one.");
    println!(
        "  3. Hosted org?     If your server isn't code.aretta.ai, point verify at it: commit\n\
        \x20                    `[instance] url = \"https://<org>.aretta.ai\"` in aristo.toml (simplest), or set a\n\
        \x20                    repository Variable ARETTA_API_URL to your org's URL. A bare token otherwise\n\
        \x20                    defaults to code.aretta.ai, so verify would hit the wrong server."
    );
}

fn serialize_default_config() -> CliResult<String> {
    let cfg = ConfigFile::default();
    toml::to_string_pretty(&cfg).map_err(|e| {
        CliError::Io(std::io::Error::other(format!(
            "serializing aristo.toml: {e}"
        )))
    })
}

fn serialize_initial_index() -> CliResult<String> {
    let index = IndexFile {
        meta: Meta {
            schema_version: 1,
            generated_by: Some(format!("aristo init {}", env!("CARGO_PKG_VERSION"))),
            generated_at: Some(now_rfc3339()),
            source_root: None,
        },
        entries: BTreeMap::new(),
    };
    toml::to_string_pretty(&index).map_err(|e| {
        CliError::Io(std::io::Error::other(format!(
            "serializing .aristo/index.toml: {e}"
        )))
    })
}

fn now_rfc3339() -> String {
    use time::format_description::well_known::Rfc3339;
    use time::OffsetDateTime;
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}

#[cfg(unix)]
fn make_executable(path: &Path) -> CliResult<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = fs::metadata(path)?.permissions();
    perms.set_mode(0o755);
    fs::set_permissions(path, perms)?;
    Ok(())
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) -> CliResult<()> {
    // Windows + non-unix: hook execution semantics differ; skipping the
    // permission bit is harmless. Cross-platform hook portability is post-MVP.
    Ok(())
}