Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 18 additions & 3 deletions cli/src/commands/ak/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use std::rc::Rc;

use crate::config::AppConfig;

mod sync;

pub const AK_LONG_ABOUT: &str =
"LLM-oriented commands for reading and writing persistent knowledge.

Expand All @@ -23,6 +25,7 @@ Key commands:
- ak read <path>...: read one or more files in full
- ak write <path>: create a new file; use --force to overwrite intentionally
- ak remove <path>: remove a file or directory recursively
- ak sync push|pull: reconcile the local store with the remote Stakpak knowledge store
- ak skill <name>: print a built-in ak skill prompt

Recommended discovery flow:
Expand All @@ -41,7 +44,9 @@ pub const AK_AFTER_HELP: &str = "Examples:
stakpak ak read services/rate-limits.md services/auth-flow.md
echo 'Rate limit is 1000/min' | stakpak ak write services/rate-limits.md
stakpak ak write notes.md --file /tmp/notes.md
stakpak ak remove services/rate-limits.md";
stakpak ak remove services/rate-limits.md
stakpak ak sync push --dry-run
stakpak ak sync pull --strategy remote";

#[derive(Subcommand, PartialEq, Debug)]
#[command(
Expand Down Expand Up @@ -160,6 +165,12 @@ Use `usage` to teach an agent how to navigate and write to the store. Use `maint
/// Built-in skill name: usage, maintain, or retrospect
name: String,
},

/// Reconcile the local ak knowledge store with the remote Stakpak knowledge store.
Sync {
#[command(subcommand)]
cmd: sync::SyncCommand,
},
}

impl AkCommands {
Expand All @@ -168,6 +179,10 @@ impl AkCommands {
return run_skill(name);
}

if let Self::Sync { cmd } = self {
return sync::run(cmd, config);
}

let backend = create_backend(&config)?;
match self {
Self::Search {
Expand All @@ -180,8 +195,8 @@ impl AkCommands {
Self::Read { paths } => run_read(backend.clone(), &paths)?,
Self::Write { path, file, force } => run_write(backend.clone(), path, file, force)?,
Self::Remove { path } => run_remove(backend.clone(), &path)?,
Self::Skill { .. } => {
unreachable!("Will never reach here because of the early return above")
Self::Skill { .. } | Self::Sync { .. } => {
unreachable!("Will never reach here because of the early returns above")
}
}

Expand Down
113 changes: 113 additions & 0 deletions cli/src/commands/ak/sync/display.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use stakpak_shared::format::{format_size, short_hash};

use super::SyncDirection;
use super::execute::SyncReport;
use super::plan::{Conflict, SyncPlan};

pub fn print_plan(p: &SyncPlan) {
let direction_label = match p.direction {
SyncDirection::Push => "push",
SyncDirection::Pull => "pull",
};
println!(
"ak sync {direction_label} (dry-run): {} upload(s), {} download(s), {} skipped, {} conflict(s)",
p.uploads.len(),
p.downloads.len(),
p.skipped.len(),
p.conflicts.len()
);

if !p.uploads.is_empty() {
println!();
println!("uploads:");
for meta in &p.uploads {
println!(" + {} ({})", meta.path, format_size(meta.size_bytes));
}
}
if !p.downloads.is_empty() {
println!();
println!("downloads:");
for meta in &p.downloads {
println!(" + {} ({})", meta.path, format_size(meta.size_bytes));
}
}
if !p.skipped.is_empty() {
println!();
println!("unchanged ({}):", p.skipped.len());
for path in &p.skipped {
println!(" = {path}");
}
}
if !p.conflicts.is_empty() {
println!();
println!("conflicts:");
for c in &p.conflicts {
println!(
" ! {} local: {} {} remote: {} {}",
c.path,
short_hash(&c.local_hash),
format_size(c.local_size),
short_hash(&c.remote_hash),
format_size(c.remote_size),
);
}
}
}

pub fn print_conflicts_to_stderr(conflicts: &[Conflict]) {
for c in conflicts {
eprintln!(
" ! {} local: {} {} remote: {} {}",
c.path,
short_hash(&c.local_hash),
format_size(c.local_size),
short_hash(&c.remote_hash),
format_size(c.remote_size),
);
}
}

pub fn print_report(r: &SyncReport) {
let direction_label = match r.direction {
SyncDirection::Push => "push",
SyncDirection::Pull => "pull",
};

let total_changed = r.uploaded.len() + r.downloaded.len() + r.conflict_resolved.len();
println!(
"ak sync {direction_label}: {total_changed} change(s), {} skipped, {} failure(s)",
r.skipped.len(),
r.failures.len(),
);

if !r.uploaded.is_empty() {
println!();
println!("uploaded:");
for path in &r.uploaded {
println!(" + {path}");
}
}
if !r.downloaded.is_empty() {
println!();
println!("downloaded:");
for path in &r.downloaded {
println!(" + {path}");
}
}
if !r.conflict_resolved.is_empty() {
println!();
println!("conflicts resolved:");
for path in &r.conflict_resolved {
println!(" ~ {path}");
}
}
if !r.failures.is_empty() {
// Use stderr for failures so a successful pipe (e.g. `... | tee
// sync.log`) still surfaces them prominently.
eprintln!();
eprintln!("failures:");
for f in &r.failures {
eprintln!(" ✗ {}: {}", f.path, f.error);
}
}
}
Loading
Loading