reallink-cli 0.1.14 → 0.1.16

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.
package/rust/src/main.rs CHANGED
@@ -1,24 +1,26 @@
1
1
  use anyhow::{anyhow, Context, Result};
2
2
  use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
3
+ use regex::RegexBuilder;
3
4
  use reqwest::{Method, StatusCode};
4
5
  use serde::{Deserialize, Serialize};
5
6
  use sha2::Digest;
6
7
  use std::fs;
7
- use std::io::{self, Read, Write};
8
+ use std::io::{self, Read, SeekFrom, Write};
8
9
  use std::path::{Path, PathBuf};
9
10
  use std::process::Command;
10
11
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12
  use tokio::fs as tokio_fs;
12
- use tokio::io::AsyncWriteExt;
13
+ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader};
13
14
  use tokio::time::sleep;
14
15
 
15
- mod unreal;
16
16
  mod generated;
17
17
  mod logs;
18
+ mod unreal;
18
19
  use unreal::{
19
- LinkDoctorArgs, LinkOpenArgs, LinkPathsArgs, LinkPluginInstallArgs, LinkPluginListArgs,
20
- LinkRemoveArgs, LinkRunArgs, LinkUnrealArgs, LinkUseArgs, PluginIndexFile, UnrealLinkRecord,
21
- UnrealLinksConfig,
20
+ LinkConnectArgs, LinkDoctorArgs, LinkOpenArgs, LinkP2PCreateArgs, LinkP2PGetArgs,
21
+ LinkP2PListArgs, LinkP2PSignalArgs, LinkP2PWaitArgs, LinkPathsArgs, LinkPluginInstallArgs,
22
+ LinkPluginListArgs, LinkRemoveArgs, LinkRunArgs, LinkSourceArgs, LinkUnrealArgs, LinkUseArgs,
23
+ PluginIndexFile, SourceLinkRecord, SourceLinksConfig, UnrealLinkRecord, UnrealLinksConfig,
22
24
  };
23
25
 
24
26
  const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
@@ -62,6 +64,11 @@ enum Commands {
62
64
  Whoami(BaseArgs),
63
65
  Logout,
64
66
  SelfUpdate(SelfUpdateArgs),
67
+ Call(CallArgs),
68
+ Ops {
69
+ #[command(subcommand)]
70
+ command: OpsCommands,
71
+ },
65
72
  Org {
66
73
  #[command(subcommand)]
67
74
  command: OrgCommands,
@@ -78,6 +85,10 @@ enum Commands {
78
85
  #[command(subcommand)]
79
86
  command: TokenCommands,
80
87
  },
88
+ Credits {
89
+ #[command(subcommand)]
90
+ command: CreditsCommands,
91
+ },
81
92
  File {
82
93
  #[command(subcommand)]
83
94
  command: FileCommands,
@@ -102,6 +113,126 @@ struct BaseArgs {
102
113
  base_url: Option<String>,
103
114
  }
104
115
 
116
+ #[derive(Subcommand)]
117
+ enum OpsCommands {
118
+ List(OpsListArgs),
119
+ Search(OpsSearchArgs),
120
+ Show(OpsShowArgs),
121
+ Invoke(OpsInvokeArgs),
122
+ }
123
+
124
+ #[derive(Args)]
125
+ struct AgentAuthArgs {
126
+ #[arg(long)]
127
+ base_url: Option<String>,
128
+ #[arg(long, help = "Bearer token override for agent/API-token usage")]
129
+ access_token: Option<String>,
130
+ }
131
+
132
+ #[derive(Args)]
133
+ struct CallArgs {
134
+ #[command(flatten)]
135
+ auth: AgentAuthArgs,
136
+ #[arg(help = "HTTP method, for example GET or POST")]
137
+ method: String,
138
+ #[arg(help = "API path, for example /v1/projects or /projects")]
139
+ path: String,
140
+ #[arg(long = "query", action = ArgAction::Append, help = "Query pair in key=value form")]
141
+ query: Vec<String>,
142
+ #[arg(long = "header", action = ArgAction::Append, help = "Header in Name=Value form")]
143
+ header: Vec<String>,
144
+ #[arg(long, help = "Inline JSON/JSON5 body or @path/to/body.json")]
145
+ body: Option<String>,
146
+ }
147
+
148
+ #[derive(Args)]
149
+ struct OpsListArgs {
150
+ #[command(flatten)]
151
+ auth: AgentAuthArgs,
152
+ #[arg(long)]
153
+ group: Option<String>,
154
+ #[arg(long)]
155
+ scope: Option<String>,
156
+ }
157
+
158
+ #[derive(Args)]
159
+ struct OpsSearchArgs {
160
+ #[command(flatten)]
161
+ auth: AgentAuthArgs,
162
+ #[arg(help = "Free-text capability query")]
163
+ query: String,
164
+ #[arg(long)]
165
+ group: Option<String>,
166
+ #[arg(long)]
167
+ scope: Option<String>,
168
+ }
169
+
170
+ #[derive(Args)]
171
+ struct OpsShowArgs {
172
+ #[command(flatten)]
173
+ auth: AgentAuthArgs,
174
+ #[arg(help = "Operation identifier from discover output")]
175
+ operation_id: String,
176
+ }
177
+
178
+ #[derive(Args)]
179
+ struct OpsInvokeArgs {
180
+ #[command(flatten)]
181
+ auth: AgentAuthArgs,
182
+ #[arg(help = "Operation identifier from discover output")]
183
+ operation_id: String,
184
+ #[arg(long)]
185
+ org_id: Option<String>,
186
+ #[arg(long)]
187
+ project_id: Option<String>,
188
+ #[arg(long = "param", action = ArgAction::Append, help = "Path or query parameter in key=value form")]
189
+ param: Vec<String>,
190
+ #[arg(long = "header", action = ArgAction::Append, help = "Header in Name=Value form")]
191
+ header: Vec<String>,
192
+ #[arg(long, help = "Inline JSON/JSON5 body or @path/to/body.json")]
193
+ body: Option<String>,
194
+ }
195
+
196
+ #[derive(Args)]
197
+ struct CreditsAccountArgs {
198
+ #[command(flatten)]
199
+ base: BaseArgs,
200
+ #[arg(long)]
201
+ org_id: String,
202
+ }
203
+
204
+ #[derive(Args)]
205
+ struct CreditsLedgerArgs {
206
+ #[command(flatten)]
207
+ base: BaseArgs,
208
+ #[arg(long)]
209
+ org_id: String,
210
+ #[arg(long, default_value_t = 50)]
211
+ limit: u32,
212
+ #[arg(long, default_value_t = 0)]
213
+ offset: u32,
214
+ }
215
+
216
+ #[derive(Args)]
217
+ struct CreditsProjectUsageArgs {
218
+ #[command(flatten)]
219
+ base: BaseArgs,
220
+ #[arg(long)]
221
+ project_id: String,
222
+ #[arg(long, default_value_t = 50)]
223
+ limit: u32,
224
+ #[arg(long, default_value_t = 0)]
225
+ offset: u32,
226
+ }
227
+
228
+ #[derive(Args)]
229
+ struct CreditsRunUsageArgs {
230
+ #[command(flatten)]
231
+ base: BaseArgs,
232
+ #[arg(long)]
233
+ run_id: String,
234
+ }
235
+
105
236
  #[derive(Args)]
106
237
  struct SelfUpdateArgs {
107
238
  #[arg(long, help = "Only check for updates; do not install")]
@@ -127,6 +258,14 @@ enum TokenCommands {
127
258
  Revoke(TokenRevokeArgs),
128
259
  }
129
260
 
261
+ #[derive(Subcommand)]
262
+ enum CreditsCommands {
263
+ Account(CreditsAccountArgs),
264
+ Ledger(CreditsLedgerArgs),
265
+ ProjectUsage(CreditsProjectUsageArgs),
266
+ RunUsage(CreditsRunUsageArgs),
267
+ }
268
+
130
269
  #[derive(Subcommand)]
131
270
  enum ProjectCommands {
132
271
  List(ProjectListArgs),
@@ -204,6 +343,8 @@ enum ToolCommands {
204
343
  Run(ToolRunArgs),
205
344
  Runs(ToolRunsArgs),
206
345
  GetRun(ToolGetRunArgs),
346
+ Cancel(ToolCancelArgs),
347
+ Retry(ToolRetryArgs),
207
348
  RunEvents(ToolRunEventsArgs),
208
349
  TraceStatus(ToolTraceStatusArgs),
209
350
  Local {
@@ -240,6 +381,8 @@ enum SkillCommands {
240
381
  Run(ToolRunArgs),
241
382
  Runs(ToolRunsArgs),
242
383
  GetRun(ToolGetRunArgs),
384
+ Cancel(ToolCancelArgs),
385
+ Retry(ToolRetryArgs),
243
386
  RunEvents(ToolRunEventsArgs),
244
387
  TraceStatus(ToolTraceStatusArgs),
245
388
  Local {
@@ -287,6 +430,12 @@ struct FileListArgs {
287
430
  #[arg(long)]
288
431
  path: Option<String>,
289
432
  #[arg(long)]
433
+ search: Option<String>,
434
+ #[arg(long)]
435
+ sort_by: Option<String>,
436
+ #[arg(long)]
437
+ kind: Option<String>,
438
+ #[arg(long)]
290
439
  offset: Option<u32>,
291
440
  #[arg(long)]
292
441
  limit: Option<u32>,
@@ -521,7 +670,11 @@ struct ToolRunArgs {
521
670
  wait: bool,
522
671
  #[arg(long, default_value_t = 300_000, help = "Wait timeout in milliseconds")]
523
672
  timeout_ms: u64,
524
- #[arg(long, default_value_t = 1_500, help = "Polling interval in milliseconds")]
673
+ #[arg(
674
+ long,
675
+ default_value_t = 1_500,
676
+ help = "Polling interval in milliseconds"
677
+ )]
525
678
  poll_interval_ms: u64,
526
679
  #[arg(long)]
527
680
  base_url: Option<String>,
@@ -555,7 +708,11 @@ struct ToolPromptArgs {
555
708
  wait: bool,
556
709
  #[arg(long, default_value_t = 300_000, help = "Wait timeout in milliseconds")]
557
710
  timeout_ms: u64,
558
- #[arg(long, default_value_t = 1_500, help = "Polling interval in milliseconds")]
711
+ #[arg(
712
+ long,
713
+ default_value_t = 1_500,
714
+ help = "Polling interval in milliseconds"
715
+ )]
559
716
  poll_interval_ms: u64,
560
717
  #[arg(long)]
561
718
  base_url: Option<String>,
@@ -583,6 +740,44 @@ struct ToolGetRunArgs {
583
740
  base_url: Option<String>,
584
741
  }
585
742
 
743
+ #[derive(Args)]
744
+ struct ToolCancelArgs {
745
+ #[arg(long)]
746
+ run_id: String,
747
+ #[arg(long)]
748
+ reason: Option<String>,
749
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
750
+ metadata_file: Option<PathBuf>,
751
+ #[arg(long)]
752
+ base_url: Option<String>,
753
+ }
754
+
755
+ #[derive(Args)]
756
+ struct ToolRetryArgs {
757
+ #[arg(long)]
758
+ run_id: String,
759
+ #[arg(long)]
760
+ reason: Option<String>,
761
+ #[arg(long, help = "Inline JSON object for retry input patch")]
762
+ input_json: Option<String>,
763
+ #[arg(long, help = "Path to JSON/JSONC file for retry input patch")]
764
+ input_file: Option<PathBuf>,
765
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
766
+ metadata_file: Option<PathBuf>,
767
+ #[arg(long, action = ArgAction::SetTrue, help = "Wait for completion before returning")]
768
+ wait: bool,
769
+ #[arg(long, default_value_t = 300_000, help = "Wait timeout in milliseconds")]
770
+ timeout_ms: u64,
771
+ #[arg(
772
+ long,
773
+ default_value_t = 1_500,
774
+ help = "Polling interval in milliseconds"
775
+ )]
776
+ poll_interval_ms: u64,
777
+ #[arg(long)]
778
+ base_url: Option<String>,
779
+ }
780
+
586
781
  #[derive(Args)]
587
782
  struct ToolRunEventsArgs {
588
783
  #[arg(long)]
@@ -593,9 +788,15 @@ struct ToolRunEventsArgs {
593
788
  status: Option<String>,
594
789
  #[arg(long)]
595
790
  stage_prefix: Option<String>,
596
- #[arg(long, help = "Only include events created after this ISO-8601 timestamp")]
791
+ #[arg(
792
+ long,
793
+ help = "Only include events created after this ISO-8601 timestamp"
794
+ )]
597
795
  since: Option<String>,
598
- #[arg(long, help = "Only include events created at/before this ISO-8601 timestamp")]
796
+ #[arg(
797
+ long,
798
+ help = "Only include events created at/before this ISO-8601 timestamp"
799
+ )]
599
800
  until: Option<String>,
600
801
  #[arg(long)]
601
802
  base_url: Option<String>,
@@ -669,7 +870,10 @@ struct ToolLocalInstallArgs {
669
870
  version: Option<String>,
670
871
  #[arg(long = "output")]
671
872
  output_path: Option<PathBuf>,
672
- #[arg(long, help = "Resume download from existing output file size using HTTP Range")]
873
+ #[arg(
874
+ long,
875
+ help = "Resume download from existing output file size using HTTP Range"
876
+ )]
673
877
  resume: bool,
674
878
  #[arg(long, help = "Only print install intent, do not download")]
675
879
  no_download: bool,
@@ -928,6 +1132,50 @@ struct SessionConfig {
928
1132
  updated_at_epoch_ms: u128,
929
1133
  }
930
1134
 
1135
+ enum AgentAuth {
1136
+ Session(SessionConfig),
1137
+ Token {
1138
+ base_url: String,
1139
+ access_token: String,
1140
+ },
1141
+ }
1142
+
1143
+ #[derive(Debug, Serialize, Deserialize, Clone)]
1144
+ #[serde(rename_all = "camelCase")]
1145
+ struct DiscoverOperationParameter {
1146
+ name: String,
1147
+ #[serde(rename = "in")]
1148
+ location: String,
1149
+ required: bool,
1150
+ #[serde(default)]
1151
+ schema: serde_json::Value,
1152
+ description: Option<String>,
1153
+ }
1154
+
1155
+ #[derive(Debug, Serialize, Deserialize, Clone)]
1156
+ #[serde(rename_all = "camelCase")]
1157
+ struct DiscoverOperation {
1158
+ operation_id: String,
1159
+ method: String,
1160
+ path: String,
1161
+ summary: String,
1162
+ description: Option<String>,
1163
+ group: String,
1164
+ #[serde(default)]
1165
+ tags: Vec<String>,
1166
+ auth: String,
1167
+ #[serde(default)]
1168
+ required_scopes: Vec<String>,
1169
+ requires_org_context: bool,
1170
+ requires_project_context: bool,
1171
+ visibility: String,
1172
+ stability: String,
1173
+ #[serde(default)]
1174
+ parameters: Vec<DiscoverOperationParameter>,
1175
+ request_body_schema: Option<serde_json::Value>,
1176
+ example: Option<serde_json::Value>,
1177
+ }
1178
+
931
1179
  #[derive(Debug, Serialize, Deserialize, Clone)]
932
1180
  struct UpdateCheckCache {
933
1181
  last_checked_epoch_ms: u128,
@@ -1287,6 +1535,44 @@ fn load_jsonc_file(path: &Path, label: &str) -> Result<serde_json::Value> {
1287
1535
  parse_jsonc_str(&raw, &format!("{} file {}", label, path.display()))
1288
1536
  }
1289
1537
 
1538
+ fn parse_key_value_arg(raw: &str, label: &str) -> Result<(String, String)> {
1539
+ let Some((key, value)) = raw.split_once('=') else {
1540
+ return Err(anyhow!("{} must use key=value format", label));
1541
+ };
1542
+ let normalized_key = key.trim().to_string();
1543
+ let normalized_value = value.trim().to_string();
1544
+ if normalized_key.is_empty() {
1545
+ return Err(anyhow!("{} key cannot be empty", label));
1546
+ }
1547
+ Ok((normalized_key, normalized_value))
1548
+ }
1549
+
1550
+ fn parse_key_value_args(values: &[String], label: &str) -> Result<Vec<(String, String)>> {
1551
+ values
1552
+ .iter()
1553
+ .map(|value| parse_key_value_arg(value, label))
1554
+ .collect()
1555
+ }
1556
+
1557
+ fn load_optional_json_body(body: Option<&str>) -> Result<Option<serde_json::Value>> {
1558
+ let Some(raw) = body
1559
+ .map(|value| value.trim())
1560
+ .filter(|value| !value.is_empty())
1561
+ else {
1562
+ return Ok(None);
1563
+ };
1564
+ if let Some(path) = raw.strip_prefix('@') {
1565
+ let path = PathBuf::from(path);
1566
+ return Ok(Some(load_jsonc_file(&path, "request body")?));
1567
+ }
1568
+ Ok(Some(parse_jsonc_str(raw, "request body")?))
1569
+ }
1570
+
1571
+ fn parse_scalar_json_value(raw: &str) -> serde_json::Value {
1572
+ json5::from_str::<serde_json::Value>(raw)
1573
+ .unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
1574
+ }
1575
+
1290
1576
  fn parse_object_from_value(
1291
1577
  value: serde_json::Value,
1292
1578
  context: &str,
@@ -1792,6 +2078,25 @@ fn clean_virtual_path(value: &str) -> String {
1792
2078
  .join("/")
1793
2079
  }
1794
2080
 
2081
+ const API_VERSION_PREFIX: &str = "/v1";
2082
+
2083
+ fn versioned_api_path(path: &str) -> String {
2084
+ if path.starts_with("http://") || path.starts_with("https://") {
2085
+ return path.to_string();
2086
+ }
2087
+ let normalized = if path.starts_with('/') {
2088
+ path.to_string()
2089
+ } else {
2090
+ format!("/{}", path)
2091
+ };
2092
+ if normalized == API_VERSION_PREFIX
2093
+ || normalized.starts_with(&format!("{}/", API_VERSION_PREFIX))
2094
+ {
2095
+ return normalized;
2096
+ }
2097
+ format!("{}{}", API_VERSION_PREFIX, normalized)
2098
+ }
2099
+
1795
2100
  fn apply_base_url_override(session: &mut SessionConfig, base_url: Option<String>) {
1796
2101
  if let Some(base_url) = base_url {
1797
2102
  session.base_url = normalize_base_url(&base_url);
@@ -1838,7 +2143,12 @@ async fn authed_request_with_headers(
1838
2143
  body: Option<serde_json::Value>,
1839
2144
  extra_headers: &[(String, String)],
1840
2145
  ) -> Result<reqwest::Response> {
1841
- let url = format!("{}{}", normalize_base_url(&session.base_url), path);
2146
+ let versioned_path = versioned_api_path(path);
2147
+ let url = format!(
2148
+ "{}{}",
2149
+ normalize_base_url(&session.base_url),
2150
+ versioned_path
2151
+ );
1842
2152
  let mut request = with_cli_headers(
1843
2153
  client
1844
2154
  .request(method.clone(), &url)
@@ -1871,8 +2181,104 @@ async fn authed_request_with_headers(
1871
2181
  Ok(retry.send().await?)
1872
2182
  }
1873
2183
 
2184
+ fn resolve_agent_auth(auth: &AgentAuthArgs) -> Result<AgentAuth> {
2185
+ if let Some(access_token) = auth
2186
+ .access_token
2187
+ .as_ref()
2188
+ .map(|value| value.trim().to_string())
2189
+ .filter(|value| !value.is_empty())
2190
+ {
2191
+ let base_url = normalize_base_url(auth.base_url.as_deref().unwrap_or(DEFAULT_BASE_URL));
2192
+ return Ok(AgentAuth::Token {
2193
+ base_url,
2194
+ access_token,
2195
+ });
2196
+ }
2197
+
2198
+ let mut session = load_session()?;
2199
+ apply_base_url_override(&mut session, auth.base_url.clone());
2200
+ Ok(AgentAuth::Session(session))
2201
+ }
2202
+
2203
+ fn agent_base_url(auth: &AgentAuth) -> &str {
2204
+ match auth {
2205
+ AgentAuth::Session(session) => &session.base_url,
2206
+ AgentAuth::Token { base_url, .. } => base_url,
2207
+ }
2208
+ }
2209
+
2210
+ async fn agent_request_json(
2211
+ client: &reqwest::Client,
2212
+ auth: &mut AgentAuth,
2213
+ method: Method,
2214
+ path: &str,
2215
+ body: Option<serde_json::Value>,
2216
+ extra_headers: &[(String, String)],
2217
+ ) -> Result<reqwest::Response> {
2218
+ match auth {
2219
+ AgentAuth::Session(session) => {
2220
+ authed_request_with_headers(client, session, method, path, body, extra_headers).await
2221
+ }
2222
+ AgentAuth::Token {
2223
+ base_url,
2224
+ access_token,
2225
+ } => {
2226
+ let url = format!(
2227
+ "{}{}",
2228
+ normalize_base_url(base_url),
2229
+ versioned_api_path(path)
2230
+ );
2231
+ let mut request =
2232
+ with_cli_headers(client.request(method, &url).bearer_auth(access_token));
2233
+ for (key, value) in extra_headers {
2234
+ request = request.header(key, value);
2235
+ }
2236
+ if let Some(body_value) = body {
2237
+ request = request.json(&body_value);
2238
+ }
2239
+ Ok(request.send().await?)
2240
+ }
2241
+ }
2242
+ }
2243
+
2244
+ async fn parse_response_body(
2245
+ response: reqwest::Response,
2246
+ ) -> Result<(StatusCode, serde_json::Value)> {
2247
+ let status = response.status();
2248
+ let text = response.text().await.unwrap_or_default();
2249
+ let body = serde_json::from_str::<serde_json::Value>(&text)
2250
+ .unwrap_or_else(|_| serde_json::Value::String(text));
2251
+ Ok((status, body))
2252
+ }
2253
+
2254
+ async fn fetch_discover_operation(
2255
+ client: &reqwest::Client,
2256
+ auth: &mut AgentAuth,
2257
+ operation_id: &str,
2258
+ ) -> Result<DiscoverOperation> {
2259
+ let path = format!("/discover/operations/{}", urlencoding::encode(operation_id));
2260
+ let response = agent_request_json(client, auth, Method::GET, &path, None, &[]).await?;
2261
+ let (status, body) = parse_response_body(response).await?;
2262
+ if !status.is_success() {
2263
+ return Err(anyhow!(
2264
+ "discover operation lookup failed ({}): {}",
2265
+ status,
2266
+ body
2267
+ ));
2268
+ }
2269
+ let operation = body
2270
+ .get("operation")
2271
+ .cloned()
2272
+ .ok_or_else(|| anyhow!("discover operation response missing operation payload"))?;
2273
+ Ok(serde_json::from_value(operation)?)
2274
+ }
2275
+
1874
2276
  async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig) -> Result<()> {
1875
- let url = format!("{}/auth/refresh", normalize_base_url(&session.base_url));
2277
+ let url = format!(
2278
+ "{}{}",
2279
+ normalize_base_url(&session.base_url),
2280
+ versioned_api_path("/auth/refresh")
2281
+ );
1876
2282
  let payload = RefreshRequest {
1877
2283
  refresh_token: session.refresh_token.clone(),
1878
2284
  session_id: session.session_id.clone(),
@@ -1918,64 +2324,304 @@ async fn existing_session_identity_for_base_url(
1918
2324
  .map(|email| email.to_string())
1919
2325
  }
1920
2326
 
1921
- fn default_login_scopes() -> Vec<String> {
1922
- generated::contract::CLI_DEFAULT_LOGIN_SCOPES
2327
+ fn append_query_pairs_to_path(path: &str, query_pairs: &[(String, String)]) -> String {
2328
+ if query_pairs.is_empty() {
2329
+ return path.to_string();
2330
+ }
2331
+ let separator = if path.contains('?') { '&' } else { '?' };
2332
+ let encoded = query_pairs
1923
2333
  .iter()
1924
- .map(|value| (*value).to_string())
1925
- .collect()
2334
+ .map(|(key, value)| {
2335
+ format!(
2336
+ "{}={}",
2337
+ urlencoding::encode(key),
2338
+ urlencoding::encode(value)
2339
+ )
2340
+ })
2341
+ .collect::<Vec<_>>()
2342
+ .join("&");
2343
+ format!("{}{}{}", path, separator, encoded)
1926
2344
  }
1927
2345
 
1928
- fn default_login_scopes_with_tools() -> Vec<String> {
1929
- generated::contract::CLI_DEFAULT_LOGIN_SCOPES_WITH_TOOLS
1930
- .iter()
1931
- .map(|value| (*value).to_string())
1932
- .collect()
2346
+ async fn call_command(
2347
+ client: &reqwest::Client,
2348
+ args: CallArgs,
2349
+ output: OutputFormat,
2350
+ ) -> Result<()> {
2351
+ let mut auth = resolve_agent_auth(&args.auth)?;
2352
+ let query_pairs = parse_key_value_args(&args.query, "--query")?;
2353
+ let headers = parse_key_value_args(&args.header, "--header")?;
2354
+ let path = append_query_pairs_to_path(&args.path, &query_pairs);
2355
+ let body = load_optional_json_body(args.body.as_deref())?;
2356
+ let method = Method::from_bytes(args.method.trim().to_uppercase().as_bytes())
2357
+ .with_context(|| format!("Unsupported HTTP method {}", args.method))?;
2358
+ let response =
2359
+ agent_request_json(client, &mut auth, method.clone(), &path, body, &headers).await?;
2360
+ let (status, payload) = parse_response_body(response).await?;
2361
+ let result = serde_json::json!({
2362
+ "ok": status.is_success(),
2363
+ "status": status.as_u16(),
2364
+ "method": method.as_str(),
2365
+ "baseUrl": agent_base_url(&auth),
2366
+ "path": versioned_api_path(&args.path),
2367
+ "body": payload
2368
+ });
2369
+ emit_text_or_json(
2370
+ output,
2371
+ &serde_json::to_string_pretty(&result)?,
2372
+ result.clone(),
2373
+ )?;
2374
+ if !status.is_success() {
2375
+ return Err(anyhow!("call failed with status {}", status));
2376
+ }
2377
+ Ok(())
1933
2378
  }
1934
2379
 
1935
- async fn login_command(
2380
+ async fn ops_list_command(
1936
2381
  client: &reqwest::Client,
1937
- args: LoginArgs,
2382
+ args: OpsListArgs,
1938
2383
  output: OutputFormat,
1939
2384
  ) -> Result<()> {
1940
- let base_url = normalize_base_url(&args.base_url);
1941
- if !args.force {
1942
- if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
1943
- let payload = serde_json::json!({
1944
- "ok": true,
1945
- "alreadyLoggedIn": true,
1946
- "baseUrl": base_url,
1947
- "email": email,
1948
- "message": "Use `reallink logout` to sign out or `reallink login --force` to replace this session."
1949
- });
1950
- emit_text_or_json(
1951
- output,
1952
- &format!(
1953
- "Already logged in. Use `reallink logout` to sign out or `reallink login --force` to replace this session."
1954
- ),
1955
- payload,
1956
- )?;
1957
- return Ok(());
1958
- }
2385
+ let mut auth = resolve_agent_auth(&args.auth)?;
2386
+ let mut query_pairs = Vec::new();
2387
+ if let Some(group) = args.group.filter(|value| !value.trim().is_empty()) {
2388
+ query_pairs.push(("group".to_string(), group));
2389
+ }
2390
+ if let Some(scope) = args.scope.filter(|value| !value.trim().is_empty()) {
2391
+ query_pairs.push(("scope".to_string(), scope));
1959
2392
  }
2393
+ let path = append_query_pairs_to_path("/discover", &query_pairs);
2394
+ let response = agent_request_json(client, &mut auth, Method::GET, &path, None, &[]).await?;
2395
+ let (status, payload) = parse_response_body(response).await?;
2396
+ if !status.is_success() {
2397
+ return Err(anyhow!("ops list failed ({}): {}", status, payload));
2398
+ }
2399
+ emit_text_or_json(output, &serde_json::to_string_pretty(&payload)?, payload)
2400
+ }
1960
2401
 
1961
- let (initial_scope, fallback_scope) = if args.scope.is_empty() {
1962
- (
1963
- default_login_scopes_with_tools(),
1964
- Some(default_login_scopes()),
1965
- )
1966
- } else {
1967
- (args.scope, None)
1968
- };
2402
+ async fn ops_search_command(
2403
+ client: &reqwest::Client,
2404
+ args: OpsSearchArgs,
2405
+ output: OutputFormat,
2406
+ ) -> Result<()> {
2407
+ let mut auth = resolve_agent_auth(&args.auth)?;
2408
+ let mut query_pairs = vec![("q".to_string(), args.query)];
2409
+ if let Some(group) = args.group.filter(|value| !value.trim().is_empty()) {
2410
+ query_pairs.push(("group".to_string(), group));
2411
+ }
2412
+ if let Some(scope) = args.scope.filter(|value| !value.trim().is_empty()) {
2413
+ query_pairs.push(("scope".to_string(), scope));
2414
+ }
2415
+ let path = append_query_pairs_to_path("/discover/search", &query_pairs);
2416
+ let response = agent_request_json(client, &mut auth, Method::GET, &path, None, &[]).await?;
2417
+ let (status, payload) = parse_response_body(response).await?;
2418
+ if !status.is_success() {
2419
+ return Err(anyhow!("ops search failed ({}): {}", status, payload));
2420
+ }
2421
+ emit_text_or_json(output, &serde_json::to_string_pretty(&payload)?, payload)
2422
+ }
1969
2423
 
1970
- let mut selected_scope = initial_scope;
1971
- let mut device_code_response =
1972
- with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
1973
- .json(&DeviceCodeRequest {
1974
- client_id: args.client_id.clone(),
1975
- scope: selected_scope.clone(),
1976
- })
1977
- .send()
1978
- .await?;
2424
+ async fn ops_show_command(
2425
+ client: &reqwest::Client,
2426
+ args: OpsShowArgs,
2427
+ output: OutputFormat,
2428
+ ) -> Result<()> {
2429
+ let mut auth = resolve_agent_auth(&args.auth)?;
2430
+ let operation = fetch_discover_operation(client, &mut auth, &args.operation_id).await?;
2431
+ let payload = serde_json::to_value(operation)?;
2432
+ emit_text_or_json(output, &serde_json::to_string_pretty(&payload)?, payload)
2433
+ }
2434
+
2435
+ async fn ops_invoke_command(
2436
+ client: &reqwest::Client,
2437
+ args: OpsInvokeArgs,
2438
+ output: OutputFormat,
2439
+ ) -> Result<()> {
2440
+ let mut auth = resolve_agent_auth(&args.auth)?;
2441
+ let operation = fetch_discover_operation(client, &mut auth, &args.operation_id).await?;
2442
+ let param_pairs = parse_key_value_args(&args.param, "--param")?;
2443
+ let headers = parse_key_value_args(&args.header, "--header")?;
2444
+ let mut path = operation.path.clone();
2445
+ let mut query_pairs: Vec<(String, String)> = Vec::new();
2446
+ let mut consumed_keys = std::collections::HashSet::new();
2447
+
2448
+ for parameter in &operation.parameters {
2449
+ let value = param_pairs
2450
+ .iter()
2451
+ .find(|(key, _)| key == &parameter.name)
2452
+ .map(|(_, value)| value.clone())
2453
+ .or_else(|| {
2454
+ if parameter.name == "orgId" {
2455
+ args.org_id.clone()
2456
+ } else if parameter.name == "projectId" {
2457
+ args.project_id.clone()
2458
+ } else {
2459
+ None
2460
+ }
2461
+ });
2462
+ if let Some(value) = value {
2463
+ consumed_keys.insert(parameter.name.clone());
2464
+ if parameter.location == "path" {
2465
+ path = path.replace(
2466
+ &format!("{{{}}}", parameter.name),
2467
+ &urlencoding::encode(&value),
2468
+ );
2469
+ } else if parameter.location == "query" {
2470
+ query_pairs.push((parameter.name.clone(), value));
2471
+ }
2472
+ }
2473
+ }
2474
+
2475
+ if path.contains('{') {
2476
+ return Err(anyhow!(
2477
+ "missing required path parameter(s) for {}",
2478
+ operation.operation_id
2479
+ ));
2480
+ }
2481
+
2482
+ let mut body = load_optional_json_body(args.body.as_deref())?;
2483
+ let should_create_body = operation.method != Method::GET.as_str()
2484
+ && (body.is_some()
2485
+ || !param_pairs.is_empty()
2486
+ || args.org_id.is_some()
2487
+ || args.project_id.is_some());
2488
+ if should_create_body && body.is_none() {
2489
+ body = Some(serde_json::Value::Object(serde_json::Map::new()));
2490
+ }
2491
+ if let Some(serde_json::Value::Object(map)) = body.as_mut() {
2492
+ if let Some(org_id) = args.org_id.clone() {
2493
+ map.entry("orgId".to_string())
2494
+ .or_insert_with(|| serde_json::Value::String(org_id));
2495
+ }
2496
+ if let Some(project_id) = args.project_id.clone() {
2497
+ map.entry("projectId".to_string())
2498
+ .or_insert_with(|| serde_json::Value::String(project_id));
2499
+ }
2500
+ for (key, value) in &param_pairs {
2501
+ if consumed_keys.contains(key) {
2502
+ continue;
2503
+ }
2504
+ map.entry(key.clone())
2505
+ .or_insert_with(|| parse_scalar_json_value(value));
2506
+ }
2507
+ }
2508
+
2509
+ if let Some(org_id) = args.org_id.clone() {
2510
+ if !query_pairs.iter().any(|(key, _)| key == "orgId")
2511
+ && operation
2512
+ .parameters
2513
+ .iter()
2514
+ .any(|parameter| parameter.location == "query" && parameter.name == "orgId")
2515
+ {
2516
+ query_pairs.push(("orgId".to_string(), org_id));
2517
+ }
2518
+ }
2519
+ if let Some(project_id) = args.project_id.clone() {
2520
+ if !query_pairs.iter().any(|(key, _)| key == "projectId")
2521
+ && operation
2522
+ .parameters
2523
+ .iter()
2524
+ .any(|parameter| parameter.location == "query" && parameter.name == "projectId")
2525
+ {
2526
+ query_pairs.push(("projectId".to_string(), project_id));
2527
+ }
2528
+ }
2529
+
2530
+ let request_path = append_query_pairs_to_path(&path, &query_pairs);
2531
+ let method = Method::from_bytes(operation.method.as_bytes())?;
2532
+ let response = agent_request_json(
2533
+ client,
2534
+ &mut auth,
2535
+ method.clone(),
2536
+ &request_path,
2537
+ body.clone(),
2538
+ &headers,
2539
+ )
2540
+ .await?;
2541
+ let (status, payload) = parse_response_body(response).await?;
2542
+ let result = serde_json::json!({
2543
+ "ok": status.is_success(),
2544
+ "status": status.as_u16(),
2545
+ "operation": operation,
2546
+ "request": {
2547
+ "method": method.as_str(),
2548
+ "path": request_path,
2549
+ "body": body
2550
+ },
2551
+ "response": payload
2552
+ });
2553
+ emit_text_or_json(
2554
+ output,
2555
+ &serde_json::to_string_pretty(&result)?,
2556
+ result.clone(),
2557
+ )?;
2558
+ if !status.is_success() {
2559
+ return Err(anyhow!("ops invoke failed with status {}", status));
2560
+ }
2561
+ Ok(())
2562
+ }
2563
+
2564
+ fn default_login_scopes() -> Vec<String> {
2565
+ generated::contract::CLI_DEFAULT_LOGIN_SCOPES
2566
+ .iter()
2567
+ .map(|value| (*value).to_string())
2568
+ .collect()
2569
+ }
2570
+
2571
+ fn default_login_scopes_with_tools() -> Vec<String> {
2572
+ generated::contract::CLI_DEFAULT_LOGIN_SCOPES_WITH_TOOLS
2573
+ .iter()
2574
+ .map(|value| (*value).to_string())
2575
+ .collect()
2576
+ }
2577
+
2578
+ async fn login_command(
2579
+ client: &reqwest::Client,
2580
+ args: LoginArgs,
2581
+ output: OutputFormat,
2582
+ ) -> Result<()> {
2583
+ let base_url = normalize_base_url(&args.base_url);
2584
+ if !args.force {
2585
+ if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
2586
+ let payload = serde_json::json!({
2587
+ "ok": true,
2588
+ "alreadyLoggedIn": true,
2589
+ "baseUrl": base_url,
2590
+ "email": email,
2591
+ "message": "Use `reallink logout` to sign out or `reallink login --force` to replace this session."
2592
+ });
2593
+ emit_text_or_json(
2594
+ output,
2595
+ &format!(
2596
+ "Already logged in. Use `reallink logout` to sign out or `reallink login --force` to replace this session."
2597
+ ),
2598
+ payload,
2599
+ )?;
2600
+ return Ok(());
2601
+ }
2602
+ }
2603
+
2604
+ let (initial_scope, fallback_scope) = if args.scope.is_empty() {
2605
+ (
2606
+ default_login_scopes_with_tools(),
2607
+ Some(default_login_scopes()),
2608
+ )
2609
+ } else {
2610
+ (args.scope, None)
2611
+ };
2612
+
2613
+ let mut selected_scope = initial_scope;
2614
+ let mut device_code_response = with_cli_headers(client.post(format!(
2615
+ "{}{}",
2616
+ base_url,
2617
+ versioned_api_path("/auth/device/code")
2618
+ )))
2619
+ .json(&DeviceCodeRequest {
2620
+ client_id: args.client_id.clone(),
2621
+ scope: selected_scope.clone(),
2622
+ })
2623
+ .send()
2624
+ .await?;
1979
2625
 
1980
2626
  if !device_code_response.status().is_success() {
1981
2627
  let body = read_error_body(device_code_response).await;
@@ -1989,14 +2635,17 @@ async fn login_command(
1989
2635
  );
1990
2636
  }
1991
2637
  selected_scope = fallback_scope.unwrap_or_default();
1992
- device_code_response =
1993
- with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
1994
- .json(&DeviceCodeRequest {
1995
- client_id: args.client_id.clone(),
1996
- scope: selected_scope.clone(),
1997
- })
1998
- .send()
1999
- .await?;
2638
+ device_code_response = with_cli_headers(client.post(format!(
2639
+ "{}{}",
2640
+ base_url,
2641
+ versioned_api_path("/auth/device/code")
2642
+ )))
2643
+ .json(&DeviceCodeRequest {
2644
+ client_id: args.client_id.clone(),
2645
+ scope: selected_scope.clone(),
2646
+ })
2647
+ .send()
2648
+ .await?;
2000
2649
  if !device_code_response.status().is_success() {
2001
2650
  let retry_body = read_error_body(device_code_response).await;
2002
2651
  return Err(anyhow!("Failed to start device flow: {}", retry_body));
@@ -2049,15 +2698,18 @@ async fn login_command(
2049
2698
 
2050
2699
  sleep(poll_interval).await;
2051
2700
 
2052
- let token_response =
2053
- with_cli_headers(client.post(format!("{}/auth/device/token", base_url)))
2054
- .json(&DeviceTokenRequest {
2055
- grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
2056
- device_code: device_code.device_code.clone(),
2057
- client_id: args.client_id.clone(),
2058
- })
2059
- .send()
2060
- .await?;
2701
+ let token_response = with_cli_headers(client.post(format!(
2702
+ "{}{}",
2703
+ base_url,
2704
+ versioned_api_path("/auth/device/token")
2705
+ )))
2706
+ .json(&DeviceTokenRequest {
2707
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
2708
+ device_code: device_code.device_code.clone(),
2709
+ client_id: args.client_id.clone(),
2710
+ })
2711
+ .send()
2712
+ .await?;
2061
2713
 
2062
2714
  if token_response.status().is_success() {
2063
2715
  let tokens: DeviceTokenSuccess = token_response.json().await?;
@@ -2375,7 +3027,10 @@ async fn logs_upload_command(
2375
3027
  });
2376
3028
  emit_text_or_json(
2377
3029
  output,
2378
- &format!("Dry run complete. {} log files are ready to upload.", payload["count"]),
3030
+ &format!(
3031
+ "Dry run complete. {} log files are ready to upload.",
3032
+ payload["count"]
3033
+ ),
2379
3034
  payload,
2380
3035
  )?;
2381
3036
  return Ok(());
@@ -2418,7 +3073,10 @@ async fn logs_upload_command(
2418
3073
  .unwrap_or_default();
2419
3074
  if file_name.eq_ignore_ascii_case("runtime.jsonl") {
2420
3075
  fs::write(&candidate.local_path, b"").with_context(|| {
2421
- format!("Failed to clear runtime log {}", candidate.local_path.display())
3076
+ format!(
3077
+ "Failed to clear runtime log {}",
3078
+ candidate.local_path.display()
3079
+ )
2422
3080
  })?;
2423
3081
  } else {
2424
3082
  let _ = fs::remove_file(&candidate.local_path);
@@ -2528,11 +3186,87 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
2528
3186
  Ok(())
2529
3187
  }
2530
3188
 
3189
+ async fn credits_account_command(client: &reqwest::Client, args: CreditsAccountArgs) -> Result<()> {
3190
+ let mut session = load_session()?;
3191
+ apply_base_url_override(&mut session, args.base.base_url);
3192
+
3193
+ let path = format!("/orgs/{}/credits/account", args.org_id);
3194
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
3195
+ if !response.status().is_success() {
3196
+ let body = read_error_body(response).await;
3197
+ return Err(anyhow!("credits account failed: {}", body));
3198
+ }
3199
+ let payload: serde_json::Value = response.json().await?;
3200
+ println!("{}", serde_json::to_string_pretty(&payload)?);
3201
+ save_session(&session)?;
3202
+ Ok(())
3203
+ }
3204
+
3205
+ async fn credits_ledger_command(client: &reqwest::Client, args: CreditsLedgerArgs) -> Result<()> {
3206
+ let mut session = load_session()?;
3207
+ apply_base_url_override(&mut session, args.base.base_url);
3208
+
3209
+ let path = format!(
3210
+ "/orgs/{}/credits/ledger?limit={}&offset={}",
3211
+ args.org_id, args.limit, args.offset
3212
+ );
3213
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
3214
+ if !response.status().is_success() {
3215
+ let body = read_error_body(response).await;
3216
+ return Err(anyhow!("credits ledger failed: {}", body));
3217
+ }
3218
+ let payload: serde_json::Value = response.json().await?;
3219
+ println!("{}", serde_json::to_string_pretty(&payload)?);
3220
+ save_session(&session)?;
3221
+ Ok(())
3222
+ }
3223
+
3224
+ async fn credits_project_usage_command(
3225
+ client: &reqwest::Client,
3226
+ args: CreditsProjectUsageArgs,
3227
+ ) -> Result<()> {
3228
+ let mut session = load_session()?;
3229
+ apply_base_url_override(&mut session, args.base.base_url);
3230
+
3231
+ let path = format!(
3232
+ "/projects/{}/usage?limit={}&offset={}",
3233
+ args.project_id, args.limit, args.offset
3234
+ );
3235
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
3236
+ if !response.status().is_success() {
3237
+ let body = read_error_body(response).await;
3238
+ return Err(anyhow!("project usage failed: {}", body));
3239
+ }
3240
+ let payload: serde_json::Value = response.json().await?;
3241
+ println!("{}", serde_json::to_string_pretty(&payload)?);
3242
+ save_session(&session)?;
3243
+ Ok(())
3244
+ }
3245
+
3246
+ async fn credits_run_usage_command(
3247
+ client: &reqwest::Client,
3248
+ args: CreditsRunUsageArgs,
3249
+ ) -> Result<()> {
3250
+ let mut session = load_session()?;
3251
+ apply_base_url_override(&mut session, args.base.base_url);
3252
+
3253
+ let path = format!("/tools/runs/{}/usage", args.run_id);
3254
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
3255
+ if !response.status().is_success() {
3256
+ let body = read_error_body(response).await;
3257
+ return Err(anyhow!("run usage failed: {}", body));
3258
+ }
3259
+ let payload: serde_json::Value = response.json().await?;
3260
+ println!("{}", serde_json::to_string_pretty(&payload)?);
3261
+ save_session(&session)?;
3262
+ Ok(())
3263
+ }
3264
+
2531
3265
  async fn org_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
2532
3266
  let mut session = load_session()?;
2533
3267
  apply_base_url_override(&mut session, args.base_url);
2534
3268
 
2535
- let response = authed_request(client, &mut session, Method::GET, "/core/orgs", None).await?;
3269
+ let response = authed_request(client, &mut session, Method::GET, "/orgs", None).await?;
2536
3270
  if !response.status().is_success() {
2537
3271
  let body = read_error_body(response).await;
2538
3272
  return Err(anyhow!("org list failed: {}", body));
@@ -2551,7 +3285,7 @@ async fn org_create_command(client: &reqwest::Client, args: OrgCreateArgs) -> Re
2551
3285
  client,
2552
3286
  &mut session,
2553
3287
  Method::POST,
2554
- "/core/orgs",
3288
+ "/orgs",
2555
3289
  Some(serde_json::json!({
2556
3290
  "name": args.name
2557
3291
  })),
@@ -2571,7 +3305,7 @@ async fn org_get_command(client: &reqwest::Client, args: OrgGetArgs) -> Result<(
2571
3305
  let mut session = load_session()?;
2572
3306
  apply_base_url_override(&mut session, args.base_url);
2573
3307
 
2574
- let path = format!("/core/orgs/{}", args.org_id);
3308
+ let path = format!("/orgs/{}", args.org_id);
2575
3309
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2576
3310
  if !response.status().is_success() {
2577
3311
  let body = read_error_body(response).await;
@@ -2587,7 +3321,7 @@ async fn org_update_command(client: &reqwest::Client, args: OrgUpdateArgs) -> Re
2587
3321
  let mut session = load_session()?;
2588
3322
  apply_base_url_override(&mut session, args.base_url);
2589
3323
 
2590
- let path = format!("/core/orgs/{}", args.org_id);
3324
+ let path = format!("/orgs/{}", args.org_id);
2591
3325
  let response = authed_request(
2592
3326
  client,
2593
3327
  &mut session,
@@ -2612,7 +3346,7 @@ async fn org_delete_command(client: &reqwest::Client, args: OrgDeleteArgs) -> Re
2612
3346
  let mut session = load_session()?;
2613
3347
  apply_base_url_override(&mut session, args.base_url);
2614
3348
 
2615
- let path = format!("/core/orgs/{}", args.org_id);
3349
+ let path = format!("/orgs/{}", args.org_id);
2616
3350
  let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2617
3351
  if !response.status().is_success() {
2618
3352
  let body = read_error_body(response).await;
@@ -2628,7 +3362,7 @@ async fn org_invites_command(client: &reqwest::Client, args: OrgInvitesArgs) ->
2628
3362
  let mut session = load_session()?;
2629
3363
  apply_base_url_override(&mut session, args.base_url);
2630
3364
 
2631
- let path = format!("/core/orgs/{}/invites", args.org_id);
3365
+ let path = format!("/orgs/{}/invites", args.org_id);
2632
3366
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2633
3367
  if !response.status().is_success() {
2634
3368
  let body = read_error_body(response).await;
@@ -2667,13 +3401,17 @@ async fn org_invite_command(client: &reqwest::Client, args: OrgInviteArgs) -> Re
2667
3401
 
2668
3402
  let role = args.role.trim().to_lowercase();
2669
3403
  if role != "member" && role != "admin" {
2670
- return Err(anyhow!("org invite role must be either 'member' or 'admin'"));
3404
+ return Err(anyhow!(
3405
+ "org invite role must be either 'member' or 'admin'"
3406
+ ));
2671
3407
  }
2672
3408
  if args.expires_in_days == 0 || args.expires_in_days > 30 {
2673
- return Err(anyhow!("org invite expires_in_days must be between 1 and 30"));
3409
+ return Err(anyhow!(
3410
+ "org invite expires_in_days must be between 1 and 30"
3411
+ ));
2674
3412
  }
2675
3413
 
2676
- let path = format!("/core/orgs/{}/invites", args.org_id);
3414
+ let path = format!("/orgs/{}/invites", args.org_id);
2677
3415
  let response = authed_request(
2678
3416
  client,
2679
3417
  &mut session,
@@ -2729,7 +3467,7 @@ async fn org_members_command(client: &reqwest::Client, args: OrgMembersArgs) ->
2729
3467
  let mut session = load_session()?;
2730
3468
  apply_base_url_override(&mut session, args.base_url);
2731
3469
 
2732
- let path = format!("/core/orgs/{}/members", args.org_id);
3470
+ let path = format!("/orgs/{}/members", args.org_id);
2733
3471
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2734
3472
  if !response.status().is_success() {
2735
3473
  let body = read_error_body(response).await;
@@ -2745,7 +3483,7 @@ async fn org_add_member_command(client: &reqwest::Client, args: OrgAddMemberArgs
2745
3483
  let mut session = load_session()?;
2746
3484
  apply_base_url_override(&mut session, args.base_url);
2747
3485
 
2748
- let path = format!("/core/orgs/{}/members", args.org_id);
3486
+ let path = format!("/orgs/{}/members", args.org_id);
2749
3487
  let response = authed_request(
2750
3488
  client,
2751
3489
  &mut session,
@@ -2767,11 +3505,14 @@ async fn org_add_member_command(client: &reqwest::Client, args: OrgAddMemberArgs
2767
3505
  Ok(())
2768
3506
  }
2769
3507
 
2770
- async fn org_update_member_command(client: &reqwest::Client, args: OrgUpdateMemberArgs) -> Result<()> {
3508
+ async fn org_update_member_command(
3509
+ client: &reqwest::Client,
3510
+ args: OrgUpdateMemberArgs,
3511
+ ) -> Result<()> {
2771
3512
  let mut session = load_session()?;
2772
3513
  apply_base_url_override(&mut session, args.base_url);
2773
3514
 
2774
- let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
3515
+ let path = format!("/orgs/{}/members/{}", args.org_id, args.user_id);
2775
3516
  let response = authed_request(
2776
3517
  client,
2777
3518
  &mut session,
@@ -2792,11 +3533,14 @@ async fn org_update_member_command(client: &reqwest::Client, args: OrgUpdateMemb
2792
3533
  Ok(())
2793
3534
  }
2794
3535
 
2795
- async fn org_remove_member_command(client: &reqwest::Client, args: OrgRemoveMemberArgs) -> Result<()> {
3536
+ async fn org_remove_member_command(
3537
+ client: &reqwest::Client,
3538
+ args: OrgRemoveMemberArgs,
3539
+ ) -> Result<()> {
2796
3540
  let mut session = load_session()?;
2797
3541
  apply_base_url_override(&mut session, args.base_url);
2798
3542
 
2799
- let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
3543
+ let path = format!("/orgs/{}/members/{}", args.org_id, args.user_id);
2800
3544
  let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2801
3545
  if !response.status().is_success() {
2802
3546
  let body = read_error_body(response).await;
@@ -2814,9 +3558,9 @@ async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -
2814
3558
 
2815
3559
  let path = match args.org_id {
2816
3560
  Some(org_id) if !org_id.trim().is_empty() => {
2817
- format!("/core/projects?orgId={}", org_id.trim())
3561
+ format!("/projects?orgId={}", org_id.trim())
2818
3562
  }
2819
- _ => "/core/projects".to_string(),
3563
+ _ => "/projects".to_string(),
2820
3564
  };
2821
3565
 
2822
3566
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
@@ -2848,7 +3592,7 @@ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArg
2848
3592
  client,
2849
3593
  &mut session,
2850
3594
  Method::POST,
2851
- "/core/projects",
3595
+ "/projects",
2852
3596
  Some(serde_json::Value::Object(body)),
2853
3597
  )
2854
3598
  .await?;
@@ -2866,7 +3610,7 @@ async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) ->
2866
3610
  let mut session = load_session()?;
2867
3611
  apply_base_url_override(&mut session, args.base_url);
2868
3612
 
2869
- let path = format!("/core/projects/{}", args.project_id);
3613
+ let path = format!("/projects/{}", args.project_id);
2870
3614
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2871
3615
  if !response.status().is_success() {
2872
3616
  let body = read_error_body(response).await;
@@ -2901,7 +3645,7 @@ async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArg
2901
3645
  ));
2902
3646
  }
2903
3647
 
2904
- let path = format!("/core/projects/{}", args.project_id);
3648
+ let path = format!("/projects/{}", args.project_id);
2905
3649
  let response = authed_request(
2906
3650
  client,
2907
3651
  &mut session,
@@ -2924,7 +3668,7 @@ async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArg
2924
3668
  let mut session = load_session()?;
2925
3669
  apply_base_url_override(&mut session, args.base_url);
2926
3670
 
2927
- let path = format!("/core/projects/{}", args.project_id);
3671
+ let path = format!("/projects/{}", args.project_id);
2928
3672
  let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2929
3673
  if !response.status().is_success() {
2930
3674
  let body = read_error_body(response).await;
@@ -2940,7 +3684,7 @@ async fn project_members_command(client: &reqwest::Client, args: ProjectMembersA
2940
3684
  let mut session = load_session()?;
2941
3685
  apply_base_url_override(&mut session, args.base_url);
2942
3686
 
2943
- let path = format!("/core/projects/{}/members", args.project_id);
3687
+ let path = format!("/projects/{}/members", args.project_id);
2944
3688
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2945
3689
  if !response.status().is_success() {
2946
3690
  let body = read_error_body(response).await;
@@ -2952,11 +3696,14 @@ async fn project_members_command(client: &reqwest::Client, args: ProjectMembersA
2952
3696
  Ok(())
2953
3697
  }
2954
3698
 
2955
- async fn project_add_member_command(client: &reqwest::Client, args: ProjectAddMemberArgs) -> Result<()> {
3699
+ async fn project_add_member_command(
3700
+ client: &reqwest::Client,
3701
+ args: ProjectAddMemberArgs,
3702
+ ) -> Result<()> {
2956
3703
  let mut session = load_session()?;
2957
3704
  apply_base_url_override(&mut session, args.base_url);
2958
3705
 
2959
- let path = format!("/core/projects/{}/members", args.project_id);
3706
+ let path = format!("/projects/{}/members", args.project_id);
2960
3707
  let response = authed_request(
2961
3708
  client,
2962
3709
  &mut session,
@@ -2985,7 +3732,7 @@ async fn project_update_member_command(
2985
3732
  let mut session = load_session()?;
2986
3733
  apply_base_url_override(&mut session, args.base_url);
2987
3734
 
2988
- let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
3735
+ let path = format!("/projects/{}/members/{}", args.project_id, args.user_id);
2989
3736
  let response = authed_request(
2990
3737
  client,
2991
3738
  &mut session,
@@ -3013,7 +3760,7 @@ async fn project_remove_member_command(
3013
3760
  let mut session = load_session()?;
3014
3761
  apply_base_url_override(&mut session, args.base_url);
3015
3762
 
3016
- let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
3763
+ let path = format!("/projects/{}/members/{}", args.project_id, args.user_id);
3017
3764
  let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
3018
3765
  if !response.status().is_success() {
3019
3766
  let body = read_error_body(response).await;
@@ -3030,7 +3777,7 @@ async fn verify_project_access(
3030
3777
  session: &mut SessionConfig,
3031
3778
  project_id: &str,
3032
3779
  ) -> Result<()> {
3033
- let path = format!("/core/projects/{}", project_id);
3780
+ let path = format!("/projects/{}", project_id);
3034
3781
  let response = authed_request(client, session, Method::GET, &path, None).await?;
3035
3782
  if response.status().is_success() {
3036
3783
  return Ok(());
@@ -3115,7 +3862,10 @@ async fn sync_unreal_link_manifest_asset(
3115
3862
  .await
3116
3863
  }
3117
3864
 
3118
- pub(crate) async fn link_unreal_command(client: &reqwest::Client, args: LinkUnrealArgs) -> Result<()> {
3865
+ pub(crate) async fn link_unreal_command(
3866
+ client: &reqwest::Client,
3867
+ args: LinkUnrealArgs,
3868
+ ) -> Result<()> {
3119
3869
  let uproject_path = resolve_uproject_path(&args.uproject)?;
3120
3870
  let project_root = uproject_path
3121
3871
  .parent()
@@ -3396,7 +4146,10 @@ pub(crate) async fn link_paths_command(args: LinkPathsArgs) -> Result<()> {
3396
4146
  Ok(())
3397
4147
  }
3398
4148
 
3399
- pub(crate) async fn link_doctor_command(client: &reqwest::Client, args: LinkDoctorArgs) -> Result<()> {
4149
+ pub(crate) async fn link_doctor_command(
4150
+ client: &reqwest::Client,
4151
+ args: LinkDoctorArgs,
4152
+ ) -> Result<()> {
3400
4153
  let config = load_unreal_links()?;
3401
4154
  if config.links.is_empty() {
3402
4155
  return Err(anyhow!(
@@ -4380,6 +5133,15 @@ async fn file_list_command(client: &reqwest::Client, args: FileListArgs) -> Resu
4380
5133
  query_parts.push(format!("path={}", cleaned));
4381
5134
  }
4382
5135
  }
5136
+ if let Some(search) = args.search.as_deref() {
5137
+ query_parts.push(format!("search={}", urlencoding::encode(search)));
5138
+ }
5139
+ if let Some(sort_by) = args.sort_by.as_deref() {
5140
+ query_parts.push(format!("sortBy={}", urlencoding::encode(sort_by)));
5141
+ }
5142
+ if let Some(kind) = args.kind.as_deref() {
5143
+ query_parts.push(format!("kind={}", urlencoding::encode(kind)));
5144
+ }
4383
5145
  if let Some(offset) = args.offset {
4384
5146
  query_parts.push(format!("offset={}", offset));
4385
5147
  }
@@ -4494,10 +5256,7 @@ async fn file_thumbnail_command(client: &reqwest::Client, args: FileThumbnailArg
4494
5256
  if let Some(parent) = output_path.parent() {
4495
5257
  if !parent.as_os_str().is_empty() {
4496
5258
  tokio_fs::create_dir_all(parent).await.with_context(|| {
4497
- format!(
4498
- "Failed to create output directory {}",
4499
- parent.display()
4500
- )
5259
+ format!("Failed to create output directory {}", parent.display())
4501
5260
  })?;
4502
5261
  }
4503
5262
  }
@@ -4756,10 +5515,16 @@ async fn file_set_command(client: &reqwest::Client, args: FileSetArgs) -> Result
4756
5515
  );
4757
5516
  }
4758
5517
  if let Some(asset_type) = args.asset_type {
4759
- body.insert("assetType".to_string(), serde_json::Value::String(asset_type));
5518
+ body.insert(
5519
+ "assetType".to_string(),
5520
+ serde_json::Value::String(asset_type),
5521
+ );
4760
5522
  }
4761
5523
  if let Some(visibility) = args.visibility {
4762
- body.insert("visibility".to_string(), serde_json::Value::String(visibility));
5524
+ body.insert(
5525
+ "visibility".to_string(),
5526
+ serde_json::Value::String(visibility),
5527
+ );
4763
5528
  }
4764
5529
 
4765
5530
  let path = format!("/assets/{}", args.asset_id);
@@ -4781,7 +5546,10 @@ async fn file_set_command(client: &reqwest::Client, args: FileSetArgs) -> Result
4781
5546
  Ok(())
4782
5547
  }
4783
5548
 
4784
- async fn file_move_folder_command(client: &reqwest::Client, args: FileMoveFolderArgs) -> Result<()> {
5549
+ async fn file_move_folder_command(
5550
+ client: &reqwest::Client,
5551
+ args: FileMoveFolderArgs,
5552
+ ) -> Result<()> {
4785
5553
  let mut session = load_session()?;
4786
5554
  apply_base_url_override(&mut session, args.base_url);
4787
5555
 
@@ -4921,7 +5689,10 @@ async fn tool_publish_command(client: &reqwest::Client, args: ToolPublishArgs) -
4921
5689
  body.insert("channel".to_string(), serde_json::Value::String(channel));
4922
5690
  }
4923
5691
  if let Some(visibility) = args.visibility {
4924
- body.insert("visibility".to_string(), serde_json::Value::String(visibility));
5692
+ body.insert(
5693
+ "visibility".to_string(),
5694
+ serde_json::Value::String(visibility),
5695
+ );
4925
5696
  }
4926
5697
  if let Some(notes) = args.notes {
4927
5698
  body.insert("notes".to_string(), serde_json::Value::String(notes));
@@ -5383,57 +6154,186 @@ async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) ->
5383
6154
  Ok(())
5384
6155
  }
5385
6156
 
5386
- async fn tool_run_events_command(client: &reqwest::Client, args: ToolRunEventsArgs) -> Result<()> {
6157
+ async fn tool_cancel_command(client: &reqwest::Client, args: ToolCancelArgs) -> Result<()> {
5387
6158
  let mut session = load_session()?;
5388
6159
  apply_base_url_override(&mut session, args.base_url);
5389
6160
 
5390
- let mut path = format!("/tools/runs/{}/events", args.run_id);
5391
- let mut query_parts: Vec<String> = Vec::new();
5392
- if let Some(limit) = args.limit {
5393
- query_parts.push(format!("limit={}", limit));
5394
- }
5395
- if let Some(status) = args.status {
5396
- query_parts.push(format!("status={}", status));
5397
- }
5398
- if let Some(stage_prefix) = args.stage_prefix {
5399
- query_parts.push(format!("stagePrefix={}", stage_prefix));
5400
- }
5401
- if let Some(since) = args.since {
5402
- query_parts.push(format!("since={}", since));
5403
- }
5404
- if let Some(until) = args.until {
5405
- query_parts.push(format!("until={}", until));
6161
+ let path = format!("/tools/runs/{}/cancel", args.run_id);
6162
+ let mut body = serde_json::Map::new();
6163
+ if let Some(reason) = args.reason {
6164
+ let normalized = reason.trim();
6165
+ if !normalized.is_empty() {
6166
+ body.insert(
6167
+ "reason".to_string(),
6168
+ serde_json::Value::String(normalized.to_string()),
6169
+ );
6170
+ }
5406
6171
  }
5407
- if !query_parts.is_empty() {
5408
- path.push_str(&format!("?{}", query_parts.join("&")));
6172
+ if let Some(metadata_file) = args.metadata_file {
6173
+ let metadata = load_jsonc_file(&metadata_file, "tool cancel metadata")?;
6174
+ let metadata_obj = parse_object_from_value(metadata, "tool cancel metadata")?;
6175
+ body.insert(
6176
+ "metadata".to_string(),
6177
+ serde_json::Value::Object(metadata_obj),
6178
+ );
5409
6179
  }
6180
+ let payload = if body.is_empty() {
6181
+ None
6182
+ } else {
6183
+ Some(serde_json::Value::Object(body))
6184
+ };
5410
6185
 
5411
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
6186
+ let response = authed_request(client, &mut session, Method::POST, &path, payload).await?;
5412
6187
  if !response.status().is_success() {
5413
6188
  let body = read_error_body(response).await;
5414
- return Err(anyhow!("tool run-events failed: {}", body));
6189
+ return Err(anyhow!("tool cancel failed: {}", body));
5415
6190
  }
5416
6191
  let payload: serde_json::Value = response.json().await?;
5417
6192
  println!(
5418
6193
  "{}",
5419
- serde_json::to_string_pretty(payload.get("events").unwrap_or(&payload))?
6194
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
5420
6195
  );
5421
6196
  save_session(&session)?;
5422
6197
  Ok(())
5423
6198
  }
5424
6199
 
5425
- fn resolve_report_url(base_url: &str, report_download_path: Option<&str>) -> Option<String> {
5426
- let path = report_download_path?.trim();
5427
- if path.is_empty() {
5428
- return None;
5429
- }
5430
- if path.starts_with("http://") || path.starts_with("https://") {
5431
- return Some(path.to_string());
6200
+ async fn tool_retry_command(client: &reqwest::Client, args: ToolRetryArgs) -> Result<()> {
6201
+ let mut session = load_session()?;
6202
+ apply_base_url_override(&mut session, args.base_url);
6203
+
6204
+ if args.input_json.is_some() && args.input_file.is_some() {
6205
+ return Err(anyhow!(
6206
+ "Provide either --input-json or --input-file, not both"
6207
+ ));
5432
6208
  }
5433
- Some(format!("{}{}", normalize_base_url(base_url), path))
5434
- }
5435
6209
 
5436
- fn extract_tool_run_report_paths(
6210
+ let mut body = serde_json::Map::new();
6211
+ if let Some(reason) = args.reason {
6212
+ let normalized = reason.trim();
6213
+ if !normalized.is_empty() {
6214
+ body.insert(
6215
+ "reason".to_string(),
6216
+ serde_json::Value::String(normalized.to_string()),
6217
+ );
6218
+ }
6219
+ }
6220
+
6221
+ if let Some(path) = args.input_file {
6222
+ let input_patch = load_jsonc_file(&path, "tool retry input patch")?;
6223
+ let input_patch_obj = parse_object_from_value(input_patch, "tool retry input patch")?;
6224
+ body.insert(
6225
+ "inputPatch".to_string(),
6226
+ serde_json::Value::Object(input_patch_obj),
6227
+ );
6228
+ } else if let Some(input_json) = args.input_json {
6229
+ let input_patch = parse_jsonc_str(&input_json, "tool retry input patch")?;
6230
+ let input_patch_obj = parse_object_from_value(input_patch, "tool retry input patch")?;
6231
+ body.insert(
6232
+ "inputPatch".to_string(),
6233
+ serde_json::Value::Object(input_patch_obj),
6234
+ );
6235
+ }
6236
+
6237
+ if let Some(metadata_file) = args.metadata_file {
6238
+ let metadata = load_jsonc_file(&metadata_file, "tool retry metadata")?;
6239
+ let metadata_obj = parse_object_from_value(metadata, "tool retry metadata")?;
6240
+ body.insert(
6241
+ "metadata".to_string(),
6242
+ serde_json::Value::Object(metadata_obj),
6243
+ );
6244
+ }
6245
+
6246
+ if args.wait {
6247
+ body.insert(
6248
+ "waitForCompletion".to_string(),
6249
+ serde_json::Value::Bool(true),
6250
+ );
6251
+ body.insert(
6252
+ "timeoutMs".to_string(),
6253
+ serde_json::Value::Number(args.timeout_ms.into()),
6254
+ );
6255
+ body.insert(
6256
+ "pollIntervalMs".to_string(),
6257
+ serde_json::Value::Number(args.poll_interval_ms.into()),
6258
+ );
6259
+ }
6260
+
6261
+ let payload = if body.is_empty() {
6262
+ None
6263
+ } else {
6264
+ Some(serde_json::Value::Object(body))
6265
+ };
6266
+
6267
+ let path = format!("/tools/runs/{}/retry", args.run_id);
6268
+ let response = authed_request(client, &mut session, Method::POST, &path, payload).await?;
6269
+ if !response.status().is_success() {
6270
+ let body = read_error_body(response).await;
6271
+ return Err(anyhow!("tool retry failed: {}", body));
6272
+ }
6273
+ let payload: serde_json::Value = response.json().await?;
6274
+ println!(
6275
+ "{}",
6276
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
6277
+ );
6278
+ save_session(&session)?;
6279
+ Ok(())
6280
+ }
6281
+
6282
+ async fn tool_run_events_command(client: &reqwest::Client, args: ToolRunEventsArgs) -> Result<()> {
6283
+ let mut session = load_session()?;
6284
+ apply_base_url_override(&mut session, args.base_url);
6285
+
6286
+ let mut path = format!("/tools/runs/{}/events", args.run_id);
6287
+ let mut query_parts: Vec<String> = Vec::new();
6288
+ if let Some(limit) = args.limit {
6289
+ query_parts.push(format!("limit={}", limit));
6290
+ }
6291
+ if let Some(status) = args.status {
6292
+ query_parts.push(format!("status={}", status));
6293
+ }
6294
+ if let Some(stage_prefix) = args.stage_prefix {
6295
+ query_parts.push(format!("stagePrefix={}", stage_prefix));
6296
+ }
6297
+ if let Some(since) = args.since {
6298
+ query_parts.push(format!("since={}", since));
6299
+ }
6300
+ if let Some(until) = args.until {
6301
+ query_parts.push(format!("until={}", until));
6302
+ }
6303
+ if !query_parts.is_empty() {
6304
+ path.push_str(&format!("?{}", query_parts.join("&")));
6305
+ }
6306
+
6307
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
6308
+ if !response.status().is_success() {
6309
+ let body = read_error_body(response).await;
6310
+ return Err(anyhow!("tool run-events failed: {}", body));
6311
+ }
6312
+ let payload: serde_json::Value = response.json().await?;
6313
+ println!(
6314
+ "{}",
6315
+ serde_json::to_string_pretty(payload.get("events").unwrap_or(&payload))?
6316
+ );
6317
+ save_session(&session)?;
6318
+ Ok(())
6319
+ }
6320
+
6321
+ fn resolve_report_url(base_url: &str, report_download_path: Option<&str>) -> Option<String> {
6322
+ let path = report_download_path?.trim();
6323
+ if path.is_empty() {
6324
+ return None;
6325
+ }
6326
+ if path.starts_with("http://") || path.starts_with("https://") {
6327
+ return Some(path.to_string());
6328
+ }
6329
+ Some(format!(
6330
+ "{}{}",
6331
+ normalize_base_url(base_url),
6332
+ versioned_api_path(path)
6333
+ ))
6334
+ }
6335
+
6336
+ fn extract_tool_run_report_paths(
5437
6337
  run: &serde_json::Map<String, serde_json::Value>,
5438
6338
  base_url: &str,
5439
6339
  ) -> serde_json::Value {
@@ -5450,7 +6350,11 @@ fn extract_tool_run_report_paths(
5450
6350
  let report_download_path = output
5451
6351
  .get("reportHtmlDownloadPath")
5452
6352
  .and_then(|value| value.as_str())
5453
- .or_else(|| output.get("reportDownloadPath").and_then(|value| value.as_str()));
6353
+ .or_else(|| {
6354
+ output
6355
+ .get("reportDownloadPath")
6356
+ .and_then(|value| value.as_str())
6357
+ });
5454
6358
  let report_url = resolve_report_url(base_url, report_download_path);
5455
6359
  let runtime = output
5456
6360
  .get("runtime")
@@ -5475,7 +6379,10 @@ fn extract_tool_run_report_paths(
5475
6379
  })
5476
6380
  }
5477
6381
 
5478
- async fn tool_trace_status_command(client: &reqwest::Client, args: ToolTraceStatusArgs) -> Result<()> {
6382
+ async fn tool_trace_status_command(
6383
+ client: &reqwest::Client,
6384
+ args: ToolTraceStatusArgs,
6385
+ ) -> Result<()> {
5479
6386
  let mut session = load_session()?;
5480
6387
  apply_base_url_override(&mut session, args.base_url);
5481
6388
 
@@ -5512,6 +6419,10 @@ async fn tool_trace_status_command(client: &reqwest::Client, args: ToolTraceStat
5512
6419
  .get("id")
5513
6420
  .and_then(|value| value.as_str())
5514
6421
  .unwrap_or_default();
6422
+ let run_name = run_obj
6423
+ .get("uniqueName")
6424
+ .and_then(|value| value.as_str())
6425
+ .unwrap_or(run_id);
5515
6426
  let status = run_obj
5516
6427
  .get("status")
5517
6428
  .and_then(|value| value.as_str())
@@ -5532,6 +6443,7 @@ async fn tool_trace_status_command(client: &reqwest::Client, args: ToolTraceStat
5532
6443
 
5533
6444
  items.push(serde_json::json!({
5534
6445
  "runId": run_id,
6446
+ "runName": run_name,
5535
6447
  "toolId": tool_id,
5536
6448
  "status": status,
5537
6449
  "createdAt": created_at,
@@ -5586,7 +6498,10 @@ fn build_tool_context_path(
5586
6498
  Ok(path)
5587
6499
  }
5588
6500
 
5589
- async fn tool_context_put_command(client: &reqwest::Client, args: ToolContextPutArgs) -> Result<()> {
6501
+ async fn tool_context_put_command(
6502
+ client: &reqwest::Client,
6503
+ args: ToolContextPutArgs,
6504
+ ) -> Result<()> {
5590
6505
  let mut session = load_session()?;
5591
6506
  apply_base_url_override(&mut session, args.base_url);
5592
6507
 
@@ -5628,7 +6543,10 @@ async fn tool_context_put_command(client: &reqwest::Client, args: ToolContextPut
5628
6543
  Ok(())
5629
6544
  }
5630
6545
 
5631
- async fn tool_context_get_command(client: &reqwest::Client, args: ToolContextGetArgs) -> Result<()> {
6546
+ async fn tool_context_get_command(
6547
+ client: &reqwest::Client,
6548
+ args: ToolContextGetArgs,
6549
+ ) -> Result<()> {
5632
6550
  let mut session = load_session()?;
5633
6551
  apply_base_url_override(&mut session, args.base_url);
5634
6552
 
@@ -5648,7 +6566,10 @@ async fn tool_context_get_command(client: &reqwest::Client, args: ToolContextGet
5648
6566
  Ok(())
5649
6567
  }
5650
6568
 
5651
- async fn tool_local_catalog_command(client: &reqwest::Client, args: ToolLocalCatalogArgs) -> Result<()> {
6569
+ async fn tool_local_catalog_command(
6570
+ client: &reqwest::Client,
6571
+ args: ToolLocalCatalogArgs,
6572
+ ) -> Result<()> {
5652
6573
  let mut session = load_session()?;
5653
6574
  apply_base_url_override(&mut session, args.base_url);
5654
6575
 
@@ -5656,10 +6577,7 @@ async fn tool_local_catalog_command(client: &reqwest::Client, args: ToolLocalCat
5656
6577
  let platform = args.platform.unwrap_or(default_platform);
5657
6578
  let arch = args.arch.unwrap_or(default_arch);
5658
6579
 
5659
- let mut query_parts = vec![
5660
- format!("platform={}", platform),
5661
- format!("arch={}", arch),
5662
- ];
6580
+ let mut query_parts = vec![format!("platform={}", platform), format!("arch={}", arch)];
5663
6581
  if let Some(org_id) = args.org_id {
5664
6582
  query_parts.push(format!("orgId={}", org_id));
5665
6583
  }
@@ -5682,7 +6600,10 @@ async fn tool_local_catalog_command(client: &reqwest::Client, args: ToolLocalCat
5682
6600
  Ok(())
5683
6601
  }
5684
6602
 
5685
- async fn tool_local_install_command(client: &reqwest::Client, args: ToolLocalInstallArgs) -> Result<()> {
6603
+ async fn tool_local_install_command(
6604
+ client: &reqwest::Client,
6605
+ args: ToolLocalInstallArgs,
6606
+ ) -> Result<()> {
5686
6607
  let mut session = load_session()?;
5687
6608
  apply_base_url_override(&mut session, args.base_url);
5688
6609
 
@@ -5754,9 +6675,9 @@ async fn tool_local_install_command(client: &reqwest::Client, args: ToolLocalIns
5754
6675
  }
5755
6676
  if let Some(parent) = output_path.parent() {
5756
6677
  if !parent.as_os_str().is_empty() {
5757
- tokio_fs::create_dir_all(parent)
5758
- .await
5759
- .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
6678
+ tokio_fs::create_dir_all(parent).await.with_context(|| {
6679
+ format!("Failed to create output directory {}", parent.display())
6680
+ })?;
5760
6681
  }
5761
6682
  }
5762
6683
 
@@ -5815,7 +6736,8 @@ async fn tool_local_install_command(client: &reqwest::Client, args: ToolLocalIns
5815
6736
  return Err(anyhow!("tool local install download failed: {}", body));
5816
6737
  }
5817
6738
 
5818
- let append_mode = resume_from.is_some() && download_response.status() == StatusCode::PARTIAL_CONTENT;
6739
+ let append_mode =
6740
+ resume_from.is_some() && download_response.status() == StatusCode::PARTIAL_CONTENT;
5819
6741
  let mut output_file = if append_mode {
5820
6742
  tokio_fs::OpenOptions::new()
5821
6743
  .append(true)
@@ -6082,14 +7004,1139 @@ async fn self_update_command(
6082
7004
  Ok(())
6083
7005
  }
6084
7006
 
7007
+ // ---------------------------------------------------------------------------
7008
+ // Source Bridge: link source + connect
7009
+ // ---------------------------------------------------------------------------
7010
+
7011
+ const SOURCE_LINKS_FILE_NAME: &str = "source-links.json";
7012
+ const DEFAULT_CONNECT_URL: &str = "wss://reallink-connect.radiantclay.workers.dev";
7013
+
7014
+ fn source_links_path() -> Result<PathBuf> {
7015
+ Ok(state_root_path()?.join(SOURCE_LINKS_FILE_NAME))
7016
+ }
7017
+
7018
+ fn load_source_links() -> SourceLinksConfig {
7019
+ let path = match source_links_path() {
7020
+ Ok(p) => p,
7021
+ Err(_) => return SourceLinksConfig::default(),
7022
+ };
7023
+ let raw = match std::fs::read(&path) {
7024
+ Ok(data) => data,
7025
+ Err(_) => return SourceLinksConfig::default(),
7026
+ };
7027
+ serde_json::from_slice(&raw).unwrap_or_default()
7028
+ }
7029
+
7030
+ fn save_source_links(config: &SourceLinksConfig) -> Result<()> {
7031
+ let path = source_links_path()?;
7032
+ if let Some(parent) = path.parent() {
7033
+ std::fs::create_dir_all(parent)?;
7034
+ }
7035
+ let json = serde_json::to_string_pretty(config)?;
7036
+ write_atomic(&path, json.as_bytes())?;
7037
+ Ok(())
7038
+ }
7039
+
7040
+ pub(crate) async fn link_source_command(
7041
+ _client: &reqwest::Client,
7042
+ args: LinkSourceArgs,
7043
+ ) -> Result<()> {
7044
+ let folder_path = args.path.canonicalize().with_context(|| {
7045
+ format!(
7046
+ "Path does not exist or is not accessible: {}",
7047
+ args.path.display()
7048
+ )
7049
+ })?;
7050
+ if !folder_path.is_dir() {
7051
+ return Err(anyhow!(
7052
+ "Path is not a directory: {}",
7053
+ folder_path.display()
7054
+ ));
7055
+ }
7056
+
7057
+ let mut config = load_source_links();
7058
+ let folder_str = folder_path.display().to_string();
7059
+
7060
+ // Upsert
7061
+ let existing = config
7062
+ .links
7063
+ .iter_mut()
7064
+ .find(|l| l.project_id == args.project_id && l.folder_path == folder_str);
7065
+ if let Some(link) = existing {
7066
+ link.folder_label = args.label.clone();
7067
+ link.created_at_epoch_ms = now_epoch_ms();
7068
+ } else {
7069
+ config.links.push(SourceLinkRecord {
7070
+ project_id: args.project_id.clone(),
7071
+ folder_path: folder_str.clone(),
7072
+ folder_label: args.label.clone(),
7073
+ created_at_epoch_ms: now_epoch_ms(),
7074
+ });
7075
+ }
7076
+ config.version = 1;
7077
+ save_source_links(&config)?;
7078
+
7079
+ let payload = serde_json::json!({
7080
+ "ok": true,
7081
+ "projectId": args.project_id,
7082
+ "folderPath": folder_str,
7083
+ "label": args.label,
7084
+ });
7085
+ println!("{}", serde_json::to_string_pretty(&payload)?);
7086
+ Ok(())
7087
+ }
7088
+
7089
+ pub(crate) async fn link_connect_command(
7090
+ client: &reqwest::Client,
7091
+ args: LinkConnectArgs,
7092
+ ) -> Result<()> {
7093
+ use futures_util::{SinkExt, StreamExt};
7094
+ use tokio_tungstenite::tungstenite::http;
7095
+ use tokio_tungstenite::tungstenite::Message;
7096
+
7097
+ let config = load_source_links();
7098
+ let maybe_session = if args.access_token.is_some() && args.base_url.is_some() {
7099
+ None
7100
+ } else {
7101
+ Some(load_session()?)
7102
+ };
7103
+
7104
+ // Determine project ID
7105
+ let project_id = args.project_id
7106
+ .or_else(|| {
7107
+ let unreal = load_unreal_links().ok().unwrap_or_default();
7108
+ unreal.default_project_id.clone()
7109
+ })
7110
+ .ok_or_else(|| anyhow!("No project ID specified and no default project set. Use --project-id or run `reallink link use`"))?;
7111
+
7112
+ // Find source links for this project
7113
+ let folders: Vec<_> = config
7114
+ .links
7115
+ .iter()
7116
+ .filter(|l| l.project_id == project_id)
7117
+ .map(|l| serde_json::json!({ "path": l.folder_path, "label": l.folder_label }))
7118
+ .collect();
7119
+
7120
+ if folders.is_empty() {
7121
+ return Err(anyhow!(
7122
+ "No source folders linked for project {}. Run `reallink link source` first.",
7123
+ project_id
7124
+ ));
7125
+ }
7126
+
7127
+ // Fetch connect ticket from API gateway (verifies user auth + project access)
7128
+ eprintln!("Requesting connect ticket...");
7129
+ let base_url = args
7130
+ .base_url
7131
+ .as_deref()
7132
+ .or_else(|| maybe_session.as_ref().map(|session| session.base_url.as_str()))
7133
+ .ok_or_else(|| anyhow!("No base URL specified and no saved session available. Use --base-url or run `reallink login`"))?;
7134
+ let access_token = args
7135
+ .access_token
7136
+ .as_deref()
7137
+ .or_else(|| maybe_session.as_ref().map(|session| session.access_token.as_str()))
7138
+ .ok_or_else(|| anyhow!("No access token specified and no saved session available. Use --access-token or run `reallink login`"))?;
7139
+ let ticket_url = format!(
7140
+ "{}/v1/projects/{}/connect/ticket",
7141
+ base_url,
7142
+ urlencoding::encode(&project_id)
7143
+ );
7144
+ let ticket_resp = client
7145
+ .post(&ticket_url)
7146
+ .header("Authorization", format!("Bearer {}", access_token))
7147
+ .header("Content-Type", "application/json")
7148
+ .send()
7149
+ .await
7150
+ .with_context(|| "Failed to request connect ticket")?;
7151
+
7152
+ if !ticket_resp.status().is_success() {
7153
+ let status = ticket_resp.status();
7154
+ let body = ticket_resp.text().await.unwrap_or_default();
7155
+ return Err(anyhow!(
7156
+ "Connect ticket request failed ({}): {}",
7157
+ status,
7158
+ body
7159
+ ));
7160
+ }
7161
+
7162
+ let ticket: serde_json::Value = ticket_resp.json().await?;
7163
+ let connect_token = ticket
7164
+ .get("connectToken")
7165
+ .and_then(|v| v.as_str())
7166
+ .ok_or_else(|| anyhow!("No connectToken in ticket response"))?
7167
+ .to_string();
7168
+ let ws_base_url = ticket
7169
+ .get("wsUrl")
7170
+ .and_then(|v| v.as_str())
7171
+ .map(|s| s.to_string());
7172
+
7173
+ // Build WS URL: use ticket wsUrl, or CLI override, or default
7174
+ let connect_base = args
7175
+ .connect_url
7176
+ .or(ws_base_url.map(|u| {
7177
+ // wsUrl is like wss://connect.../ws/projId — extract the base
7178
+ u.rsplit_once("/ws/")
7179
+ .map(|(base, _)| base.to_string())
7180
+ .unwrap_or(u)
7181
+ }))
7182
+ .unwrap_or_else(|| DEFAULT_CONNECT_URL.to_string());
7183
+ let ws_url = format!("{}/ws/{}", connect_base, project_id);
7184
+
7185
+ eprintln!("Connecting to project {} ...", project_id);
7186
+ eprintln!("Source folders:");
7187
+ for link in &config.links {
7188
+ if link.project_id == project_id {
7189
+ eprintln!(" [{}] {}", link.folder_label, link.folder_path);
7190
+ }
7191
+ }
7192
+
7193
+ // Resolve allowed root paths for security
7194
+ let allowed_roots: Vec<std::path::PathBuf> = config
7195
+ .links
7196
+ .iter()
7197
+ .filter(|l| l.project_id == project_id)
7198
+ .filter_map(|l| std::path::PathBuf::from(&l.folder_path).canonicalize().ok())
7199
+ .collect();
7200
+
7201
+ let hostname = hostname::get()
7202
+ .map(|h| h.to_string_lossy().to_string())
7203
+ .unwrap_or_else(|_| "unknown".to_string());
7204
+ let workspace_id = args.workspace_id.clone().unwrap_or_else(|| {
7205
+ format!(
7206
+ "{}:{}:{}",
7207
+ project_id,
7208
+ hostname,
7209
+ args.transport.trim().to_lowercase()
7210
+ )
7211
+ });
7212
+
7213
+ // Reconnect loop with exponential backoff
7214
+ let mut backoff_ms: u64 = 1000;
7215
+ let max_backoff_ms: u64 = 30_000;
7216
+
7217
+ loop {
7218
+ eprintln!("Connecting WebSocket...");
7219
+ let ws_request = http::Request::builder()
7220
+ .uri(&ws_url)
7221
+ .header("x-reallink-service-token", &connect_token)
7222
+ .header(
7223
+ "Host",
7224
+ ws_url
7225
+ .replace("wss://", "")
7226
+ .replace("ws://", "")
7227
+ .split('/')
7228
+ .next()
7229
+ .unwrap_or(""),
7230
+ )
7231
+ .header("Upgrade", "websocket")
7232
+ .header("Connection", "Upgrade")
7233
+ .header(
7234
+ "Sec-WebSocket-Key",
7235
+ tokio_tungstenite::tungstenite::handshake::client::generate_key(),
7236
+ )
7237
+ .header("Sec-WebSocket-Version", "13")
7238
+ .body(())
7239
+ .expect("Failed to build WS request");
7240
+ let connect_result = tokio_tungstenite::connect_async(ws_request).await;
7241
+ let (mut ws_stream, _) = match connect_result {
7242
+ Ok(pair) => {
7243
+ backoff_ms = 1000; // Reset on successful connect
7244
+ eprintln!("Connected.");
7245
+ pair
7246
+ }
7247
+ Err(e) => {
7248
+ eprintln!("Connection failed: {}. Retrying in {}ms...", e, backoff_ms);
7249
+ tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
7250
+ backoff_ms = (backoff_ms * 2).min(max_backoff_ms);
7251
+ continue;
7252
+ }
7253
+ };
7254
+
7255
+ // Send register message
7256
+ let register_msg = serde_json::json!({
7257
+ "type": "register",
7258
+ "workspaceId": workspace_id,
7259
+ "transport": args.transport.trim().to_lowercase(),
7260
+ "hostname": hostname,
7261
+ "os": std::env::consts::OS,
7262
+ "userId": "",
7263
+ "folders": folders,
7264
+ "capabilities": ["stat", "read", "readRange", "readLines", "list", "search", "write", "mkdir", "delete"],
7265
+ "metadata": {
7266
+ "cliVersion": env!("CARGO_PKG_VERSION"),
7267
+ }
7268
+ });
7269
+ if let Err(e) = ws_stream
7270
+ .send(Message::Text(register_msg.to_string()))
7271
+ .await
7272
+ {
7273
+ eprintln!("Failed to send register: {}. Reconnecting...", e);
7274
+ tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
7275
+ backoff_ms = (backoff_ms * 2).min(max_backoff_ms);
7276
+ continue;
7277
+ }
7278
+ backoff_ms = 1000; // Reset backoff after successful register
7279
+
7280
+ // Main message loop with heartbeat
7281
+ let heartbeat_interval = std::time::Duration::from_secs(30);
7282
+ let mut heartbeat_timer = tokio::time::interval(heartbeat_interval);
7283
+ heartbeat_timer.tick().await; // consume initial tick
7284
+
7285
+ loop {
7286
+ tokio::select! {
7287
+ msg_opt = ws_stream.next() => {
7288
+ match msg_opt {
7289
+ Some(Ok(Message::Text(text))) => {
7290
+ if let Err(e) = handle_ws_message(&text, &allowed_roots, &mut ws_stream).await {
7291
+ eprintln!("Error handling message: {}", e);
7292
+ }
7293
+ },
7294
+ Some(Ok(Message::Close(_))) | None => {
7295
+ eprintln!("Connection closed. Reconnecting...");
7296
+ break;
7297
+ },
7298
+ Some(Ok(_)) => {}, // ping/pong/binary — ignore
7299
+ Some(Err(e)) => {
7300
+ eprintln!("WebSocket error: {}. Reconnecting...", e);
7301
+ break;
7302
+ }
7303
+ }
7304
+ },
7305
+ _ = heartbeat_timer.tick() => {
7306
+ let hb = serde_json::json!({"type": "heartbeat"});
7307
+ if let Err(e) = ws_stream.send(Message::Text(hb.to_string())).await {
7308
+ eprintln!("Failed to send heartbeat: {}. Reconnecting...", e);
7309
+ break;
7310
+ }
7311
+ },
7312
+ _ = tokio::signal::ctrl_c() => {
7313
+ eprintln!("\nDisconnecting...");
7314
+ let _ = ws_stream.close(None).await;
7315
+ return Ok(());
7316
+ }
7317
+ }
7318
+ }
7319
+
7320
+ // Backoff before reconnect
7321
+ tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
7322
+ backoff_ms = (backoff_ms * 2).min(max_backoff_ms);
7323
+ }
7324
+ }
7325
+
7326
+ pub(crate) async fn link_p2p_create_command(
7327
+ client: &reqwest::Client,
7328
+ args: LinkP2PCreateArgs,
7329
+ ) -> Result<()> {
7330
+ let mut auth = resolve_agent_auth(&AgentAuthArgs {
7331
+ base_url: args.base_url.clone(),
7332
+ access_token: args.access_token.clone(),
7333
+ })?;
7334
+ let metadata = match load_optional_json_body(args.metadata.as_deref())? {
7335
+ Some(value) => Some(serde_json::Value::Object(parse_object_from_value(
7336
+ value,
7337
+ "p2p metadata",
7338
+ )?)),
7339
+ None => None,
7340
+ };
7341
+ let mut body = serde_json::Map::new();
7342
+ if let Some(workspace_id) = args.workspace_id.filter(|value| !value.trim().is_empty()) {
7343
+ body.insert(
7344
+ "workspaceId".to_string(),
7345
+ serde_json::Value::String(workspace_id),
7346
+ );
7347
+ }
7348
+ if let Some(client_id) = args.client_id.filter(|value| !value.trim().is_empty()) {
7349
+ body.insert("clientId".to_string(), serde_json::Value::String(client_id));
7350
+ }
7351
+ if let Some(peer_client_id) = args.peer_client_id.filter(|value| !value.trim().is_empty()) {
7352
+ body.insert(
7353
+ "peerClientId".to_string(),
7354
+ serde_json::Value::String(peer_client_id),
7355
+ );
7356
+ }
7357
+ if let Some(ttl_seconds) = args.ttl_seconds {
7358
+ body.insert(
7359
+ "ttlSeconds".to_string(),
7360
+ serde_json::Value::Number(serde_json::Number::from(ttl_seconds)),
7361
+ );
7362
+ }
7363
+ if let Some(metadata_value) = metadata {
7364
+ body.insert("metadata".to_string(), metadata_value);
7365
+ }
7366
+ let path = format!(
7367
+ "/projects/{}/connect/p2p/sessions",
7368
+ urlencoding::encode(&args.project_id)
7369
+ );
7370
+ let response = agent_request_json(
7371
+ client,
7372
+ &mut auth,
7373
+ Method::POST,
7374
+ &path,
7375
+ Some(serde_json::Value::Object(body)),
7376
+ &[],
7377
+ )
7378
+ .await?;
7379
+ let (status, payload) = parse_response_body(response).await?;
7380
+ print_json(&payload)?;
7381
+ if !status.is_success() {
7382
+ return Err(anyhow!("link p2p create failed with status {}", status));
7383
+ }
7384
+ Ok(())
7385
+ }
7386
+
7387
+ pub(crate) async fn link_p2p_get_command(
7388
+ client: &reqwest::Client,
7389
+ args: LinkP2PGetArgs,
7390
+ ) -> Result<()> {
7391
+ let mut auth = resolve_agent_auth(&AgentAuthArgs {
7392
+ base_url: args.base_url.clone(),
7393
+ access_token: args.access_token.clone(),
7394
+ })?;
7395
+ let path = format!(
7396
+ "/projects/{}/connect/p2p/sessions/{}",
7397
+ urlencoding::encode(&args.project_id),
7398
+ urlencoding::encode(&args.session_id)
7399
+ );
7400
+ let response = agent_request_json(client, &mut auth, Method::GET, &path, None, &[]).await?;
7401
+ let (status, payload) = parse_response_body(response).await?;
7402
+ print_json(&payload)?;
7403
+ if !status.is_success() {
7404
+ return Err(anyhow!("link p2p get failed with status {}", status));
7405
+ }
7406
+ Ok(())
7407
+ }
7408
+
7409
+ pub(crate) async fn link_p2p_list_command(
7410
+ client: &reqwest::Client,
7411
+ args: LinkP2PListArgs,
7412
+ ) -> Result<()> {
7413
+ let mut auth = resolve_agent_auth(&AgentAuthArgs {
7414
+ base_url: args.base_url.clone(),
7415
+ access_token: args.access_token.clone(),
7416
+ })?;
7417
+ let mut query_pairs = Vec::new();
7418
+ if let Some(workspace_id) = args.workspace_id.filter(|value| !value.trim().is_empty()) {
7419
+ query_pairs.push(("workspaceId".to_string(), workspace_id));
7420
+ }
7421
+ if let Some(client_id) = args.client_id.filter(|value| !value.trim().is_empty()) {
7422
+ query_pairs.push(("clientId".to_string(), client_id));
7423
+ }
7424
+ if let Some(status) = args.status.filter(|value| !value.trim().is_empty()) {
7425
+ query_pairs.push(("status".to_string(), status));
7426
+ }
7427
+ query_pairs.push(("limit".to_string(), args.limit.to_string()));
7428
+ let path = append_query_pairs_to_path(
7429
+ &format!(
7430
+ "/projects/{}/connect/p2p/sessions",
7431
+ urlencoding::encode(&args.project_id)
7432
+ ),
7433
+ &query_pairs,
7434
+ );
7435
+ let response = agent_request_json(client, &mut auth, Method::GET, &path, None, &[]).await?;
7436
+ let (status, payload) = parse_response_body(response).await?;
7437
+ print_json(&payload)?;
7438
+ if !status.is_success() {
7439
+ return Err(anyhow!("link p2p list failed with status {}", status));
7440
+ }
7441
+ Ok(())
7442
+ }
7443
+
7444
+ pub(crate) async fn link_p2p_wait_command(
7445
+ client: &reqwest::Client,
7446
+ args: LinkP2PWaitArgs,
7447
+ ) -> Result<()> {
7448
+ let mut auth = resolve_agent_auth(&AgentAuthArgs {
7449
+ base_url: args.base_url.clone(),
7450
+ access_token: args.access_token.clone(),
7451
+ })?;
7452
+ let desired_status = args.status.trim().to_string();
7453
+ if desired_status.is_empty() {
7454
+ return Err(anyhow!("wait status must not be empty"));
7455
+ }
7456
+ let path = format!(
7457
+ "/projects/{}/connect/p2p/sessions/{}",
7458
+ urlencoding::encode(&args.project_id),
7459
+ urlencoding::encode(&args.session_id)
7460
+ );
7461
+ let start = std::time::Instant::now();
7462
+ loop {
7463
+ let response = agent_request_json(client, &mut auth, Method::GET, &path, None, &[]).await?;
7464
+ let (status, payload) = parse_response_body(response).await?;
7465
+ if !status.is_success() {
7466
+ print_json(&payload)?;
7467
+ return Err(anyhow!("link p2p wait failed with status {}", status));
7468
+ }
7469
+ let current_status = payload
7470
+ .get("session")
7471
+ .and_then(|session| session.get("status"))
7472
+ .and_then(|value| value.as_str())
7473
+ .unwrap_or("");
7474
+ if current_status == desired_status {
7475
+ print_json(&payload)?;
7476
+ return Ok(());
7477
+ }
7478
+ if start.elapsed() >= Duration::from_millis(args.timeout_ms) {
7479
+ print_json(&payload)?;
7480
+ return Err(anyhow!(
7481
+ "link p2p wait timed out waiting for status {}",
7482
+ desired_status
7483
+ ));
7484
+ }
7485
+ sleep(Duration::from_millis(args.poll_interval_ms)).await;
7486
+ }
7487
+ }
7488
+
7489
+ pub(crate) async fn link_p2p_signal_command(
7490
+ client: &reqwest::Client,
7491
+ args: LinkP2PSignalArgs,
7492
+ ) -> Result<()> {
7493
+ let mut auth = resolve_agent_auth(&AgentAuthArgs {
7494
+ base_url: args.base_url.clone(),
7495
+ access_token: args.access_token.clone(),
7496
+ })?;
7497
+ let payload = load_optional_json_body(Some(args.payload.as_str()))?
7498
+ .ok_or_else(|| anyhow!("signal payload is required"))?;
7499
+ let payload =
7500
+ serde_json::Value::Object(parse_object_from_value(payload, "p2p signal payload")?);
7501
+ let mut body = serde_json::Map::new();
7502
+ body.insert(
7503
+ "role".to_string(),
7504
+ serde_json::Value::String(args.role.as_api_str().to_string()),
7505
+ );
7506
+ body.insert(
7507
+ "signalType".to_string(),
7508
+ serde_json::Value::String(args.signal_type.as_api_str().to_string()),
7509
+ );
7510
+ body.insert("payload".to_string(), payload);
7511
+ if let Some(signal_id) = args.signal_id.filter(|value| !value.trim().is_empty()) {
7512
+ body.insert("signalId".to_string(), serde_json::Value::String(signal_id));
7513
+ }
7514
+ if let Some(from_client_id) = args.from_client_id.filter(|value| !value.trim().is_empty()) {
7515
+ body.insert(
7516
+ "fromClientId".to_string(),
7517
+ serde_json::Value::String(from_client_id),
7518
+ );
7519
+ }
7520
+ let path = format!(
7521
+ "/projects/{}/connect/p2p/sessions/{}/signal",
7522
+ urlencoding::encode(&args.project_id),
7523
+ urlencoding::encode(&args.session_id)
7524
+ );
7525
+ let response = agent_request_json(
7526
+ client,
7527
+ &mut auth,
7528
+ Method::POST,
7529
+ &path,
7530
+ Some(serde_json::Value::Object(body)),
7531
+ &[],
7532
+ )
7533
+ .await?;
7534
+ let (status, payload) = parse_response_body(response).await?;
7535
+ print_json(&payload)?;
7536
+ if !status.is_success() {
7537
+ return Err(anyhow!("link p2p signal failed with status {}", status));
7538
+ }
7539
+ Ok(())
7540
+ }
7541
+
7542
+ async fn handle_ws_message(
7543
+ text: &str,
7544
+ allowed_roots: &[std::path::PathBuf],
7545
+ ws_stream: &mut (impl futures_util::Sink<
7546
+ tokio_tungstenite::tungstenite::Message,
7547
+ Error = tokio_tungstenite::tungstenite::Error,
7548
+ > + Unpin),
7549
+ ) -> Result<()> {
7550
+ use futures_util::SinkExt;
7551
+ use tokio_tungstenite::tungstenite::Message;
7552
+
7553
+ let msg: serde_json::Value = match serde_json::from_str(text) {
7554
+ Ok(v) => v,
7555
+ Err(e) => {
7556
+ eprintln!("Ignoring malformed server message: {}", e);
7557
+ return Ok(());
7558
+ }
7559
+ };
7560
+ let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("");
7561
+
7562
+ match msg_type {
7563
+ "registered" => {
7564
+ let client_id = msg.get("clientId").and_then(|v| v.as_str()).unwrap_or("?");
7565
+ eprintln!("Registered as {}", client_id);
7566
+ }
7567
+ "tool_request" => {
7568
+ let request_id = msg
7569
+ .get("requestId")
7570
+ .and_then(|v| v.as_str())
7571
+ .unwrap_or("")
7572
+ .to_string();
7573
+ let tool = msg.get("tool").and_then(|v| v.as_str()).unwrap_or("");
7574
+ let input = msg.get("input").cloned().unwrap_or(serde_json::Value::Null);
7575
+
7576
+ let result = execute_local_tool(tool, &input, allowed_roots).await;
7577
+ let response = serde_json::json!({
7578
+ "type": "tool_result",
7579
+ "requestId": request_id,
7580
+ "result": result,
7581
+ });
7582
+ ws_stream.send(Message::Text(response.to_string())).await?;
7583
+ }
7584
+ "error" => {
7585
+ let message = msg
7586
+ .get("message")
7587
+ .and_then(|v| v.as_str())
7588
+ .unwrap_or("unknown");
7589
+ eprintln!("Server error: {}", message);
7590
+ }
7591
+ _ => {} // ignore unknown types
7592
+ }
7593
+ Ok(())
7594
+ }
7595
+
7596
+ async fn execute_local_tool(
7597
+ tool: &str,
7598
+ input: &serde_json::Value,
7599
+ allowed_roots: &[std::path::PathBuf],
7600
+ ) -> serde_json::Value {
7601
+ match tool {
7602
+ "stat" => execute_stat_tool(input, allowed_roots).await,
7603
+ "read" => execute_read_tool(input, allowed_roots).await,
7604
+ "readRange" => execute_read_range_tool(input, allowed_roots).await,
7605
+ "readLines" => execute_read_lines_tool(input, allowed_roots).await,
7606
+ "list" => execute_list_tool(input, allowed_roots).await,
7607
+ "search" => execute_search_tool(input, allowed_roots).await,
7608
+ "write" => execute_write_tool(input, allowed_roots).await,
7609
+ "mkdir" => execute_mkdir_tool(input, allowed_roots).await,
7610
+ "delete" => execute_delete_tool(input, allowed_roots).await,
7611
+ _ => serde_json::json!({"ok": false, "error": format!("unknown tool: {}", tool)}),
7612
+ }
7613
+ }
7614
+
7615
+ fn resolve_safe_path(
7616
+ path_str: &str,
7617
+ allowed_roots: &[std::path::PathBuf],
7618
+ ) -> Option<std::path::PathBuf> {
7619
+ // Try each allowed root and resolve the path relative to it
7620
+ for root in allowed_roots {
7621
+ let candidate = root.join(path_str);
7622
+ // Canonicalize to resolve .. and symlinks
7623
+ if let Ok(resolved) = candidate.canonicalize() {
7624
+ if resolved.starts_with(root) {
7625
+ return Some(resolved);
7626
+ }
7627
+ }
7628
+ }
7629
+ None
7630
+ }
7631
+
7632
+ fn resolve_safe_path_allow_missing(
7633
+ path_str: &str,
7634
+ allowed_roots: &[std::path::PathBuf],
7635
+ ) -> Option<std::path::PathBuf> {
7636
+ for root in allowed_roots {
7637
+ let candidate = root.join(path_str);
7638
+ if let Some(parent) = candidate.parent() {
7639
+ if let Ok(resolved_parent) = parent.canonicalize() {
7640
+ if resolved_parent.starts_with(root) {
7641
+ return Some(candidate);
7642
+ }
7643
+ }
7644
+ } else if candidate.starts_with(root) {
7645
+ return Some(candidate);
7646
+ }
7647
+ }
7648
+ None
7649
+ }
7650
+
7651
+ async fn execute_stat_tool(
7652
+ input: &serde_json::Value,
7653
+ allowed_roots: &[std::path::PathBuf],
7654
+ ) -> serde_json::Value {
7655
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
7656
+ if path_str.is_empty() {
7657
+ return serde_json::json!({"ok": false, "error": "path required"});
7658
+ }
7659
+ let resolved = match resolve_safe_path(path_str, allowed_roots) {
7660
+ Some(path) => path,
7661
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
7662
+ };
7663
+ match tokio::fs::metadata(&resolved).await {
7664
+ Ok(metadata) => serde_json::json!({
7665
+ "ok": true,
7666
+ "data": {
7667
+ "path": path_str.replace("\\\\", "/"),
7668
+ "name": resolved.file_name().map(|value| value.to_string_lossy().to_string()).unwrap_or_default(),
7669
+ "isDirectory": metadata.is_dir(),
7670
+ "size": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
7671
+ "lastModified": metadata.modified().ok().and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()).map(|value| value.as_secs()),
7672
+ "backend": "bridge",
7673
+ }
7674
+ }),
7675
+ Err(error) => serde_json::json!({"ok": false, "error": error.to_string()}),
7676
+ }
7677
+ }
7678
+
7679
+ async fn execute_read_tool(
7680
+ input: &serde_json::Value,
7681
+ allowed_roots: &[std::path::PathBuf],
7682
+ ) -> serde_json::Value {
7683
+ if input.get("startByte").and_then(|v| v.as_u64()).is_some()
7684
+ || input.get("length").and_then(|v| v.as_u64()).is_some()
7685
+ {
7686
+ return execute_read_range_tool(input, allowed_roots).await;
7687
+ }
7688
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
7689
+ if path_str.is_empty() {
7690
+ return serde_json::json!({"ok": false, "error": "path required"});
7691
+ }
7692
+ let resolved = match resolve_safe_path(path_str, allowed_roots) {
7693
+ Some(p) => p,
7694
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
7695
+ };
7696
+ match tokio::fs::read_to_string(&resolved).await {
7697
+ Ok(content) => serde_json::json!({"ok": true, "data": content}),
7698
+ Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
7699
+ }
7700
+ }
7701
+
7702
+ async fn execute_read_range_tool(
7703
+ input: &serde_json::Value,
7704
+ allowed_roots: &[std::path::PathBuf],
7705
+ ) -> serde_json::Value {
7706
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
7707
+ let start_byte = input.get("startByte").and_then(|v| v.as_u64()).unwrap_or(0);
7708
+ let length = input
7709
+ .get("length")
7710
+ .and_then(|v| v.as_u64())
7711
+ .unwrap_or(4096)
7712
+ .clamp(1, 256 * 1024);
7713
+ if path_str.is_empty() {
7714
+ return serde_json::json!({"ok": false, "error": "path required"});
7715
+ }
7716
+ let resolved = match resolve_safe_path(path_str, allowed_roots) {
7717
+ Some(p) => p,
7718
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
7719
+ };
7720
+
7721
+ let mut file = match tokio::fs::File::open(&resolved).await {
7722
+ Ok(file) => file,
7723
+ Err(error) => return serde_json::json!({"ok": false, "error": error.to_string()}),
7724
+ };
7725
+ let metadata = match file.metadata().await {
7726
+ Ok(metadata) => metadata,
7727
+ Err(error) => return serde_json::json!({"ok": false, "error": error.to_string()}),
7728
+ };
7729
+ let total_bytes = metadata.len();
7730
+ let safe_start = start_byte.min(total_bytes);
7731
+ if let Err(error) = file.seek(SeekFrom::Start(safe_start)).await {
7732
+ return serde_json::json!({"ok": false, "error": error.to_string()});
7733
+ }
7734
+ let mut reader = file.take(length);
7735
+ let mut buffer = Vec::new();
7736
+ if let Err(error) = reader.read_to_end(&mut buffer).await {
7737
+ return serde_json::json!({"ok": false, "error": error.to_string()});
7738
+ }
7739
+ let end_byte = safe_start + buffer.len() as u64;
7740
+ serde_json::json!({
7741
+ "ok": true,
7742
+ "data": {
7743
+ "path": path_str.replace("\\\\", "/"),
7744
+ "content": String::from_utf8_lossy(&buffer).to_string(),
7745
+ "startByte": safe_start,
7746
+ "endByte": end_byte,
7747
+ "totalBytes": total_bytes,
7748
+ "truncated": end_byte < total_bytes,
7749
+ }
7750
+ })
7751
+ }
7752
+
7753
+ async fn execute_read_lines_tool(
7754
+ input: &serde_json::Value,
7755
+ allowed_roots: &[std::path::PathBuf],
7756
+ ) -> serde_json::Value {
7757
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
7758
+ let center_line = input
7759
+ .get("centerLine")
7760
+ .and_then(|v| v.as_u64())
7761
+ .unwrap_or(1) as usize;
7762
+ let context_lines = input
7763
+ .get("contextLines")
7764
+ .and_then(|v| v.as_u64())
7765
+ .unwrap_or(20) as usize;
7766
+ // Clamp context to prevent excessive memory use
7767
+ let context_lines = context_lines.min(500);
7768
+
7769
+ if path_str.is_empty() {
7770
+ return serde_json::json!({"ok": false, "error": "path required"});
7771
+ }
7772
+ if center_line == 0 {
7773
+ return serde_json::json!({"ok": false, "error": "centerLine must be >= 1"});
7774
+ }
7775
+ let resolved = match resolve_safe_path(path_str, allowed_roots) {
7776
+ Some(p) => p,
7777
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
7778
+ };
7779
+ match tokio::fs::read_to_string(&resolved).await {
7780
+ Ok(content) => {
7781
+ // Use .lines() to avoid trailing empty element from split('\n')
7782
+ let lines: Vec<&str> = content.lines().collect();
7783
+ let start = center_line.saturating_sub(1 + context_lines);
7784
+ let end = (center_line + context_lines).min(lines.len());
7785
+ let formatted: Vec<String> = lines[start..end]
7786
+ .iter()
7787
+ .enumerate()
7788
+ .map(|(idx, line)| {
7789
+ let line_num = start + idx + 1;
7790
+ let marker = if line_num == center_line {
7791
+ ">>>"
7792
+ } else {
7793
+ " "
7794
+ };
7795
+ format!("{} {:5}: {}", marker, line_num, line)
7796
+ })
7797
+ .collect();
7798
+ serde_json::json!({"ok": true, "data": formatted.join("\n")})
7799
+ }
7800
+ Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
7801
+ }
7802
+ }
7803
+
7804
+ async fn execute_list_tool(
7805
+ input: &serde_json::Value,
7806
+ allowed_roots: &[std::path::PathBuf],
7807
+ ) -> serde_json::Value {
7808
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
7809
+ let resolved = if path_str.is_empty() {
7810
+ // List all allowed roots
7811
+ let entries: Vec<serde_json::Value> = allowed_roots.iter().map(|root| {
7812
+ serde_json::json!({
7813
+ "path": root.display().to_string(),
7814
+ "name": root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
7815
+ "isDirectory": true,
7816
+ })
7817
+ }).collect();
7818
+ return serde_json::json!({"ok": true, "data": entries});
7819
+ } else {
7820
+ match resolve_safe_path(path_str, allowed_roots) {
7821
+ Some(p) => p,
7822
+ None => {
7823
+ return serde_json::json!({"ok": false, "error": "path not within allowed folders"})
7824
+ }
7825
+ }
7826
+ };
7827
+
7828
+ match tokio::fs::read_dir(&resolved).await {
7829
+ Ok(mut dir) => {
7830
+ let mut entries = Vec::new();
7831
+ while let Ok(Some(entry)) = dir.next_entry().await {
7832
+ let meta = entry.metadata().await.ok();
7833
+ entries.push(serde_json::json!({
7834
+ "path": entry.path().display().to_string(),
7835
+ "name": entry.file_name().to_string_lossy().to_string(),
7836
+ "isDirectory": meta.as_ref().map(|m| m.is_dir()).unwrap_or(false),
7837
+ "size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
7838
+ }));
7839
+ }
7840
+ serde_json::json!({"ok": true, "data": entries})
7841
+ }
7842
+ Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
7843
+ }
7844
+ }
7845
+
7846
+ async fn execute_search_tool(
7847
+ input: &serde_json::Value,
7848
+ allowed_roots: &[std::path::PathBuf],
7849
+ ) -> serde_json::Value {
7850
+ let mode = input.get("mode").and_then(|v| v.as_str()).unwrap_or("");
7851
+ let filename = input.get("filename").and_then(|v| v.as_str()).unwrap_or("");
7852
+ let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
7853
+ let content_pattern = input
7854
+ .get("contentPattern")
7855
+ .and_then(|v| v.as_str())
7856
+ .unwrap_or("");
7857
+ let target = if !content_pattern.is_empty() {
7858
+ content_pattern
7859
+ } else if !filename.is_empty() {
7860
+ filename
7861
+ } else {
7862
+ pattern
7863
+ };
7864
+ if target.is_empty() {
7865
+ return serde_json::json!({"ok": false, "error": "filename, pattern, or contentPattern required"});
7866
+ }
7867
+
7868
+ let content_mode = mode.eq_ignore_ascii_case("content") || !content_pattern.is_empty();
7869
+ let case_sensitive = input
7870
+ .get("caseSensitive")
7871
+ .and_then(|v| v.as_bool())
7872
+ .unwrap_or(false);
7873
+ let regex_enabled = input
7874
+ .get("regex")
7875
+ .and_then(|v| v.as_bool())
7876
+ .unwrap_or(false);
7877
+ let max_results = input
7878
+ .get("maxResults")
7879
+ .and_then(|v| v.as_u64())
7880
+ .unwrap_or(25)
7881
+ .clamp(1, 250) as usize;
7882
+ let path_prefix = input
7883
+ .get("pathPrefix")
7884
+ .and_then(|v| v.as_str())
7885
+ .map(|value| value.replace("\\", "/").trim_start_matches('/').to_string());
7886
+ let target_lower = target.to_lowercase();
7887
+ let matcher: Option<regex::Regex> = if regex_enabled {
7888
+ match RegexBuilder::new(target)
7889
+ .case_insensitive(!case_sensitive)
7890
+ .build()
7891
+ {
7892
+ Ok(regex) => Some(regex),
7893
+ Err(error) => {
7894
+ return serde_json::json!({"ok": false, "error": format!("invalid regex: {}", error)})
7895
+ }
7896
+ }
7897
+ } else {
7898
+ None
7899
+ };
7900
+
7901
+ let mut results: Vec<serde_json::Value> = Vec::new();
7902
+ 'outer: for root in allowed_roots {
7903
+ for entry in walkdir::WalkDir::new(root)
7904
+ .max_depth(20)
7905
+ .follow_links(false)
7906
+ .into_iter()
7907
+ .filter_map(|e| e.ok())
7908
+ {
7909
+ if results.len() >= max_results {
7910
+ break 'outer;
7911
+ }
7912
+ if !entry.file_type().is_file() {
7913
+ continue;
7914
+ }
7915
+ let rel_path = entry
7916
+ .path()
7917
+ .strip_prefix(root)
7918
+ .map(|p| p.display().to_string().replace("\\", "/"))
7919
+ .unwrap_or_else(|_| entry.path().display().to_string().replace("\\", "/"));
7920
+ if let Some(prefix) = path_prefix.as_ref() {
7921
+ if !rel_path.starts_with(prefix) {
7922
+ continue;
7923
+ }
7924
+ }
7925
+
7926
+ if !content_mode {
7927
+ let name = entry.file_name().to_string_lossy().to_string();
7928
+ let matched = if let Some(regex) = matcher.as_ref() {
7929
+ regex.is_match(&name)
7930
+ } else if case_sensitive {
7931
+ name.contains(target)
7932
+ } else {
7933
+ name.to_lowercase().contains(&target_lower)
7934
+ };
7935
+ if matched {
7936
+ results.push(serde_json::json!({
7937
+ "path": rel_path,
7938
+ "kind": "filename",
7939
+ }));
7940
+ }
7941
+ continue;
7942
+ }
7943
+
7944
+ let file = match tokio::fs::File::open(entry.path()).await {
7945
+ Ok(file) => file,
7946
+ Err(_) => continue,
7947
+ };
7948
+ let mut reader = BufReader::new(file);
7949
+ let mut line = String::new();
7950
+ let mut line_number = 0usize;
7951
+ let mut scanned_bytes = 0usize;
7952
+ let max_bytes_per_file = 2 * 1024 * 1024usize;
7953
+ loop {
7954
+ line.clear();
7955
+ let bytes_read = match reader.read_line(&mut line).await {
7956
+ Ok(bytes) => bytes,
7957
+ Err(_) => break,
7958
+ };
7959
+ if bytes_read == 0 {
7960
+ break;
7961
+ }
7962
+ scanned_bytes += bytes_read;
7963
+ if scanned_bytes > max_bytes_per_file || line.as_bytes().contains(&0) {
7964
+ break;
7965
+ }
7966
+ line_number += 1;
7967
+ let line_text = line.trim_end_matches(&['\r', '\n'][..]);
7968
+ let found_at = if let Some(regex) = matcher.as_ref() {
7969
+ regex
7970
+ .find(line_text)
7971
+ .map(|m| (m.start(), m.as_str().to_string()))
7972
+ } else if case_sensitive {
7973
+ line_text
7974
+ .find(target)
7975
+ .map(|index| (index, line_text[index..index + target.len()].to_string()))
7976
+ } else {
7977
+ line_text
7978
+ .to_lowercase()
7979
+ .find(&target_lower)
7980
+ .map(|index| (index, line_text[index..index + target.len()].to_string()))
7981
+ };
7982
+ if let Some((column, matched_text)) = found_at {
7983
+ results.push(serde_json::json!({
7984
+ "path": rel_path,
7985
+ "kind": "content",
7986
+ "line": line_number,
7987
+ "column": column + 1,
7988
+ "match": matched_text,
7989
+ "preview": line_text,
7990
+ }));
7991
+ if results.len() >= max_results {
7992
+ break 'outer;
7993
+ }
7994
+ }
7995
+ }
7996
+ }
7997
+ }
7998
+ serde_json::json!({"ok": true, "data": results})
7999
+ }
8000
+
8001
+ async fn execute_write_tool(
8002
+ input: &serde_json::Value,
8003
+ allowed_roots: &[std::path::PathBuf],
8004
+ ) -> serde_json::Value {
8005
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
8006
+ let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("");
8007
+ let create_parents = input
8008
+ .get("createParents")
8009
+ .and_then(|v| v.as_bool())
8010
+ .unwrap_or(true);
8011
+ let overwrite = input
8012
+ .get("overwrite")
8013
+ .and_then(|v| v.as_bool())
8014
+ .unwrap_or(true);
8015
+
8016
+ if path_str.is_empty() {
8017
+ return serde_json::json!({"ok": false, "error": "path required"});
8018
+ }
8019
+
8020
+ let resolved = match resolve_safe_path_allow_missing(path_str, allowed_roots) {
8021
+ Some(path) => path,
8022
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
8023
+ };
8024
+
8025
+ if create_parents {
8026
+ if let Some(parent) = resolved.parent() {
8027
+ if let Err(error) = tokio::fs::create_dir_all(parent).await {
8028
+ return serde_json::json!({"ok": false, "error": error.to_string()});
8029
+ }
8030
+ }
8031
+ }
8032
+
8033
+ if !overwrite && resolved.exists() {
8034
+ return serde_json::json!({"ok": false, "error": format!("file already exists: {}", path_str)});
8035
+ }
8036
+
8037
+ match tokio::fs::write(&resolved, content).await {
8038
+ Ok(_) => match tokio::fs::metadata(&resolved).await {
8039
+ Ok(metadata) => serde_json::json!({
8040
+ "ok": true,
8041
+ "data": {
8042
+ "path": path_str.replace("\\\\", "/"),
8043
+ "name": resolved.file_name().map(|value| value.to_string_lossy().to_string()).unwrap_or_default(),
8044
+ "isDirectory": metadata.is_dir(),
8045
+ "size": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
8046
+ "lastModified": metadata.modified().ok().and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()).map(|value| value.as_secs()),
8047
+ "backend": "bridge",
8048
+ }
8049
+ }),
8050
+ Err(error) => serde_json::json!({"ok": false, "error": error.to_string()}),
8051
+ },
8052
+ Err(error) => serde_json::json!({"ok": false, "error": error.to_string()}),
8053
+ }
8054
+ }
8055
+
8056
+ async fn execute_mkdir_tool(
8057
+ input: &serde_json::Value,
8058
+ allowed_roots: &[std::path::PathBuf],
8059
+ ) -> serde_json::Value {
8060
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
8061
+ let create_parents = input
8062
+ .get("createParents")
8063
+ .and_then(|v| v.as_bool())
8064
+ .unwrap_or(true);
8065
+ if path_str.is_empty() {
8066
+ return serde_json::json!({"ok": false, "error": "path required"});
8067
+ }
8068
+
8069
+ let resolved = match resolve_safe_path_allow_missing(path_str, allowed_roots) {
8070
+ Some(path) => path,
8071
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
8072
+ };
8073
+
8074
+ let result = if create_parents {
8075
+ tokio::fs::create_dir_all(&resolved).await
8076
+ } else {
8077
+ tokio::fs::create_dir(&resolved).await
8078
+ };
8079
+
8080
+ match result {
8081
+ Ok(_) => serde_json::json!({
8082
+ "ok": true,
8083
+ "data": {
8084
+ "path": path_str.replace("\\\\", "/"),
8085
+ "name": resolved.file_name().map(|value| value.to_string_lossy().to_string()).unwrap_or_default(),
8086
+ "isDirectory": true,
8087
+ "backend": "bridge",
8088
+ }
8089
+ }),
8090
+ Err(error) => serde_json::json!({"ok": false, "error": error.to_string()}),
8091
+ }
8092
+ }
8093
+
8094
+ async fn execute_delete_tool(
8095
+ input: &serde_json::Value,
8096
+ allowed_roots: &[std::path::PathBuf],
8097
+ ) -> serde_json::Value {
8098
+ let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
8099
+ let recursive = input
8100
+ .get("recursive")
8101
+ .and_then(|v| v.as_bool())
8102
+ .unwrap_or(false);
8103
+ if path_str.is_empty() {
8104
+ return serde_json::json!({"ok": false, "error": "path required"});
8105
+ }
8106
+
8107
+ let resolved = match resolve_safe_path(path_str, allowed_roots) {
8108
+ Some(path) => path,
8109
+ None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
8110
+ };
8111
+
8112
+ match tokio::fs::metadata(&resolved).await {
8113
+ Ok(metadata) => {
8114
+ let result = if metadata.is_dir() {
8115
+ if recursive {
8116
+ tokio::fs::remove_dir_all(&resolved).await
8117
+ } else {
8118
+ tokio::fs::remove_dir(&resolved).await
8119
+ }
8120
+ } else {
8121
+ tokio::fs::remove_file(&resolved).await
8122
+ };
8123
+ match result {
8124
+ Ok(_) => serde_json::json!({"ok": true, "data": true}),
8125
+ Err(error) => serde_json::json!({"ok": false, "error": error.to_string()}),
8126
+ }
8127
+ }
8128
+ Err(error) => serde_json::json!({"ok": false, "error": error.to_string()}),
8129
+ }
8130
+ }
8131
+
6085
8132
  #[cfg(test)]
6086
8133
  mod tests {
8134
+ use super::unreal::{PluginIndexFile, PluginIndexPlugin, PluginIndexVersion};
6087
8135
  use super::{
6088
8136
  compose_plugin_archive_url, default_login_scopes, default_login_scopes_with_tools,
6089
8137
  file_name_component, normalize_public_bucket_base, normalize_sha256_hex, parse_api_error,
6090
8138
  resolve_plugin_from_index,
6091
8139
  };
6092
- use super::unreal::{PluginIndexFile, PluginIndexPlugin, PluginIndexVersion};
6093
8140
 
6094
8141
  #[test]
6095
8142
  fn normalizes_public_bucket_base() {
@@ -6223,6 +8270,13 @@ async fn run_cli(cli: Cli) -> Result<()> {
6223
8270
  Commands::Whoami(args) => whoami_command(&client, args).await?,
6224
8271
  Commands::Logout => logout_command(&client, cli.output).await?,
6225
8272
  Commands::SelfUpdate(args) => self_update_command(&client, args, cli.output).await?,
8273
+ Commands::Call(args) => call_command(&client, args, cli.output).await?,
8274
+ Commands::Ops { command } => match command {
8275
+ OpsCommands::List(args) => ops_list_command(&client, args, cli.output).await?,
8276
+ OpsCommands::Search(args) => ops_search_command(&client, args, cli.output).await?,
8277
+ OpsCommands::Show(args) => ops_show_command(&client, args, cli.output).await?,
8278
+ OpsCommands::Invoke(args) => ops_invoke_command(&client, args, cli.output).await?,
8279
+ },
6226
8280
  Commands::Org { command } => match command {
6227
8281
  OrgCommands::List(args) => org_list_command(&client, args).await?,
6228
8282
  OrgCommands::Create(args) => org_create_command(&client, args).await?,
@@ -6257,6 +8311,14 @@ async fn run_cli(cli: Cli) -> Result<()> {
6257
8311
  TokenCommands::Create(args) => token_create_command(&client, args).await?,
6258
8312
  TokenCommands::Revoke(args) => token_revoke_command(&client, args).await?,
6259
8313
  },
8314
+ Commands::Credits { command } => match command {
8315
+ CreditsCommands::Account(args) => credits_account_command(&client, args).await?,
8316
+ CreditsCommands::Ledger(args) => credits_ledger_command(&client, args).await?,
8317
+ CreditsCommands::ProjectUsage(args) => {
8318
+ credits_project_usage_command(&client, args).await?
8319
+ }
8320
+ CreditsCommands::RunUsage(args) => credits_run_usage_command(&client, args).await?,
8321
+ },
6260
8322
  Commands::File { command } => match command {
6261
8323
  FileCommands::List(args) => file_list_command(&client, args).await?,
6262
8324
  FileCommands::Tree(args) => file_tree_command(&client, args).await?,
@@ -6286,11 +8348,17 @@ async fn run_cli(cli: Cli) -> Result<()> {
6286
8348
  SkillCommands::Run(args) => tool_run_command(&client, args).await?,
6287
8349
  SkillCommands::Runs(args) => tool_runs_command(&client, args).await?,
6288
8350
  SkillCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
8351
+ SkillCommands::Cancel(args) => tool_cancel_command(&client, args).await?,
8352
+ SkillCommands::Retry(args) => tool_retry_command(&client, args).await?,
6289
8353
  SkillCommands::RunEvents(args) => tool_run_events_command(&client, args).await?,
6290
8354
  SkillCommands::TraceStatus(args) => tool_trace_status_command(&client, args).await?,
6291
8355
  SkillCommands::Local { command } => match command {
6292
- ToolLocalCommands::Catalog(args) => tool_local_catalog_command(&client, args).await?,
6293
- ToolLocalCommands::Install(args) => tool_local_install_command(&client, args).await?,
8356
+ ToolLocalCommands::Catalog(args) => {
8357
+ tool_local_catalog_command(&client, args).await?
8358
+ }
8359
+ ToolLocalCommands::Install(args) => {
8360
+ tool_local_install_command(&client, args).await?
8361
+ }
6294
8362
  ToolLocalCommands::CompleteRun(args) => {
6295
8363
  tool_local_complete_run_command(&client, args).await?
6296
8364
  }
@@ -6310,11 +8378,17 @@ async fn run_cli(cli: Cli) -> Result<()> {
6310
8378
  ToolCommands::Run(args) => tool_run_command(&client, args).await?,
6311
8379
  ToolCommands::Runs(args) => tool_runs_command(&client, args).await?,
6312
8380
  ToolCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
8381
+ ToolCommands::Cancel(args) => tool_cancel_command(&client, args).await?,
8382
+ ToolCommands::Retry(args) => tool_retry_command(&client, args).await?,
6313
8383
  ToolCommands::RunEvents(args) => tool_run_events_command(&client, args).await?,
6314
8384
  ToolCommands::TraceStatus(args) => tool_trace_status_command(&client, args).await?,
6315
8385
  ToolCommands::Local { command } => match command {
6316
- ToolLocalCommands::Catalog(args) => tool_local_catalog_command(&client, args).await?,
6317
- ToolLocalCommands::Install(args) => tool_local_install_command(&client, args).await?,
8386
+ ToolLocalCommands::Catalog(args) => {
8387
+ tool_local_catalog_command(&client, args).await?
8388
+ }
8389
+ ToolLocalCommands::Install(args) => {
8390
+ tool_local_install_command(&client, args).await?
8391
+ }
6318
8392
  ToolLocalCommands::CompleteRun(args) => {
6319
8393
  tool_local_complete_run_command(&client, args).await?
6320
8394
  }