Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .agents/skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ This skill implements the complete "Shipping" definition and Pre-PR Checklist fr
### Phase 1: Pre-flight

1. Confirm we're NOT on `main` or `master`
2. Confirm there are no uncommitted changes (`git diff --quiet && git diff --cached --quiet`)
3. If uncommitted changes exist, stop and tell the user
2. If `HEAD` is detached and the current task has local changes ready to ship, create a branch first instead of stopping
3. Confirm whether uncommitted changes belong to the task being shipped
4. If the worktree is dirty only because of the current task, keep going: validate, commit, and ship those changes
5. If unrelated uncommitted changes exist, stop and tell the user

### Phase 2: Test Coverage

Expand Down
4 changes: 4 additions & 0 deletions crates/bashkit-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ await bash.execute(

const snapshot = bash.snapshot();
const shellOnly = bash.snapshot({ excludeFilesystem: true });
const promptOnly = bash.snapshot({
excludeFilesystem: true,
excludeFunctions: true,
});

const restored = Bash.fromSnapshot(snapshot);
console.log((await restored.execute("echo $BUILD_ID")).stdout); // 42\n
Expand Down
5 changes: 5 additions & 0 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ pub struct BashOptions {
#[napi(object)]
pub struct SnapshotOptions {
pub exclude_filesystem: Option<bool>,
pub exclude_functions: Option<bool>,
}

fn default_opts() -> BashOptions {
Expand Down Expand Up @@ -753,8 +754,12 @@ fn default_opts() -> BashOptions {
fn to_snapshot_options(options: Option<SnapshotOptions>) -> RustSnapshotOptions {
RustSnapshotOptions {
exclude_filesystem: options
.as_ref()
.and_then(|options| options.exclude_filesystem)
.unwrap_or(false),
exclude_functions: options
.and_then(|options| options.exclude_functions)
.unwrap_or(false),
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export interface BashOptions {

export interface SnapshotOptions {
excludeFilesystem?: boolean;
excludeFunctions?: boolean;
}

export interface OutputChunk {
Expand Down Expand Up @@ -310,6 +311,7 @@ function toNativeSnapshotOptions(
if (!options) return undefined;
return {
excludeFilesystem: options.excludeFilesystem,
excludeFunctions: options.excludeFunctions,
};
}

Expand Down
4 changes: 3 additions & 1 deletion crates/bashkit-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ print(state.cwd) # still /workspace
It is a Python-friendly inspection view rather than a full Rust-shell mirror,
and fields like `env`, `variables`, and `arrays` are exposed as immutable
mappings. Use `snapshot(exclude_filesystem=True)` when you need shell-only
restore bytes.
restore bytes, or `snapshot(exclude_filesystem=True, exclude_functions=True)`
when prompt rendering does not need function restore.
Transient fields like `last_exit_code` and `traps` are captured on the snapshot,
but the next top-level `execute()` / `execute_sync()` clears them before running
the new command.
Expand Down Expand Up @@ -351,6 +352,7 @@ bash.execute_sync(

snapshot = bash.snapshot()
shell_only = bash.snapshot(exclude_filesystem=True)
prompt_only = bash.snapshot(exclude_filesystem=True, exclude_functions=True)

restored = Bash.from_snapshot(snapshot, username="agent", max_commands=100)
assert restored.execute_sync("echo $BUILD_ID").stdout.strip() == "42"
Expand Down
12 changes: 10 additions & 2 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,11 @@ class Bash:
"""
...

def snapshot(self, exclude_filesystem: bool = False) -> bytes:
def snapshot(
self,
exclude_filesystem: bool = False,
exclude_functions: bool = False,
) -> bytes:
"""Serialize interpreter state to bytes."""
...

Expand Down Expand Up @@ -931,7 +935,11 @@ class BashTool:
"""
...

def snapshot(self, exclude_filesystem: bool = False) -> bytes:
def snapshot(
self,
exclude_filesystem: bool = False,
exclude_functions: bool = False,
) -> bytes:
"""Serialize interpreter state to bytes."""
...

Expand Down
30 changes: 24 additions & 6 deletions crates/bashkit-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,14 +797,18 @@ fn snapshot_live_bash(
rt: &Arc<Runtime>,
inner: &Arc<Mutex<Bash>>,
exclude_filesystem: bool,
exclude_functions: bool,
) -> PyResult<Vec<u8>> {
let rt = rt.clone();
let inner = inner.clone();
py.detach(|| {
rt.block_on(async move {
let bash = inner.lock().await;
bash.snapshot_with_options(RustSnapshotOptions { exclude_filesystem })
.map_err(raise_snapshot_error)
bash.snapshot_with_options(RustSnapshotOptions {
exclude_filesystem,
exclude_functions,
})
.map_err(raise_snapshot_error)
})
})
}
Expand Down Expand Up @@ -2700,14 +2704,21 @@ impl PyBash {
}

/// Serialize interpreter state to bytes for checkpoint/restore flows.
#[pyo3(signature = (exclude_filesystem=false))]
#[pyo3(signature = (exclude_filesystem=false, exclude_functions=false))]
fn snapshot<'py>(
&self,
py: Python<'py>,
exclude_filesystem: bool,
exclude_functions: bool,
) -> PyResult<Bound<'py, PyBytes>> {
self.reject_external_handler_reentry()?;
let bytes = snapshot_live_bash(py, &self.rt, &self.inner, exclude_filesystem)?;
let bytes = snapshot_live_bash(
py,
&self.rt,
&self.inner,
exclude_filesystem,
exclude_functions,
)?;
Ok(PyBytes::new(py, &bytes))
}

Expand Down Expand Up @@ -3264,13 +3275,20 @@ impl BashTool {
}

/// Serialize interpreter state to bytes for checkpoint/restore flows.
#[pyo3(signature = (exclude_filesystem=false))]
#[pyo3(signature = (exclude_filesystem=false, exclude_functions=false))]
fn snapshot<'py>(
&self,
py: Python<'py>,
exclude_filesystem: bool,
exclude_functions: bool,
) -> PyResult<Bound<'py, PyBytes>> {
let bytes = snapshot_live_bash(py, &self.rt, &self.inner, exclude_filesystem)?;
let bytes = snapshot_live_bash(
py,
&self.rt,
&self.inner,
exclude_filesystem,
exclude_functions,
)?;
Ok(PyBytes::new(py, &bytes))
}

Expand Down
26 changes: 26 additions & 0 deletions crates/bashkit-python/tests/_bashkit_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ def test_bash_snapshot_can_exclude_filesystem():
assert bash.execute_sync("cat /tmp/state.txt").stdout.strip() == "changed"


def test_bash_snapshot_can_exclude_functions():
bash = Bash()
bash.execute_sync('greet() { echo "hi $1"; }; export KEEP=1')

snapshot = bash.snapshot(exclude_functions=True)

bash.execute_sync("export KEEP=2")
bash.restore_snapshot(snapshot)

assert bash.execute_sync("echo $KEEP").stdout.strip() == "1"
assert bash.execute_sync("type greet >/dev/null 2>&1; echo $?").stdout.strip() == "1"


def test_bash_shell_state_exposes_read_only_snapshot_view():
bash = Bash()
result = bash.execute_sync(
Expand Down Expand Up @@ -742,6 +755,19 @@ def test_bashtool_snapshot_can_exclude_filesystem():
assert tool.execute_sync("cat /tmp/tool.txt").stdout.strip() == "changed"


def test_bashtool_snapshot_can_exclude_functions():
tool = BashTool()
tool.execute_sync('greet() { echo "hi $1"; }; export KEEP=1')

snapshot = tool.snapshot(exclude_functions=True)

tool.execute_sync("export KEEP=2")
tool.restore_snapshot(snapshot)

assert tool.execute_sync("echo $KEEP").stdout.strip() == "1"
assert tool.execute_sync("type greet >/dev/null 2>&1; echo $?").stdout.strip() == "1"


def test_bashtool_shell_state_exposes_read_only_snapshot_view():
tool = BashTool()
result = tool.execute_sync(
Expand Down
139 changes: 137 additions & 2 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,11 @@ pub struct ShellState {
/// Last exit code
pub last_exit_code: i32,
/// Defined shell functions
#[serde(default)]
#[serde(
default,
serialize_with = "serialize_snapshotted_functions",
deserialize_with = "deserialize_snapshotted_functions"
)]
pub functions: HashMap<String, FunctionDef>,
/// Shell aliases
pub aliases: HashMap<String, String>,
Expand Down Expand Up @@ -459,6 +463,129 @@ pub struct ShellStateView {
pub traps: HashMap<String, String>,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct ShellStateOptions {
pub(crate) include_functions: bool,
}

impl Default for ShellStateOptions {
fn default() -> Self {
Self {
include_functions: true,
}
}
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct SnapshottedFunction {
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ast: Option<FunctionDef>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
enum SnapshottedFunctionRepr {
Snapshot(SnapshottedFunction),
Legacy(FunctionDef),
}

fn serialize_snapshotted_functions<S>(
functions: &HashMap<String, FunctionDef>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let snapshotted: HashMap<String, SnapshottedFunction> = functions
.iter()
.map(|(name, func)| {
let mut ast = func.clone();
ast.source = None;
(
name.clone(),
SnapshottedFunction {
source: func.source.clone(),
ast: Some(ast),
},
)
})
.collect();
serde::Serialize::serialize(&snapshotted, serializer)
}

fn deserialize_snapshotted_functions<'de, D>(
deserializer: D,
) -> std::result::Result<HashMap<String, FunctionDef>, D::Error>
where
D: serde::Deserializer<'de>,
{
let snapshotted =
<HashMap<String, SnapshottedFunctionRepr> as serde::Deserialize>::deserialize(
deserializer,
)?;
snapshotted
.into_iter()
.map(|(name, repr)| {
let func = match repr {
SnapshottedFunctionRepr::Legacy(func) => func,
SnapshottedFunctionRepr::Snapshot(snapshot) => {
match (snapshot.ast, snapshot.source) {
(Some(mut func), source) => {
if func.source.is_none() {
func.source = source;
}
func
}
(None, Some(source)) => deserialize_function_from_source(&name, &source)
.map_err(serde::de::Error::custom)?,
(None, None) => {
return Err(serde::de::Error::custom(format!(
"snapshot function '{name}' missing both ast and source"
)));
}
}
}
};
if func.name != name {
return Err(serde::de::Error::custom(format!(
"snapshot function key '{name}' does not match parsed name '{}'",
func.name
)));
}
Ok((name, func))
})
.collect()
}

fn deserialize_function_from_source(
name: &str,
source: &str,
) -> std::result::Result<FunctionDef, String> {
let script = Parser::new(source)
.parse()
.map_err(|err| format!("failed to parse function '{name}' from source: {err}"))?;
let mut commands = script.commands.into_iter();
let command = commands.next().ok_or_else(|| {
format!("failed to parse function '{name}' from source: missing function command")
})?;
if commands.next().is_some() {
return Err(format!(
"failed to parse function '{name}' from source: expected exactly one command"
));
}
match command {
Command::Function(mut func) => {
func.source = Some(source.to_string());
Ok(func)
}
other => Err(format!(
"failed to parse function '{name}' from source: expected function definition, got {other:?}"
)),
}
}

/// Interpreter state.
pub struct Interpreter {
fs: Arc<dyn FileSystem>,
Expand Down Expand Up @@ -1153,14 +1280,22 @@ impl Interpreter {

/// Capture the current shell state (variables, env, cwd, options).
pub fn shell_state(&self) -> ShellState {
self.shell_state_with_options(ShellStateOptions::default())
}

pub(crate) fn shell_state_with_options(&self, options: ShellStateOptions) -> ShellState {
ShellState {
env: self.env.clone(),
variables: self.variables.clone(),
arrays: self.arrays.clone(),
assoc_arrays: self.assoc_arrays.clone(),
cwd: self.cwd.clone(),
last_exit_code: self.last_exit_code,
functions: self.functions.clone(),
functions: if options.include_functions {
self.functions.clone()
} else {
HashMap::new()
},
aliases: self.aliases.clone(),
traps: self.traps.clone(),
}
Expand Down
Loading
Loading