practicode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml ADDED
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "practicode"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ description = "Local-first coding-test practice in a Rust terminal UI with optional AI help."
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ repository = "https://github.com/baba9811/practicode"
9
+ homepage = "https://github.com/baba9811/practicode"
10
+ keywords = ["algorithms", "ai", "coding-practice", "competitive", "tui"]
11
+ categories = ["command-line-utilities", "development-tools", "text-editors"]
12
+
13
+ [dependencies]
14
+ anyhow = "1"
15
+ crossterm = "0.29"
16
+ ratatui = "0.29"
17
+ serde = { version = "1", features = ["derive"] }
18
+ serde_json = "1"
19
+ unicode-width = "0.2"
20
+ wait-timeout = "0.2"
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 baba9811
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # practicode
2
+
3
+ ![Rust](https://img.shields.io/badge/Rust-terminal%20app-000000?logo=rust&logoColor=white)
4
+ ![Ratatui](https://img.shields.io/badge/Ratatui-TUI-00B4D8)
5
+ ![Local first](https://img.shields.io/badge/local--first-practice-14B8A6)
6
+ ![AI ready](https://img.shields.io/badge/AI-Codex%20%2B%20Claude-111827)
7
+ ![CI](https://github.com/baba9811/practicode/actions/workflows/ci.yml/badge.svg)
8
+
9
+ ![practicode terminal UI](assets/practicode-terminal.svg)
10
+
11
+ Personal coding practice, right in your terminal.
12
+
13
+ `practicode` is a small Rust TUI for stdin/stdout practice: problem on the left, code on the right, judge loop in the same terminal.
14
+ No browser tab shuffle, no paste dance, just solve and run.
15
+
16
+ ## Why It Exists
17
+
18
+ - Fast local judging for Python, TypeScript, Java, and Rust
19
+ - Gradual problem flow with local history
20
+ - AI-powered `/next <request>` when you want a custom problem
21
+ - Personal problem-generation notes
22
+ - Small stack: Rust, Ratatui, Crossterm, and plain process execution
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ git clone https://github.com/baba9811/practicode.git
28
+ cd practicode
29
+ cargo run --
30
+ ```
31
+
32
+ Want a local binary?
33
+
34
+ ```bash
35
+ cargo install practicode
36
+ practicode
37
+ ```
38
+
39
+ Prefer npm from GitHub?
40
+
41
+ ```bash
42
+ npm install -g github:baba9811/practicode
43
+ practicode
44
+ ```
45
+
46
+ Working from a local checkout?
47
+
48
+ ```bash
49
+ cargo install --path .
50
+ practicode
51
+ ```
52
+
53
+ Or with npm:
54
+
55
+ ```bash
56
+ npm install -g .
57
+ practicode
58
+ ```
59
+
60
+ The npm wrapper builds the Rust binary with Cargo, so Rust/Cargo is still required.
61
+
62
+ ## Daily Loop
63
+
64
+ The code editor starts focused.
65
+
66
+ ```text
67
+ write code
68
+ Esc, then /run
69
+ Esc, then /next easy string problem
70
+ ```
71
+
72
+ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>`.
73
+
74
+ ## Commands
75
+
76
+ Press `Esc`, then `/`, to focus the command bar.
77
+
78
+ | Command | Action |
79
+ | --- | --- |
80
+ | `/run` | Judge the current submission |
81
+ | `/next` | Open the next local problem, or ask AI to create one |
82
+ | `/next easy string problem` | Ask AI for a custom next problem |
83
+ | `/prev` | Go back through problem history |
84
+ | `/list` | Browse problems with `up/down` or `j/k`, open with `Enter` |
85
+ | `/open 2` | Open by number, id, or slug |
86
+ | `/giveup` | Show the reference answer |
87
+ | `/ai hint` | Ask the selected AI about the current problem and submission |
88
+ | `/provider codex` | Set AI provider: `codex` or `claude` |
89
+ | `/model sonnet` | Set the model for `/ai` and AI-backed `/next`; use `auto` for the CLI default |
90
+ | `/note prefer hashmap practice` | Append a standing note for future problem generation |
91
+ | `/notes` | Show your local next-problem notes |
92
+ | `/lang python` | Set language: `python`, `ts`, `java`, `rust` |
93
+ | `/ui ko` | Set UI language: `ko`, `en` |
94
+ | `/theme` | Toggle dark/light theme |
95
+ | `/source ai` | Prefer AI for next-problem generation |
96
+ | `/exit` | Quit |
97
+
98
+ The editor owns normal typing keys.
99
+ Press `Esc`, then `/`, when you want the command bar.
100
+
101
+ ## Custom Problem Generation
102
+
103
+ `/next <request>` passes your request into the selected AI problem generator. Examples:
104
+
105
+ ```text
106
+ /next a slightly harder string problem
107
+ /next hashmap practice, easy
108
+ /next sorting problem, no graph yet
109
+ ```
110
+
111
+ AI generation reads [docs/problem-authoring-notes.md](docs/problem-authoring-notes.md) every time it creates a problem. Add personal preferences from inside the TUI:
112
+
113
+ ```text
114
+ /note Prefer concise statements.
115
+ /note I want more string and hashmap practice.
116
+ /note Avoid DP until I ask for it.
117
+ ```
118
+
119
+ Those notes are stored in `.practicode/problem_notes.md`, so they stay local.
120
+
121
+ ## AI Providers
122
+
123
+ Codex is the default:
124
+
125
+ ```text
126
+ /provider codex
127
+ /model auto
128
+ ```
129
+
130
+ Claude Code is also supported:
131
+
132
+ ```text
133
+ /provider claude
134
+ /model sonnet
135
+ /source ai
136
+ ```
137
+
138
+ `/ai <prompt>` uses the current provider for coaching. AI-backed `/next` uses the same provider and model.
139
+ If you want a custom daemon or wrapper script, set `/ai-next-command <shell command>`; practicode passes `PRACTICODE_NEXT_REQUEST`, `PRACTICODE_AI_PROVIDER`, and `PRACTICODE_AI_MODEL`.
140
+
141
+ Generated problem banks stay local:
142
+
143
+ | Path | Purpose |
144
+ | --- | --- |
145
+ | `.practicode/problem_bank.json` | Local/custom/generated problem bank |
146
+ | `.practicode/problem_notes.md` | Optional personal problem-generation notes |
147
+ | `.practicode/problem-state.json` | Current problem, history, settings |
148
+ | `problems/` | Generated problem markdown/index files |
149
+ | `submissions/` | Your answer files |
150
+
151
+ Those paths are ignored by git, so your practice history stays yours.
152
+
153
+ ## Safety
154
+
155
+ `/run` executes your local submission as a normal process. practicode runs it from `.practicode/build/<problem-id>/run`, but this is not an OS sandbox. Only run code you trust.
156
+
157
+ ## Debug Prints
158
+
159
+ `/run` shows raw stdout when a case fails. If you want debug output without changing the judged answer, print to stderr:
160
+
161
+ ```python
162
+ import sys
163
+
164
+ print("debug", value, file=sys.stderr)
165
+ ```
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ cargo test
171
+ cargo run -- --smoke
172
+ cargo run --
173
+ ```
174
+
175
+ The source is split by boring responsibility:
176
+
177
+ | Path | Role |
178
+ | --- | --- |
179
+ | `src/core.rs` | Problem bank, state, rendering, judging |
180
+ | `src/tui.rs` | Ratatui app, editor, command parser |
181
+ | `src/ai.rs` | Codex/Claude command integration and notes |
182
+ | `src/text.rs` | UTF-8 cursor math and Hangul composition |
183
+ | `src/process.rs` | Process execution helpers |
184
+ | `tests/` | Integration tests split by module |
185
+
186
+ ## Discovery Notes
187
+
188
+ Recommended GitHub topics for this repo:
189
+ `coding-practice`, `competitive-programming`, `algorithms`, `ratatui`, `tui`, `rust`, `codex`, `claude-code`, `local-first`.
190
+
191
+ ## References
192
+
193
+ - Ratatui terminal UI library: https://ratatui.rs/
194
+ - Crossterm terminal backend/events: https://github.com/crossterm-rs/crossterm
195
+ - Codex CLI open-source repo: https://github.com/openai/codex
196
+ - Claude Code CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-reference
197
+ - Kattis problem package format: https://www.kattis.com/problem-package-format/
198
+ - ICPC judging guidelines: https://icpc.global/regionals/regional-contest-cookbook-judging-guidelines
199
+ - GitHub README image guidance: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-readmes
200
+ - GitHub repository topics: https://docs.github.com/articles/classifying-your-repository-with-topics
@@ -0,0 +1,24 @@
1
+ <svg width="1200" height="720" viewBox="0 0 1200 720" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="1200" height="720" fill="#070b12"/>
3
+ <rect x="64" y="52" width="1072" height="616" rx="10" fill="#0b111a" stroke="#1f6f7a" stroke-width="2"/>
4
+ <rect x="88" y="78" width="594" height="440" rx="4" fill="#0f1720" stroke="#00c2d1" stroke-width="2"/>
5
+ <rect x="704" y="78" width="408" height="440" rx="4" fill="#0d141c" stroke="#31536b" stroke-width="2"/>
6
+ <text x="112" y="112" fill="#d7e1ec" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18" font-weight="700">Problem</text>
7
+ <text x="728" y="112" fill="#d7e1ec" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18" font-weight="700">solution.py</text>
8
+ <text x="112" y="158" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="22" font-weight="700">001. Hello World</text>
9
+ <text x="112" y="204" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">Difficulty: easy</text>
10
+ <text x="112" y="232" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">Topics: io</text>
11
+ <text x="112" y="286" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">Print exactly Hello, World! to stdout.</text>
12
+ <text x="112" y="352" fill="#00a8ff" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17" text-decoration="underline">Input</text>
13
+ <text x="112" y="386" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">No input.</text>
14
+ <text x="112" y="442" fill="#00a8ff" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17" text-decoration="underline">Output</text>
15
+ <text x="112" y="476" fill="#f6c177" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">Hello, World!</text>
16
+ <text x="728" y="158" fill="#6b7280" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17"> 1</text>
17
+ <text x="776" y="158" fill="#7dd3fc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">print</text>
18
+ <text x="828" y="158" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">('Hello, World!')</text>
19
+ <rect x="64" y="536" width="1072" height="28" fill="#152033"/>
20
+ <text x="84" y="556" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">PRACTICODE | 001-hello-world | easy | idle | solved | code:written | python | next:ai | ai:codex/auto</text>
21
+ <rect x="64" y="586" width="1072" height="58" rx="2" fill="#0b1017" stroke="#008cff" stroke-width="2"/>
22
+ <text x="84" y="606" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">Command</text>
23
+ <text x="84" y="630" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18">/ai hint about edge cases</text>
24
+ </svg>
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawnSync } = require("node:child_process");
4
+ const { existsSync } = require("node:fs");
5
+ const path = require("node:path");
6
+
7
+ const root = path.resolve(__dirname, "..");
8
+ const exe = path.join(
9
+ root,
10
+ "target",
11
+ "release",
12
+ process.platform === "win32" ? "practicode.exe" : "practicode",
13
+ );
14
+
15
+ if (!existsSync(exe)) {
16
+ const build = spawnSync(
17
+ "cargo",
18
+ ["build", "--release", "--locked", "--manifest-path", path.join(root, "Cargo.toml")],
19
+ { stdio: "inherit" },
20
+ );
21
+ if (build.error) {
22
+ console.error(`practicode: failed to run cargo: ${build.error.message}`);
23
+ process.exit(1);
24
+ }
25
+ if (build.status !== 0) {
26
+ process.exit(build.status ?? 1);
27
+ }
28
+ }
29
+
30
+ const run = spawnSync(exe, process.argv.slice(2), {
31
+ cwd: process.cwd(),
32
+ stdio: "inherit",
33
+ });
34
+
35
+ if (run.error) {
36
+ console.error(`practicode: failed to run binary: ${run.error.message}`);
37
+ process.exit(1);
38
+ }
39
+ process.exit(run.status ?? 1);
@@ -0,0 +1,33 @@
1
+ # Problem Authoring Notes
2
+
3
+ Use this when AI creates a new `/next` problem.
4
+
5
+ ## Shape
6
+
7
+ - Keep one clear task per problem.
8
+ - Prefer stdin/stdout fundamentals first: arithmetic, strings, arrays, maps, sorting, two pointers, then graphs/DP later.
9
+ - State input, output, examples, and constraints explicitly.
10
+ - Make examples small enough to verify by hand.
11
+ - Include enough hidden cases to catch empty/min/max, duplicates, ties, ordering, and whitespace mistakes.
12
+ - Keep answers for `python`, `ts`, `java`, and `rust` in `.practicode/problem_bank.json`; never put answers in `README.md`.
13
+
14
+ ## Difficulty
15
+
16
+ - `easy`: one idea, direct parsing, O(n) or O(n log n), no tricky proof.
17
+ - `medium`: combine two ideas or need a careful invariant.
18
+ - Move up only after recent submitted solutions pass and look clean.
19
+
20
+ ## Local Preferences
21
+
22
+ If `.practicode/problem_notes.md` exists, read it too. That file is for personal themes like:
23
+
24
+ ```text
25
+ Prefer Korean statements.
26
+ I want more string and hashmap practice.
27
+ Avoid DP until I ask for it.
28
+ ```
29
+
30
+ ## References
31
+
32
+ - Kattis problem package format: https://www.kattis.com/problem-package-format/
33
+ - ICPC judging guidelines: https://icpc.global/regionals/regional-contest-cookbook-judging-guidelines
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "practicode",
3
+ "version": "0.1.0",
4
+ "description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/baba9811/practicode.git"
9
+ },
10
+ "homepage": "https://github.com/baba9811/practicode#readme",
11
+ "bin": {
12
+ "practicode": "bin/practicode.js"
13
+ },
14
+ "scripts": {
15
+ "build": "cargo build --release --locked",
16
+ "postinstall": "node scripts/npm-postinstall.js",
17
+ "smoke": "node bin/practicode.js --smoke"
18
+ },
19
+ "files": [
20
+ "assets/",
21
+ "bin/",
22
+ "docs/",
23
+ "scripts/",
24
+ "src/",
25
+ "Cargo.lock",
26
+ "Cargo.toml",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "keywords": [
31
+ "algorithms",
32
+ "coding-practice",
33
+ "competitive-programming",
34
+ "ratatui",
35
+ "rust",
36
+ "tui"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,20 @@
1
+ const { spawnSync } = require("node:child_process");
2
+ const path = require("node:path");
3
+
4
+ if (process.env.PRACTICODE_SKIP_BUILD === "1") {
5
+ process.exit(0);
6
+ }
7
+
8
+ const root = path.resolve(__dirname, "..");
9
+ const build = spawnSync("cargo", ["build", "--release", "--locked"], {
10
+ cwd: root,
11
+ stdio: "inherit",
12
+ });
13
+
14
+ if (build.error) {
15
+ console.warn(`practicode: cargo build skipped: ${build.error.message}`);
16
+ console.warn("practicode: install Rust/Cargo before first run, or set PRACTICODE_SKIP_BUILD=1.");
17
+ process.exit(0);
18
+ }
19
+
20
+ process.exit(build.status ?? 1);
package/src/ai.rs ADDED
@@ -0,0 +1,213 @@
1
+ use crate::{
2
+ core::{
3
+ AppState, PROBLEM_NOTES_PATH, Problem, Settings, ensure_submission, normalize_ai_provider,
4
+ render_problem,
5
+ },
6
+ process::{run_capture, sh_quote, shell_process, unique_temp_path},
7
+ };
8
+ use anyhow::Result;
9
+ use std::{
10
+ fs::{self, OpenOptions},
11
+ io::Write,
12
+ path::Path,
13
+ process::Command,
14
+ time::Duration,
15
+ };
16
+
17
+ pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String {
18
+ let solution = match ensure_submission(root, problem, settings) {
19
+ Ok(path) => path,
20
+ Err(error) => return format!("AI prompt failed\n{error}"),
21
+ };
22
+ let code = fs::read_to_string(&solution).unwrap_or_default();
23
+ let relative = solution
24
+ .strip_prefix(root)
25
+ .unwrap_or(&solution)
26
+ .display()
27
+ .to_string();
28
+ let full_prompt = format!(
29
+ "You are a concise coding-test coach. Help with the current problem and current submission. Prefer hints over full answers unless the user explicitly asks for the answer.\n\nUser request:\n{prompt}\n\nProblem:\n{}\n\nCurrent {} submission ({relative}):\n```{}\n{code}\n```",
30
+ render_problem(problem, &settings.ui_language),
31
+ settings.language,
32
+ settings.language
33
+ );
34
+ match normalize_ai_provider(&settings.ai_provider).as_str() {
35
+ "claude" => run_claude_prompt(root, settings, &full_prompt),
36
+ _ => run_codex_prompt(root, settings, &full_prompt),
37
+ }
38
+ }
39
+
40
+ pub fn run_ai_next(root: &Path, state: &AppState, force: bool, request: &str) -> String {
41
+ if state.settings.next_source != "ai" && !force {
42
+ return "AI next is disabled; using local problem bank.".to_string();
43
+ }
44
+ let provider = normalize_ai_provider(&state.settings.ai_provider);
45
+ let command = if state.settings.next_ai_command().trim().is_empty() {
46
+ default_ai_next_command(root, &state.settings, request)
47
+ } else {
48
+ state.settings.next_ai_command().to_string()
49
+ };
50
+ let mut process = shell_process(&command);
51
+ process
52
+ .current_dir(root)
53
+ .env("PRACTICODE_NEXT_REQUEST", request)
54
+ .env("PRACTICODE_AI_PROVIDER", &provider)
55
+ .env("PRACTICODE_AI_MODEL", &state.settings.ai_model);
56
+ match run_capture(&mut process, "", Duration::from_secs(900)) {
57
+ Ok(run) if run.code == Some(0) => {
58
+ let output = output_text(&run.stdout, &run.stderr);
59
+ format!("{provider} command finished\n{output}")
60
+ .trim()
61
+ .to_string()
62
+ }
63
+ Ok(run) => {
64
+ let output = output_text(&run.stdout, &run.stderr);
65
+ format!(
66
+ "{provider} command failed ({})\n{output}",
67
+ run.code.unwrap_or(-1)
68
+ )
69
+ }
70
+ Err(error) => format!("{provider} command failed\n{error}"),
71
+ }
72
+ }
73
+
74
+ pub fn default_ai_next_command(root: &Path, settings: &Settings, request: &str) -> String {
75
+ match normalize_ai_provider(&settings.ai_provider).as_str() {
76
+ "claude" => default_claude_next_command(root, settings, request),
77
+ _ => default_codex_next_command(root, settings, request),
78
+ }
79
+ }
80
+
81
+ pub fn default_ai_next_prompt(request: &str) -> String {
82
+ format!(
83
+ "Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. The app has a built-in starter problem 001-hello-world, so do not duplicate it. Create exactly one new non-duplicate coding practice problem. User request for this problem: {}. Update .practicode/problem_bank.json, the local problem files, the index, and state files. Do not include the answer in the problem statement.",
84
+ if request.is_empty() {
85
+ "(none)"
86
+ } else {
87
+ request
88
+ }
89
+ )
90
+ }
91
+
92
+ pub fn append_problem_note(root: &Path, note: &str) -> Result<()> {
93
+ let path = root.join(PROBLEM_NOTES_PATH);
94
+ if let Some(parent) = path.parent() {
95
+ fs::create_dir_all(parent)?;
96
+ }
97
+ let mut file = OpenOptions::new().create(true).append(true).open(path)?;
98
+ writeln!(file, "{}", note.trim())?;
99
+ Ok(())
100
+ }
101
+
102
+ pub fn read_problem_notes(root: &Path) -> Result<String> {
103
+ let path = root.join(PROBLEM_NOTES_PATH);
104
+ if path.exists() {
105
+ Ok(fs::read_to_string(path)?.trim_end().to_string())
106
+ } else {
107
+ Ok(String::new())
108
+ }
109
+ }
110
+
111
+ fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
112
+ let output_path = unique_temp_path("practicode-last-message", "txt");
113
+ let mut command = Command::new("codex");
114
+ command
115
+ .args([
116
+ "exec",
117
+ "--cd",
118
+ &root.display().to_string(),
119
+ "--sandbox",
120
+ "read-only",
121
+ ])
122
+ .current_dir(root);
123
+ if let Some(model) = settings.model_arg() {
124
+ command.args(["--model", model]);
125
+ }
126
+ command.args(["-o", &output_path.display().to_string(), prompt]);
127
+ let result = run_capture(&mut command, "", Duration::from_secs(600));
128
+ let last_message = fs::read_to_string(&output_path).unwrap_or_default();
129
+ let _ = fs::remove_file(&output_path);
130
+ match result {
131
+ Ok(run) if run.code == Some(0) => {
132
+ let output = output_text(&run.stdout, &run.stderr);
133
+ let last = last_message.trim();
134
+ if !last.is_empty() {
135
+ last.to_string()
136
+ } else if !output.is_empty() {
137
+ output
138
+ } else {
139
+ "Codex returned no output.".to_string()
140
+ }
141
+ }
142
+ Ok(run) => {
143
+ let output = output_text(&run.stdout, &run.stderr);
144
+ format!("Codex prompt failed ({})\n{output}", run.code.unwrap_or(-1))
145
+ }
146
+ Err(error) => format!("Codex prompt failed\n{error}"),
147
+ }
148
+ }
149
+
150
+ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
151
+ let mut command = Command::new("claude");
152
+ command
153
+ .args(["--permission-mode", "plan"])
154
+ .current_dir(root);
155
+ if let Some(model) = settings.model_arg() {
156
+ command.args(["--model", model]);
157
+ }
158
+ command.args(["-p", prompt]);
159
+ match run_capture(&mut command, "", Duration::from_secs(600)) {
160
+ Ok(run) if run.code == Some(0) => {
161
+ let output = output_text(&run.stdout, &run.stderr);
162
+ if output.is_empty() {
163
+ "Claude returned no output.".to_string()
164
+ } else {
165
+ output
166
+ }
167
+ }
168
+ Ok(run) => {
169
+ let output = output_text(&run.stdout, &run.stderr);
170
+ format!(
171
+ "Claude prompt failed ({})\n{output}",
172
+ run.code.unwrap_or(-1)
173
+ )
174
+ }
175
+ Err(error) => format!("Claude prompt failed\n{error}"),
176
+ }
177
+ }
178
+
179
+ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String {
180
+ let start = "codex app-server daemon start >/dev/null 2>&1 || true";
181
+ let mut exec = format!(
182
+ "codex exec --cd {} --sandbox workspace-write",
183
+ sh_quote(&root.display().to_string())
184
+ );
185
+ if let Some(model) = settings.model_arg() {
186
+ exec.push_str(&format!(" --model {}", sh_quote(model)));
187
+ }
188
+ exec.push(' ');
189
+ exec.push_str(&sh_quote(&default_ai_next_prompt(request)));
190
+ format!("{start}; {exec}")
191
+ }
192
+
193
+ fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String {
194
+ let mut claude = "claude --permission-mode acceptEdits".to_string();
195
+ if let Some(model) = settings.model_arg() {
196
+ claude.push_str(&format!(" --model {}", sh_quote(model)));
197
+ }
198
+ claude.push_str(" -p ");
199
+ claude.push_str(&sh_quote(&default_ai_next_prompt(request)));
200
+ format!(
201
+ "claude daemon status >/dev/null 2>&1 || true; cd {}; {}",
202
+ sh_quote(&root.display().to_string()),
203
+ claude
204
+ )
205
+ }
206
+
207
+ fn output_text(stdout: &str, stderr: &str) -> String {
208
+ [stdout.trim(), stderr.trim()]
209
+ .into_iter()
210
+ .filter(|part| !part.is_empty())
211
+ .collect::<Vec<_>>()
212
+ .join("\n")
213
+ }