reallink-cli 0.1.12 → 0.1.14

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
@@ -14,6 +14,7 @@ use tokio::time::sleep;
14
14
 
15
15
  mod unreal;
16
16
  mod generated;
17
+ mod logs;
17
18
  use unreal::{
18
19
  LinkDoctorArgs, LinkOpenArgs, LinkPathsArgs, LinkPluginInstallArgs, LinkPluginListArgs,
19
20
  LinkRemoveArgs, LinkRunArgs, LinkUnrealArgs, LinkUseArgs, PluginIndexFile, UnrealLinkRecord,
@@ -81,10 +82,18 @@ enum Commands {
81
82
  #[command(subcommand)]
82
83
  command: FileCommands,
83
84
  },
85
+ Skill {
86
+ #[command(subcommand)]
87
+ command: SkillCommands,
88
+ },
84
89
  Tool {
85
90
  #[command(subcommand)]
86
91
  command: ToolCommands,
87
92
  },
93
+ Logs {
94
+ #[command(subcommand)]
95
+ command: LogsCommands,
96
+ },
88
97
  }
89
98
 
90
99
  #[derive(Args)]
@@ -149,24 +158,102 @@ enum OrgCommands {
149
158
  #[derive(Subcommand)]
150
159
  enum FileCommands {
151
160
  List(FileListArgs),
161
+ Tree(FileTreeArgs),
152
162
  Get(FileGetArgs),
153
163
  Stat(FileStatArgs),
164
+ Thumbnail(FileThumbnailArgs),
154
165
  Download(FileDownloadArgs),
155
166
  Upload(FileUploadArgs),
156
167
  Mkdir(FileMkdirArgs),
157
168
  Move(FileMoveArgs),
169
+ Set(FileSetArgs),
170
+ MoveFolder(FileMoveFolderArgs),
171
+ Rmdir(FileRemoveFolderArgs),
158
172
  Remove(FileRemoveArgs),
159
173
  }
160
174
 
175
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
176
+ enum FileThumbnailSize {
177
+ Small,
178
+ Medium,
179
+ Large,
180
+ }
181
+
182
+ impl FileThumbnailSize {
183
+ fn as_str(self) -> &'static str {
184
+ match self {
185
+ FileThumbnailSize::Small => "small",
186
+ FileThumbnailSize::Medium => "medium",
187
+ FileThumbnailSize::Large => "large",
188
+ }
189
+ }
190
+ }
191
+
161
192
  #[derive(Subcommand)]
162
193
  enum ToolCommands {
163
194
  List(ToolListArgs),
164
195
  Register(ToolRegisterArgs),
196
+ Publish(ToolPublishArgs),
197
+ Enable(ToolEnableArgs),
198
+ Disable(ToolDisableArgs),
199
+ Context {
200
+ #[command(subcommand)]
201
+ command: ToolContextCommands,
202
+ },
203
+ Prompt(ToolPromptArgs),
204
+ Run(ToolRunArgs),
205
+ Runs(ToolRunsArgs),
206
+ GetRun(ToolGetRunArgs),
207
+ RunEvents(ToolRunEventsArgs),
208
+ TraceStatus(ToolTraceStatusArgs),
209
+ Local {
210
+ #[command(subcommand)]
211
+ command: ToolLocalCommands,
212
+ },
213
+ }
214
+
215
+ #[derive(Subcommand)]
216
+ enum ToolLocalCommands {
217
+ Catalog(ToolLocalCatalogArgs),
218
+ Install(ToolLocalInstallArgs),
219
+ CompleteRun(ToolLocalCompleteRunArgs),
220
+ }
221
+
222
+ #[derive(Subcommand)]
223
+ enum ToolContextCommands {
224
+ Put(ToolContextPutArgs),
225
+ Get(ToolContextGetArgs),
226
+ }
227
+
228
+ #[derive(Subcommand)]
229
+ enum SkillCommands {
230
+ List(ToolListArgs),
231
+ Register(ToolRegisterArgs),
232
+ Publish(ToolPublishArgs),
165
233
  Enable(ToolEnableArgs),
166
234
  Disable(ToolDisableArgs),
235
+ Context {
236
+ #[command(subcommand)]
237
+ command: ToolContextCommands,
238
+ },
239
+ Prompt(ToolPromptArgs),
167
240
  Run(ToolRunArgs),
168
241
  Runs(ToolRunsArgs),
169
242
  GetRun(ToolGetRunArgs),
243
+ RunEvents(ToolRunEventsArgs),
244
+ TraceStatus(ToolTraceStatusArgs),
245
+ Local {
246
+ #[command(subcommand)]
247
+ command: ToolLocalCommands,
248
+ },
249
+ }
250
+
251
+ #[derive(Subcommand)]
252
+ enum LogsCommands {
253
+ Status,
254
+ Consent(LogsConsentArgs),
255
+ Tail(LogsTailArgs),
256
+ Upload(LogsUploadArgs),
170
257
  }
171
258
 
172
259
  #[derive(Args)]
@@ -200,6 +287,12 @@ struct FileListArgs {
200
287
  #[arg(long)]
201
288
  path: Option<String>,
202
289
  #[arg(long)]
290
+ offset: Option<u32>,
291
+ #[arg(long)]
292
+ limit: Option<u32>,
293
+ #[arg(long)]
294
+ include_folder_markers: Option<bool>,
295
+ #[arg(long)]
203
296
  base_url: Option<String>,
204
297
  }
205
298
 
@@ -211,6 +304,14 @@ struct FileGetArgs {
211
304
  base_url: Option<String>,
212
305
  }
213
306
 
307
+ #[derive(Args)]
308
+ struct FileTreeArgs {
309
+ #[arg(long)]
310
+ project_id: String,
311
+ #[arg(long)]
312
+ base_url: Option<String>,
313
+ }
314
+
214
315
  #[derive(Args)]
215
316
  struct FileStatArgs {
216
317
  #[arg(long)]
@@ -219,6 +320,18 @@ struct FileStatArgs {
219
320
  base_url: Option<String>,
220
321
  }
221
322
 
323
+ #[derive(Args)]
324
+ struct FileThumbnailArgs {
325
+ #[arg(long)]
326
+ asset_id: String,
327
+ #[arg(long, value_enum, default_value_t = FileThumbnailSize::Medium)]
328
+ size: FileThumbnailSize,
329
+ #[arg(long = "output")]
330
+ output_path: Option<PathBuf>,
331
+ #[arg(long)]
332
+ base_url: Option<String>,
333
+ }
334
+
222
335
  #[derive(Args)]
223
336
  struct FileDownloadArgs {
224
337
  #[arg(long)]
@@ -256,6 +369,8 @@ struct FileMkdirArgs {
256
369
  project_id: String,
257
370
  #[arg(long)]
258
371
  path: String,
372
+ #[arg(long, default_value = "private")]
373
+ visibility: String,
259
374
  #[arg(long)]
260
375
  base_url: Option<String>,
261
376
  }
@@ -270,6 +385,48 @@ struct FileMoveArgs {
270
385
  base_url: Option<String>,
271
386
  }
272
387
 
388
+ #[derive(Args)]
389
+ struct FileSetArgs {
390
+ #[arg(long)]
391
+ asset_id: String,
392
+ #[arg(long)]
393
+ file_name: Option<String>,
394
+ #[arg(long)]
395
+ asset_type: Option<String>,
396
+ #[arg(long)]
397
+ visibility: Option<String>,
398
+ #[arg(long)]
399
+ base_url: Option<String>,
400
+ }
401
+
402
+ #[derive(Args)]
403
+ struct FileMoveFolderArgs {
404
+ #[arg(long)]
405
+ project_id: String,
406
+ #[arg(long)]
407
+ source_path: String,
408
+ #[arg(long, default_value = "")]
409
+ target_path: String,
410
+ #[arg(long)]
411
+ base_url: Option<String>,
412
+ }
413
+
414
+ #[derive(Args)]
415
+ struct FileRemoveFolderArgs {
416
+ #[arg(long)]
417
+ project_id: String,
418
+ #[arg(long)]
419
+ path: String,
420
+ #[arg(long, default_value_t = false)]
421
+ recursive: bool,
422
+ #[arg(long, default_value_t = false)]
423
+ dry_run: bool,
424
+ #[arg(long, default_value_t = true)]
425
+ include_folder_markers: bool,
426
+ #[arg(long)]
427
+ base_url: Option<String>,
428
+ }
429
+
273
430
  #[derive(Args)]
274
431
  struct FileRemoveArgs {
275
432
  #[arg(long)]
@@ -314,6 +471,20 @@ struct ToolEnableArgs {
314
471
  base_url: Option<String>,
315
472
  }
316
473
 
474
+ #[derive(Args)]
475
+ struct ToolPublishArgs {
476
+ #[arg(long)]
477
+ tool_id: String,
478
+ #[arg(long)]
479
+ channel: Option<String>,
480
+ #[arg(long)]
481
+ visibility: Option<String>,
482
+ #[arg(long)]
483
+ notes: Option<String>,
484
+ #[arg(long)]
485
+ base_url: Option<String>,
486
+ }
487
+
317
488
  #[derive(Args)]
318
489
  struct ToolDisableArgs {
319
490
  #[arg(long)]
@@ -346,6 +517,46 @@ struct ToolRunArgs {
346
517
  metadata_file: Option<PathBuf>,
347
518
  #[arg(long, help = "Idempotency key for deduplicating retries")]
348
519
  idempotency_key: Option<String>,
520
+ #[arg(long, action = ArgAction::SetTrue, help = "Wait until the run reaches a terminal state")]
521
+ wait: bool,
522
+ #[arg(long, default_value_t = 300_000, help = "Wait timeout in milliseconds")]
523
+ timeout_ms: u64,
524
+ #[arg(long, default_value_t = 1_500, help = "Polling interval in milliseconds")]
525
+ poll_interval_ms: u64,
526
+ #[arg(long)]
527
+ base_url: Option<String>,
528
+ }
529
+
530
+ #[derive(Args)]
531
+ struct ToolPromptArgs {
532
+ #[arg(long)]
533
+ tool_id: String,
534
+ #[arg(long)]
535
+ prompt: String,
536
+ #[arg(long)]
537
+ org_id: Option<String>,
538
+ #[arg(long)]
539
+ project_id: Option<String>,
540
+ #[arg(long, help = "Optional system prompt")]
541
+ system_prompt: Option<String>,
542
+ #[arg(long, help = "Optional model hint passed to remote runtime")]
543
+ model: Option<String>,
544
+ #[arg(long, help = "Session key used by remote runtime for context restore")]
545
+ session_key: Option<String>,
546
+ #[arg(long, help = "Inline JSON object merged into tool input")]
547
+ input_json: Option<String>,
548
+ #[arg(long, help = "Path to JSON/JSONC file merged into tool input")]
549
+ input_file: Option<PathBuf>,
550
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
551
+ metadata_file: Option<PathBuf>,
552
+ #[arg(long, help = "Idempotency key for deduplicating retries")]
553
+ idempotency_key: Option<String>,
554
+ #[arg(long, action = ArgAction::SetTrue, help = "Wait until the run reaches a terminal state")]
555
+ wait: bool,
556
+ #[arg(long, default_value_t = 300_000, help = "Wait timeout in milliseconds")]
557
+ timeout_ms: u64,
558
+ #[arg(long, default_value_t = 1_500, help = "Polling interval in milliseconds")]
559
+ poll_interval_ms: u64,
349
560
  #[arg(long)]
350
561
  base_url: Option<String>,
351
562
  }
@@ -372,6 +583,152 @@ struct ToolGetRunArgs {
372
583
  base_url: Option<String>,
373
584
  }
374
585
 
586
+ #[derive(Args)]
587
+ struct ToolRunEventsArgs {
588
+ #[arg(long)]
589
+ run_id: String,
590
+ #[arg(long)]
591
+ limit: Option<u32>,
592
+ #[arg(long)]
593
+ status: Option<String>,
594
+ #[arg(long)]
595
+ stage_prefix: Option<String>,
596
+ #[arg(long, help = "Only include events created after this ISO-8601 timestamp")]
597
+ since: Option<String>,
598
+ #[arg(long, help = "Only include events created at/before this ISO-8601 timestamp")]
599
+ until: Option<String>,
600
+ #[arg(long)]
601
+ base_url: Option<String>,
602
+ }
603
+
604
+ #[derive(Args)]
605
+ struct ToolTraceStatusArgs {
606
+ #[arg(long)]
607
+ project_id: String,
608
+ #[arg(long)]
609
+ limit: Option<u32>,
610
+ #[arg(long)]
611
+ base_url: Option<String>,
612
+ }
613
+
614
+ #[derive(Args)]
615
+ struct ToolContextPutArgs {
616
+ #[arg(long)]
617
+ context_id: String,
618
+ #[arg(long)]
619
+ text: Option<String>,
620
+ #[arg(long, help = "Path to UTF-8 text file for context payload")]
621
+ text_file: Option<PathBuf>,
622
+ #[arg(long)]
623
+ org_id: Option<String>,
624
+ #[arg(long)]
625
+ project_id: Option<String>,
626
+ #[arg(long)]
627
+ base_url: Option<String>,
628
+ }
629
+
630
+ #[derive(Args)]
631
+ struct ToolContextGetArgs {
632
+ #[arg(long)]
633
+ context_id: String,
634
+ #[arg(long)]
635
+ org_id: Option<String>,
636
+ #[arg(long)]
637
+ project_id: Option<String>,
638
+ #[arg(long)]
639
+ base_url: Option<String>,
640
+ }
641
+
642
+ #[derive(Args)]
643
+ struct ToolLocalCatalogArgs {
644
+ #[arg(long)]
645
+ org_id: Option<String>,
646
+ #[arg(long)]
647
+ project_id: Option<String>,
648
+ #[arg(long)]
649
+ platform: Option<String>,
650
+ #[arg(long)]
651
+ arch: Option<String>,
652
+ #[arg(long)]
653
+ base_url: Option<String>,
654
+ }
655
+
656
+ #[derive(Args)]
657
+ struct ToolLocalInstallArgs {
658
+ #[arg(long)]
659
+ tool_id: String,
660
+ #[arg(long)]
661
+ org_id: Option<String>,
662
+ #[arg(long)]
663
+ project_id: Option<String>,
664
+ #[arg(long)]
665
+ platform: Option<String>,
666
+ #[arg(long)]
667
+ arch: Option<String>,
668
+ #[arg(long)]
669
+ version: Option<String>,
670
+ #[arg(long = "output")]
671
+ output_path: Option<PathBuf>,
672
+ #[arg(long, help = "Resume download from existing output file size using HTTP Range")]
673
+ resume: bool,
674
+ #[arg(long, help = "Only print install intent, do not download")]
675
+ no_download: bool,
676
+ #[arg(long)]
677
+ base_url: Option<String>,
678
+ }
679
+
680
+ #[derive(Args)]
681
+ struct ToolLocalCompleteRunArgs {
682
+ #[arg(long)]
683
+ run_id: String,
684
+ #[arg(long, default_value = "succeeded")]
685
+ status: String,
686
+ #[arg(long, help = "Path to JSON/JSONC file for run output")]
687
+ output_file: Option<PathBuf>,
688
+ #[arg(long, help = "Inline JSON object for run output")]
689
+ output_json: Option<String>,
690
+ #[arg(long)]
691
+ error_message: Option<String>,
692
+ #[arg(long, help = "Path to JSON/JSONC metadata file")]
693
+ metadata_file: Option<PathBuf>,
694
+ #[arg(long)]
695
+ base_url: Option<String>,
696
+ }
697
+
698
+ #[derive(Args)]
699
+ struct LogsConsentArgs {
700
+ #[arg(long, default_value_t = false, conflicts_with = "disable")]
701
+ enable: bool,
702
+ #[arg(long, default_value_t = false, conflicts_with = "enable")]
703
+ disable: bool,
704
+ }
705
+
706
+ #[derive(Args)]
707
+ struct LogsTailArgs {
708
+ #[arg(long, default_value_t = 80)]
709
+ lines: usize,
710
+ }
711
+
712
+ #[derive(Args)]
713
+ struct LogsUploadArgs {
714
+ #[arg(long)]
715
+ project_id: String,
716
+ #[arg(long, action = ArgAction::Set, default_value_t = true)]
717
+ include_runtime: bool,
718
+ #[arg(long, action = ArgAction::Set, default_value_t = true)]
719
+ include_crash: bool,
720
+ #[arg(long)]
721
+ clear_on_success: bool,
722
+ #[arg(long, default_value = "other")]
723
+ asset_type: String,
724
+ #[arg(long, default_value = "private")]
725
+ visibility: String,
726
+ #[arg(long)]
727
+ dry_run: bool,
728
+ #[arg(long)]
729
+ base_url: Option<String>,
730
+ }
731
+
375
732
  #[derive(Args)]
376
733
  struct ProjectListArgs {
377
734
  #[arg(long)]
@@ -813,6 +1170,12 @@ struct AssetRecord {
813
1170
  #[derive(Debug, Serialize, Deserialize)]
814
1171
  struct ListAssetsResponse {
815
1172
  assets: Vec<AssetRecord>,
1173
+ #[serde(default)]
1174
+ total: Option<usize>,
1175
+ #[serde(default)]
1176
+ offset: Option<usize>,
1177
+ #[serde(default)]
1178
+ limit: Option<usize>,
816
1179
  }
817
1180
 
818
1181
  #[derive(Debug, Serialize, Deserialize)]
@@ -966,18 +1329,20 @@ fn resolve_config_root() -> Result<PathBuf> {
966
1329
  }
967
1330
 
968
1331
  fn config_path() -> Result<PathBuf> {
969
- let base = resolve_config_root()?;
970
- Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
1332
+ Ok(state_root_path()?.join(SESSION_FILE_NAME))
971
1333
  }
972
1334
 
973
1335
  fn update_cache_path() -> Result<PathBuf> {
974
- let base = resolve_config_root()?;
975
- Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
1336
+ Ok(state_root_path()?.join(UPDATE_CACHE_FILE_NAME))
976
1337
  }
977
1338
 
978
1339
  fn unreal_links_path() -> Result<PathBuf> {
1340
+ Ok(state_root_path()?.join(UNREAL_LINKS_FILE_NAME))
1341
+ }
1342
+
1343
+ fn state_root_path() -> Result<PathBuf> {
979
1344
  let base = resolve_config_root()?;
980
- Ok(base.join(SESSION_DIR_NAME).join(UNREAL_LINKS_FILE_NAME))
1345
+ Ok(base.join(SESSION_DIR_NAME))
981
1346
  }
982
1347
 
983
1348
  fn session_path_display() -> String {
@@ -1315,6 +1680,16 @@ async fn read_error_body(response: reqwest::Response) -> String {
1315
1680
  }
1316
1681
  }
1317
1682
 
1683
+ #[derive(Deserialize)]
1684
+ struct ApiErrorEnvelope {
1685
+ code: Option<String>,
1686
+ message: Option<String>,
1687
+ }
1688
+
1689
+ fn parse_api_error(body: &str) -> Option<ApiErrorEnvelope> {
1690
+ serde_json::from_str::<ApiErrorEnvelope>(body).ok()
1691
+ }
1692
+
1318
1693
  fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1319
1694
  request
1320
1695
  .header("x-reallink-client", "cli")
@@ -1831,42 +2206,282 @@ async fn logout_command(client: &reqwest::Client, output: OutputFormat) -> Resul
1831
2206
  Ok(())
1832
2207
  }
1833
2208
 
1834
- async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
1835
- let mut session = load_session()?;
1836
- apply_base_url_override(&mut session, args.base_url);
1837
-
1838
- let response = authed_request(client, &mut session, Method::GET, "/auth/me", None).await?;
1839
- if !response.status().is_success() {
1840
- let body = read_error_body(response).await;
1841
- return Err(anyhow!("whoami failed: {}", body));
2209
+ fn sanitize_cli_args(raw: &[String]) -> Vec<String> {
2210
+ let sensitive_flags = [
2211
+ "--input-json",
2212
+ "--input-file",
2213
+ "--metadata-file",
2214
+ "--token",
2215
+ "--refresh-token",
2216
+ "--password",
2217
+ "--secret",
2218
+ ];
2219
+ let mut sanitized = Vec::with_capacity(raw.len());
2220
+ let mut redact_next = false;
2221
+ for value in raw {
2222
+ if redact_next {
2223
+ sanitized.push("<redacted>".to_string());
2224
+ redact_next = false;
2225
+ continue;
2226
+ }
2227
+ if let Some((flag, _rest)) = value.split_once('=') {
2228
+ if sensitive_flags.iter().any(|candidate| candidate == &flag) {
2229
+ sanitized.push(format!("{}=<redacted>", flag));
2230
+ continue;
2231
+ }
2232
+ }
2233
+ if sensitive_flags.iter().any(|candidate| candidate == value) {
2234
+ sanitized.push(value.clone());
2235
+ redact_next = true;
2236
+ continue;
2237
+ }
2238
+ sanitized.push(value.clone());
1842
2239
  }
1843
- let payload: serde_json::Value = response.json().await?;
1844
- println!("{}", serde_json::to_string_pretty(&payload)?);
1845
- save_session(&session)?;
1846
- Ok(())
2240
+ sanitized
1847
2241
  }
1848
2242
 
1849
- async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
1850
- let mut session = load_session()?;
1851
- apply_base_url_override(&mut session, args.base_url);
1852
-
1853
- let response = authed_request(client, &mut session, Method::GET, "/auth/tokens", None).await?;
1854
- if !response.status().is_success() {
1855
- let body = read_error_body(response).await;
1856
- return Err(anyhow!("token list failed: {}", body));
2243
+ fn command_summary_for_logs() -> String {
2244
+ let args: Vec<String> = std::env::args().skip(1).collect();
2245
+ let sanitized = sanitize_cli_args(&args);
2246
+ if sanitized.is_empty() {
2247
+ "reallink".to_string()
2248
+ } else {
2249
+ format!("reallink {}", sanitized.join(" "))
1857
2250
  }
1858
- let payload: ListApiTokensResponse = response.json().await?;
1859
- println!("{}", serde_json::to_string_pretty(&payload.tokens)?);
1860
- save_session(&session)?;
1861
- Ok(())
1862
2251
  }
1863
2252
 
1864
- async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
1865
- let mut session = load_session()?;
1866
- apply_base_url_override(&mut session, args.base_url);
1867
-
1868
- let scopes = if args.scope.is_empty() {
1869
- return Err(anyhow!("At least one --scope must be provided"));
2253
+ fn append_runtime_log_event(
2254
+ command: &str,
2255
+ message: &str,
2256
+ level: &str,
2257
+ duration_ms: Option<u128>,
2258
+ exit_code: Option<i32>,
2259
+ ) {
2260
+ let state_root = match state_root_path() {
2261
+ Ok(path) => path,
2262
+ Err(_) => return,
2263
+ };
2264
+ let event = logs::RuntimeLogEvent {
2265
+ ts_epoch_ms: now_epoch_ms(),
2266
+ level: level.to_string(),
2267
+ command: command.to_string(),
2268
+ message: message.to_string(),
2269
+ duration_ms,
2270
+ exit_code,
2271
+ };
2272
+ let _ = logs::append_runtime_event(&state_root, &event);
2273
+ }
2274
+
2275
+ fn record_cli_crash_report(command: &str, error: &anyhow::Error) {
2276
+ let state_root = match state_root_path() {
2277
+ Ok(path) => path,
2278
+ Err(_) => return,
2279
+ };
2280
+ let report = logs::CrashReport {
2281
+ ts_epoch_ms: now_epoch_ms(),
2282
+ command: command.to_string(),
2283
+ message: error.to_string(),
2284
+ detail: Some(format!("{:#}", error)),
2285
+ };
2286
+ let _ = logs::write_crash_report(&state_root, &report);
2287
+ }
2288
+
2289
+ async fn logs_status_command(output: OutputFormat) -> Result<()> {
2290
+ let status = logs::status(&state_root_path()?)?;
2291
+ let payload = serde_json::to_value(&status)?;
2292
+ if output == OutputFormat::Text {
2293
+ println!(
2294
+ "Logs root: {}\nRuntime log: {}\nCrash dir: {}\nUpload consent: {}\nCrash reports: {}",
2295
+ status.logs_root,
2296
+ status.runtime_log_path,
2297
+ status.crash_dir,
2298
+ if status.consent.upload_enabled {
2299
+ "enabled"
2300
+ } else {
2301
+ "disabled"
2302
+ },
2303
+ status.crash_report_count
2304
+ );
2305
+ } else {
2306
+ print_json(&payload)?;
2307
+ }
2308
+ Ok(())
2309
+ }
2310
+
2311
+ async fn logs_consent_command(args: LogsConsentArgs, output: OutputFormat) -> Result<()> {
2312
+ let state_root = state_root_path()?;
2313
+ let consent = if args.enable {
2314
+ logs::set_upload_consent(&state_root, true)?
2315
+ } else if args.disable {
2316
+ logs::set_upload_consent(&state_root, false)?
2317
+ } else {
2318
+ logs::load_consent(&state_root)?
2319
+ };
2320
+ let payload = serde_json::json!({
2321
+ "ok": true,
2322
+ "consent": consent
2323
+ });
2324
+ if output == OutputFormat::Text {
2325
+ println!(
2326
+ "Log upload consent is {}.",
2327
+ if consent.upload_enabled {
2328
+ "enabled"
2329
+ } else {
2330
+ "disabled"
2331
+ }
2332
+ );
2333
+ } else {
2334
+ print_json(&payload)?;
2335
+ }
2336
+ Ok(())
2337
+ }
2338
+
2339
+ async fn logs_tail_command(args: LogsTailArgs, output: OutputFormat) -> Result<()> {
2340
+ let lines = logs::read_runtime_tail(&state_root_path()?, args.lines)?;
2341
+ if output == OutputFormat::Text {
2342
+ for line in lines {
2343
+ println!("{}", line);
2344
+ }
2345
+ } else {
2346
+ print_json(&serde_json::json!({
2347
+ "ok": true,
2348
+ "lines": lines
2349
+ }))?;
2350
+ }
2351
+ Ok(())
2352
+ }
2353
+
2354
+ async fn logs_upload_command(
2355
+ client: &reqwest::Client,
2356
+ args: LogsUploadArgs,
2357
+ output: OutputFormat,
2358
+ ) -> Result<()> {
2359
+ let state_root = state_root_path()?;
2360
+ let consent = logs::load_consent(&state_root)?;
2361
+ if !consent.upload_enabled {
2362
+ return Err(anyhow!(
2363
+ "Log upload consent is disabled. Run `reallink logs consent --enable` first."
2364
+ ));
2365
+ }
2366
+
2367
+ let candidates =
2368
+ logs::list_upload_candidates(&state_root, args.include_runtime, args.include_crash)?;
2369
+ if args.dry_run {
2370
+ let payload = serde_json::json!({
2371
+ "ok": true,
2372
+ "dryRun": true,
2373
+ "count": candidates.len(),
2374
+ "candidates": candidates
2375
+ });
2376
+ emit_text_or_json(
2377
+ output,
2378
+ &format!("Dry run complete. {} log files are ready to upload.", payload["count"]),
2379
+ payload,
2380
+ )?;
2381
+ return Ok(());
2382
+ }
2383
+ if candidates.is_empty() {
2384
+ let payload = serde_json::json!({
2385
+ "ok": true,
2386
+ "uploaded": [],
2387
+ "count": 0
2388
+ });
2389
+ emit_text_or_json(output, "No local log files to upload.", payload)?;
2390
+ return Ok(());
2391
+ }
2392
+
2393
+ let mut session = load_session()?;
2394
+ apply_base_url_override(&mut session, args.base_url);
2395
+ let mut uploaded = Vec::new();
2396
+
2397
+ for candidate in candidates {
2398
+ let bytes = fs::read(&candidate.local_path).with_context(|| {
2399
+ format!("Failed to read log file {}", candidate.local_path.display())
2400
+ })?;
2401
+ let asset = upload_asset_via_intent(
2402
+ client,
2403
+ &mut session,
2404
+ &args.project_id,
2405
+ &candidate.remote_path,
2406
+ bytes,
2407
+ &candidate.content_type,
2408
+ &args.asset_type,
2409
+ &args.visibility,
2410
+ )
2411
+ .await?;
2412
+
2413
+ if args.clear_on_success {
2414
+ let file_name = candidate
2415
+ .local_path
2416
+ .file_name()
2417
+ .and_then(|value| value.to_str())
2418
+ .unwrap_or_default();
2419
+ if file_name.eq_ignore_ascii_case("runtime.jsonl") {
2420
+ fs::write(&candidate.local_path, b"").with_context(|| {
2421
+ format!("Failed to clear runtime log {}", candidate.local_path.display())
2422
+ })?;
2423
+ } else {
2424
+ let _ = fs::remove_file(&candidate.local_path);
2425
+ }
2426
+ }
2427
+
2428
+ uploaded.push(serde_json::json!({
2429
+ "localPath": candidate.local_path.display().to_string(),
2430
+ "remotePath": candidate.remote_path,
2431
+ "asset": asset
2432
+ }));
2433
+ }
2434
+
2435
+ save_session(&session)?;
2436
+ let payload = serde_json::json!({
2437
+ "ok": true,
2438
+ "count": uploaded.len(),
2439
+ "uploaded": uploaded
2440
+ });
2441
+ emit_text_or_json(
2442
+ output,
2443
+ &format!("Uploaded {} log files.", payload["count"]),
2444
+ payload,
2445
+ )?;
2446
+ Ok(())
2447
+ }
2448
+
2449
+ async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
2450
+ let mut session = load_session()?;
2451
+ apply_base_url_override(&mut session, args.base_url);
2452
+
2453
+ let response = authed_request(client, &mut session, Method::GET, "/auth/me", None).await?;
2454
+ if !response.status().is_success() {
2455
+ let body = read_error_body(response).await;
2456
+ return Err(anyhow!("whoami failed: {}", body));
2457
+ }
2458
+ let payload: serde_json::Value = response.json().await?;
2459
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2460
+ save_session(&session)?;
2461
+ Ok(())
2462
+ }
2463
+
2464
+ async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
2465
+ let mut session = load_session()?;
2466
+ apply_base_url_override(&mut session, args.base_url);
2467
+
2468
+ let response = authed_request(client, &mut session, Method::GET, "/auth/tokens", None).await?;
2469
+ if !response.status().is_success() {
2470
+ let body = read_error_body(response).await;
2471
+ return Err(anyhow!("token list failed: {}", body));
2472
+ }
2473
+ let payload: ListApiTokensResponse = response.json().await?;
2474
+ println!("{}", serde_json::to_string_pretty(&payload.tokens)?);
2475
+ save_session(&session)?;
2476
+ Ok(())
2477
+ }
2478
+
2479
+ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
2480
+ let mut session = load_session()?;
2481
+ apply_base_url_override(&mut session, args.base_url);
2482
+
2483
+ let scopes = if args.scope.is_empty() {
2484
+ return Err(anyhow!("At least one --scope must be provided"));
1870
2485
  } else {
1871
2486
  args.scope
1872
2487
  };
@@ -2017,6 +2632,27 @@ async fn org_invites_command(client: &reqwest::Client, args: OrgInvitesArgs) ->
2017
2632
  let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2018
2633
  if !response.status().is_success() {
2019
2634
  let body = read_error_body(response).await;
2635
+ if let Some(api_error) = parse_api_error(&body) {
2636
+ match api_error.code.as_deref() {
2637
+ Some("CLERK_ORG_NOT_LINKED") => {
2638
+ return Err(anyhow!(
2639
+ "org invites unavailable: {}. Action: create this organization from a Clerk-authenticated session, then retry.",
2640
+ api_error
2641
+ .message
2642
+ .unwrap_or_else(|| "organization is not linked to Clerk".to_string())
2643
+ ));
2644
+ }
2645
+ Some("CLERK_NOT_CONFIGURED") => {
2646
+ return Err(anyhow!(
2647
+ "org invites unavailable: {}. Action: configure CLERK_SECRET_KEY on the API deployment.",
2648
+ api_error
2649
+ .message
2650
+ .unwrap_or_else(|| "Clerk is not configured".to_string())
2651
+ ));
2652
+ }
2653
+ _ => {}
2654
+ }
2655
+ }
2020
2656
  return Err(anyhow!("org invites failed: {}", body));
2021
2657
  }
2022
2658
  let payload: ListOrgInvitesResponse = response.json().await?;
@@ -2052,6 +2688,35 @@ async fn org_invite_command(client: &reqwest::Client, args: OrgInviteArgs) -> Re
2052
2688
  .await?;
2053
2689
  if !response.status().is_success() {
2054
2690
  let body = read_error_body(response).await;
2691
+ if let Some(api_error) = parse_api_error(&body) {
2692
+ match api_error.code.as_deref() {
2693
+ Some("CLERK_USER_NOT_LINKED") => {
2694
+ return Err(anyhow!(
2695
+ "org invite unavailable: {}. Action: login via Clerk on the web once, then rerun this command.",
2696
+ api_error
2697
+ .message
2698
+ .unwrap_or_else(|| "current user is not linked to Clerk".to_string())
2699
+ ));
2700
+ }
2701
+ Some("CLERK_ORG_NOT_LINKED") => {
2702
+ return Err(anyhow!(
2703
+ "org invite unavailable: {}. Action: create this organization from a Clerk-authenticated session, then retry.",
2704
+ api_error
2705
+ .message
2706
+ .unwrap_or_else(|| "organization is not linked to Clerk".to_string())
2707
+ ));
2708
+ }
2709
+ Some("CLERK_NOT_CONFIGURED") => {
2710
+ return Err(anyhow!(
2711
+ "org invite unavailable: {}. Action: configure CLERK_SECRET_KEY on the API deployment.",
2712
+ api_error
2713
+ .message
2714
+ .unwrap_or_else(|| "Clerk is not configured".to_string())
2715
+ ));
2716
+ }
2717
+ _ => {}
2718
+ }
2719
+ }
2055
2720
  return Err(anyhow!("org invite failed: {}", body));
2056
2721
  }
2057
2722
  let payload: OrgInviteResponse = response.json().await?;
@@ -2169,16 +2834,22 @@ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArg
2169
2834
  let mut session = load_session()?;
2170
2835
  apply_base_url_override(&mut session, args.base_url);
2171
2836
 
2837
+ let mut body = serde_json::Map::new();
2838
+ body.insert("orgId".to_string(), serde_json::Value::String(args.org_id));
2839
+ body.insert("name".to_string(), serde_json::Value::String(args.name));
2840
+ if let Some(description) = args.description {
2841
+ body.insert(
2842
+ "description".to_string(),
2843
+ serde_json::Value::String(description),
2844
+ );
2845
+ }
2846
+
2172
2847
  let response = authed_request(
2173
2848
  client,
2174
2849
  &mut session,
2175
2850
  Method::POST,
2176
2851
  "/core/projects",
2177
- Some(serde_json::json!({
2178
- "orgId": args.org_id,
2179
- "name": args.name,
2180
- "description": args.description
2181
- })),
2852
+ Some(serde_json::Value::Object(body)),
2182
2853
  )
2183
2854
  .await?;
2184
2855
  if !response.status().is_success() {
@@ -2380,6 +3051,23 @@ fn file_name_component(path: &str) -> String {
2380
3051
  .to_string()
2381
3052
  }
2382
3053
 
3054
+ fn detect_local_runtime_platform_arch() -> (String, String) {
3055
+ let platform = match std::env::consts::OS {
3056
+ "windows" => "win32",
3057
+ "macos" => "darwin",
3058
+ "linux" => "linux",
3059
+ other => other,
3060
+ }
3061
+ .to_string();
3062
+ let arch = match std::env::consts::ARCH {
3063
+ "x86_64" => "x64",
3064
+ "aarch64" => "arm64",
3065
+ other => other,
3066
+ }
3067
+ .to_string();
3068
+ (platform, arch)
3069
+ }
3070
+
2383
3071
  fn build_unreal_link_manifest_payload(
2384
3072
  link: &UnrealLinkRecord,
2385
3073
  include_local_paths: bool,
@@ -3685,29 +4373,51 @@ async fn file_list_command(client: &reqwest::Client, args: FileListArgs) -> Resu
3685
4373
  let mut session = load_session()?;
3686
4374
  apply_base_url_override(&mut session, args.base_url);
3687
4375
 
3688
- let path = format!("/assets?projectId={}", args.project_id);
3689
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
3690
- if !response.status().is_success() {
3691
- let body = read_error_body(response).await;
3692
- return Err(anyhow!("file list failed: {}", body));
3693
- }
3694
- let mut payload: ListAssetsResponse = response.json().await?;
4376
+ let mut query_parts = vec![format!("projectId={}", args.project_id)];
3695
4377
  if let Some(prefix) = args.path {
3696
4378
  let cleaned = clean_virtual_path(&prefix);
3697
4379
  if !cleaned.is_empty() {
3698
- let strict = format!("{}/", cleaned);
3699
- payload.assets = payload
3700
- .assets
3701
- .into_iter()
3702
- .filter(|asset| asset.file_name == cleaned || asset.file_name.starts_with(&strict))
3703
- .collect();
4380
+ query_parts.push(format!("path={}", cleaned));
3704
4381
  }
3705
4382
  }
4383
+ if let Some(offset) = args.offset {
4384
+ query_parts.push(format!("offset={}", offset));
4385
+ }
4386
+ if let Some(limit) = args.limit {
4387
+ query_parts.push(format!("limit={}", limit));
4388
+ }
4389
+ if let Some(include_folder_markers) = args.include_folder_markers {
4390
+ query_parts.push(format!("includeFolderMarkers={}", include_folder_markers));
4391
+ }
4392
+
4393
+ let path = format!("/assets?{}", query_parts.join("&"));
4394
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4395
+ if !response.status().is_success() {
4396
+ let body = read_error_body(response).await;
4397
+ return Err(anyhow!("file list failed: {}", body));
4398
+ }
4399
+ let payload: ListAssetsResponse = response.json().await?;
3706
4400
  println!("{}", serde_json::to_string_pretty(&payload.assets)?);
3707
4401
  save_session(&session)?;
3708
4402
  Ok(())
3709
4403
  }
3710
4404
 
4405
+ async fn file_tree_command(client: &reqwest::Client, args: FileTreeArgs) -> Result<()> {
4406
+ let mut session = load_session()?;
4407
+ apply_base_url_override(&mut session, args.base_url);
4408
+
4409
+ let path = format!("/assets/tree?projectId={}", args.project_id);
4410
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4411
+ if !response.status().is_success() {
4412
+ let body = read_error_body(response).await;
4413
+ return Err(anyhow!("file tree failed: {}", body));
4414
+ }
4415
+ let payload: serde_json::Value = response.json().await?;
4416
+ println!("{}", serde_json::to_string_pretty(&payload)?);
4417
+ save_session(&session)?;
4418
+ Ok(())
4419
+ }
4420
+
3711
4421
  async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result<()> {
3712
4422
  let mut session = load_session()?;
3713
4423
  apply_base_url_override(&mut session, args.base_url);
@@ -3740,6 +4450,77 @@ async fn file_stat_command(client: &reqwest::Client, args: FileStatArgs) -> Resu
3740
4450
  Ok(())
3741
4451
  }
3742
4452
 
4453
+ fn infer_thumbnail_extension(content_type: Option<&str>) -> &'static str {
4454
+ let normalized = content_type.unwrap_or("").to_ascii_lowercase();
4455
+ if normalized.contains("image/png") {
4456
+ "png"
4457
+ } else if normalized.contains("image/jpeg") || normalized.contains("image/jpg") {
4458
+ "jpg"
4459
+ } else if normalized.contains("image/webp") {
4460
+ "webp"
4461
+ } else if normalized.contains("image/svg+xml") {
4462
+ "svg"
4463
+ } else {
4464
+ "bin"
4465
+ }
4466
+ }
4467
+
4468
+ async fn file_thumbnail_command(client: &reqwest::Client, args: FileThumbnailArgs) -> Result<()> {
4469
+ let mut session = load_session()?;
4470
+ apply_base_url_override(&mut session, args.base_url);
4471
+
4472
+ let size = args.size.as_str();
4473
+ let path = format!("/assets/{}/thumbnail/{}", args.asset_id, size);
4474
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4475
+ if !response.status().is_success() {
4476
+ let body = read_error_body(response).await;
4477
+ return Err(anyhow!("file thumbnail failed: {}", body));
4478
+ }
4479
+
4480
+ let content_type = response
4481
+ .headers()
4482
+ .get("content-type")
4483
+ .and_then(|value| value.to_str().ok())
4484
+ .map(|value| value.to_string());
4485
+ let bytes = response.bytes().await?;
4486
+ let output_path = args.output_path.unwrap_or_else(|| {
4487
+ PathBuf::from(format!(
4488
+ "{}-{}.{}",
4489
+ args.asset_id,
4490
+ size,
4491
+ infer_thumbnail_extension(content_type.as_deref())
4492
+ ))
4493
+ });
4494
+ if let Some(parent) = output_path.parent() {
4495
+ if !parent.as_os_str().is_empty() {
4496
+ tokio_fs::create_dir_all(parent).await.with_context(|| {
4497
+ format!(
4498
+ "Failed to create output directory {}",
4499
+ parent.display()
4500
+ )
4501
+ })?;
4502
+ }
4503
+ }
4504
+ tokio_fs::write(&output_path, &bytes)
4505
+ .await
4506
+ .with_context(|| format!("Failed to write thumbnail {}", output_path.display()))?;
4507
+
4508
+ println!(
4509
+ "{}",
4510
+ serde_json::to_string_pretty(&serde_json::json!({
4511
+ "ok": true,
4512
+ "assetId": args.asset_id,
4513
+ "size": size,
4514
+ "output": output_path.display().to_string(),
4515
+ "bytesWritten": bytes.len(),
4516
+ "contentType": content_type
4517
+ }))?
4518
+ );
4519
+
4520
+ save_session(&session)?;
4521
+ Ok(())
4522
+ }
4523
+
3743
4524
  async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs) -> Result<()> {
3744
4525
  let mut session = load_session()?;
3745
4526
  apply_base_url_override(&mut session, args.base_url);
@@ -3902,20 +4683,24 @@ async fn file_mkdir_command(client: &reqwest::Client, args: FileMkdirArgs) -> Re
3902
4683
  if folder.is_empty() {
3903
4684
  return Err(anyhow!("folder path is empty"));
3904
4685
  }
3905
- let marker_file = join_remote_path(Some(&folder), ".reallink.keep");
3906
- let marker_bytes = format!("folder marker {}\n", now_epoch_ms()).into_bytes();
3907
- let asset = upload_asset_via_intent(
4686
+ let response = authed_request(
3908
4687
  client,
3909
4688
  &mut session,
3910
- &args.project_id,
3911
- &marker_file,
3912
- marker_bytes,
3913
- "text/plain",
3914
- "other",
3915
- "private",
4689
+ Method::POST,
4690
+ "/assets/folder",
4691
+ Some(serde_json::json!({
4692
+ "projectId": args.project_id,
4693
+ "path": folder,
4694
+ "visibility": args.visibility
4695
+ })),
3916
4696
  )
3917
4697
  .await?;
3918
- println!("{}", serde_json::to_string_pretty(&asset)?);
4698
+ if !response.status().is_success() {
4699
+ let body = read_error_body(response).await;
4700
+ return Err(anyhow!("file mkdir failed: {}", body));
4701
+ }
4702
+ let payload: serde_json::Value = response.json().await?;
4703
+ println!("{}", serde_json::to_string_pretty(&payload)?);
3919
4704
  save_session(&session)?;
3920
4705
  Ok(())
3921
4706
  }
@@ -3949,27 +4734,139 @@ async fn file_move_command(client: &reqwest::Client, args: FileMoveArgs) -> Resu
3949
4734
  Ok(())
3950
4735
  }
3951
4736
 
3952
- async fn file_remove_command(client: &reqwest::Client, args: FileRemoveArgs) -> Result<()> {
4737
+ async fn file_set_command(client: &reqwest::Client, args: FileSetArgs) -> Result<()> {
3953
4738
  let mut session = load_session()?;
3954
4739
  apply_base_url_override(&mut session, args.base_url);
3955
4740
 
4741
+ if args.file_name.is_none() && args.asset_type.is_none() && args.visibility.is_none() {
4742
+ return Err(anyhow!(
4743
+ "At least one of --file-name, --asset-type, or --visibility is required"
4744
+ ));
4745
+ }
4746
+
4747
+ let mut body = serde_json::Map::new();
4748
+ if let Some(file_name) = args.file_name {
4749
+ let normalized = clean_virtual_path(&file_name);
4750
+ if normalized.is_empty() {
4751
+ return Err(anyhow!("--file-name resolved to an empty path"));
4752
+ }
4753
+ body.insert(
4754
+ "fileName".to_string(),
4755
+ serde_json::Value::String(normalized),
4756
+ );
4757
+ }
4758
+ if let Some(asset_type) = args.asset_type {
4759
+ body.insert("assetType".to_string(), serde_json::Value::String(asset_type));
4760
+ }
4761
+ if let Some(visibility) = args.visibility {
4762
+ body.insert("visibility".to_string(), serde_json::Value::String(visibility));
4763
+ }
4764
+
3956
4765
  let path = format!("/assets/{}", args.asset_id);
3957
- let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
4766
+ let response = authed_request(
4767
+ client,
4768
+ &mut session,
4769
+ Method::PATCH,
4770
+ &path,
4771
+ Some(serde_json::Value::Object(body)),
4772
+ )
4773
+ .await?;
3958
4774
  if !response.status().is_success() {
3959
4775
  let body = read_error_body(response).await;
3960
- return Err(anyhow!("file remove failed: {}", body));
4776
+ return Err(anyhow!("file set failed: {}", body));
3961
4777
  }
3962
- let payload: serde_json::Value = response.json().await?;
3963
- println!("{}", serde_json::to_string_pretty(&payload)?);
4778
+ let payload: AssetResponse = response.json().await?;
4779
+ println!("{}", serde_json::to_string_pretty(&payload.asset)?);
3964
4780
  save_session(&session)?;
3965
4781
  Ok(())
3966
4782
  }
3967
4783
 
3968
- async fn tool_list_command(client: &reqwest::Client, args: ToolListArgs) -> Result<()> {
4784
+ async fn file_move_folder_command(client: &reqwest::Client, args: FileMoveFolderArgs) -> Result<()> {
3969
4785
  let mut session = load_session()?;
3970
4786
  apply_base_url_override(&mut session, args.base_url);
3971
4787
 
3972
- let path = format!(
4788
+ let source_path = clean_virtual_path(&args.source_path);
4789
+ if source_path.is_empty() {
4790
+ return Err(anyhow!("source_path is empty"));
4791
+ }
4792
+ let target_path = clean_virtual_path(&args.target_path);
4793
+
4794
+ let response = authed_request(
4795
+ client,
4796
+ &mut session,
4797
+ Method::POST,
4798
+ "/assets/folder/move",
4799
+ Some(serde_json::json!({
4800
+ "projectId": args.project_id,
4801
+ "sourcePath": source_path,
4802
+ "targetPath": target_path
4803
+ })),
4804
+ )
4805
+ .await?;
4806
+ if !response.status().is_success() {
4807
+ let body = read_error_body(response).await;
4808
+ return Err(anyhow!("file move-folder failed: {}", body));
4809
+ }
4810
+ let payload: serde_json::Value = response.json().await?;
4811
+ println!("{}", serde_json::to_string_pretty(&payload)?);
4812
+ save_session(&session)?;
4813
+ Ok(())
4814
+ }
4815
+
4816
+ async fn file_rmdir_command(client: &reqwest::Client, args: FileRemoveFolderArgs) -> Result<()> {
4817
+ let mut session = load_session()?;
4818
+ apply_base_url_override(&mut session, args.base_url);
4819
+
4820
+ let folder_path = clean_virtual_path(&args.path);
4821
+ if folder_path.is_empty() {
4822
+ return Err(anyhow!("path is empty"));
4823
+ }
4824
+
4825
+ let response = authed_request(
4826
+ client,
4827
+ &mut session,
4828
+ Method::POST,
4829
+ "/assets/folder/delete",
4830
+ Some(serde_json::json!({
4831
+ "projectId": args.project_id,
4832
+ "path": folder_path,
4833
+ "recursive": args.recursive,
4834
+ "dryRun": args.dry_run,
4835
+ "includeFolderMarkers": args.include_folder_markers
4836
+ })),
4837
+ )
4838
+ .await?;
4839
+ if !response.status().is_success() {
4840
+ let body = read_error_body(response).await;
4841
+ return Err(anyhow!("file rmdir failed: {}", body));
4842
+ }
4843
+ let payload: serde_json::Value = response.json().await?;
4844
+ println!("{}", serde_json::to_string_pretty(&payload)?);
4845
+ save_session(&session)?;
4846
+ Ok(())
4847
+ }
4848
+
4849
+ async fn file_remove_command(client: &reqwest::Client, args: FileRemoveArgs) -> Result<()> {
4850
+ let mut session = load_session()?;
4851
+ apply_base_url_override(&mut session, args.base_url);
4852
+
4853
+ let path = format!("/assets/{}", args.asset_id);
4854
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
4855
+ if !response.status().is_success() {
4856
+ let body = read_error_body(response).await;
4857
+ return Err(anyhow!("file remove failed: {}", body));
4858
+ }
4859
+ let payload: serde_json::Value = response.json().await?;
4860
+ println!("{}", serde_json::to_string_pretty(&payload)?);
4861
+ save_session(&session)?;
4862
+ Ok(())
4863
+ }
4864
+
4865
+ async fn tool_list_command(client: &reqwest::Client, args: ToolListArgs) -> Result<()> {
4866
+ let mut session = load_session()?;
4867
+ apply_base_url_override(&mut session, args.base_url);
4868
+
4869
+ let path = format!(
3973
4870
  "/tools/definitions?includeInactive={}&includeDisabledChannel={}",
3974
4871
  args.include_inactive, args.include_disabled_channel
3975
4872
  );
@@ -4015,6 +4912,43 @@ async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs)
4015
4912
  Ok(())
4016
4913
  }
4017
4914
 
4915
+ async fn tool_publish_command(client: &reqwest::Client, args: ToolPublishArgs) -> Result<()> {
4916
+ let mut session = load_session()?;
4917
+ apply_base_url_override(&mut session, args.base_url);
4918
+
4919
+ let mut body = serde_json::Map::new();
4920
+ if let Some(channel) = args.channel {
4921
+ body.insert("channel".to_string(), serde_json::Value::String(channel));
4922
+ }
4923
+ if let Some(visibility) = args.visibility {
4924
+ body.insert("visibility".to_string(), serde_json::Value::String(visibility));
4925
+ }
4926
+ if let Some(notes) = args.notes {
4927
+ body.insert("notes".to_string(), serde_json::Value::String(notes));
4928
+ }
4929
+
4930
+ let path = format!("/tools/definitions/{}/publish", args.tool_id);
4931
+ let response = authed_request(
4932
+ client,
4933
+ &mut session,
4934
+ Method::POST,
4935
+ &path,
4936
+ Some(serde_json::Value::Object(body)),
4937
+ )
4938
+ .await?;
4939
+ if !response.status().is_success() {
4940
+ let body = read_error_body(response).await;
4941
+ return Err(anyhow!("tool publish failed: {}", body));
4942
+ }
4943
+ let payload: serde_json::Value = response.json().await?;
4944
+ println!(
4945
+ "{}",
4946
+ serde_json::to_string_pretty(payload.get("definition").unwrap_or(&payload))?
4947
+ );
4948
+ save_session(&session)?;
4949
+ Ok(())
4950
+ }
4951
+
4018
4952
  async fn tool_set_entitlement_command(
4019
4953
  client: &reqwest::Client,
4020
4954
  mut session: SessionConfig,
@@ -4079,80 +5013,693 @@ async fn tool_set_entitlement_command(
4079
5013
  .await?;
4080
5014
  if !response.status().is_success() {
4081
5015
  let body = read_error_body(response).await;
4082
- return Err(anyhow!("tool entitlement update failed: {}", body));
5016
+ return Err(anyhow!("tool entitlement update failed: {}", body));
5017
+ }
5018
+ let payload: serde_json::Value = response.json().await?;
5019
+ println!(
5020
+ "{}",
5021
+ serde_json::to_string_pretty(payload.get("entitlement").unwrap_or(&payload))?
5022
+ );
5023
+ save_session(&session)?;
5024
+ Ok(())
5025
+ }
5026
+
5027
+ async fn tool_enable_command(client: &reqwest::Client, args: ToolEnableArgs) -> Result<()> {
5028
+ let mut session = load_session()?;
5029
+ apply_base_url_override(&mut session, args.base_url);
5030
+
5031
+ tool_set_entitlement_command(
5032
+ client,
5033
+ session,
5034
+ args.tool_id,
5035
+ args.org_id,
5036
+ args.project_id,
5037
+ args.user_id,
5038
+ "enabled",
5039
+ args.expires_at,
5040
+ args.metadata_file,
5041
+ )
5042
+ .await
5043
+ }
5044
+
5045
+ async fn tool_disable_command(client: &reqwest::Client, args: ToolDisableArgs) -> Result<()> {
5046
+ let mut session = load_session()?;
5047
+ apply_base_url_override(&mut session, args.base_url);
5048
+
5049
+ tool_set_entitlement_command(
5050
+ client,
5051
+ session,
5052
+ args.tool_id,
5053
+ args.org_id,
5054
+ args.project_id,
5055
+ args.user_id,
5056
+ "disabled",
5057
+ None,
5058
+ args.metadata_file,
5059
+ )
5060
+ .await
5061
+ }
5062
+
5063
+ async fn tool_prompt_command(client: &reqwest::Client, args: ToolPromptArgs) -> Result<()> {
5064
+ let mut session = load_session()?;
5065
+ apply_base_url_override(&mut session, args.base_url);
5066
+
5067
+ if args.input_json.is_some() && args.input_file.is_some() {
5068
+ return Err(anyhow!(
5069
+ "Provide either --input-json or --input-file, not both"
5070
+ ));
5071
+ }
5072
+
5073
+ let prompt = args.prompt.trim();
5074
+ if prompt.is_empty() {
5075
+ return Err(anyhow!("--prompt is required"));
5076
+ }
5077
+
5078
+ let merged_input = if let Some(path) = args.input_file {
5079
+ load_jsonc_file(&path, "tool prompt input")?
5080
+ } else if let Some(input_json) = args.input_json {
5081
+ parse_jsonc_str(&input_json, "tool prompt input")?
5082
+ } else {
5083
+ serde_json::Value::Object(serde_json::Map::new())
5084
+ };
5085
+ let mut input_map = parse_object_from_value(merged_input, "tool prompt input")?;
5086
+ input_map.insert(
5087
+ "prompt".to_string(),
5088
+ serde_json::Value::String(prompt.to_string()),
5089
+ );
5090
+ if let Some(system_prompt) = args.system_prompt {
5091
+ let normalized = system_prompt.trim();
5092
+ if !normalized.is_empty() {
5093
+ input_map.insert(
5094
+ "systemPrompt".to_string(),
5095
+ serde_json::Value::String(normalized.to_string()),
5096
+ );
5097
+ }
5098
+ }
5099
+ if let Some(model) = args.model {
5100
+ let normalized = model.trim();
5101
+ if !normalized.is_empty() {
5102
+ input_map.insert(
5103
+ "model".to_string(),
5104
+ serde_json::Value::String(normalized.to_string()),
5105
+ );
5106
+ }
5107
+ }
5108
+ if let Some(session_key) = args.session_key {
5109
+ let normalized = session_key.trim();
5110
+ if !normalized.is_empty() {
5111
+ input_map.insert(
5112
+ "sessionKey".to_string(),
5113
+ serde_json::Value::String(normalized.to_string()),
5114
+ );
5115
+ }
5116
+ }
5117
+
5118
+ let mut body = serde_json::Map::new();
5119
+ body.insert(
5120
+ "toolId".to_string(),
5121
+ serde_json::Value::String(args.tool_id),
5122
+ );
5123
+ body.insert("input".to_string(), serde_json::Value::Object(input_map));
5124
+ if let Some(org_id) = args.org_id {
5125
+ body.insert("orgId".to_string(), serde_json::Value::String(org_id));
5126
+ }
5127
+ if let Some(project_id) = args.project_id {
5128
+ body.insert(
5129
+ "projectId".to_string(),
5130
+ serde_json::Value::String(project_id),
5131
+ );
5132
+ }
5133
+
5134
+ let mut metadata_map = if let Some(path) = args.metadata_file {
5135
+ let metadata = load_jsonc_file(&path, "tool prompt metadata")?;
5136
+ parse_object_from_value(metadata, "tool prompt metadata")?
5137
+ } else {
5138
+ serde_json::Map::new()
5139
+ };
5140
+ if let Some(idempotency_key) = args.idempotency_key {
5141
+ let normalized = idempotency_key.trim();
5142
+ if !normalized.is_empty() {
5143
+ metadata_map.insert(
5144
+ "idempotencyKey".to_string(),
5145
+ serde_json::Value::String(normalized.to_string()),
5146
+ );
5147
+ }
5148
+ }
5149
+ metadata_map.insert(
5150
+ "clientCommand".to_string(),
5151
+ serde_json::Value::String("tool.prompt".to_string()),
5152
+ );
5153
+ if !metadata_map.is_empty() {
5154
+ body.insert(
5155
+ "metadata".to_string(),
5156
+ serde_json::Value::Object(metadata_map),
5157
+ );
5158
+ }
5159
+
5160
+ let response = authed_request(
5161
+ client,
5162
+ &mut session,
5163
+ Method::POST,
5164
+ "/tools/runs",
5165
+ Some(serde_json::Value::Object(body)),
5166
+ )
5167
+ .await?;
5168
+ if !response.status().is_success() {
5169
+ let body = read_error_body(response).await;
5170
+ return Err(anyhow!("tool prompt failed: {}", body));
5171
+ }
5172
+ let payload: serde_json::Value = response.json().await?;
5173
+ let payload = if args.wait {
5174
+ let run_id = payload
5175
+ .get("run")
5176
+ .and_then(|run| run.get("id"))
5177
+ .and_then(serde_json::Value::as_str)
5178
+ .ok_or_else(|| anyhow!("tool prompt response missing run.id"))?;
5179
+ wait_for_tool_run_completion(
5180
+ client,
5181
+ &mut session,
5182
+ run_id,
5183
+ args.timeout_ms,
5184
+ args.poll_interval_ms,
5185
+ )
5186
+ .await?
5187
+ } else {
5188
+ payload
5189
+ };
5190
+ println!(
5191
+ "{}",
5192
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
5193
+ );
5194
+ save_session(&session)?;
5195
+ Ok(())
5196
+ }
5197
+
5198
+ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result<()> {
5199
+ let mut session = load_session()?;
5200
+ apply_base_url_override(&mut session, args.base_url);
5201
+
5202
+ if args.input_json.is_some() && args.input_file.is_some() {
5203
+ return Err(anyhow!(
5204
+ "Provide either --input-json or --input-file, not both"
5205
+ ));
5206
+ }
5207
+
5208
+ let input_value = if let Some(path) = args.input_file {
5209
+ load_jsonc_file(&path, "tool run input")?
5210
+ } else if let Some(input_json) = args.input_json {
5211
+ parse_jsonc_str(&input_json, "tool run input")?
5212
+ } else {
5213
+ serde_json::Value::Object(serde_json::Map::new())
5214
+ };
5215
+
5216
+ let input_object =
5217
+ serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
5218
+
5219
+ let mut body = serde_json::Map::new();
5220
+ body.insert(
5221
+ "toolId".to_string(),
5222
+ serde_json::Value::String(args.tool_id),
5223
+ );
5224
+ body.insert("input".to_string(), input_object);
5225
+ if let Some(org_id) = args.org_id {
5226
+ body.insert("orgId".to_string(), serde_json::Value::String(org_id));
5227
+ }
5228
+ if let Some(project_id) = args.project_id {
5229
+ body.insert(
5230
+ "projectId".to_string(),
5231
+ serde_json::Value::String(project_id),
5232
+ );
5233
+ }
5234
+ let mut metadata_map = if let Some(path) = args.metadata_file {
5235
+ let metadata = load_jsonc_file(&path, "tool run metadata")?;
5236
+ parse_object_from_value(metadata, "tool run metadata")?
5237
+ } else {
5238
+ serde_json::Map::new()
5239
+ };
5240
+ if let Some(idempotency_key) = args.idempotency_key {
5241
+ let normalized = idempotency_key.trim();
5242
+ if !normalized.is_empty() {
5243
+ metadata_map.insert(
5244
+ "idempotencyKey".to_string(),
5245
+ serde_json::Value::String(normalized.to_string()),
5246
+ );
5247
+ }
5248
+ }
5249
+ if !metadata_map.is_empty() {
5250
+ body.insert(
5251
+ "metadata".to_string(),
5252
+ serde_json::Value::Object(metadata_map),
5253
+ );
5254
+ }
5255
+
5256
+ let response = authed_request(
5257
+ client,
5258
+ &mut session,
5259
+ Method::POST,
5260
+ "/tools/runs",
5261
+ Some(serde_json::Value::Object(body)),
5262
+ )
5263
+ .await?;
5264
+ if !response.status().is_success() {
5265
+ let body = read_error_body(response).await;
5266
+ return Err(anyhow!("tool run failed: {}", body));
5267
+ }
5268
+ let payload: serde_json::Value = response.json().await?;
5269
+ let payload = if args.wait {
5270
+ let run_id = payload
5271
+ .get("run")
5272
+ .and_then(|run| run.get("id"))
5273
+ .and_then(serde_json::Value::as_str)
5274
+ .ok_or_else(|| anyhow!("tool run response missing run.id"))?;
5275
+ wait_for_tool_run_completion(
5276
+ client,
5277
+ &mut session,
5278
+ run_id,
5279
+ args.timeout_ms,
5280
+ args.poll_interval_ms,
5281
+ )
5282
+ .await?
5283
+ } else {
5284
+ payload
5285
+ };
5286
+ println!(
5287
+ "{}",
5288
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
5289
+ );
5290
+ save_session(&session)?;
5291
+ Ok(())
5292
+ }
5293
+
5294
+ async fn wait_for_tool_run_completion(
5295
+ client: &reqwest::Client,
5296
+ session: &mut SessionConfig,
5297
+ run_id: &str,
5298
+ timeout_ms: u64,
5299
+ poll_interval_ms: u64,
5300
+ ) -> Result<serde_json::Value> {
5301
+ let poll_interval = Duration::from_millis(poll_interval_ms.max(250));
5302
+ let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms.max(1_000));
5303
+
5304
+ loop {
5305
+ let path = format!("/tools/runs/{}", run_id);
5306
+ let response = authed_request(client, session, Method::GET, &path, None).await?;
5307
+ if !response.status().is_success() {
5308
+ let body = read_error_body(response).await;
5309
+ return Err(anyhow!("tool wait failed: {}", body));
5310
+ }
5311
+ let payload: serde_json::Value = response.json().await?;
5312
+ let status = payload
5313
+ .get("run")
5314
+ .and_then(|run| run.get("status"))
5315
+ .and_then(serde_json::Value::as_str)
5316
+ .unwrap_or_default();
5317
+ if matches!(status, "succeeded" | "failed" | "cancelled") {
5318
+ return Ok(payload);
5319
+ }
5320
+ if std::time::Instant::now() >= deadline {
5321
+ return Err(anyhow!(
5322
+ "Timed out waiting for tool run {} to finish",
5323
+ run_id
5324
+ ));
5325
+ }
5326
+ sleep(poll_interval).await;
5327
+ }
5328
+ }
5329
+
5330
+ async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
5331
+ let mut session = load_session()?;
5332
+ apply_base_url_override(&mut session, args.base_url);
5333
+
5334
+ let mut query_parts: Vec<String> = Vec::new();
5335
+ if let Some(tool_id) = args.tool_id {
5336
+ query_parts.push(format!("toolId={}", tool_id));
5337
+ }
5338
+ if let Some(project_id) = args.project_id {
5339
+ query_parts.push(format!("projectId={}", project_id));
5340
+ }
5341
+ if let Some(requested_by_user_id) = args.requested_by_user_id {
5342
+ query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
5343
+ }
5344
+ if let Some(status) = args.status {
5345
+ query_parts.push(format!("status={}", status));
5346
+ }
5347
+
5348
+ let path = if query_parts.is_empty() {
5349
+ "/tools/runs".to_string()
5350
+ } else {
5351
+ format!("/tools/runs?{}", query_parts.join("&"))
5352
+ };
5353
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5354
+ if !response.status().is_success() {
5355
+ let body = read_error_body(response).await;
5356
+ return Err(anyhow!("tool runs failed: {}", body));
5357
+ }
5358
+ let payload: serde_json::Value = response.json().await?;
5359
+ println!(
5360
+ "{}",
5361
+ serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
5362
+ );
5363
+ save_session(&session)?;
5364
+ Ok(())
5365
+ }
5366
+
5367
+ async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
5368
+ let mut session = load_session()?;
5369
+ apply_base_url_override(&mut session, args.base_url);
5370
+
5371
+ let path = format!("/tools/runs/{}", args.run_id);
5372
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5373
+ if !response.status().is_success() {
5374
+ let body = read_error_body(response).await;
5375
+ return Err(anyhow!("tool get-run failed: {}", body));
5376
+ }
5377
+ let payload: serde_json::Value = response.json().await?;
5378
+ println!(
5379
+ "{}",
5380
+ serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
5381
+ );
5382
+ save_session(&session)?;
5383
+ Ok(())
5384
+ }
5385
+
5386
+ async fn tool_run_events_command(client: &reqwest::Client, args: ToolRunEventsArgs) -> Result<()> {
5387
+ let mut session = load_session()?;
5388
+ apply_base_url_override(&mut session, args.base_url);
5389
+
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));
5406
+ }
5407
+ if !query_parts.is_empty() {
5408
+ path.push_str(&format!("?{}", query_parts.join("&")));
5409
+ }
5410
+
5411
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5412
+ if !response.status().is_success() {
5413
+ let body = read_error_body(response).await;
5414
+ return Err(anyhow!("tool run-events failed: {}", body));
5415
+ }
5416
+ let payload: serde_json::Value = response.json().await?;
5417
+ println!(
5418
+ "{}",
5419
+ serde_json::to_string_pretty(payload.get("events").unwrap_or(&payload))?
5420
+ );
5421
+ save_session(&session)?;
5422
+ Ok(())
5423
+ }
5424
+
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());
5432
+ }
5433
+ Some(format!("{}{}", normalize_base_url(base_url), path))
5434
+ }
5435
+
5436
+ fn extract_tool_run_report_paths(
5437
+ run: &serde_json::Map<String, serde_json::Value>,
5438
+ base_url: &str,
5439
+ ) -> serde_json::Value {
5440
+ let output = run
5441
+ .get("output")
5442
+ .and_then(|value| value.as_object())
5443
+ .cloned()
5444
+ .unwrap_or_default();
5445
+
5446
+ let report_asset_id = output
5447
+ .get("reportHtmlAssetId")
5448
+ .and_then(|value| value.as_str())
5449
+ .or_else(|| output.get("reportAssetId").and_then(|value| value.as_str()));
5450
+ let report_download_path = output
5451
+ .get("reportHtmlDownloadPath")
5452
+ .and_then(|value| value.as_str())
5453
+ .or_else(|| output.get("reportDownloadPath").and_then(|value| value.as_str()));
5454
+ let report_url = resolve_report_url(base_url, report_download_path);
5455
+ let runtime = output
5456
+ .get("runtime")
5457
+ .and_then(|value| value.as_str())
5458
+ .unwrap_or_default();
5459
+ let workflow_mode = output
5460
+ .get("workflowMode")
5461
+ .and_then(|value| value.as_str())
5462
+ .unwrap_or_default();
5463
+ let summary = output
5464
+ .get("summary")
5465
+ .and_then(|value| value.as_str())
5466
+ .unwrap_or_default();
5467
+
5468
+ serde_json::json!({
5469
+ "reportAssetId": report_asset_id,
5470
+ "reportDownloadPath": report_download_path,
5471
+ "reportUrl": report_url,
5472
+ "runtime": runtime,
5473
+ "workflowMode": workflow_mode,
5474
+ "summary": summary
5475
+ })
5476
+ }
5477
+
5478
+ async fn tool_trace_status_command(client: &reqwest::Client, args: ToolTraceStatusArgs) -> Result<()> {
5479
+ let mut session = load_session()?;
5480
+ apply_base_url_override(&mut session, args.base_url);
5481
+
5482
+ let limit = args.limit.unwrap_or(80).max(1);
5483
+ let path = format!("/tools/runs?projectId={}&limit={}", args.project_id, limit);
5484
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5485
+ if !response.status().is_success() {
5486
+ let body = read_error_body(response).await;
5487
+ return Err(anyhow!("tool trace-status failed: {}", body));
5488
+ }
5489
+ let payload: serde_json::Value = response.json().await?;
5490
+ let runs = payload
5491
+ .get("runs")
5492
+ .and_then(|value| value.as_array())
5493
+ .cloned()
5494
+ .unwrap_or_default();
5495
+
5496
+ let mut items: Vec<serde_json::Value> = Vec::new();
5497
+ for run in &runs {
5498
+ let Some(run_obj) = run.as_object() else {
5499
+ continue;
5500
+ };
5501
+ let tool_id = run_obj
5502
+ .get("toolId")
5503
+ .and_then(|value| value.as_str())
5504
+ .unwrap_or_default()
5505
+ .to_string();
5506
+ let normalized_tool_id = tool_id.to_ascii_lowercase();
5507
+ if !normalized_tool_id.starts_with("trace") && !normalized_tool_id.starts_with("crash") {
5508
+ continue;
5509
+ }
5510
+
5511
+ let run_id = run_obj
5512
+ .get("id")
5513
+ .and_then(|value| value.as_str())
5514
+ .unwrap_or_default();
5515
+ let status = run_obj
5516
+ .get("status")
5517
+ .and_then(|value| value.as_str())
5518
+ .unwrap_or_default();
5519
+ let created_at = run_obj
5520
+ .get("createdAt")
5521
+ .and_then(|value| value.as_str())
5522
+ .unwrap_or_default();
5523
+ let completed_at = run_obj
5524
+ .get("completedAt")
5525
+ .and_then(|value| value.as_str())
5526
+ .unwrap_or_default();
5527
+ let error_message = run_obj
5528
+ .get("errorMessage")
5529
+ .and_then(|value| value.as_str())
5530
+ .unwrap_or_default();
5531
+ let report = extract_tool_run_report_paths(run_obj, &session.base_url);
5532
+
5533
+ items.push(serde_json::json!({
5534
+ "runId": run_id,
5535
+ "toolId": tool_id,
5536
+ "status": status,
5537
+ "createdAt": created_at,
5538
+ "completedAt": if completed_at.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(completed_at.to_string()) },
5539
+ "errorMessage": if error_message.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(error_message.to_string()) },
5540
+ "report": report
5541
+ }));
5542
+ }
5543
+
5544
+ println!(
5545
+ "{}",
5546
+ serde_json::to_string_pretty(&serde_json::json!({
5547
+ "projectId": args.project_id,
5548
+ "count": items.len(),
5549
+ "items": items
5550
+ }))?
5551
+ );
5552
+ save_session(&session)?;
5553
+ Ok(())
5554
+ }
5555
+
5556
+ fn build_tool_context_path(
5557
+ context_id: &str,
5558
+ org_id: Option<&str>,
5559
+ project_id: Option<&str>,
5560
+ ) -> Result<String> {
5561
+ if org_id.is_some() && project_id.is_some() {
5562
+ return Err(anyhow!("Only one of --org-id or --project-id can be set"));
5563
+ }
5564
+ let normalized_context_id = context_id.trim();
5565
+ if normalized_context_id.is_empty() {
5566
+ return Err(anyhow!("--context-id is required"));
5567
+ }
5568
+
5569
+ let mut path = format!("/tools/runtime/contexts/{}", normalized_context_id);
5570
+ let mut query_parts: Vec<String> = Vec::new();
5571
+ if let Some(org_id) = org_id {
5572
+ let normalized = org_id.trim();
5573
+ if !normalized.is_empty() {
5574
+ query_parts.push(format!("orgId={}", normalized));
5575
+ }
5576
+ }
5577
+ if let Some(project_id) = project_id {
5578
+ let normalized = project_id.trim();
5579
+ if !normalized.is_empty() {
5580
+ query_parts.push(format!("projectId={}", normalized));
5581
+ }
5582
+ }
5583
+ if !query_parts.is_empty() {
5584
+ path.push_str(&format!("?{}", query_parts.join("&")));
5585
+ }
5586
+ Ok(path)
5587
+ }
5588
+
5589
+ async fn tool_context_put_command(client: &reqwest::Client, args: ToolContextPutArgs) -> Result<()> {
5590
+ let mut session = load_session()?;
5591
+ apply_base_url_override(&mut session, args.base_url);
5592
+
5593
+ if args.text.is_some() && args.text_file.is_some() {
5594
+ return Err(anyhow!("Provide either --text or --text-file, not both"));
5595
+ }
5596
+ let context_text = if let Some(text_file) = args.text_file {
5597
+ fs::read_to_string(&text_file)
5598
+ .with_context(|| format!("Failed to read context text file {}", text_file.display()))?
5599
+ } else {
5600
+ args.text.unwrap_or_default()
5601
+ };
5602
+ if context_text.trim().is_empty() {
5603
+ return Err(anyhow!("Context text is required (--text or --text-file)"));
5604
+ }
5605
+
5606
+ let path = build_tool_context_path(
5607
+ &args.context_id,
5608
+ args.org_id.as_deref(),
5609
+ args.project_id.as_deref(),
5610
+ )?;
5611
+ let response = authed_request(
5612
+ client,
5613
+ &mut session,
5614
+ Method::PUT,
5615
+ &path,
5616
+ Some(serde_json::json!({
5617
+ "text": context_text
5618
+ })),
5619
+ )
5620
+ .await?;
5621
+ if !response.status().is_success() {
5622
+ let body = read_error_body(response).await;
5623
+ return Err(anyhow!("tool context put failed: {}", body));
5624
+ }
5625
+ let payload: serde_json::Value = response.json().await?;
5626
+ println!("{}", serde_json::to_string_pretty(&payload)?);
5627
+ save_session(&session)?;
5628
+ Ok(())
5629
+ }
5630
+
5631
+ async fn tool_context_get_command(client: &reqwest::Client, args: ToolContextGetArgs) -> Result<()> {
5632
+ let mut session = load_session()?;
5633
+ apply_base_url_override(&mut session, args.base_url);
5634
+
5635
+ let path = build_tool_context_path(
5636
+ &args.context_id,
5637
+ args.org_id.as_deref(),
5638
+ args.project_id.as_deref(),
5639
+ )?;
5640
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5641
+ if !response.status().is_success() {
5642
+ let body = read_error_body(response).await;
5643
+ return Err(anyhow!("tool context get failed: {}", body));
5644
+ }
5645
+ let payload: serde_json::Value = response.json().await?;
5646
+ println!("{}", serde_json::to_string_pretty(&payload)?);
5647
+ save_session(&session)?;
5648
+ Ok(())
5649
+ }
5650
+
5651
+ async fn tool_local_catalog_command(client: &reqwest::Client, args: ToolLocalCatalogArgs) -> Result<()> {
5652
+ let mut session = load_session()?;
5653
+ apply_base_url_override(&mut session, args.base_url);
5654
+
5655
+ let (default_platform, default_arch) = detect_local_runtime_platform_arch();
5656
+ let platform = args.platform.unwrap_or(default_platform);
5657
+ let arch = args.arch.unwrap_or(default_arch);
5658
+
5659
+ let mut query_parts = vec![
5660
+ format!("platform={}", platform),
5661
+ format!("arch={}", arch),
5662
+ ];
5663
+ if let Some(org_id) = args.org_id {
5664
+ query_parts.push(format!("orgId={}", org_id));
5665
+ }
5666
+ if let Some(project_id) = args.project_id {
5667
+ query_parts.push(format!("projectId={}", project_id));
5668
+ }
5669
+ let path = format!("/tools/local/catalog?{}", query_parts.join("&"));
5670
+
5671
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5672
+ if !response.status().is_success() {
5673
+ let body = read_error_body(response).await;
5674
+ return Err(anyhow!("tool local catalog failed: {}", body));
4083
5675
  }
4084
5676
  let payload: serde_json::Value = response.json().await?;
4085
5677
  println!(
4086
5678
  "{}",
4087
- serde_json::to_string_pretty(payload.get("entitlement").unwrap_or(&payload))?
5679
+ serde_json::to_string_pretty(payload.get("tools").unwrap_or(&payload))?
4088
5680
  );
4089
5681
  save_session(&session)?;
4090
5682
  Ok(())
4091
5683
  }
4092
5684
 
4093
- async fn tool_enable_command(client: &reqwest::Client, args: ToolEnableArgs) -> Result<()> {
4094
- let mut session = load_session()?;
4095
- apply_base_url_override(&mut session, args.base_url);
4096
-
4097
- tool_set_entitlement_command(
4098
- client,
4099
- session,
4100
- args.tool_id,
4101
- args.org_id,
4102
- args.project_id,
4103
- args.user_id,
4104
- "enabled",
4105
- args.expires_at,
4106
- args.metadata_file,
4107
- )
4108
- .await
4109
- }
4110
-
4111
- async fn tool_disable_command(client: &reqwest::Client, args: ToolDisableArgs) -> Result<()> {
4112
- let mut session = load_session()?;
4113
- apply_base_url_override(&mut session, args.base_url);
4114
-
4115
- tool_set_entitlement_command(
4116
- client,
4117
- session,
4118
- args.tool_id,
4119
- args.org_id,
4120
- args.project_id,
4121
- args.user_id,
4122
- "disabled",
4123
- None,
4124
- args.metadata_file,
4125
- )
4126
- .await
4127
- }
4128
-
4129
- async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result<()> {
5685
+ async fn tool_local_install_command(client: &reqwest::Client, args: ToolLocalInstallArgs) -> Result<()> {
4130
5686
  let mut session = load_session()?;
4131
5687
  apply_base_url_override(&mut session, args.base_url);
4132
5688
 
4133
- if args.input_json.is_some() && args.input_file.is_some() {
4134
- return Err(anyhow!(
4135
- "Provide either --input-json or --input-file, not both"
4136
- ));
4137
- }
4138
-
4139
- let input_value = if let Some(path) = args.input_file {
4140
- load_jsonc_file(&path, "tool run input")?
4141
- } else if let Some(input_json) = args.input_json {
4142
- parse_jsonc_str(&input_json, "tool run input")?
4143
- } else {
4144
- serde_json::Value::Object(serde_json::Map::new())
4145
- };
4146
-
4147
- let input_object =
4148
- serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
5689
+ let (default_platform, default_arch) = detect_local_runtime_platform_arch();
5690
+ let platform = args.platform.unwrap_or(default_platform);
5691
+ let arch = args.arch.unwrap_or(default_arch);
4149
5692
 
4150
5693
  let mut body = serde_json::Map::new();
4151
5694
  body.insert(
4152
5695
  "toolId".to_string(),
4153
- serde_json::Value::String(args.tool_id),
5696
+ serde_json::Value::String(args.tool_id.clone()),
4154
5697
  );
4155
- body.insert("input".to_string(), input_object);
5698
+ body.insert(
5699
+ "platform".to_string(),
5700
+ serde_json::Value::String(platform.clone()),
5701
+ );
5702
+ body.insert("arch".to_string(), serde_json::Value::String(arch.clone()));
4156
5703
  if let Some(org_id) = args.org_id {
4157
5704
  body.insert("orgId".to_string(), serde_json::Value::String(org_id));
4158
5705
  }
@@ -4162,95 +5709,231 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
4162
5709
  serde_json::Value::String(project_id),
4163
5710
  );
4164
5711
  }
4165
- let mut metadata_map = if let Some(path) = args.metadata_file {
4166
- let metadata = load_jsonc_file(&path, "tool run metadata")?;
4167
- parse_object_from_value(metadata, "tool run metadata")?
4168
- } else {
4169
- serde_json::Map::new()
4170
- };
4171
- if let Some(idempotency_key) = args.idempotency_key {
4172
- let normalized = idempotency_key.trim();
4173
- if !normalized.is_empty() {
4174
- metadata_map.insert(
4175
- "idempotencyKey".to_string(),
4176
- serde_json::Value::String(normalized.to_string()),
4177
- );
4178
- }
4179
- }
4180
- if !metadata_map.is_empty() {
4181
- body.insert(
4182
- "metadata".to_string(),
4183
- serde_json::Value::Object(metadata_map),
4184
- );
5712
+ if let Some(version) = args.version {
5713
+ body.insert("version".to_string(), serde_json::Value::String(version));
4185
5714
  }
4186
5715
 
4187
5716
  let response = authed_request(
4188
5717
  client,
4189
5718
  &mut session,
4190
5719
  Method::POST,
4191
- "/tools/runs",
5720
+ "/tools/local/install-intent",
4192
5721
  Some(serde_json::Value::Object(body)),
4193
5722
  )
4194
5723
  .await?;
4195
5724
  if !response.status().is_success() {
4196
5725
  let body = read_error_body(response).await;
4197
- return Err(anyhow!("tool run failed: {}", body));
5726
+ return Err(anyhow!("tool local install-intent failed: {}", body));
4198
5727
  }
5728
+
4199
5729
  let payload: serde_json::Value = response.json().await?;
4200
- println!(
4201
- "{}",
4202
- serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
4203
- );
4204
- save_session(&session)?;
4205
- Ok(())
4206
- }
5730
+ if args.no_download {
5731
+ println!("{}", serde_json::to_string_pretty(&payload)?);
5732
+ save_session(&session)?;
5733
+ return Ok(());
5734
+ }
4207
5735
 
4208
- async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
4209
- let mut session = load_session()?;
4210
- apply_base_url_override(&mut session, args.base_url);
5736
+ let download_path = payload
5737
+ .pointer("/install/downloadPath")
5738
+ .and_then(|value| value.as_str())
5739
+ .ok_or_else(|| anyhow!("install intent is missing install.downloadPath"))?;
5740
+ let asset_id = payload
5741
+ .pointer("/asset/assetId")
5742
+ .and_then(|value| value.as_str())
5743
+ .unwrap_or("unknown");
5744
+ let fallback_file_name = payload
5745
+ .pointer("/asset/fileName")
5746
+ .and_then(|value| value.as_str())
5747
+ .unwrap_or("tool-bundle.bin");
4211
5748
 
4212
- let mut query_parts: Vec<String> = Vec::new();
4213
- if let Some(tool_id) = args.tool_id {
4214
- query_parts.push(format!("toolId={}", tool_id));
5749
+ let mut output_path = args
5750
+ .output_path
5751
+ .unwrap_or_else(|| PathBuf::from(base_name_from_virtual_path(fallback_file_name)));
5752
+ if output_path.exists() && output_path.is_dir() {
5753
+ output_path = output_path.join(base_name_from_virtual_path(fallback_file_name));
4215
5754
  }
4216
- if let Some(project_id) = args.project_id {
4217
- query_parts.push(format!("projectId={}", project_id));
5755
+ if let Some(parent) = output_path.parent() {
5756
+ 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()))?;
5760
+ }
4218
5761
  }
4219
- if let Some(requested_by_user_id) = args.requested_by_user_id {
4220
- query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
5762
+
5763
+ let mut resume_from: Option<u64> = None;
5764
+ if args.resume && output_path.exists() && output_path.is_file() {
5765
+ let existing_size = tokio_fs::metadata(&output_path)
5766
+ .await
5767
+ .with_context(|| {
5768
+ format!(
5769
+ "Failed to read output file metadata {}",
5770
+ output_path.display()
5771
+ )
5772
+ })?
5773
+ .len();
5774
+ if existing_size > 0 {
5775
+ let remote_size = payload
5776
+ .pointer("/asset/sizeBytes")
5777
+ .and_then(|value| value.as_u64())
5778
+ .unwrap_or(0);
5779
+ if remote_size > 0 && existing_size >= remote_size {
5780
+ println!(
5781
+ "{}",
5782
+ serde_json::to_string_pretty(&serde_json::json!({
5783
+ "toolId": args.tool_id,
5784
+ "assetId": asset_id,
5785
+ "output": output_path.display().to_string(),
5786
+ "bytesWritten": 0,
5787
+ "resumedFrom": existing_size,
5788
+ "alreadyComplete": true
5789
+ }))?
5790
+ );
5791
+ save_session(&session)?;
5792
+ return Ok(());
5793
+ }
5794
+ resume_from = Some(existing_size);
5795
+ }
4221
5796
  }
4222
- if let Some(status) = args.status {
4223
- query_parts.push(format!("status={}", status));
5797
+
5798
+ let mut headers = Vec::new();
5799
+ if let Some(offset) = resume_from {
5800
+ headers.push(("range".to_string(), format!("bytes={}-", offset)));
5801
+ }
5802
+ let mut download_response = authed_request_with_headers(
5803
+ client,
5804
+ &mut session,
5805
+ Method::GET,
5806
+ download_path,
5807
+ None,
5808
+ &headers,
5809
+ )
5810
+ .await?;
5811
+ if !(download_response.status().is_success()
5812
+ || download_response.status() == StatusCode::PARTIAL_CONTENT)
5813
+ {
5814
+ let body = read_error_body(download_response).await;
5815
+ return Err(anyhow!("tool local install download failed: {}", body));
4224
5816
  }
4225
5817
 
4226
- let path = if query_parts.is_empty() {
4227
- "/tools/runs".to_string()
5818
+ let append_mode = resume_from.is_some() && download_response.status() == StatusCode::PARTIAL_CONTENT;
5819
+ let mut output_file = if append_mode {
5820
+ tokio_fs::OpenOptions::new()
5821
+ .append(true)
5822
+ .open(&output_path)
5823
+ .await
5824
+ .with_context(|| {
5825
+ format!(
5826
+ "Failed to open output file for append {}",
5827
+ output_path.display()
5828
+ )
5829
+ })?
4228
5830
  } else {
4229
- format!("/tools/runs?{}", query_parts.join("&"))
5831
+ tokio_fs::File::create(&output_path)
5832
+ .await
5833
+ .with_context(|| format!("Failed to create output file {}", output_path.display()))?
4230
5834
  };
4231
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
4232
- if !response.status().is_success() {
4233
- let body = read_error_body(response).await;
4234
- return Err(anyhow!("tool runs failed: {}", body));
5835
+
5836
+ let mut bytes_written: u64 = 0;
5837
+ while let Some(chunk) = download_response.chunk().await? {
5838
+ output_file
5839
+ .write_all(&chunk)
5840
+ .await
5841
+ .with_context(|| format!("Failed to write to {}", output_path.display()))?;
5842
+ bytes_written = bytes_written.saturating_add(chunk.len() as u64);
4235
5843
  }
4236
- let payload: serde_json::Value = response.json().await?;
5844
+ output_file.flush().await?;
5845
+
4237
5846
  println!(
4238
5847
  "{}",
4239
- serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
5848
+ serde_json::to_string_pretty(&serde_json::json!({
5849
+ "toolId": args.tool_id,
5850
+ "platform": platform,
5851
+ "arch": arch,
5852
+ "assetId": asset_id,
5853
+ "output": output_path.display().to_string(),
5854
+ "bytesWritten": bytes_written,
5855
+ "resumedFrom": resume_from,
5856
+ "partialContent": download_response.status() == StatusCode::PARTIAL_CONTENT
5857
+ }))?
4240
5858
  );
4241
5859
  save_session(&session)?;
4242
5860
  Ok(())
4243
5861
  }
4244
5862
 
4245
- async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
5863
+ async fn tool_local_complete_run_command(
5864
+ client: &reqwest::Client,
5865
+ args: ToolLocalCompleteRunArgs,
5866
+ ) -> Result<()> {
4246
5867
  let mut session = load_session()?;
4247
5868
  apply_base_url_override(&mut session, args.base_url);
4248
5869
 
4249
- let path = format!("/tools/runs/{}", args.run_id);
4250
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
5870
+ let normalized_status = args.status.trim().to_lowercase();
5871
+ if normalized_status != "succeeded"
5872
+ && normalized_status != "failed"
5873
+ && normalized_status != "cancelled"
5874
+ {
5875
+ return Err(anyhow!(
5876
+ "status must be one of: succeeded, failed, cancelled"
5877
+ ));
5878
+ }
5879
+
5880
+ if args.output_file.is_some() && args.output_json.is_some() {
5881
+ return Err(anyhow!(
5882
+ "Provide either --output-file or --output-json, not both"
5883
+ ));
5884
+ }
5885
+
5886
+ let output_value = if let Some(path) = args.output_file {
5887
+ Some(load_jsonc_file(&path, "tool local complete output")?)
5888
+ } else if let Some(raw) = args.output_json {
5889
+ Some(parse_jsonc_str(&raw, "tool local complete output")?)
5890
+ } else {
5891
+ None
5892
+ };
5893
+
5894
+ let mut body = serde_json::Map::new();
5895
+ body.insert(
5896
+ "status".to_string(),
5897
+ serde_json::Value::String(normalized_status),
5898
+ );
5899
+ if let Some(output) = output_value {
5900
+ body.insert(
5901
+ "output".to_string(),
5902
+ serde_json::Value::Object(parse_object_from_value(
5903
+ output,
5904
+ "tool local complete output",
5905
+ )?),
5906
+ );
5907
+ }
5908
+ if let Some(error_message) = args.error_message {
5909
+ body.insert(
5910
+ "errorMessage".to_string(),
5911
+ serde_json::Value::String(error_message),
5912
+ );
5913
+ }
5914
+ if let Some(path) = args.metadata_file {
5915
+ let metadata = load_jsonc_file(&path, "tool local complete metadata")?;
5916
+ body.insert(
5917
+ "metadata".to_string(),
5918
+ serde_json::Value::Object(parse_object_from_value(
5919
+ metadata,
5920
+ "tool local complete metadata",
5921
+ )?),
5922
+ );
5923
+ }
5924
+
5925
+ let path = format!("/tools/runs/{}/complete", args.run_id);
5926
+ let response = authed_request(
5927
+ client,
5928
+ &mut session,
5929
+ Method::POST,
5930
+ &path,
5931
+ Some(serde_json::Value::Object(body)),
5932
+ )
5933
+ .await?;
4251
5934
  if !response.status().is_success() {
4252
5935
  let body = read_error_body(response).await;
4253
- return Err(anyhow!("tool get-run failed: {}", body));
5936
+ return Err(anyhow!("tool local complete-run failed: {}", body));
4254
5937
  }
4255
5938
  let payload: serde_json::Value = response.json().await?;
4256
5939
  println!(
@@ -4403,7 +6086,8 @@ async fn self_update_command(
4403
6086
  mod tests {
4404
6087
  use super::{
4405
6088
  compose_plugin_archive_url, default_login_scopes, default_login_scopes_with_tools,
4406
- file_name_component, normalize_public_bucket_base, normalize_sha256_hex, resolve_plugin_from_index,
6089
+ file_name_component, normalize_public_bucket_base, normalize_sha256_hex, parse_api_error,
6090
+ resolve_plugin_from_index,
4407
6091
  };
4408
6092
  use super::unreal::{PluginIndexFile, PluginIndexPlugin, PluginIndexVersion};
4409
6093
 
@@ -4508,6 +6192,20 @@ mod tests {
4508
6192
  assert!(scopes.contains(&"tools:write".to_string()));
4509
6193
  assert!(scopes.contains(&"tools:run".to_string()));
4510
6194
  }
6195
+
6196
+ #[test]
6197
+ fn parses_api_error_envelope() {
6198
+ let parsed = parse_api_error(r#"{"code":"CLERK_ORG_NOT_LINKED","message":"Not linked"}"#)
6199
+ .expect("api error should parse");
6200
+ assert_eq!(parsed.code.as_deref(), Some("CLERK_ORG_NOT_LINKED"));
6201
+ assert_eq!(parsed.message.as_deref(), Some("Not linked"));
6202
+ }
6203
+
6204
+ #[test]
6205
+ fn ignores_non_json_api_error_body() {
6206
+ let parsed = parse_api_error("plain text error");
6207
+ assert!(parsed.is_none());
6208
+ }
4511
6209
  }
4512
6210
 
4513
6211
  async fn run_cli(cli: Cli) -> Result<()> {
@@ -4561,22 +6259,72 @@ async fn run_cli(cli: Cli) -> Result<()> {
4561
6259
  },
4562
6260
  Commands::File { command } => match command {
4563
6261
  FileCommands::List(args) => file_list_command(&client, args).await?,
6262
+ FileCommands::Tree(args) => file_tree_command(&client, args).await?,
4564
6263
  FileCommands::Get(args) => file_get_command(&client, args).await?,
4565
6264
  FileCommands::Stat(args) => file_stat_command(&client, args).await?,
6265
+ FileCommands::Thumbnail(args) => file_thumbnail_command(&client, args).await?,
4566
6266
  FileCommands::Download(args) => file_download_command(&client, args).await?,
4567
6267
  FileCommands::Upload(args) => file_upload_command(&client, args).await?,
4568
6268
  FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
4569
6269
  FileCommands::Move(args) => file_move_command(&client, args).await?,
6270
+ FileCommands::Set(args) => file_set_command(&client, args).await?,
6271
+ FileCommands::MoveFolder(args) => file_move_folder_command(&client, args).await?,
6272
+ FileCommands::Rmdir(args) => file_rmdir_command(&client, args).await?,
4570
6273
  FileCommands::Remove(args) => file_remove_command(&client, args).await?,
4571
6274
  },
6275
+ Commands::Skill { command } => match command {
6276
+ SkillCommands::List(args) => tool_list_command(&client, args).await?,
6277
+ SkillCommands::Register(args) => tool_register_command(&client, args).await?,
6278
+ SkillCommands::Publish(args) => tool_publish_command(&client, args).await?,
6279
+ SkillCommands::Enable(args) => tool_enable_command(&client, args).await?,
6280
+ SkillCommands::Disable(args) => tool_disable_command(&client, args).await?,
6281
+ SkillCommands::Context { command } => match command {
6282
+ ToolContextCommands::Put(args) => tool_context_put_command(&client, args).await?,
6283
+ ToolContextCommands::Get(args) => tool_context_get_command(&client, args).await?,
6284
+ },
6285
+ SkillCommands::Prompt(args) => tool_prompt_command(&client, args).await?,
6286
+ SkillCommands::Run(args) => tool_run_command(&client, args).await?,
6287
+ SkillCommands::Runs(args) => tool_runs_command(&client, args).await?,
6288
+ SkillCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
6289
+ SkillCommands::RunEvents(args) => tool_run_events_command(&client, args).await?,
6290
+ SkillCommands::TraceStatus(args) => tool_trace_status_command(&client, args).await?,
6291
+ 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?,
6294
+ ToolLocalCommands::CompleteRun(args) => {
6295
+ tool_local_complete_run_command(&client, args).await?
6296
+ }
6297
+ },
6298
+ },
4572
6299
  Commands::Tool { command } => match command {
4573
6300
  ToolCommands::List(args) => tool_list_command(&client, args).await?,
4574
6301
  ToolCommands::Register(args) => tool_register_command(&client, args).await?,
6302
+ ToolCommands::Publish(args) => tool_publish_command(&client, args).await?,
4575
6303
  ToolCommands::Enable(args) => tool_enable_command(&client, args).await?,
4576
6304
  ToolCommands::Disable(args) => tool_disable_command(&client, args).await?,
6305
+ ToolCommands::Context { command } => match command {
6306
+ ToolContextCommands::Put(args) => tool_context_put_command(&client, args).await?,
6307
+ ToolContextCommands::Get(args) => tool_context_get_command(&client, args).await?,
6308
+ },
6309
+ ToolCommands::Prompt(args) => tool_prompt_command(&client, args).await?,
4577
6310
  ToolCommands::Run(args) => tool_run_command(&client, args).await?,
4578
6311
  ToolCommands::Runs(args) => tool_runs_command(&client, args).await?,
4579
6312
  ToolCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
6313
+ ToolCommands::RunEvents(args) => tool_run_events_command(&client, args).await?,
6314
+ ToolCommands::TraceStatus(args) => tool_trace_status_command(&client, args).await?,
6315
+ 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?,
6318
+ ToolLocalCommands::CompleteRun(args) => {
6319
+ tool_local_complete_run_command(&client, args).await?
6320
+ }
6321
+ },
6322
+ },
6323
+ Commands::Logs { command } => match command {
6324
+ LogsCommands::Status => logs_status_command(cli.output).await?,
6325
+ LogsCommands::Consent(args) => logs_consent_command(args, cli.output).await?,
6326
+ LogsCommands::Tail(args) => logs_tail_command(args, cli.output).await?,
6327
+ LogsCommands::Upload(args) => logs_upload_command(&client, args, cli.output).await?,
4580
6328
  },
4581
6329
  }
4582
6330
 
@@ -4587,7 +6335,19 @@ async fn run_cli(cli: Cli) -> Result<()> {
4587
6335
  async fn main() {
4588
6336
  let cli = Cli::parse();
4589
6337
  let output = cli.output;
6338
+ let command_summary = command_summary_for_logs();
6339
+ let started_at = now_epoch_ms();
6340
+
4590
6341
  if let Err(error) = run_cli(cli).await {
6342
+ let duration_ms = now_epoch_ms().saturating_sub(started_at);
6343
+ append_runtime_log_event(
6344
+ &command_summary,
6345
+ &format!("command failed: {}", error),
6346
+ "error",
6347
+ Some(duration_ms),
6348
+ Some(1),
6349
+ );
6350
+ record_cli_crash_report(&command_summary, &error);
4591
6351
  match output {
4592
6352
  OutputFormat::Json => {
4593
6353
  let payload = serde_json::json!({
@@ -4605,4 +6365,13 @@ async fn main() {
4605
6365
  }
4606
6366
  std::process::exit(1);
4607
6367
  }
6368
+
6369
+ let duration_ms = now_epoch_ms().saturating_sub(started_at);
6370
+ append_runtime_log_event(
6371
+ &command_summary,
6372
+ "command completed",
6373
+ "info",
6374
+ Some(duration_ms),
6375
+ Some(0),
6376
+ );
4608
6377
  }