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/README.md +39 -0
- package/bin/reallink.cjs +7 -2
- package/package.json +11 -4
- package/prebuilt/linux-x64/reallink-cli +0 -0
- package/rust/Cargo.lock +212 -7
- package/rust/Cargo.toml +8 -2
- package/rust/src/logs.rs +8 -3
- package/rust/src/main.rs +2241 -167
- package/rust/src/unreal.rs +225 -2
- package/scripts/postinstall.cjs +1 -1
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
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,
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
791
|
+
#[arg(
|
|
792
|
+
long,
|
|
793
|
+
help = "Only include events created after this ISO-8601 timestamp"
|
|
794
|
+
)]
|
|
597
795
|
since: Option<String>,
|
|
598
|
-
#[arg(
|
|
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(
|
|
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
|
|
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!(
|
|
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
|
|
1922
|
-
|
|
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|
|
|
1925
|
-
|
|
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
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
|
2380
|
+
async fn ops_list_command(
|
|
1936
2381
|
client: &reqwest::Client,
|
|
1937
|
-
args:
|
|
2382
|
+
args: OpsListArgs,
|
|
1938
2383
|
output: OutputFormat,
|
|
1939
2384
|
) -> Result<()> {
|
|
1940
|
-
let
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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 == ¶meter.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 ¶m_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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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!(
|
|
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!(
|
|
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, "/
|
|
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
|
-
"/
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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!(
|
|
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!(
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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(
|
|
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!("/
|
|
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(
|
|
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!("/
|
|
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!("/
|
|
3561
|
+
format!("/projects?orgId={}", org_id.trim())
|
|
2818
3562
|
}
|
|
2819
|
-
_ => "/
|
|
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
|
-
"/
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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(
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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!("/
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
5391
|
-
let mut
|
|
5392
|
-
if let Some(
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
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
|
|
5408
|
-
|
|
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::
|
|
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
|
|
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("
|
|
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
|
|
5426
|
-
let
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
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
|
-
|
|
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(||
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
.
|
|
5759
|
-
|
|
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 =
|
|
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) =>
|
|
6293
|
-
|
|
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) =>
|
|
6317
|
-
|
|
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
|
}
|