dkdc-sh 0.1.0__tar.gz
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.
- dkdc_sh-0.1.0/Cargo.toml +9 -0
- dkdc_sh-0.1.0/LICENSE +7 -0
- dkdc_sh-0.1.0/PKG-INFO +72 -0
- dkdc_sh-0.1.0/README.md +61 -0
- dkdc_sh-0.1.0/crates/sh-core/Cargo.toml +16 -0
- dkdc_sh-0.1.0/crates/sh-core/README.md +61 -0
- dkdc_sh-0.1.0/crates/sh-core/src/git.rs +124 -0
- dkdc_sh-0.1.0/crates/sh-core/src/lib.rs +126 -0
- dkdc_sh-0.1.0/crates/sh-core/src/tmux.rs +136 -0
- dkdc_sh-0.1.0/crates/sh-py/Cargo.lock +221 -0
- dkdc_sh-0.1.0/crates/sh-py/Cargo.toml +16 -0
- dkdc_sh-0.1.0/crates/sh-py/src/lib.rs +146 -0
- dkdc_sh-0.1.0/py/dkdc_sh/__init__.py +35 -0
- dkdc_sh-0.1.0/py/dkdc_sh/core.pyi +67 -0
- dkdc_sh-0.1.0/py/dkdc_sh/py.typed +0 -0
- dkdc_sh-0.1.0/pyproject.toml +31 -0
dkdc_sh-0.1.0/Cargo.toml
ADDED
dkdc_sh-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Cody
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
dkdc_sh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dkdc-sh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
License-File: LICENSE
|
|
5
|
+
Summary: Shell utilities for tmux, git, and command management
|
|
6
|
+
Author-email: Cody <cody@dkdc.io>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
10
|
+
|
|
11
|
+
# sh
|
|
12
|
+
|
|
13
|
+
Shell utilities for tmux, git, and command management.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cargo add dkdc-sh
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv add dkdc-sh
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Rust
|
|
28
|
+
|
|
29
|
+
```rust
|
|
30
|
+
use dkdc_sh::{which, require, run, tmux, git};
|
|
31
|
+
|
|
32
|
+
// Command checking
|
|
33
|
+
if which("tmux").is_some() { /* ... */ }
|
|
34
|
+
require("git")?;
|
|
35
|
+
|
|
36
|
+
// Run arbitrary commands
|
|
37
|
+
let output = run("echo", &["hello"])?;
|
|
38
|
+
|
|
39
|
+
// Tmux session management
|
|
40
|
+
tmux::new_session("my-service", "python server.py")?;
|
|
41
|
+
tmux::send_keys("my-service", "reload")?;
|
|
42
|
+
let logs = tmux::capture_pane("my-service", Some(50))?;
|
|
43
|
+
tmux::kill_session("my-service")?;
|
|
44
|
+
|
|
45
|
+
// Git operations
|
|
46
|
+
git::clone_shallow("https://github.com/org/repo.git", &dest, "main")?;
|
|
47
|
+
git::checkout_new_branch(&repo_dir, "feature/branch")?;
|
|
48
|
+
git::config_set(&repo_dir, "user.email", "bot@example.com")?;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Python
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import dkdc_sh
|
|
55
|
+
|
|
56
|
+
# Command checking
|
|
57
|
+
path = dkdc_sh.which("tmux")
|
|
58
|
+
dkdc_sh.require("git")
|
|
59
|
+
|
|
60
|
+
# Run commands
|
|
61
|
+
output = dkdc_sh.run("echo", ["hello"])
|
|
62
|
+
|
|
63
|
+
# Tmux
|
|
64
|
+
dkdc_sh.tmux_new_session("my-service", "python server.py")
|
|
65
|
+
logs = dkdc_sh.tmux_capture_pane("my-service", lines=50)
|
|
66
|
+
dkdc_sh.tmux_kill_session("my-service")
|
|
67
|
+
|
|
68
|
+
# Git
|
|
69
|
+
dkdc_sh.git_clone_shallow("https://github.com/org/repo.git", "./dest", "main")
|
|
70
|
+
dkdc_sh.git_checkout_new_branch("./repo", "feature/branch")
|
|
71
|
+
```
|
|
72
|
+
|
dkdc_sh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# sh
|
|
2
|
+
|
|
3
|
+
Shell utilities for tmux, git, and command management.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cargo add dkdc-sh
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add dkdc-sh
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Rust
|
|
18
|
+
|
|
19
|
+
```rust
|
|
20
|
+
use dkdc_sh::{which, require, run, tmux, git};
|
|
21
|
+
|
|
22
|
+
// Command checking
|
|
23
|
+
if which("tmux").is_some() { /* ... */ }
|
|
24
|
+
require("git")?;
|
|
25
|
+
|
|
26
|
+
// Run arbitrary commands
|
|
27
|
+
let output = run("echo", &["hello"])?;
|
|
28
|
+
|
|
29
|
+
// Tmux session management
|
|
30
|
+
tmux::new_session("my-service", "python server.py")?;
|
|
31
|
+
tmux::send_keys("my-service", "reload")?;
|
|
32
|
+
let logs = tmux::capture_pane("my-service", Some(50))?;
|
|
33
|
+
tmux::kill_session("my-service")?;
|
|
34
|
+
|
|
35
|
+
// Git operations
|
|
36
|
+
git::clone_shallow("https://github.com/org/repo.git", &dest, "main")?;
|
|
37
|
+
git::checkout_new_branch(&repo_dir, "feature/branch")?;
|
|
38
|
+
git::config_set(&repo_dir, "user.email", "bot@example.com")?;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Python
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import dkdc_sh
|
|
45
|
+
|
|
46
|
+
# Command checking
|
|
47
|
+
path = dkdc_sh.which("tmux")
|
|
48
|
+
dkdc_sh.require("git")
|
|
49
|
+
|
|
50
|
+
# Run commands
|
|
51
|
+
output = dkdc_sh.run("echo", ["hello"])
|
|
52
|
+
|
|
53
|
+
# Tmux
|
|
54
|
+
dkdc_sh.tmux_new_session("my-service", "python server.py")
|
|
55
|
+
logs = dkdc_sh.tmux_capture_pane("my-service", lines=50)
|
|
56
|
+
dkdc_sh.tmux_kill_session("my-service")
|
|
57
|
+
|
|
58
|
+
# Git
|
|
59
|
+
dkdc_sh.git_clone_shallow("https://github.com/org/repo.git", "./dest", "main")
|
|
60
|
+
dkdc_sh.git_checkout_new_branch("./repo", "feature/branch")
|
|
61
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "dkdc-sh"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
authors = ["Cody <cody@dkdc.io>"]
|
|
6
|
+
description = "Shell utilities for tmux, git, and command management"
|
|
7
|
+
repository = "https://github.com/dkdc-io/sh"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
|
|
11
|
+
[lib]
|
|
12
|
+
name = "dkdc_sh"
|
|
13
|
+
path = "src/lib.rs"
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
which = "7"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# sh
|
|
2
|
+
|
|
3
|
+
Shell utilities for tmux, git, and command management.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cargo add dkdc-sh
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add dkdc-sh
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Rust
|
|
18
|
+
|
|
19
|
+
```rust
|
|
20
|
+
use dkdc_sh::{which, require, run, tmux, git};
|
|
21
|
+
|
|
22
|
+
// Command checking
|
|
23
|
+
if which("tmux").is_some() { /* ... */ }
|
|
24
|
+
require("git")?;
|
|
25
|
+
|
|
26
|
+
// Run arbitrary commands
|
|
27
|
+
let output = run("echo", &["hello"])?;
|
|
28
|
+
|
|
29
|
+
// Tmux session management
|
|
30
|
+
tmux::new_session("my-service", "python server.py")?;
|
|
31
|
+
tmux::send_keys("my-service", "reload")?;
|
|
32
|
+
let logs = tmux::capture_pane("my-service", Some(50))?;
|
|
33
|
+
tmux::kill_session("my-service")?;
|
|
34
|
+
|
|
35
|
+
// Git operations
|
|
36
|
+
git::clone_shallow("https://github.com/org/repo.git", &dest, "main")?;
|
|
37
|
+
git::checkout_new_branch(&repo_dir, "feature/branch")?;
|
|
38
|
+
git::config_set(&repo_dir, "user.email", "bot@example.com")?;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Python
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import dkdc_sh
|
|
45
|
+
|
|
46
|
+
# Command checking
|
|
47
|
+
path = dkdc_sh.which("tmux")
|
|
48
|
+
dkdc_sh.require("git")
|
|
49
|
+
|
|
50
|
+
# Run commands
|
|
51
|
+
output = dkdc_sh.run("echo", ["hello"])
|
|
52
|
+
|
|
53
|
+
# Tmux
|
|
54
|
+
dkdc_sh.tmux_new_session("my-service", "python server.py")
|
|
55
|
+
logs = dkdc_sh.tmux_capture_pane("my-service", lines=50)
|
|
56
|
+
dkdc_sh.tmux_kill_session("my-service")
|
|
57
|
+
|
|
58
|
+
# Git
|
|
59
|
+
dkdc_sh.git_clone_shallow("https://github.com/org/repo.git", "./dest", "main")
|
|
60
|
+
dkdc_sh.git_checkout_new_branch("./repo", "feature/branch")
|
|
61
|
+
```
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//! Git command abstractions (sync).
|
|
2
|
+
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
use std::process::Command;
|
|
5
|
+
|
|
6
|
+
use crate::{Error, require};
|
|
7
|
+
|
|
8
|
+
/// Run a git command in a directory and return stdout.
|
|
9
|
+
pub fn cmd(dir: &Path, args: &[&str]) -> Result<String, Error> {
|
|
10
|
+
cmd_with_env(dir, args, &[])
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Run a git command with extra environment variables.
|
|
14
|
+
///
|
|
15
|
+
/// When `GIT_ASKPASS` is present in `env`, credential helpers are disabled
|
|
16
|
+
/// to prevent interception by system keychains.
|
|
17
|
+
pub fn cmd_with_env(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String, Error> {
|
|
18
|
+
require("git")?;
|
|
19
|
+
|
|
20
|
+
let has_askpass = env.iter().any(|(k, _)| *k == "GIT_ASKPASS");
|
|
21
|
+
|
|
22
|
+
let mut command = Command::new("git");
|
|
23
|
+
if has_askpass {
|
|
24
|
+
command.args(["-c", "credential.helper="]);
|
|
25
|
+
}
|
|
26
|
+
command.args(args).current_dir(dir);
|
|
27
|
+
for (k, v) in env {
|
|
28
|
+
command.env(k, v);
|
|
29
|
+
}
|
|
30
|
+
let output = command.output()?;
|
|
31
|
+
|
|
32
|
+
if !output.status.success() {
|
|
33
|
+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
34
|
+
return Err(Error::CommandFailed {
|
|
35
|
+
cmd: format!("git {}", args.first().unwrap_or(&"")),
|
|
36
|
+
detail: stderr,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Shallow-clone a repo (single branch, depth 1).
|
|
44
|
+
pub fn clone_shallow(url: &str, dest: &Path, branch: &str) -> Result<(), Error> {
|
|
45
|
+
clone_shallow_with_env(url, dest, branch, &[])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Shallow-clone a repo with extra environment variables.
|
|
49
|
+
pub fn clone_shallow_with_env(
|
|
50
|
+
url: &str,
|
|
51
|
+
dest: &Path,
|
|
52
|
+
branch: &str,
|
|
53
|
+
env: &[(&str, &str)],
|
|
54
|
+
) -> Result<(), Error> {
|
|
55
|
+
require("git")?;
|
|
56
|
+
|
|
57
|
+
let has_askpass = env.iter().any(|(k, _)| *k == "GIT_ASKPASS");
|
|
58
|
+
|
|
59
|
+
let mut command = Command::new("git");
|
|
60
|
+
if has_askpass {
|
|
61
|
+
command.args(["-c", "credential.helper="]);
|
|
62
|
+
}
|
|
63
|
+
command.args([
|
|
64
|
+
"clone",
|
|
65
|
+
"--depth",
|
|
66
|
+
"1",
|
|
67
|
+
"--branch",
|
|
68
|
+
branch,
|
|
69
|
+
url,
|
|
70
|
+
&dest.to_string_lossy(),
|
|
71
|
+
]);
|
|
72
|
+
for (k, v) in env {
|
|
73
|
+
command.env(k, v);
|
|
74
|
+
}
|
|
75
|
+
let output = command.output()?;
|
|
76
|
+
|
|
77
|
+
if !output.status.success() {
|
|
78
|
+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
79
|
+
return Err(Error::CommandFailed {
|
|
80
|
+
cmd: "git clone".to_string(),
|
|
81
|
+
detail: stderr,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Clone from a local repo directory (fast, shares objects via hardlinks).
|
|
89
|
+
pub fn clone_local(source: &Path, dest: &Path, branch: &str) -> Result<(), Error> {
|
|
90
|
+
require("git")?;
|
|
91
|
+
|
|
92
|
+
let output = Command::new("git")
|
|
93
|
+
.args([
|
|
94
|
+
"clone",
|
|
95
|
+
"--branch",
|
|
96
|
+
branch,
|
|
97
|
+
"--single-branch",
|
|
98
|
+
&source.to_string_lossy(),
|
|
99
|
+
&dest.to_string_lossy(),
|
|
100
|
+
])
|
|
101
|
+
.output()?;
|
|
102
|
+
|
|
103
|
+
if !output.status.success() {
|
|
104
|
+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
105
|
+
return Err(Error::CommandFailed {
|
|
106
|
+
cmd: "git clone (local)".to_string(),
|
|
107
|
+
detail: stderr,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Ok(())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Create and switch to a new branch.
|
|
115
|
+
pub fn checkout_new_branch(dir: &Path, branch: &str) -> Result<(), Error> {
|
|
116
|
+
cmd(dir, &["checkout", "-b", branch])?;
|
|
117
|
+
Ok(())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Set a git config key in a repo.
|
|
121
|
+
pub fn config_set(dir: &Path, key: &str, value: &str) -> Result<(), Error> {
|
|
122
|
+
cmd(dir, &["config", key, value])?;
|
|
123
|
+
Ok(())
|
|
124
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//! Shell utilities for tmux, git, and command management.
|
|
2
|
+
//!
|
|
3
|
+
//! Minimal, synchronous shell abstractions. No async runtime required.
|
|
4
|
+
|
|
5
|
+
use std::fmt;
|
|
6
|
+
use std::path::PathBuf;
|
|
7
|
+
use std::process::Command;
|
|
8
|
+
|
|
9
|
+
pub mod git;
|
|
10
|
+
pub mod tmux;
|
|
11
|
+
|
|
12
|
+
/// Shell operation errors.
|
|
13
|
+
#[derive(Debug)]
|
|
14
|
+
pub enum Error {
|
|
15
|
+
CommandNotFound(String),
|
|
16
|
+
CommandFailed { cmd: String, detail: String },
|
|
17
|
+
Tmux(String),
|
|
18
|
+
Io(std::io::Error),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl fmt::Display for Error {
|
|
22
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
23
|
+
match self {
|
|
24
|
+
Error::CommandNotFound(cmd) => write!(f, "command not found: {cmd}"),
|
|
25
|
+
Error::CommandFailed { cmd, detail } => write!(f, "command failed: {cmd} — {detail}"),
|
|
26
|
+
Error::Tmux(msg) => write!(f, "tmux error: {msg}"),
|
|
27
|
+
Error::Io(err) => write!(f, "io error: {err}"),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl std::error::Error for Error {
|
|
33
|
+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
34
|
+
match self {
|
|
35
|
+
Error::Io(err) => Some(err),
|
|
36
|
+
_ => None,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl From<std::io::Error> for Error {
|
|
42
|
+
fn from(err: std::io::Error) -> Self {
|
|
43
|
+
Error::Io(err)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Check if a command exists in PATH.
|
|
48
|
+
pub fn which(cmd: &str) -> Option<PathBuf> {
|
|
49
|
+
::which::which(cmd).ok()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Require a command to exist, returning an error if not found.
|
|
53
|
+
pub fn require(cmd: &str) -> Result<PathBuf, Error> {
|
|
54
|
+
::which::which(cmd).map_err(|_| Error::CommandNotFound(cmd.to_string()))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Run a command and return its stdout.
|
|
58
|
+
pub fn run(program: &str, args: &[&str]) -> Result<String, Error> {
|
|
59
|
+
run_with_env(program, args, &[])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Run a command with extra environment variables and return its stdout.
|
|
63
|
+
pub fn run_with_env(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<String, Error> {
|
|
64
|
+
require(program)?;
|
|
65
|
+
|
|
66
|
+
let mut command = Command::new(program);
|
|
67
|
+
command.args(args);
|
|
68
|
+
for (k, v) in env {
|
|
69
|
+
command.env(k, v);
|
|
70
|
+
}
|
|
71
|
+
let output = command.output()?;
|
|
72
|
+
|
|
73
|
+
if !output.status.success() {
|
|
74
|
+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
75
|
+
return Err(Error::CommandFailed {
|
|
76
|
+
cmd: format!("{program} {}", args.first().unwrap_or(&"")),
|
|
77
|
+
detail: stderr,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[cfg(test)]
|
|
85
|
+
mod tests {
|
|
86
|
+
use super::*;
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn test_which_exists() {
|
|
90
|
+
assert!(which("ls").is_some());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn test_which_not_exists() {
|
|
95
|
+
assert!(which("nonexistent_command_12345").is_none());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[test]
|
|
99
|
+
fn test_require_exists() {
|
|
100
|
+
assert!(require("ls").is_ok());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[test]
|
|
104
|
+
fn test_require_not_exists() {
|
|
105
|
+
let result = require("nonexistent_command_12345");
|
|
106
|
+
assert!(result.is_err());
|
|
107
|
+
assert!(matches!(result, Err(Error::CommandNotFound(_))));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#[test]
|
|
111
|
+
fn test_run() {
|
|
112
|
+
let output = run("echo", &["hello"]).unwrap();
|
|
113
|
+
assert_eq!(output.trim(), "hello");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn test_run_not_found() {
|
|
118
|
+
let result = run("nonexistent_command_12345", &[]);
|
|
119
|
+
assert!(matches!(result, Err(Error::CommandNotFound(_))));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[test]
|
|
123
|
+
fn test_has_session_nonexistent() {
|
|
124
|
+
assert!(!tmux::has_session("dkdc_test_nonexistent_12345"));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
//! Tmux session management.
|
|
2
|
+
|
|
3
|
+
use std::process::{Command, Stdio};
|
|
4
|
+
|
|
5
|
+
use crate::{Error, require};
|
|
6
|
+
|
|
7
|
+
/// Check if a tmux session exists.
|
|
8
|
+
pub fn has_session(name: &str) -> bool {
|
|
9
|
+
Command::new("tmux")
|
|
10
|
+
.args(["has-session", "-t", name])
|
|
11
|
+
.stdout(Stdio::null())
|
|
12
|
+
.stderr(Stdio::null())
|
|
13
|
+
.status()
|
|
14
|
+
.map(|s| s.success())
|
|
15
|
+
.unwrap_or(false)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Create a new detached tmux session and run a command in it.
|
|
19
|
+
///
|
|
20
|
+
/// The session is created with `remain-on-exit on` so it persists
|
|
21
|
+
/// even after the command exits.
|
|
22
|
+
pub fn new_session(name: &str, cmd: &str) -> Result<(), Error> {
|
|
23
|
+
require("tmux")?;
|
|
24
|
+
|
|
25
|
+
if has_session(name) {
|
|
26
|
+
return Err(Error::Tmux(format!("session '{name}' already exists")));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let status = Command::new("tmux")
|
|
30
|
+
.args(["new-session", "-d", "-s", name])
|
|
31
|
+
.status()?;
|
|
32
|
+
|
|
33
|
+
if !status.success() {
|
|
34
|
+
return Err(Error::Tmux(format!("failed to create session '{name}'")));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let status = Command::new("tmux")
|
|
38
|
+
.args(["set-option", "-t", name, "remain-on-exit", "on"])
|
|
39
|
+
.status()?;
|
|
40
|
+
|
|
41
|
+
if !status.success() {
|
|
42
|
+
return Err(Error::Tmux(
|
|
43
|
+
"failed to set remain-on-exit option".to_string(),
|
|
44
|
+
));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
send_keys(name, cmd)?;
|
|
48
|
+
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Kill a tmux session. Idempotent — returns Ok if already gone.
|
|
53
|
+
pub fn kill_session(name: &str) -> Result<(), Error> {
|
|
54
|
+
require("tmux")?;
|
|
55
|
+
|
|
56
|
+
if !has_session(name) {
|
|
57
|
+
return Ok(());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let status = Command::new("tmux")
|
|
61
|
+
.args(["kill-session", "-t", name])
|
|
62
|
+
.status()?;
|
|
63
|
+
|
|
64
|
+
if !status.success() {
|
|
65
|
+
return Err(Error::Tmux(format!("failed to kill session '{name}'")));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Ok(())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Attach to a tmux session (takes over the terminal).
|
|
72
|
+
pub fn attach(name: &str) -> Result<(), Error> {
|
|
73
|
+
require("tmux")?;
|
|
74
|
+
|
|
75
|
+
if !has_session(name) {
|
|
76
|
+
return Err(Error::Tmux(format!("session '{name}' does not exist")));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let status = Command::new("tmux").args(["attach", "-t", name]).status()?;
|
|
80
|
+
|
|
81
|
+
if !status.success() {
|
|
82
|
+
return Err(Error::Tmux(format!("failed to attach to session '{name}'")));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Send keys followed by Enter to a tmux session.
|
|
89
|
+
pub fn send_keys(name: &str, keys: &str) -> Result<(), Error> {
|
|
90
|
+
require("tmux")?;
|
|
91
|
+
|
|
92
|
+
if !has_session(name) {
|
|
93
|
+
return Err(Error::Tmux(format!("session '{name}' does not exist")));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let status = Command::new("tmux")
|
|
97
|
+
.args(["send-keys", "-t", name, keys, "Enter"])
|
|
98
|
+
.status()?;
|
|
99
|
+
|
|
100
|
+
if !status.success() {
|
|
101
|
+
return Err(Error::Tmux(format!(
|
|
102
|
+
"failed to send keys to session '{name}'"
|
|
103
|
+
)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Ok(())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Capture output from a tmux pane.
|
|
110
|
+
///
|
|
111
|
+
/// Returns the last `lines` lines of output, or all visible lines if None.
|
|
112
|
+
pub fn capture_pane(name: &str, lines: Option<usize>) -> Result<String, Error> {
|
|
113
|
+
require("tmux")?;
|
|
114
|
+
|
|
115
|
+
if !has_session(name) {
|
|
116
|
+
return Err(Error::Tmux(format!("session '{name}' does not exist")));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let mut args = vec!["capture-pane", "-t", name, "-p"];
|
|
120
|
+
|
|
121
|
+
let start_arg;
|
|
122
|
+
if let Some(n) = lines {
|
|
123
|
+
start_arg = format!("-{n}");
|
|
124
|
+
args.extend(["-S", &start_arg]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let output = Command::new("tmux").args(&args).output()?;
|
|
128
|
+
|
|
129
|
+
if !output.status.success() {
|
|
130
|
+
return Err(Error::Tmux(format!(
|
|
131
|
+
"failed to capture pane from session '{name}'"
|
|
132
|
+
)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
136
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "bitflags"
|
|
7
|
+
version = "2.11.0"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "dkdc-sh"
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"which",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[package]]
|
|
19
|
+
name = "either"
|
|
20
|
+
version = "1.15.0"
|
|
21
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
22
|
+
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|
23
|
+
|
|
24
|
+
[[package]]
|
|
25
|
+
name = "env_home"
|
|
26
|
+
version = "0.1.0"
|
|
27
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
28
|
+
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
|
29
|
+
|
|
30
|
+
[[package]]
|
|
31
|
+
name = "errno"
|
|
32
|
+
version = "0.3.14"
|
|
33
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
34
|
+
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
35
|
+
dependencies = [
|
|
36
|
+
"libc",
|
|
37
|
+
"windows-sys",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[[package]]
|
|
41
|
+
name = "heck"
|
|
42
|
+
version = "0.5.0"
|
|
43
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
44
|
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
45
|
+
|
|
46
|
+
[[package]]
|
|
47
|
+
name = "libc"
|
|
48
|
+
version = "0.2.184"
|
|
49
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
50
|
+
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
|
51
|
+
|
|
52
|
+
[[package]]
|
|
53
|
+
name = "linux-raw-sys"
|
|
54
|
+
version = "0.12.1"
|
|
55
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
56
|
+
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|
57
|
+
|
|
58
|
+
[[package]]
|
|
59
|
+
name = "once_cell"
|
|
60
|
+
version = "1.21.4"
|
|
61
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
62
|
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
63
|
+
|
|
64
|
+
[[package]]
|
|
65
|
+
name = "portable-atomic"
|
|
66
|
+
version = "1.13.1"
|
|
67
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
68
|
+
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
69
|
+
|
|
70
|
+
[[package]]
|
|
71
|
+
name = "proc-macro2"
|
|
72
|
+
version = "1.0.106"
|
|
73
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
74
|
+
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
|
75
|
+
dependencies = [
|
|
76
|
+
"unicode-ident",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[[package]]
|
|
80
|
+
name = "pyo3"
|
|
81
|
+
version = "0.28.3"
|
|
82
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
83
|
+
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
|
|
84
|
+
dependencies = [
|
|
85
|
+
"libc",
|
|
86
|
+
"once_cell",
|
|
87
|
+
"portable-atomic",
|
|
88
|
+
"pyo3-build-config",
|
|
89
|
+
"pyo3-ffi",
|
|
90
|
+
"pyo3-macros",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
[[package]]
|
|
94
|
+
name = "pyo3-build-config"
|
|
95
|
+
version = "0.28.3"
|
|
96
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
97
|
+
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
|
|
98
|
+
dependencies = [
|
|
99
|
+
"target-lexicon",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[[package]]
|
|
103
|
+
name = "pyo3-ffi"
|
|
104
|
+
version = "0.28.3"
|
|
105
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
106
|
+
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
|
|
107
|
+
dependencies = [
|
|
108
|
+
"libc",
|
|
109
|
+
"pyo3-build-config",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
[[package]]
|
|
113
|
+
name = "pyo3-macros"
|
|
114
|
+
version = "0.28.3"
|
|
115
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
116
|
+
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
|
|
117
|
+
dependencies = [
|
|
118
|
+
"proc-macro2",
|
|
119
|
+
"pyo3-macros-backend",
|
|
120
|
+
"quote",
|
|
121
|
+
"syn",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
[[package]]
|
|
125
|
+
name = "pyo3-macros-backend"
|
|
126
|
+
version = "0.28.3"
|
|
127
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
128
|
+
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
|
|
129
|
+
dependencies = [
|
|
130
|
+
"heck",
|
|
131
|
+
"proc-macro2",
|
|
132
|
+
"pyo3-build-config",
|
|
133
|
+
"quote",
|
|
134
|
+
"syn",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
[[package]]
|
|
138
|
+
name = "quote"
|
|
139
|
+
version = "1.0.45"
|
|
140
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
141
|
+
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
|
142
|
+
dependencies = [
|
|
143
|
+
"proc-macro2",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
[[package]]
|
|
147
|
+
name = "rustix"
|
|
148
|
+
version = "1.1.4"
|
|
149
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
150
|
+
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
|
151
|
+
dependencies = [
|
|
152
|
+
"bitflags",
|
|
153
|
+
"errno",
|
|
154
|
+
"libc",
|
|
155
|
+
"linux-raw-sys",
|
|
156
|
+
"windows-sys",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
[[package]]
|
|
160
|
+
name = "sh-py"
|
|
161
|
+
version = "0.1.0"
|
|
162
|
+
dependencies = [
|
|
163
|
+
"dkdc-sh",
|
|
164
|
+
"pyo3",
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
[[package]]
|
|
168
|
+
name = "syn"
|
|
169
|
+
version = "2.0.117"
|
|
170
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
171
|
+
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
|
172
|
+
dependencies = [
|
|
173
|
+
"proc-macro2",
|
|
174
|
+
"quote",
|
|
175
|
+
"unicode-ident",
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
[[package]]
|
|
179
|
+
name = "target-lexicon"
|
|
180
|
+
version = "0.13.5"
|
|
181
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
182
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
183
|
+
|
|
184
|
+
[[package]]
|
|
185
|
+
name = "unicode-ident"
|
|
186
|
+
version = "1.0.24"
|
|
187
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
188
|
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
189
|
+
|
|
190
|
+
[[package]]
|
|
191
|
+
name = "which"
|
|
192
|
+
version = "7.0.3"
|
|
193
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
194
|
+
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
|
|
195
|
+
dependencies = [
|
|
196
|
+
"either",
|
|
197
|
+
"env_home",
|
|
198
|
+
"rustix",
|
|
199
|
+
"winsafe",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
[[package]]
|
|
203
|
+
name = "windows-link"
|
|
204
|
+
version = "0.2.1"
|
|
205
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
206
|
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
|
207
|
+
|
|
208
|
+
[[package]]
|
|
209
|
+
name = "windows-sys"
|
|
210
|
+
version = "0.61.2"
|
|
211
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
212
|
+
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
|
213
|
+
dependencies = [
|
|
214
|
+
"windows-link",
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
[[package]]
|
|
218
|
+
name = "winsafe"
|
|
219
|
+
version = "0.0.19"
|
|
220
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
221
|
+
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
|
|
3
|
+
[package]
|
|
4
|
+
name = "sh-py"
|
|
5
|
+
version = "0.1.0"
|
|
6
|
+
edition = "2024"
|
|
7
|
+
authors = ["Cody <cody@dkdc.io>"]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
name = "_core"
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
dkdc-sh = { path = "../sh-core" }
|
|
16
|
+
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py311"] }
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
use std::path::PathBuf;
|
|
2
|
+
|
|
3
|
+
use pyo3::exceptions::PyRuntimeError;
|
|
4
|
+
use pyo3::prelude::*;
|
|
5
|
+
|
|
6
|
+
fn to_py_err(e: dkdc_sh::Error) -> PyErr {
|
|
7
|
+
PyErr::new::<PyRuntimeError, _>(e.to_string())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// -- Root functions -----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
#[pyfunction]
|
|
13
|
+
fn which(cmd: &str) -> Option<String> {
|
|
14
|
+
dkdc_sh::which(cmd).map(|p| p.to_string_lossy().into_owned())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[pyfunction]
|
|
18
|
+
fn require(cmd: &str) -> PyResult<String> {
|
|
19
|
+
dkdc_sh::require(cmd)
|
|
20
|
+
.map(|p| p.to_string_lossy().into_owned())
|
|
21
|
+
.map_err(to_py_err)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[pyfunction]
|
|
25
|
+
#[pyo3(signature = (program, args, env=None))]
|
|
26
|
+
fn run(program: &str, args: Vec<String>, env: Option<Vec<(String, String)>>) -> PyResult<String> {
|
|
27
|
+
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
28
|
+
match env {
|
|
29
|
+
Some(env_pairs) => {
|
|
30
|
+
let env_ref: Vec<(&str, &str)> = env_pairs
|
|
31
|
+
.iter()
|
|
32
|
+
.map(|(k, v)| (k.as_str(), v.as_str()))
|
|
33
|
+
.collect();
|
|
34
|
+
dkdc_sh::run_with_env(program, &args_ref, &env_ref).map_err(to_py_err)
|
|
35
|
+
}
|
|
36
|
+
None => dkdc_sh::run(program, &args_ref).map_err(to_py_err),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// -- Tmux functions -----------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
#[pyfunction]
|
|
43
|
+
fn tmux_has_session(name: &str) -> bool {
|
|
44
|
+
dkdc_sh::tmux::has_session(name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[pyfunction]
|
|
48
|
+
fn tmux_new_session(name: &str, cmd: &str) -> PyResult<()> {
|
|
49
|
+
dkdc_sh::tmux::new_session(name, cmd).map_err(to_py_err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[pyfunction]
|
|
53
|
+
fn tmux_kill_session(name: &str) -> PyResult<()> {
|
|
54
|
+
dkdc_sh::tmux::kill_session(name).map_err(to_py_err)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[pyfunction]
|
|
58
|
+
fn tmux_attach(name: &str) -> PyResult<()> {
|
|
59
|
+
dkdc_sh::tmux::attach(name).map_err(to_py_err)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[pyfunction]
|
|
63
|
+
fn tmux_send_keys(name: &str, keys: &str) -> PyResult<()> {
|
|
64
|
+
dkdc_sh::tmux::send_keys(name, keys).map_err(to_py_err)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[pyfunction]
|
|
68
|
+
#[pyo3(signature = (name, lines=None))]
|
|
69
|
+
fn tmux_capture_pane(name: &str, lines: Option<usize>) -> PyResult<String> {
|
|
70
|
+
dkdc_sh::tmux::capture_pane(name, lines).map_err(to_py_err)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// -- Git functions ------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
#[pyfunction]
|
|
76
|
+
fn git_cmd(dir: &str, args: Vec<String>) -> PyResult<String> {
|
|
77
|
+
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
78
|
+
dkdc_sh::git::cmd(&PathBuf::from(dir), &args_ref).map_err(to_py_err)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[pyfunction]
|
|
82
|
+
#[pyo3(signature = (dir, args, env=None))]
|
|
83
|
+
fn git_cmd_with_env(
|
|
84
|
+
dir: &str,
|
|
85
|
+
args: Vec<String>,
|
|
86
|
+
env: Option<Vec<(String, String)>>,
|
|
87
|
+
) -> PyResult<String> {
|
|
88
|
+
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
89
|
+
let env_pairs = env.unwrap_or_default();
|
|
90
|
+
let env_ref: Vec<(&str, &str)> = env_pairs
|
|
91
|
+
.iter()
|
|
92
|
+
.map(|(k, v)| (k.as_str(), v.as_str()))
|
|
93
|
+
.collect();
|
|
94
|
+
dkdc_sh::git::cmd_with_env(&PathBuf::from(dir), &args_ref, &env_ref).map_err(to_py_err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[pyfunction]
|
|
98
|
+
fn git_clone_shallow(url: &str, dest: &str, branch: &str) -> PyResult<()> {
|
|
99
|
+
dkdc_sh::git::clone_shallow(url, &PathBuf::from(dest), branch).map_err(to_py_err)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[pyfunction]
|
|
103
|
+
fn git_clone_local(source: &str, dest: &str, branch: &str) -> PyResult<()> {
|
|
104
|
+
dkdc_sh::git::clone_local(&PathBuf::from(source), &PathBuf::from(dest), branch)
|
|
105
|
+
.map_err(to_py_err)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[pyfunction]
|
|
109
|
+
fn git_checkout_new_branch(dir: &str, branch: &str) -> PyResult<()> {
|
|
110
|
+
dkdc_sh::git::checkout_new_branch(&PathBuf::from(dir), branch).map_err(to_py_err)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[pyfunction]
|
|
114
|
+
fn git_config_set(dir: &str, key: &str, value: &str) -> PyResult<()> {
|
|
115
|
+
dkdc_sh::git::config_set(&PathBuf::from(dir), key, value).map_err(to_py_err)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -- Module -------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
#[pymodule]
|
|
121
|
+
mod core {
|
|
122
|
+
use super::*;
|
|
123
|
+
|
|
124
|
+
#[pymodule_init]
|
|
125
|
+
fn module_init(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
126
|
+
// Root
|
|
127
|
+
m.add_function(wrap_pyfunction!(which, m)?)?;
|
|
128
|
+
m.add_function(wrap_pyfunction!(require, m)?)?;
|
|
129
|
+
m.add_function(wrap_pyfunction!(run, m)?)?;
|
|
130
|
+
// Tmux
|
|
131
|
+
m.add_function(wrap_pyfunction!(tmux_has_session, m)?)?;
|
|
132
|
+
m.add_function(wrap_pyfunction!(tmux_new_session, m)?)?;
|
|
133
|
+
m.add_function(wrap_pyfunction!(tmux_kill_session, m)?)?;
|
|
134
|
+
m.add_function(wrap_pyfunction!(tmux_attach, m)?)?;
|
|
135
|
+
m.add_function(wrap_pyfunction!(tmux_send_keys, m)?)?;
|
|
136
|
+
m.add_function(wrap_pyfunction!(tmux_capture_pane, m)?)?;
|
|
137
|
+
// Git
|
|
138
|
+
m.add_function(wrap_pyfunction!(git_cmd, m)?)?;
|
|
139
|
+
m.add_function(wrap_pyfunction!(git_cmd_with_env, m)?)?;
|
|
140
|
+
m.add_function(wrap_pyfunction!(git_clone_shallow, m)?)?;
|
|
141
|
+
m.add_function(wrap_pyfunction!(git_clone_local, m)?)?;
|
|
142
|
+
m.add_function(wrap_pyfunction!(git_checkout_new_branch, m)?)?;
|
|
143
|
+
m.add_function(wrap_pyfunction!(git_config_set, m)?)?;
|
|
144
|
+
Ok(())
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from dkdc_sh.core import (
|
|
2
|
+
git_checkout_new_branch,
|
|
3
|
+
git_clone_local,
|
|
4
|
+
git_clone_shallow,
|
|
5
|
+
git_cmd,
|
|
6
|
+
git_cmd_with_env,
|
|
7
|
+
git_config_set,
|
|
8
|
+
require,
|
|
9
|
+
run,
|
|
10
|
+
tmux_attach,
|
|
11
|
+
tmux_capture_pane,
|
|
12
|
+
tmux_has_session,
|
|
13
|
+
tmux_kill_session,
|
|
14
|
+
tmux_new_session,
|
|
15
|
+
tmux_send_keys,
|
|
16
|
+
which,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"which",
|
|
21
|
+
"require",
|
|
22
|
+
"run",
|
|
23
|
+
"tmux_has_session",
|
|
24
|
+
"tmux_new_session",
|
|
25
|
+
"tmux_kill_session",
|
|
26
|
+
"tmux_attach",
|
|
27
|
+
"tmux_send_keys",
|
|
28
|
+
"tmux_capture_pane",
|
|
29
|
+
"git_cmd",
|
|
30
|
+
"git_cmd_with_env",
|
|
31
|
+
"git_clone_shallow",
|
|
32
|
+
"git_clone_local",
|
|
33
|
+
"git_checkout_new_branch",
|
|
34
|
+
"git_config_set",
|
|
35
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
def which(cmd: str) -> str | None:
|
|
2
|
+
"""Check if a command exists in PATH. Returns the path or None."""
|
|
3
|
+
...
|
|
4
|
+
|
|
5
|
+
def require(cmd: str) -> str:
|
|
6
|
+
"""Require a command to exist. Returns the path or raises RuntimeError."""
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
def run(
|
|
10
|
+
program: str,
|
|
11
|
+
args: list[str],
|
|
12
|
+
env: list[tuple[str, str]] | None = None,
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Run a command and return its stdout."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def tmux_has_session(name: str) -> bool:
|
|
18
|
+
"""Check if a tmux session exists."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def tmux_new_session(name: str, cmd: str) -> None:
|
|
22
|
+
"""Create a new detached tmux session and run a command in it."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def tmux_kill_session(name: str) -> None:
|
|
26
|
+
"""Kill a tmux session. Idempotent."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def tmux_attach(name: str) -> None:
|
|
30
|
+
"""Attach to a tmux session (takes over the terminal)."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def tmux_send_keys(name: str, keys: str) -> None:
|
|
34
|
+
"""Send keys followed by Enter to a tmux session."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def tmux_capture_pane(name: str, lines: int | None = None) -> str:
|
|
38
|
+
"""Capture output from a tmux pane."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def git_cmd(dir: str, args: list[str]) -> str:
|
|
42
|
+
"""Run a git command in a directory and return stdout."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def git_cmd_with_env(
|
|
46
|
+
dir: str,
|
|
47
|
+
args: list[str],
|
|
48
|
+
env: list[tuple[str, str]] | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Run a git command with extra environment variables."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def git_clone_shallow(url: str, dest: str, branch: str) -> None:
|
|
54
|
+
"""Shallow-clone a repo (single branch, depth 1)."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def git_clone_local(source: str, dest: str, branch: str) -> None:
|
|
58
|
+
"""Clone from a local repo directory (fast, shares objects via hardlinks)."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def git_checkout_new_branch(dir: str, branch: str) -> None:
|
|
62
|
+
"""Create and switch to a new branch."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def git_config_set(dir: str, key: str, value: str) -> None:
|
|
66
|
+
"""Set a git config key in a repo."""
|
|
67
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dkdc-sh"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shell utilities for tmux, git, and command management"
|
|
5
|
+
authors = [{ name = "Cody", email = "cody@dkdc.io" }]
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = ["maturin>=1.0,<2.0", "ruff>=0.9", "ty>=0.0.19", "pytest>=8"]
|
|
14
|
+
|
|
15
|
+
[tool.maturin]
|
|
16
|
+
module-name = "dkdc_sh.core"
|
|
17
|
+
python-packages = ["dkdc_sh"]
|
|
18
|
+
python-source = "py"
|
|
19
|
+
manifest-path = "crates/sh-py/Cargo.toml"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["maturin>=1.0,<2.0"]
|
|
23
|
+
build-backend = "maturin"
|
|
24
|
+
|
|
25
|
+
[tool.uv]
|
|
26
|
+
cache-keys = [
|
|
27
|
+
{ file = "pyproject.toml" },
|
|
28
|
+
{ file = "crates/sh-core/**/*.rs" },
|
|
29
|
+
{ file = "crates/sh-py/**/*.rs" },
|
|
30
|
+
{ file = "py/dkdc_sh/**/*.py" },
|
|
31
|
+
]
|