Add GitHub integration for PRs, issues and AI PR description (#205)
* feat: integrate GitHub OAuth device flow across runtimes Add GitHub OAuth device flow endpoints across runtimes Introduce GitHubSettings UI panel and sidebar entry Persist GitHub auth state in per-runtime storage * feat: add GitHub PR status and PR description generation Show PR status for the current branch in the Git view Generate a pull request description from the diff between base and head Expose prStatus, prCreate, and prMerge APIs in web and desktop clients * feat: add GitHub PR ready for review Add API to mark pull requests as ready for review Show a Ready button for draft PRs and reflect status in UI Handle token expiration and GraphQL errors when marking ready
This commit is contained in:
committed by
GitHub
parent
0e715be7d6
commit
463e9ec4e3
@@ -267,7 +267,6 @@ async fn handle_question_asked(
|
||||
properties: &Value,
|
||||
notified_questions: &Mutex<HashSet<String>>,
|
||||
) {
|
||||
|
||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
||||
let question_id = properties.get("id").and_then(Value::as_str);
|
||||
|
||||
|
||||
@@ -234,13 +234,11 @@ pub async fn list_directory(
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
Err(_) => HashSet::new(),
|
||||
}
|
||||
})
|
||||
@@ -507,9 +505,13 @@ pub async fn delete_path(
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_delete_message())?;
|
||||
let resolved_path = resolve_sandboxed_path(
|
||||
Some(trimmed.to_string()),
|
||||
&workspace_roots,
|
||||
default_root.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.to_delete_message())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_path)
|
||||
.await
|
||||
@@ -544,9 +546,13 @@ pub async fn rename_path(
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_old = resolve_sandboxed_path(Some(trimmed_old.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_rename_message())?;
|
||||
let resolved_old = resolve_sandboxed_path(
|
||||
Some(trimmed_old.to_string()),
|
||||
&workspace_roots,
|
||||
default_root.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.to_rename_message())?;
|
||||
let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_rename_message())?;
|
||||
@@ -895,9 +901,13 @@ pub async fn read_file(
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|_| "File not found or access denied".to_string())?;
|
||||
let resolved_path = resolve_sandboxed_path(
|
||||
Some(trimmed.to_string()),
|
||||
&workspace_roots,
|
||||
default_root.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "File not found or access denied".to_string())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_path)
|
||||
.await
|
||||
@@ -932,9 +942,13 @@ pub async fn read_file_binary(
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|_| "File not found or access denied".to_string())?;
|
||||
let resolved_path = resolve_sandboxed_path(
|
||||
Some(trimmed.to_string()),
|
||||
&workspace_roots,
|
||||
default_root.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "File not found or access denied".to_string())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_path)
|
||||
.await
|
||||
@@ -1041,14 +1055,8 @@ fn build_shell_path_command(shell: &str) -> Vec<String> {
|
||||
"-lic".to_string(),
|
||||
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(),
|
||||
],
|
||||
"fish" => vec![
|
||||
"-lic".to_string(),
|
||||
"echo \"__PATH__=$PATH\"".to_string(),
|
||||
],
|
||||
_ => vec![
|
||||
"-lic".to_string(),
|
||||
"echo \"__PATH__=$PATH\"".to_string(),
|
||||
],
|
||||
"fish" => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
|
||||
_ => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,9 +1138,13 @@ pub async fn exec_commands(
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|_| "Working directory not found or access denied".to_string())?;
|
||||
let resolved_cwd = resolve_sandboxed_path(
|
||||
Some(cwd_trimmed.to_string()),
|
||||
&workspace_roots,
|
||||
default_root.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Working directory not found or access denied".to_string())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_cwd)
|
||||
.await
|
||||
|
||||
@@ -2042,7 +2042,9 @@ pub async fn get_remote_url(
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
|
||||
let url = run_git(&["remote", "get-url", &remote_name], &root).await.ok();
|
||||
let url = run_git(&["remote", "get-url", &remote_name], &root)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(url.filter(|s| !s.is_empty()))
|
||||
}
|
||||
@@ -2162,7 +2164,11 @@ pub async fn set_git_identity(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let _ = run_git(&["config", "--local", "--unset", "credential.helper"], &root).await;
|
||||
let _ = run_git(
|
||||
&["config", "--local", "--unset", "credential.helper"],
|
||||
&root,
|
||||
)
|
||||
.await;
|
||||
} else if auth_type == "token" && profile.host.is_some() {
|
||||
run_git(&["config", "--local", "credential.helper", "store"], &root)
|
||||
.await
|
||||
@@ -2330,3 +2336,134 @@ Diff summary:
|
||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_pr_description(
|
||||
directory: String,
|
||||
base: String,
|
||||
head: String,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let root = validate_git_path(&directory, state.settings())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if base.trim().is_empty() || head.trim().is_empty() {
|
||||
return Err("base and head are required".to_string());
|
||||
}
|
||||
|
||||
// 1. Collect PR range diffs (base...head)
|
||||
let range = format!("{}...{}", base.trim(), head.trim());
|
||||
let files = {
|
||||
let args = vec!["diff", "--name-only", range.as_str()];
|
||||
let raw = run_git(&args, &root).await.unwrap_or_default();
|
||||
raw.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
if files.is_empty() {
|
||||
return Err("No diffs available for base...head".to_string());
|
||||
}
|
||||
let mut diff_summaries = String::new();
|
||||
for file in files.iter() {
|
||||
let context = "-U3";
|
||||
let args = vec![
|
||||
"diff",
|
||||
"--no-color",
|
||||
context,
|
||||
range.as_str(),
|
||||
"--",
|
||||
file.as_str(),
|
||||
];
|
||||
if let Ok(diff) = run_git(&args, &root).await {
|
||||
if !diff.trim().is_empty() {
|
||||
diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, diff));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if diff_summaries.is_empty() {
|
||||
return Err("No diffs available for selected files".to_string());
|
||||
}
|
||||
|
||||
// 2. Construct PR-specific prompt
|
||||
let prompt = format!(
|
||||
r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{\"title\": string, \"body\": string}} (ONLY JSON in response, no markdown fences) with these rules:
|
||||
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\")
|
||||
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
|
||||
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
|
||||
- Testing: bullet list (\"- Not tested\" allowed)
|
||||
- Notes: bullet list; include breaking/rollout notes only when relevant
|
||||
Context:
|
||||
- base branch: {base}
|
||||
- head branch: {head}
|
||||
|
||||
Diff summary:
|
||||
{diffs}"#,
|
||||
base = base.trim(),
|
||||
head = head.trim(),
|
||||
diffs = diff_summaries
|
||||
);
|
||||
|
||||
let model = "gpt-5-nano";
|
||||
|
||||
// 3. Call API
|
||||
let client = Client::new();
|
||||
let res = client
|
||||
.post("https://opencode.ai/zen/v1/responses")
|
||||
.json(&serde_json::json!({
|
||||
"model": model,
|
||||
"input": [{ "role": "user", "content": prompt }],
|
||||
"max_output_tokens": 1200,
|
||||
"stream": false,
|
||||
"reasoning": { "effort": "low" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(format!("API request failed: {}", res.status()));
|
||||
}
|
||||
|
||||
let body_json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||
let raw_content = body_json["output"]
|
||||
.as_array()
|
||||
.and_then(|items| items.iter().find(|item| item["type"] == "message"))
|
||||
.and_then(|item| item["content"].as_array())
|
||||
.and_then(|content| content.iter().find(|entry| entry["type"] == "output_text"))
|
||||
.and_then(|entry| entry["text"].as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if raw_content.is_empty() {
|
||||
return Err("No PR description returned by generator".to_string());
|
||||
}
|
||||
|
||||
let cleaned = raw_content
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim();
|
||||
|
||||
let extracted = extract_json_object(cleaned);
|
||||
let candidates = [
|
||||
Some(cleaned.to_string()),
|
||||
extracted,
|
||||
Some(raw_content.to_string()),
|
||||
];
|
||||
|
||||
for candidate in candidates.iter().flatten() {
|
||||
if !(candidate.starts_with('{') || candidate.starts_with('[')) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(candidate) {
|
||||
let title = parsed.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let body = parsed.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
return Ok(serde_json::json!({ "title": title, "body": body }));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "title": "", "body": raw_content }))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod logs;
|
||||
pub mod notifications;
|
||||
pub mod permissions;
|
||||
|
||||
@@ -129,7 +129,10 @@ pub async fn process_directory_selection(
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
|
||||
obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update));
|
||||
obj.insert(
|
||||
"lastDirectory".to_string(),
|
||||
json!(normalized_path_for_update),
|
||||
);
|
||||
}
|
||||
|
||||
(settings, project_id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
@@ -233,6 +233,20 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub OAuth config (non-secret)
|
||||
if let Some(Value::String(s)) = obj.get("githubClientId") {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result_obj.insert("githubClientId".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("githubScopes") {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result_obj.insert("githubScopes".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("defaultModel") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -304,7 +318,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(10).min(500);
|
||||
@@ -314,7 +331,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(20).min(500);
|
||||
@@ -324,7 +344,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(30).min(1000);
|
||||
|
||||
@@ -28,18 +28,26 @@ use axum::{
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use commands::files::{create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, rename_path, search_files, write_file};
|
||||
use commands::files::{
|
||||
create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary,
|
||||
rename_path, search_files, write_file,
|
||||
};
|
||||
use commands::git::{
|
||||
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
|
||||
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
|
||||
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
||||
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
||||
get_current_git_identity, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
||||
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message,
|
||||
get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
||||
get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url,
|
||||
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
|
||||
revert_git_file, set_git_identity, update_git_identity,
|
||||
git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees,
|
||||
remove_git_worktree, rename_branch, revert_git_file, set_git_identity, update_git_identity,
|
||||
generate_pr_description,
|
||||
};
|
||||
use commands::logs::fetch_desktop_logs;
|
||||
|
||||
use commands::github::{
|
||||
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
|
||||
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
|
||||
};
|
||||
use commands::notifications::desktop_notify;
|
||||
use commands::permissions::{
|
||||
pick_directory, process_directory_selection, request_directory_access,
|
||||
@@ -880,6 +888,7 @@ fn main() {
|
||||
set_git_identity,
|
||||
discover_git_credentials,
|
||||
generate_commit_message,
|
||||
generate_pr_description,
|
||||
create_terminal_session,
|
||||
send_terminal_input,
|
||||
resize_terminal,
|
||||
@@ -888,6 +897,15 @@ fn main() {
|
||||
force_kill_terminal,
|
||||
fetch_desktop_logs,
|
||||
desktop_notify,
|
||||
github_auth_status,
|
||||
github_auth_start,
|
||||
github_auth_complete,
|
||||
github_auth_disconnect,
|
||||
github_me,
|
||||
github_pr_status,
|
||||
github_pr_create,
|
||||
github_pr_merge,
|
||||
github_pr_ready,
|
||||
])
|
||||
.on_menu_event(|app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -2256,7 +2274,9 @@ async fn handle_config_routes(
|
||||
resolve_project_directory(&state, None).await.ok()
|
||||
};
|
||||
|
||||
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()).await {
|
||||
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(mut sources) => {
|
||||
let auth = opencode_auth::get_provider_auth(trimmed).await;
|
||||
sources.auth.exists = auth.ok().flatten().is_some();
|
||||
@@ -2324,13 +2344,30 @@ async fn handle_config_routes(
|
||||
let removal_result = if scope == "auth" {
|
||||
opencode_auth::remove_provider_auth(trimmed).await
|
||||
} else if scope == "user" {
|
||||
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::User).await
|
||||
opencode_config::remove_provider_config(
|
||||
trimmed,
|
||||
working_directory.as_deref(),
|
||||
opencode_config::ProviderScope::User,
|
||||
)
|
||||
.await
|
||||
} else if scope == "project" {
|
||||
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Project).await
|
||||
opencode_config::remove_provider_config(
|
||||
trimmed,
|
||||
working_directory.as_deref(),
|
||||
opencode_config::ProviderScope::Project,
|
||||
)
|
||||
.await
|
||||
} else if scope == "custom" {
|
||||
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Custom).await
|
||||
opencode_config::remove_provider_config(
|
||||
trimmed,
|
||||
working_directory.as_deref(),
|
||||
opencode_config::ProviderScope::Custom,
|
||||
)
|
||||
.await
|
||||
} else if scope == "all" {
|
||||
let auth_removed = opencode_auth::remove_provider_auth(trimmed).await.unwrap_or(false);
|
||||
let auth_removed = opencode_auth::remove_provider_auth(trimmed)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let user_removed = opencode_config::remove_provider_config(
|
||||
trimmed,
|
||||
working_directory.as_deref(),
|
||||
@@ -2500,7 +2537,10 @@ async fn change_directory_handler(
|
||||
"activeProjectId".to_string(),
|
||||
Value::String(active_project_id.clone()),
|
||||
);
|
||||
map.insert("lastDirectory".to_string(), Value::String(path_value.clone()));
|
||||
map.insert(
|
||||
"lastDirectory".to_string(),
|
||||
Value::String(path_value.clone()),
|
||||
);
|
||||
|
||||
settings
|
||||
})
|
||||
@@ -2645,7 +2685,6 @@ impl SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
|
||||
where
|
||||
F: FnOnce(Value) -> (Value, R),
|
||||
@@ -2653,7 +2692,9 @@ impl SettingsStore {
|
||||
let _lock = self.guard.lock().await;
|
||||
|
||||
let current = match fs::read(&self.path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())),
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default()))
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Value::Object(Default::default())
|
||||
}
|
||||
|
||||
@@ -143,7 +143,10 @@ fn get_project_config_file(working_directory: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
// Default to root opencode.json for new configs
|
||||
candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json"))
|
||||
candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| working_directory.join("opencode.json"))
|
||||
}
|
||||
|
||||
/// Get custom config file path from OPENCODE_CONFIG env var
|
||||
@@ -390,15 +393,29 @@ pub async fn get_provider_sources(
|
||||
.is_some();
|
||||
|
||||
Ok(ProviderSources {
|
||||
auth: ProviderSourceInfo { exists: false, path: None },
|
||||
user: ProviderSourceInfo { exists: user_exists, path: Some(layers.paths.user.to_string_lossy().to_string()) },
|
||||
auth: ProviderSourceInfo {
|
||||
exists: false,
|
||||
path: None,
|
||||
},
|
||||
user: ProviderSourceInfo {
|
||||
exists: user_exists,
|
||||
path: Some(layers.paths.user.to_string_lossy().to_string()),
|
||||
},
|
||||
project: ProviderSourceInfo {
|
||||
exists: project_exists,
|
||||
path: layers.paths.project.as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||
path: layers
|
||||
.paths
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string()),
|
||||
},
|
||||
custom: ProviderSourceInfo {
|
||||
exists: custom_exists,
|
||||
path: layers.paths.custom.as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||
path: layers
|
||||
.paths
|
||||
.custom
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string()),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -432,10 +449,7 @@ pub async fn remove_provider_config(
|
||||
let mut remove_provider_key = false;
|
||||
let mut remove_providers_key = false;
|
||||
|
||||
if let Some(provider_section) = config
|
||||
.get_mut("provider")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
if let Some(provider_section) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
if provider_section.remove(provider_id).is_some() {
|
||||
removed = true;
|
||||
if provider_section.is_empty() {
|
||||
@@ -444,10 +458,7 @@ pub async fn remove_provider_config(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(provider_section) = config
|
||||
.get_mut("providers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
if let Some(provider_section) = config.get_mut("providers").and_then(|v| v.as_object_mut()) {
|
||||
if provider_section.remove(provider_id).is_some() {
|
||||
removed = true;
|
||||
if provider_section.is_empty() {
|
||||
@@ -485,7 +496,8 @@ fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf {
|
||||
/// Get project-level agent path
|
||||
fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf {
|
||||
let plural_path = get_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
|
||||
let legacy_path = get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
|
||||
let legacy_path =
|
||||
get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
|
||||
if legacy_path.exists() && !plural_path.exists() {
|
||||
return legacy_path;
|
||||
}
|
||||
@@ -566,8 +578,10 @@ fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf {
|
||||
|
||||
/// Get project-level command path
|
||||
fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf {
|
||||
let plural_path = get_project_command_dir(working_directory).join(format!("{}.md", command_name));
|
||||
let legacy_path = get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
|
||||
let plural_path =
|
||||
get_project_command_dir(working_directory).join(format!("{}.md", command_name));
|
||||
let legacy_path =
|
||||
get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
|
||||
if legacy_path.exists() && !plural_path.exists() {
|
||||
return legacy_path;
|
||||
}
|
||||
|
||||
@@ -791,7 +791,12 @@ async fn scan_clawdhub() -> Result<Vec<SkillsCatalogItem>> {
|
||||
|
||||
for _ in 0..max_pages {
|
||||
let url = match &cursor {
|
||||
Some(c) => format!("{}{}?cursor={}", CLAWDHUB_API_BASE, "/skills", urlencoding::encode(c)),
|
||||
Some(c) => format!(
|
||||
"{}{}?cursor={}",
|
||||
CLAWDHUB_API_BASE,
|
||||
"/skills",
|
||||
urlencoding::encode(c)
|
||||
),
|
||||
None => format!("{}/skills", CLAWDHUB_API_BASE),
|
||||
};
|
||||
|
||||
@@ -1440,7 +1445,11 @@ async fn install_skills_from_clawdhub(
|
||||
// Check for conflicts first
|
||||
let mut conflicts = vec![];
|
||||
for sel in &req.selections {
|
||||
let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir);
|
||||
let slug = sel
|
||||
.clawdhub
|
||||
.as_ref()
|
||||
.map(|c| c.slug.as_str())
|
||||
.unwrap_or(&sel.skill_dir);
|
||||
if !validate_skill_name(slug) {
|
||||
continue;
|
||||
}
|
||||
@@ -1478,8 +1487,17 @@ async fn install_skills_from_clawdhub(
|
||||
}
|
||||
|
||||
for sel in &req.selections {
|
||||
let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir);
|
||||
let mut version = sel.clawdhub.as_ref().map(|c| c.version.as_str()).unwrap_or("latest").to_string();
|
||||
let slug = sel
|
||||
.clawdhub
|
||||
.as_ref()
|
||||
.map(|c| c.slug.as_str())
|
||||
.unwrap_or(&sel.skill_dir);
|
||||
let mut version = sel
|
||||
.clawdhub
|
||||
.as_ref()
|
||||
.map(|c| c.version.as_str())
|
||||
.unwrap_or("latest")
|
||||
.to_string();
|
||||
|
||||
if !validate_skill_name(slug) {
|
||||
skipped.push(SkippedSkill {
|
||||
@@ -1548,7 +1566,8 @@ async fn install_skills_from_clawdhub(
|
||||
// Download and extract
|
||||
match download_clawdhub_skill(slug, &version).await {
|
||||
Ok(zip_data) => {
|
||||
let temp_dir = std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4()));
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4()));
|
||||
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
|
||||
|
||||
// Extract ZIP using the zip crate
|
||||
|
||||
Reference in New Issue
Block a user