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.
@@ -0,0 +1,9 @@
1
+ [workspace]
2
+ resolver = "2"
3
+ members = ["crates/sh-core"]
4
+ exclude = ["crates/sh-py"]
5
+
6
+ [profile.release]
7
+ lto = true
8
+ strip = true
9
+ codegen-units = 1
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
+
@@ -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
+ ]