44 files changed
+5850
.github/workflows/ci.yml
new
+38
@@ -0,0 +1,38 @@
1
+name: CI
2
+
3
+on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+env:
9
+ CARGO_TERM_COLOR: always
10
+
11
+jobs:
12
+ test:
13
+ name: fmt / clippy / test
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Read Rust toolchain
19
+ shell: bash
20
+ run: |
21
+ rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
22
+ echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
23
+
24
+ - uses: dtolnay/rust-toolchain@stable
25
+ with:
26
+ toolchain: ${{ env.RUST_TOOLCHAIN }}
27
+ components: rustfmt, clippy
28
+
29
+ - uses: Swatinem/rust-cache@v2
30
+
31
+ - name: Check formatting
32
+ run: cargo fmt --all -- --check
33
+
34
+ - name: Clippy
35
+ run: cargo clippy --workspace --all-targets -- -D warnings
36
+
37
+ - name: Test
38
+ run: cargo test --workspace
.gitignore
new
+9
@@ -0,0 +1,9 @@
1
+/target
2
+*.log
3
+
4
+# Editor
5
+.zed
6
+.vscode
7
+
8
+# Secrets
9
+.env
Cargo.lock
new
+2020
@@ -0,0 +1,2020 @@
1
+# This file is automatically @generated by Cargo.
2
+# It is not intended for manual editing.
3
+version = 3
4
+
5
+[[package]]
6
+name = "aho-corasick"
7
+version = "1.1.5"
8
+source = "registry+https://github.com/rust-lang/crates.io-index"
9
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
10
+dependencies = [
11
+ "memchr",
12
+]
13
+
14
+[[package]]
15
+name = "android_system_properties"
16
+version = "0.1.6"
17
+source = "registry+https://github.com/rust-lang/crates.io-index"
18
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
19
+dependencies = [
20
+ "libc",
21
+]
22
+
23
+[[package]]
24
+name = "anstream"
25
+version = "1.0.0"
26
+source = "registry+https://github.com/rust-lang/crates.io-index"
27
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
28
+dependencies = [
29
+ "anstyle",
30
+ "anstyle-parse",
31
+ "anstyle-query",
32
+ "anstyle-wincon",
33
+ "colorchoice",
34
+ "is_terminal_polyfill",
35
+ "utf8parse",
36
+]
37
+
38
+[[package]]
39
+name = "anstyle"
40
+version = "1.0.14"
41
+source = "registry+https://github.com/rust-lang/crates.io-index"
42
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
43
+
44
+[[package]]
45
+name = "anstyle-parse"
46
+version = "1.0.0"
47
+source = "registry+https://github.com/rust-lang/crates.io-index"
48
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
49
+dependencies = [
50
+ "utf8parse",
51
+]
52
+
53
+[[package]]
54
+name = "anstyle-query"
55
+version = "1.1.5"
56
+source = "registry+https://github.com/rust-lang/crates.io-index"
57
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
58
+dependencies = [
59
+ "windows-sys 0.61.2",
60
+]
61
+
62
+[[package]]
63
+name = "anstyle-wincon"
64
+version = "3.0.11"
65
+source = "registry+https://github.com/rust-lang/crates.io-index"
66
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
67
+dependencies = [
68
+ "anstyle",
69
+ "once_cell_polyfill",
70
+ "windows-sys 0.61.2",
71
+]
72
+
73
+[[package]]
74
+name = "anyhow"
75
+version = "1.0.104"
76
+source = "registry+https://github.com/rust-lang/crates.io-index"
77
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
78
+
79
+[[package]]
80
+name = "atomic-waker"
81
+version = "1.1.2"
82
+source = "registry+https://github.com/rust-lang/crates.io-index"
83
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
84
+
85
+[[package]]
86
+name = "autocfg"
87
+version = "1.5.1"
88
+source = "registry+https://github.com/rust-lang/crates.io-index"
89
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
90
+
91
+[[package]]
92
+name = "base64"
93
+version = "0.22.1"
94
+source = "registry+https://github.com/rust-lang/crates.io-index"
95
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
96
+
97
+[[package]]
98
+name = "bitflags"
99
+version = "2.13.1"
100
+source = "registry+https://github.com/rust-lang/crates.io-index"
101
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
102
+
103
+[[package]]
104
+name = "bumpalo"
105
+version = "3.20.3"
106
+source = "registry+https://github.com/rust-lang/crates.io-index"
107
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
108
+
109
+[[package]]
110
+name = "bytecount"
111
+version = "0.6.9"
112
+source = "registry+https://github.com/rust-lang/crates.io-index"
113
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
114
+
115
+[[package]]
116
+name = "bytes"
117
+version = "1.12.1"
118
+source = "registry+https://github.com/rust-lang/crates.io-index"
119
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
120
+
121
+[[package]]
122
+name = "cc"
123
+version = "1.4.3"
124
+source = "registry+https://github.com/rust-lang/crates.io-index"
125
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
126
+dependencies = [
127
+ "find-msvc-tools",
128
+ "jobserver",
129
+ "libc",
130
+ "shlex",
131
+]
132
+
133
+[[package]]
134
+name = "cfg-if"
135
+version = "1.0.4"
136
+source = "registry+https://github.com/rust-lang/crates.io-index"
137
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
138
+
139
+[[package]]
140
+name = "cfg_aliases"
141
+version = "0.2.2"
142
+source = "registry+https://github.com/rust-lang/crates.io-index"
143
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
144
+
145
+[[package]]
146
+name = "chacha20"
147
+version = "0.10.1"
148
+source = "registry+https://github.com/rust-lang/crates.io-index"
149
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
150
+dependencies = [
151
+ "cfg-if",
152
+ "cpufeatures",
153
+ "rand_core",
154
+]
155
+
156
+[[package]]
157
+name = "chrono"
158
+version = "0.4.45"
159
+source = "registry+https://github.com/rust-lang/crates.io-index"
160
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
161
+dependencies = [
162
+ "iana-time-zone",
163
+ "js-sys",
164
+ "num-traits",
165
+ "serde",
166
+ "wasm-bindgen",
167
+ "windows-link",
168
+]
169
+
170
+[[package]]
171
+name = "clap"
172
+version = "4.6.6"
173
+source = "registry+https://github.com/rust-lang/crates.io-index"
174
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
175
+dependencies = [
176
+ "clap_builder",
177
+ "clap_derive",
178
+]
179
+
180
+[[package]]
181
+name = "clap_builder"
182
+version = "4.6.6"
183
+source = "registry+https://github.com/rust-lang/crates.io-index"
184
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
185
+dependencies = [
186
+ "anstream",
187
+ "anstyle",
188
+ "clap_lex",
189
+ "strsim",
190
+]
191
+
192
+[[package]]
193
+name = "clap_derive"
194
+version = "4.6.4"
195
+source = "registry+https://github.com/rust-lang/crates.io-index"
196
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
197
+dependencies = [
198
+ "heck",
199
+ "proc-macro2",
200
+ "quote",
201
+ "syn 3.0.3",
202
+]
203
+
204
+[[package]]
205
+name = "clap_lex"
206
+version = "1.1.0"
207
+source = "registry+https://github.com/rust-lang/crates.io-index"
208
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
209
+
210
+[[package]]
211
+name = "colorchoice"
212
+version = "1.0.5"
213
+source = "registry+https://github.com/rust-lang/crates.io-index"
214
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
215
+
216
+[[package]]
217
+name = "console"
218
+version = "0.15.11"
219
+source = "registry+https://github.com/rust-lang/crates.io-index"
220
+checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
221
+dependencies = [
222
+ "encode_unicode",
223
+ "libc",
224
+ "once_cell",
225
+ "unicode-width",
226
+ "windows-sys 0.59.0",
227
+]
228
+
229
+[[package]]
230
+name = "core-foundation"
231
+version = "0.10.1"
232
+source = "registry+https://github.com/rust-lang/crates.io-index"
233
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
234
+dependencies = [
235
+ "core-foundation-sys",
236
+ "libc",
237
+]
238
+
239
+[[package]]
240
+name = "core-foundation-sys"
241
+version = "0.8.7"
242
+source = "registry+https://github.com/rust-lang/crates.io-index"
243
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
244
+
245
+[[package]]
246
+name = "cpufeatures"
247
+version = "0.3.0"
248
+source = "registry+https://github.com/rust-lang/crates.io-index"
249
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
250
+dependencies = [
251
+ "libc",
252
+]
253
+
254
+[[package]]
255
+name = "dialoguer"
256
+version = "0.11.0"
257
+source = "registry+https://github.com/rust-lang/crates.io-index"
258
+checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
259
+dependencies = [
260
+ "console",
261
+ "shell-words",
262
+ "tempfile",
263
+ "thiserror 1.0.69",
264
+ "zeroize",
265
+]
266
+
267
+[[package]]
268
+name = "dirs"
269
+version = "6.0.0"
270
+source = "registry+https://github.com/rust-lang/crates.io-index"
271
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
272
+dependencies = [
273
+ "dirs-sys",
274
+]
275
+
276
+[[package]]
277
+name = "dirs-sys"
278
+version = "0.5.0"
279
+source = "registry+https://github.com/rust-lang/crates.io-index"
280
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
281
+dependencies = [
282
+ "libc",
283
+ "option-ext",
284
+ "redox_users",
285
+ "windows-sys 0.61.2",
286
+]
287
+
288
+[[package]]
289
+name = "displaydoc"
290
+version = "0.2.7"
291
+source = "registry+https://github.com/rust-lang/crates.io-index"
292
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
293
+dependencies = [
294
+ "proc-macro2",
295
+ "quote",
296
+ "syn 3.0.3",
297
+]
298
+
299
+[[package]]
300
+name = "encode_unicode"
301
+version = "1.0.0"
302
+source = "registry+https://github.com/rust-lang/crates.io-index"
303
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
304
+
305
+[[package]]
306
+name = "errno"
307
+version = "0.3.14"
308
+source = "registry+https://github.com/rust-lang/crates.io-index"
309
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
310
+dependencies = [
311
+ "libc",
312
+ "windows-sys 0.61.2",
313
+]
314
+
315
+[[package]]
316
+name = "fastrand"
317
+version = "2.5.0"
318
+source = "registry+https://github.com/rust-lang/crates.io-index"
319
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
320
+
321
+[[package]]
322
+name = "find-msvc-tools"
323
+version = "0.1.11"
324
+source = "registry+https://github.com/rust-lang/crates.io-index"
325
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
326
+
327
+[[package]]
328
+name = "fnv"
329
+version = "1.0.7"
330
+source = "registry+https://github.com/rust-lang/crates.io-index"
331
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
332
+
333
+[[package]]
334
+name = "form_urlencoded"
335
+version = "1.2.2"
336
+source = "registry+https://github.com/rust-lang/crates.io-index"
337
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
338
+dependencies = [
339
+ "percent-encoding",
340
+]
341
+
342
+[[package]]
343
+name = "futures-channel"
344
+version = "0.3.34"
345
+source = "registry+https://github.com/rust-lang/crates.io-index"
346
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
347
+dependencies = [
348
+ "futures-core",
349
+]
350
+
351
+[[package]]
352
+name = "futures-core"
353
+version = "0.3.34"
354
+source = "registry+https://github.com/rust-lang/crates.io-index"
355
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
356
+
357
+[[package]]
358
+name = "futures-task"
359
+version = "0.3.34"
360
+source = "registry+https://github.com/rust-lang/crates.io-index"
361
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
362
+
363
+[[package]]
364
+name = "futures-util"
365
+version = "0.3.34"
366
+source = "registry+https://github.com/rust-lang/crates.io-index"
367
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
368
+dependencies = [
369
+ "futures-core",
370
+ "futures-task",
371
+ "pin-project-lite",
372
+ "slab",
373
+]
374
+
375
+[[package]]
376
+name = "getrandom"
377
+version = "0.2.17"
378
+source = "registry+https://github.com/rust-lang/crates.io-index"
379
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
380
+dependencies = [
381
+ "cfg-if",
382
+ "js-sys",
383
+ "libc",
384
+ "wasi",
385
+ "wasm-bindgen",
386
+]
387
+
388
+[[package]]
389
+name = "getrandom"
390
+version = "0.4.3"
391
+source = "registry+https://github.com/rust-lang/crates.io-index"
392
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
393
+dependencies = [
394
+ "cfg-if",
395
+ "js-sys",
396
+ "libc",
397
+ "r-efi",
398
+ "rand_core",
399
+ "wasm-bindgen",
400
+]
401
+
402
+[[package]]
403
+name = "git2"
404
+version = "0.20.4"
405
+source = "registry+https://github.com/rust-lang/crates.io-index"
406
+checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
407
+dependencies = [
408
+ "bitflags",
409
+ "libc",
410
+ "libgit2-sys",
411
+ "log",
412
+ "url",
413
+]
414
+
415
+[[package]]
416
+name = "heck"
417
+version = "0.5.0"
418
+source = "registry+https://github.com/rust-lang/crates.io-index"
419
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
420
+
421
+[[package]]
422
+name = "http"
423
+version = "1.5.0"
424
+source = "registry+https://github.com/rust-lang/crates.io-index"
425
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
426
+dependencies = [
427
+ "bytes",
428
+ "itoa",
429
+]
430
+
431
+[[package]]
432
+name = "http-body"
433
+version = "1.1.0"
434
+source = "registry+https://github.com/rust-lang/crates.io-index"
435
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
436
+dependencies = [
437
+ "bytes",
438
+ "http",
439
+]
440
+
441
+[[package]]
442
+name = "http-body-util"
443
+version = "0.1.5"
444
+source = "registry+https://github.com/rust-lang/crates.io-index"
445
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
446
+dependencies = [
447
+ "bytes",
448
+ "futures-core",
449
+ "http",
450
+ "http-body",
451
+ "pin-project-lite",
452
+]
453
+
454
+[[package]]
455
+name = "httparse"
456
+version = "1.10.1"
457
+source = "registry+https://github.com/rust-lang/crates.io-index"
458
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
459
+
460
+[[package]]
461
+name = "hyper"
462
+version = "1.11.0"
463
+source = "registry+https://github.com/rust-lang/crates.io-index"
464
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
465
+dependencies = [
466
+ "atomic-waker",
467
+ "bytes",
468
+ "futures-channel",
469
+ "futures-core",
470
+ "http",
471
+ "http-body",
472
+ "httparse",
473
+ "itoa",
474
+ "pin-project-lite",
475
+ "smallvec",
476
+ "tokio",
477
+ "want",
478
+]
479
+
480
+[[package]]
481
+name = "hyper-rustls"
482
+version = "0.27.9"
483
+source = "registry+https://github.com/rust-lang/crates.io-index"
484
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
485
+dependencies = [
486
+ "http",
487
+ "hyper",
488
+ "hyper-util",
489
+ "rustls",
490
+ "rustls-native-certs",
491
+ "tokio",
492
+ "tokio-rustls",
493
+ "tower-service",
494
+]
495
+
496
+[[package]]
497
+name = "hyper-util"
498
+version = "0.1.20"
499
+source = "registry+https://github.com/rust-lang/crates.io-index"
500
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
501
+dependencies = [
502
+ "base64",
503
+ "bytes",
504
+ "futures-channel",
505
+ "futures-util",
506
+ "http",
507
+ "http-body",
508
+ "hyper",
509
+ "ipnet",
510
+ "libc",
511
+ "percent-encoding",
512
+ "pin-project-lite",
513
+ "socket2",
514
+ "tokio",
515
+ "tower-service",
516
+ "tracing",
517
+]
518
+
519
+[[package]]
520
+name = "iana-time-zone"
521
+version = "0.1.65"
522
+source = "registry+https://github.com/rust-lang/crates.io-index"
523
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
524
+dependencies = [
525
+ "android_system_properties",
526
+ "core-foundation-sys",
527
+ "iana-time-zone-haiku",
528
+ "js-sys",
529
+ "log",
530
+ "wasm-bindgen",
531
+ "windows-core",
532
+]
533
+
534
+[[package]]
535
+name = "iana-time-zone-haiku"
536
+version = "0.1.2"
537
+source = "registry+https://github.com/rust-lang/crates.io-index"
538
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
539
+dependencies = [
540
+ "cc",
541
+]
542
+
543
+[[package]]
544
+name = "icu_collections"
545
+version = "2.3.0"
546
+source = "registry+https://github.com/rust-lang/crates.io-index"
547
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
548
+dependencies = [
549
+ "displaydoc",
550
+ "potential_utf",
551
+ "utf8_iter",
552
+ "yoke",
553
+ "zerofrom",
554
+ "zerovec",
555
+]
556
+
557
+[[package]]
558
+name = "icu_locale_core"
559
+version = "2.3.0"
560
+source = "registry+https://github.com/rust-lang/crates.io-index"
561
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
562
+dependencies = [
563
+ "displaydoc",
564
+ "litemap",
565
+ "tinystr",
566
+ "writeable",
567
+ "zerovec",
568
+]
569
+
570
+[[package]]
571
+name = "icu_normalizer"
572
+version = "2.3.0"
573
+source = "registry+https://github.com/rust-lang/crates.io-index"
574
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
575
+dependencies = [
576
+ "icu_collections",
577
+ "icu_normalizer_data",
578
+ "icu_properties",
579
+ "icu_provider",
580
+ "smallvec",
581
+ "zerovec",
582
+]
583
+
584
+[[package]]
585
+name = "icu_normalizer_data"
586
+version = "2.3.0"
587
+source = "registry+https://github.com/rust-lang/crates.io-index"
588
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
589
+
590
+[[package]]
591
+name = "icu_properties"
592
+version = "2.3.0"
593
+source = "registry+https://github.com/rust-lang/crates.io-index"
594
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
595
+dependencies = [
596
+ "displaydoc",
597
+ "icu_collections",
598
+ "icu_locale_core",
599
+ "icu_properties_data",
600
+ "icu_provider",
601
+ "zerotrie",
602
+ "zerovec",
603
+]
604
+
605
+[[package]]
606
+name = "icu_properties_data"
607
+version = "2.3.0"
608
+source = "registry+https://github.com/rust-lang/crates.io-index"
609
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
610
+
611
+[[package]]
612
+name = "icu_provider"
613
+version = "2.3.0"
614
+source = "registry+https://github.com/rust-lang/crates.io-index"
615
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
616
+dependencies = [
617
+ "displaydoc",
618
+ "icu_locale_core",
619
+ "writeable",
620
+ "yoke",
621
+ "zerofrom",
622
+ "zerotrie",
623
+ "zerovec",
624
+]
625
+
626
+[[package]]
627
+name = "idna"
628
+version = "1.1.0"
629
+source = "registry+https://github.com/rust-lang/crates.io-index"
630
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
631
+dependencies = [
632
+ "idna_adapter",
633
+ "smallvec",
634
+ "utf8_iter",
635
+]
636
+
637
+[[package]]
638
+name = "idna_adapter"
639
+version = "1.2.2"
640
+source = "registry+https://github.com/rust-lang/crates.io-index"
641
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
642
+dependencies = [
643
+ "icu_normalizer",
644
+ "icu_properties",
645
+]
646
+
647
+[[package]]
648
+name = "ipnet"
649
+version = "2.12.1"
650
+source = "registry+https://github.com/rust-lang/crates.io-index"
651
+checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
652
+
653
+[[package]]
654
+name = "is-docker"
655
+version = "0.2.0"
656
+source = "registry+https://github.com/rust-lang/crates.io-index"
657
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
658
+dependencies = [
659
+ "once_cell",
660
+]
661
+
662
+[[package]]
663
+name = "is-wsl"
664
+version = "0.4.0"
665
+source = "registry+https://github.com/rust-lang/crates.io-index"
666
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
667
+dependencies = [
668
+ "is-docker",
669
+ "once_cell",
670
+]
671
+
672
+[[package]]
673
+name = "is_terminal_polyfill"
674
+version = "1.70.2"
675
+source = "registry+https://github.com/rust-lang/crates.io-index"
676
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
677
+
678
+[[package]]
679
+name = "itoa"
680
+version = "1.0.18"
681
+source = "registry+https://github.com/rust-lang/crates.io-index"
682
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
683
+
684
+[[package]]
685
+name = "jobserver"
686
+version = "0.1.35"
687
+source = "registry+https://github.com/rust-lang/crates.io-index"
688
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
689
+dependencies = [
690
+ "getrandom 0.4.3",
691
+ "libc",
692
+]
693
+
694
+[[package]]
695
+name = "js-sys"
696
+version = "0.3.104"
697
+source = "registry+https://github.com/rust-lang/crates.io-index"
698
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
699
+dependencies = [
700
+ "cfg-if",
701
+ "futures-util",
702
+ "wasm-bindgen",
703
+]
704
+
705
+[[package]]
706
+name = "lazy_static"
707
+version = "1.5.0"
708
+source = "registry+https://github.com/rust-lang/crates.io-index"
709
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
710
+
711
+[[package]]
712
+name = "libc"
713
+version = "0.2.189"
714
+source = "registry+https://github.com/rust-lang/crates.io-index"
715
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
716
+
717
+[[package]]
718
+name = "libgit2-sys"
719
+version = "0.18.7+1.9.6"
720
+source = "registry+https://github.com/rust-lang/crates.io-index"
721
+checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89"
722
+dependencies = [
723
+ "cc",
724
+ "libc",
725
+ "libz-sys",
726
+ "pkg-config",
727
+]
728
+
729
+[[package]]
730
+name = "libredox"
731
+version = "0.1.20"
732
+source = "registry+https://github.com/rust-lang/crates.io-index"
733
+checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
734
+dependencies = [
735
+ "libc",
736
+]
737
+
738
+[[package]]
739
+name = "libz-sys"
740
+version = "1.1.29"
741
+source = "registry+https://github.com/rust-lang/crates.io-index"
742
+checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
743
+dependencies = [
744
+ "cc",
745
+ "libc",
746
+ "pkg-config",
747
+ "vcpkg",
748
+]
749
+
750
+[[package]]
751
+name = "linux-raw-sys"
752
+version = "0.12.1"
753
+source = "registry+https://github.com/rust-lang/crates.io-index"
754
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
755
+
756
+[[package]]
757
+name = "litemap"
758
+version = "0.8.3"
759
+source = "registry+https://github.com/rust-lang/crates.io-index"
760
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
761
+
762
+[[package]]
763
+name = "log"
764
+version = "0.4.33"
765
+source = "registry+https://github.com/rust-lang/crates.io-index"
766
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
767
+
768
+[[package]]
769
+name = "lru-slab"
770
+version = "0.1.2"
771
+source = "registry+https://github.com/rust-lang/crates.io-index"
772
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
773
+
774
+[[package]]
775
+name = "matchers"
776
+version = "0.2.0"
777
+source = "registry+https://github.com/rust-lang/crates.io-index"
778
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
779
+dependencies = [
780
+ "regex-automata",
781
+]
782
+
783
+[[package]]
784
+name = "memchr"
785
+version = "2.8.3"
786
+source = "registry+https://github.com/rust-lang/crates.io-index"
787
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
788
+
789
+[[package]]
790
+name = "mio"
791
+version = "1.2.2"
792
+source = "registry+https://github.com/rust-lang/crates.io-index"
793
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
794
+dependencies = [
795
+ "libc",
796
+ "wasi",
797
+ "windows-sys 0.61.2",
798
+]
799
+
800
+[[package]]
801
+name = "nu-ansi-term"
802
+version = "0.50.3"
803
+source = "registry+https://github.com/rust-lang/crates.io-index"
804
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
805
+dependencies = [
806
+ "windows-sys 0.61.2",
807
+]
808
+
809
+[[package]]
810
+name = "num-traits"
811
+version = "0.2.19"
812
+source = "registry+https://github.com/rust-lang/crates.io-index"
813
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
814
+dependencies = [
815
+ "autocfg",
816
+]
817
+
818
+[[package]]
819
+name = "once_cell"
820
+version = "1.21.4"
821
+source = "registry+https://github.com/rust-lang/crates.io-index"
822
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
823
+
824
+[[package]]
825
+name = "once_cell_polyfill"
826
+version = "1.70.2"
827
+source = "registry+https://github.com/rust-lang/crates.io-index"
828
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
829
+
830
+[[package]]
831
+name = "open"
832
+version = "5.4.1"
833
+source = "registry+https://github.com/rust-lang/crates.io-index"
834
+checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408"
835
+dependencies = [
836
+ "is-wsl",
837
+ "libc",
838
+]
839
+
840
+[[package]]
841
+name = "openssl-probe"
842
+version = "0.2.1"
843
+source = "registry+https://github.com/rust-lang/crates.io-index"
844
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
845
+
846
+[[package]]
847
+name = "option-ext"
848
+version = "0.2.0"
849
+source = "registry+https://github.com/rust-lang/crates.io-index"
850
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
851
+
852
+[[package]]
853
+name = "papergrid"
854
+version = "0.17.0"
855
+source = "registry+https://github.com/rust-lang/crates.io-index"
856
+checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1"
857
+dependencies = [
858
+ "bytecount",
859
+ "fnv",
860
+ "unicode-width",
861
+]
862
+
863
+[[package]]
864
+name = "percent-encoding"
865
+version = "2.3.2"
866
+source = "registry+https://github.com/rust-lang/crates.io-index"
867
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
868
+
869
+[[package]]
870
+name = "pin-project-lite"
871
+version = "0.2.17"
872
+source = "registry+https://github.com/rust-lang/crates.io-index"
873
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
874
+
875
+[[package]]
876
+name = "pkg-config"
877
+version = "0.3.34"
878
+source = "registry+https://github.com/rust-lang/crates.io-index"
879
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
880
+
881
+[[package]]
882
+name = "potential_utf"
883
+version = "0.1.6"
884
+source = "registry+https://github.com/rust-lang/crates.io-index"
885
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
886
+dependencies = [
887
+ "zerovec",
888
+]
889
+
890
+[[package]]
891
+name = "proc-macro2"
892
+version = "1.0.107"
893
+source = "registry+https://github.com/rust-lang/crates.io-index"
894
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
895
+dependencies = [
896
+ "unicode-ident",
897
+]
898
+
899
+[[package]]
900
+name = "quinn"
901
+version = "0.11.11"
902
+source = "registry+https://github.com/rust-lang/crates.io-index"
903
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
904
+dependencies = [
905
+ "bytes",
906
+ "cfg_aliases",
907
+ "pin-project-lite",
908
+ "quinn-proto",
909
+ "quinn-udp",
910
+ "rustc-hash",
911
+ "rustls",
912
+ "socket2",
913
+ "thiserror 2.0.20",
914
+ "tokio",
915
+ "tracing",
916
+ "web-time",
917
+]
918
+
919
+[[package]]
920
+name = "quinn-proto"
921
+version = "0.11.16"
922
+source = "registry+https://github.com/rust-lang/crates.io-index"
923
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
924
+dependencies = [
925
+ "bytes",
926
+ "getrandom 0.4.3",
927
+ "lru-slab",
928
+ "rand",
929
+ "rand_pcg",
930
+ "ring",
931
+ "rustc-hash",
932
+ "rustls",
933
+ "rustls-pki-types",
934
+ "slab",
935
+ "thiserror 2.0.20",
936
+ "tinyvec",
937
+ "tracing",
938
+ "web-time",
939
+]
940
+
941
+[[package]]
942
+name = "quinn-udp"
943
+version = "0.5.15"
944
+source = "registry+https://github.com/rust-lang/crates.io-index"
945
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
946
+dependencies = [
947
+ "cfg_aliases",
948
+ "libc",
949
+ "once_cell",
950
+ "socket2",
951
+ "tracing",
952
+ "windows-sys 0.61.2",
953
+]
954
+
955
+[[package]]
956
+name = "quote"
957
+version = "1.0.47"
958
+source = "registry+https://github.com/rust-lang/crates.io-index"
959
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
960
+dependencies = [
961
+ "proc-macro2",
962
+]
963
+
964
+[[package]]
965
+name = "r-efi"
966
+version = "6.0.0"
967
+source = "registry+https://github.com/rust-lang/crates.io-index"
968
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
969
+
970
+[[package]]
971
+name = "rand"
972
+version = "0.10.2"
973
+source = "registry+https://github.com/rust-lang/crates.io-index"
974
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
975
+dependencies = [
976
+ "chacha20",
977
+ "getrandom 0.4.3",
978
+ "rand_core",
979
+]
980
+
981
+[[package]]
982
+name = "rand_core"
983
+version = "0.10.1"
984
+source = "registry+https://github.com/rust-lang/crates.io-index"
985
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
986
+
987
+[[package]]
988
+name = "rand_pcg"
989
+version = "0.10.2"
990
+source = "registry+https://github.com/rust-lang/crates.io-index"
991
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
992
+dependencies = [
993
+ "rand_core",
994
+]
995
+
996
+[[package]]
997
+name = "redox_users"
998
+version = "0.5.2"
999
+source = "registry+https://github.com/rust-lang/crates.io-index"
1000
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
1001
+dependencies = [
1002
+ "getrandom 0.2.17",
1003
+ "libredox",
1004
+ "thiserror 2.0.20",
1005
+]
1006
+
1007
+[[package]]
1008
+name = "regex-automata"
1009
+version = "0.4.18"
1010
+source = "registry+https://github.com/rust-lang/crates.io-index"
1011
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1012
+dependencies = [
1013
+ "aho-corasick",
1014
+ "memchr",
1015
+ "regex-syntax",
1016
+]
1017
+
1018
+[[package]]
1019
+name = "regex-syntax"
1020
+version = "0.8.11"
1021
+source = "registry+https://github.com/rust-lang/crates.io-index"
1022
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1023
+
1024
+[[package]]
1025
+name = "reqwest"
1026
+version = "0.12.28"
1027
+source = "registry+https://github.com/rust-lang/crates.io-index"
1028
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1029
+dependencies = [
1030
+ "base64",
1031
+ "bytes",
1032
+ "futures-core",
1033
+ "http",
1034
+ "http-body",
1035
+ "http-body-util",
1036
+ "hyper",
1037
+ "hyper-rustls",
1038
+ "hyper-util",
1039
+ "js-sys",
1040
+ "log",
1041
+ "percent-encoding",
1042
+ "pin-project-lite",
1043
+ "quinn",
1044
+ "rustls",
1045
+ "rustls-native-certs",
1046
+ "rustls-pki-types",
1047
+ "serde",
1048
+ "serde_json",
1049
+ "serde_urlencoded",
1050
+ "sync_wrapper",
1051
+ "tokio",
1052
+ "tokio-rustls",
1053
+ "tower",
1054
+ "tower-http",
1055
+ "tower-service",
1056
+ "url",
1057
+ "wasm-bindgen",
1058
+ "wasm-bindgen-futures",
1059
+ "web-sys",
1060
+]
1061
+
1062
+[[package]]
1063
+name = "ring"
1064
+version = "0.17.14"
1065
+source = "registry+https://github.com/rust-lang/crates.io-index"
1066
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1067
+dependencies = [
1068
+ "cc",
1069
+ "cfg-if",
1070
+ "getrandom 0.2.17",
1071
+ "libc",
1072
+ "untrusted",
1073
+ "windows-sys 0.52.0",
1074
+]
1075
+
1076
+[[package]]
1077
+name = "rustc-hash"
1078
+version = "2.1.3"
1079
+source = "registry+https://github.com/rust-lang/crates.io-index"
1080
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
1081
+
1082
+[[package]]
1083
+name = "rustix"
1084
+version = "1.1.4"
1085
+source = "registry+https://github.com/rust-lang/crates.io-index"
1086
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1087
+dependencies = [
1088
+ "bitflags",
1089
+ "errno",
1090
+ "libc",
1091
+ "linux-raw-sys",
1092
+ "windows-sys 0.61.2",
1093
+]
1094
+
1095
+[[package]]
1096
+name = "rustls"
1097
+version = "0.23.43"
1098
+source = "registry+https://github.com/rust-lang/crates.io-index"
1099
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
1100
+dependencies = [
1101
+ "once_cell",
1102
+ "ring",
1103
+ "rustls-pki-types",
1104
+ "rustls-webpki",
1105
+ "subtle",
1106
+ "zeroize",
1107
+]
1108
+
1109
+[[package]]
1110
+name = "rustls-native-certs"
1111
+version = "0.8.4"
1112
+source = "registry+https://github.com/rust-lang/crates.io-index"
1113
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
1114
+dependencies = [
1115
+ "openssl-probe",
1116
+ "rustls-pki-types",
1117
+ "schannel",
1118
+ "security-framework",
1119
+]
1120
+
1121
+[[package]]
1122
+name = "rustls-pki-types"
1123
+version = "1.15.1"
1124
+source = "registry+https://github.com/rust-lang/crates.io-index"
1125
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1126
+dependencies = [
1127
+ "web-time",
1128
+ "zeroize",
1129
+]
1130
+
1131
+[[package]]
1132
+name = "rustls-webpki"
1133
+version = "0.103.14"
1134
+source = "registry+https://github.com/rust-lang/crates.io-index"
1135
+checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
1136
+dependencies = [
1137
+ "ring",
1138
+ "rustls-pki-types",
1139
+ "untrusted",
1140
+]
1141
+
1142
+[[package]]
1143
+name = "rustversion"
1144
+version = "1.0.23"
1145
+source = "registry+https://github.com/rust-lang/crates.io-index"
1146
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1147
+
1148
+[[package]]
1149
+name = "ryu"
1150
+version = "1.0.23"
1151
+source = "registry+https://github.com/rust-lang/crates.io-index"
1152
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1153
+
1154
+[[package]]
1155
+name = "schannel"
1156
+version = "0.1.29"
1157
+source = "registry+https://github.com/rust-lang/crates.io-index"
1158
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
1159
+dependencies = [
1160
+ "windows-sys 0.61.2",
1161
+]
1162
+
1163
+[[package]]
1164
+name = "security-framework"
1165
+version = "3.7.0"
1166
+source = "registry+https://github.com/rust-lang/crates.io-index"
1167
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1168
+dependencies = [
1169
+ "bitflags",
1170
+ "core-foundation",
1171
+ "core-foundation-sys",
1172
+ "libc",
1173
+ "security-framework-sys",
1174
+]
1175
+
1176
+[[package]]
1177
+name = "security-framework-sys"
1178
+version = "2.17.0"
1179
+source = "registry+https://github.com/rust-lang/crates.io-index"
1180
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1181
+dependencies = [
1182
+ "core-foundation-sys",
1183
+ "libc",
1184
+]
1185
+
1186
+[[package]]
1187
+name = "serde"
1188
+version = "1.0.229"
1189
+source = "registry+https://github.com/rust-lang/crates.io-index"
1190
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
1191
+dependencies = [
1192
+ "serde_core",
1193
+ "serde_derive",
1194
+]
1195
+
1196
+[[package]]
1197
+name = "serde_core"
1198
+version = "1.0.229"
1199
+source = "registry+https://github.com/rust-lang/crates.io-index"
1200
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
1201
+dependencies = [
1202
+ "serde_derive",
1203
+]
1204
+
1205
+[[package]]
1206
+name = "serde_derive"
1207
+version = "1.0.229"
1208
+source = "registry+https://github.com/rust-lang/crates.io-index"
1209
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1210
+dependencies = [
1211
+ "proc-macro2",
1212
+ "quote",
1213
+ "syn 3.0.3",
1214
+]
1215
+
1216
+[[package]]
1217
+name = "serde_json"
1218
+version = "1.0.151"
1219
+source = "registry+https://github.com/rust-lang/crates.io-index"
1220
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
1221
+dependencies = [
1222
+ "itoa",
1223
+ "memchr",
1224
+ "serde",
1225
+ "serde_core",
1226
+ "zmij",
1227
+]
1228
+
1229
+[[package]]
1230
+name = "serde_urlencoded"
1231
+version = "0.7.1"
1232
+source = "registry+https://github.com/rust-lang/crates.io-index"
1233
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1234
+dependencies = [
1235
+ "form_urlencoded",
1236
+ "itoa",
1237
+ "ryu",
1238
+ "serde",
1239
+]
1240
+
1241
+[[package]]
1242
+name = "sharded-slab"
1243
+version = "0.1.7"
1244
+source = "registry+https://github.com/rust-lang/crates.io-index"
1245
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1246
+dependencies = [
1247
+ "lazy_static",
1248
+]
1249
+
1250
+[[package]]
1251
+name = "shell-words"
1252
+version = "1.1.1"
1253
+source = "registry+https://github.com/rust-lang/crates.io-index"
1254
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
1255
+
1256
+[[package]]
1257
+name = "shlex"
1258
+version = "2.0.1"
1259
+source = "registry+https://github.com/rust-lang/crates.io-index"
1260
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1261
+
1262
+[[package]]
1263
+name = "sigit-api"
1264
+version = "1.0.0"
1265
+dependencies = [
1266
+ "chrono",
1267
+ "reqwest",
1268
+ "serde",
1269
+ "serde_json",
1270
+ "thiserror 2.0.20",
1271
+ "tracing",
1272
+]
1273
+
1274
+[[package]]
1275
+name = "sigit-si-cli"
1276
+version = "1.0.0"
1277
+dependencies = [
1278
+ "anyhow",
1279
+ "chrono",
1280
+ "clap",
1281
+ "console",
1282
+ "dialoguer",
1283
+ "dirs",
1284
+ "git2",
1285
+ "open",
1286
+ "reqwest",
1287
+ "serde",
1288
+ "serde_json",
1289
+ "sigit-api",
1290
+ "tabled",
1291
+ "tempfile",
1292
+ "tokio",
1293
+ "tracing",
1294
+ "tracing-subscriber",
1295
+]
1296
+
1297
+[[package]]
1298
+name = "slab"
1299
+version = "0.4.12"
1300
+source = "registry+https://github.com/rust-lang/crates.io-index"
1301
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1302
+
1303
+[[package]]
1304
+name = "smallvec"
1305
+version = "1.15.2"
1306
+source = "registry+https://github.com/rust-lang/crates.io-index"
1307
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1308
+
1309
+[[package]]
1310
+name = "socket2"
1311
+version = "0.6.5"
1312
+source = "registry+https://github.com/rust-lang/crates.io-index"
1313
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1314
+dependencies = [
1315
+ "libc",
1316
+ "windows-sys 0.61.2",
1317
+]
1318
+
1319
+[[package]]
1320
+name = "stable_deref_trait"
1321
+version = "1.2.1"
1322
+source = "registry+https://github.com/rust-lang/crates.io-index"
1323
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1324
+
1325
+[[package]]
1326
+name = "strsim"
1327
+version = "0.11.1"
1328
+source = "registry+https://github.com/rust-lang/crates.io-index"
1329
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1330
+
1331
+[[package]]
1332
+name = "subtle"
1333
+version = "2.6.1"
1334
+source = "registry+https://github.com/rust-lang/crates.io-index"
1335
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1336
+
1337
+[[package]]
1338
+name = "syn"
1339
+version = "2.0.119"
1340
+source = "registry+https://github.com/rust-lang/crates.io-index"
1341
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1342
+dependencies = [
1343
+ "proc-macro2",
1344
+ "quote",
1345
+ "unicode-ident",
1346
+]
1347
+
1348
+[[package]]
1349
+name = "syn"
1350
+version = "3.0.3"
1351
+source = "registry+https://github.com/rust-lang/crates.io-index"
1352
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
1353
+dependencies = [
1354
+ "proc-macro2",
1355
+ "quote",
1356
+ "unicode-ident",
1357
+]
1358
+
1359
+[[package]]
1360
+name = "sync_wrapper"
1361
+version = "1.0.2"
1362
+source = "registry+https://github.com/rust-lang/crates.io-index"
1363
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1364
+dependencies = [
1365
+ "futures-core",
1366
+]
1367
+
1368
+[[package]]
1369
+name = "synstructure"
1370
+version = "0.13.2"
1371
+source = "registry+https://github.com/rust-lang/crates.io-index"
1372
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1373
+dependencies = [
1374
+ "proc-macro2",
1375
+ "quote",
1376
+ "syn 2.0.119",
1377
+]
1378
+
1379
+[[package]]
1380
+name = "tabled"
1381
+version = "0.20.0"
1382
+source = "registry+https://github.com/rust-lang/crates.io-index"
1383
+checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d"
1384
+dependencies = [
1385
+ "papergrid",
1386
+ "testing_table",
1387
+]
1388
+
1389
+[[package]]
1390
+name = "tempfile"
1391
+version = "3.27.0"
1392
+source = "registry+https://github.com/rust-lang/crates.io-index"
1393
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1394
+dependencies = [
1395
+ "fastrand",
1396
+ "getrandom 0.4.3",
1397
+ "once_cell",
1398
+ "rustix",
1399
+ "windows-sys 0.61.2",
1400
+]
1401
+
1402
+[[package]]
1403
+name = "testing_table"
1404
+version = "0.3.0"
1405
+source = "registry+https://github.com/rust-lang/crates.io-index"
1406
+checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc"
1407
+dependencies = [
1408
+ "unicode-width",
1409
+]
1410
+
1411
+[[package]]
1412
+name = "thiserror"
1413
+version = "1.0.69"
1414
+source = "registry+https://github.com/rust-lang/crates.io-index"
1415
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1416
+dependencies = [
1417
+ "thiserror-impl 1.0.69",
1418
+]
1419
+
1420
+[[package]]
1421
+name = "thiserror"
1422
+version = "2.0.20"
1423
+source = "registry+https://github.com/rust-lang/crates.io-index"
1424
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
1425
+dependencies = [
1426
+ "thiserror-impl 2.0.20",
1427
+]
1428
+
1429
+[[package]]
1430
+name = "thiserror-impl"
1431
+version = "1.0.69"
1432
+source = "registry+https://github.com/rust-lang/crates.io-index"
1433
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1434
+dependencies = [
1435
+ "proc-macro2",
1436
+ "quote",
1437
+ "syn 2.0.119",
1438
+]
1439
+
1440
+[[package]]
1441
+name = "thiserror-impl"
1442
+version = "2.0.20"
1443
+source = "registry+https://github.com/rust-lang/crates.io-index"
1444
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
1445
+dependencies = [
1446
+ "proc-macro2",
1447
+ "quote",
1448
+ "syn 3.0.3",
1449
+]
1450
+
1451
+[[package]]
1452
+name = "thread_local"
1453
+version = "1.1.10"
1454
+source = "registry+https://github.com/rust-lang/crates.io-index"
1455
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
1456
+dependencies = [
1457
+ "cfg-if",
1458
+]
1459
+
1460
+[[package]]
1461
+name = "tinystr"
1462
+version = "0.8.4"
1463
+source = "registry+https://github.com/rust-lang/crates.io-index"
1464
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
1465
+dependencies = [
1466
+ "displaydoc",
1467
+ "zerovec",
1468
+]
1469
+
1470
+[[package]]
1471
+name = "tinyvec"
1472
+version = "1.12.0"
1473
+source = "registry+https://github.com/rust-lang/crates.io-index"
1474
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
1475
+dependencies = [
1476
+ "tinyvec_macros",
1477
+]
1478
+
1479
+[[package]]
1480
+name = "tinyvec_macros"
1481
+version = "0.1.1"
1482
+source = "registry+https://github.com/rust-lang/crates.io-index"
1483
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1484
+
1485
+[[package]]
1486
+name = "tokio"
1487
+version = "1.53.1"
1488
+source = "registry+https://github.com/rust-lang/crates.io-index"
1489
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
1490
+dependencies = [
1491
+ "bytes",
1492
+ "libc",
1493
+ "mio",
1494
+ "pin-project-lite",
1495
+ "socket2",
1496
+ "tokio-macros",
1497
+ "windows-sys 0.61.2",
1498
+]
1499
+
1500
+[[package]]
1501
+name = "tokio-macros"
1502
+version = "2.7.2"
1503
+source = "registry+https://github.com/rust-lang/crates.io-index"
1504
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
1505
+dependencies = [
1506
+ "proc-macro2",
1507
+ "quote",
1508
+ "syn 3.0.3",
1509
+]
1510
+
1511
+[[package]]
1512
+name = "tokio-rustls"
1513
+version = "0.26.4"
1514
+source = "registry+https://github.com/rust-lang/crates.io-index"
1515
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
1516
+dependencies = [
1517
+ "rustls",
1518
+ "tokio",
1519
+]
1520
+
1521
+[[package]]
1522
+name = "tower"
1523
+version = "0.5.3"
1524
+source = "registry+https://github.com/rust-lang/crates.io-index"
1525
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1526
+dependencies = [
1527
+ "futures-core",
1528
+ "futures-util",
1529
+ "pin-project-lite",
1530
+ "sync_wrapper",
1531
+ "tokio",
1532
+ "tower-layer",
1533
+ "tower-service",
1534
+]
1535
+
1536
+[[package]]
1537
+name = "tower-http"
1538
+version = "0.6.11"
1539
+source = "registry+https://github.com/rust-lang/crates.io-index"
1540
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1541
+dependencies = [
1542
+ "bitflags",
1543
+ "bytes",
1544
+ "futures-util",
1545
+ "http",
1546
+ "http-body",
1547
+ "pin-project-lite",
1548
+ "tower",
1549
+ "tower-layer",
1550
+ "tower-service",
1551
+ "url",
1552
+]
1553
+
1554
+[[package]]
1555
+name = "tower-layer"
1556
+version = "0.3.3"
1557
+source = "registry+https://github.com/rust-lang/crates.io-index"
1558
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1559
+
1560
+[[package]]
1561
+name = "tower-service"
1562
+version = "0.3.3"
1563
+source = "registry+https://github.com/rust-lang/crates.io-index"
1564
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1565
+
1566
+[[package]]
1567
+name = "tracing"
1568
+version = "0.1.44"
1569
+source = "registry+https://github.com/rust-lang/crates.io-index"
1570
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1571
+dependencies = [
1572
+ "pin-project-lite",
1573
+ "tracing-attributes",
1574
+ "tracing-core",
1575
+]
1576
+
1577
+[[package]]
1578
+name = "tracing-attributes"
1579
+version = "0.1.31"
1580
+source = "registry+https://github.com/rust-lang/crates.io-index"
1581
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1582
+dependencies = [
1583
+ "proc-macro2",
1584
+ "quote",
1585
+ "syn 2.0.119",
1586
+]
1587
+
1588
+[[package]]
1589
+name = "tracing-core"
1590
+version = "0.1.36"
1591
+source = "registry+https://github.com/rust-lang/crates.io-index"
1592
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1593
+dependencies = [
1594
+ "once_cell",
1595
+ "valuable",
1596
+]
1597
+
1598
+[[package]]
1599
+name = "tracing-log"
1600
+version = "0.2.0"
1601
+source = "registry+https://github.com/rust-lang/crates.io-index"
1602
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1603
+dependencies = [
1604
+ "log",
1605
+ "once_cell",
1606
+ "tracing-core",
1607
+]
1608
+
1609
+[[package]]
1610
+name = "tracing-subscriber"
1611
+version = "0.3.23"
1612
+source = "registry+https://github.com/rust-lang/crates.io-index"
1613
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1614
+dependencies = [
1615
+ "matchers",
1616
+ "nu-ansi-term",
1617
+ "once_cell",
1618
+ "regex-automata",
1619
+ "sharded-slab",
1620
+ "smallvec",
1621
+ "thread_local",
1622
+ "tracing",
1623
+ "tracing-core",
1624
+ "tracing-log",
1625
+]
1626
+
1627
+[[package]]
1628
+name = "try-lock"
1629
+version = "0.2.5"
1630
+source = "registry+https://github.com/rust-lang/crates.io-index"
1631
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
1632
+
1633
+[[package]]
1634
+name = "unicode-ident"
1635
+version = "1.0.24"
1636
+source = "registry+https://github.com/rust-lang/crates.io-index"
1637
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1638
+
1639
+[[package]]
1640
+name = "unicode-width"
1641
+version = "0.2.2"
1642
+source = "registry+https://github.com/rust-lang/crates.io-index"
1643
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1644
+
1645
+[[package]]
1646
+name = "untrusted"
1647
+version = "0.9.0"
1648
+source = "registry+https://github.com/rust-lang/crates.io-index"
1649
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1650
+
1651
+[[package]]
1652
+name = "url"
1653
+version = "2.5.8"
1654
+source = "registry+https://github.com/rust-lang/crates.io-index"
1655
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1656
+dependencies = [
1657
+ "form_urlencoded",
1658
+ "idna",
1659
+ "percent-encoding",
1660
+ "serde",
1661
+]
1662
+
1663
+[[package]]
1664
+name = "utf8_iter"
1665
+version = "1.0.4"
1666
+source = "registry+https://github.com/rust-lang/crates.io-index"
1667
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1668
+
1669
+[[package]]
1670
+name = "utf8parse"
1671
+version = "0.2.2"
1672
+source = "registry+https://github.com/rust-lang/crates.io-index"
1673
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1674
+
1675
+[[package]]
1676
+name = "valuable"
1677
+version = "0.1.1"
1678
+source = "registry+https://github.com/rust-lang/crates.io-index"
1679
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1680
+
1681
+[[package]]
1682
+name = "vcpkg"
1683
+version = "0.2.15"
1684
+source = "registry+https://github.com/rust-lang/crates.io-index"
1685
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1686
+
1687
+[[package]]
1688
+name = "want"
1689
+version = "0.3.1"
1690
+source = "registry+https://github.com/rust-lang/crates.io-index"
1691
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
1692
+dependencies = [
1693
+ "try-lock",
1694
+]
1695
+
1696
+[[package]]
1697
+name = "wasi"
1698
+version = "0.11.1+wasi-snapshot-preview1"
1699
+source = "registry+https://github.com/rust-lang/crates.io-index"
1700
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1701
+
1702
+[[package]]
1703
+name = "wasm-bindgen"
1704
+version = "0.2.127"
1705
+source = "registry+https://github.com/rust-lang/crates.io-index"
1706
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
1707
+dependencies = [
1708
+ "cfg-if",
1709
+ "once_cell",
1710
+ "rustversion",
1711
+ "wasm-bindgen-macro",
1712
+ "wasm-bindgen-shared",
1713
+]
1714
+
1715
+[[package]]
1716
+name = "wasm-bindgen-futures"
1717
+version = "0.4.77"
1718
+source = "registry+https://github.com/rust-lang/crates.io-index"
1719
+checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
1720
+dependencies = [
1721
+ "js-sys",
1722
+ "wasm-bindgen",
1723
+]
1724
+
1725
+[[package]]
1726
+name = "wasm-bindgen-macro"
1727
+version = "0.2.127"
1728
+source = "registry+https://github.com/rust-lang/crates.io-index"
1729
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
1730
+dependencies = [
1731
+ "quote",
1732
+ "wasm-bindgen-macro-support",
1733
+]
1734
+
1735
+[[package]]
1736
+name = "wasm-bindgen-macro-support"
1737
+version = "0.2.127"
1738
+source = "registry+https://github.com/rust-lang/crates.io-index"
1739
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
1740
+dependencies = [
1741
+ "bumpalo",
1742
+ "proc-macro2",
1743
+ "quote",
1744
+ "syn 2.0.119",
1745
+ "wasm-bindgen-shared",
1746
+]
1747
+
1748
+[[package]]
1749
+name = "wasm-bindgen-shared"
1750
+version = "0.2.127"
1751
+source = "registry+https://github.com/rust-lang/crates.io-index"
1752
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
1753
+dependencies = [
1754
+ "unicode-ident",
1755
+]
1756
+
1757
+[[package]]
1758
+name = "web-sys"
1759
+version = "0.3.104"
1760
+source = "registry+https://github.com/rust-lang/crates.io-index"
1761
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
1762
+dependencies = [
1763
+ "js-sys",
1764
+ "wasm-bindgen",
1765
+]
1766
+
1767
+[[package]]
1768
+name = "web-time"
1769
+version = "1.1.0"
1770
+source = "registry+https://github.com/rust-lang/crates.io-index"
1771
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
1772
+dependencies = [
1773
+ "js-sys",
1774
+ "wasm-bindgen",
1775
+]
1776
+
1777
+[[package]]
1778
+name = "windows-core"
1779
+version = "0.62.2"
1780
+source = "registry+https://github.com/rust-lang/crates.io-index"
1781
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
1782
+dependencies = [
1783
+ "windows-implement",
1784
+ "windows-interface",
1785
+ "windows-link",
1786
+ "windows-result",
1787
+ "windows-strings",
1788
+]
1789
+
1790
+[[package]]
1791
+name = "windows-implement"
1792
+version = "0.60.2"
1793
+source = "registry+https://github.com/rust-lang/crates.io-index"
1794
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
1795
+dependencies = [
1796
+ "proc-macro2",
1797
+ "quote",
1798
+ "syn 2.0.119",
1799
+]
1800
+
1801
+[[package]]
1802
+name = "windows-interface"
1803
+version = "0.59.3"
1804
+source = "registry+https://github.com/rust-lang/crates.io-index"
1805
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
1806
+dependencies = [
1807
+ "proc-macro2",
1808
+ "quote",
1809
+ "syn 2.0.119",
1810
+]
1811
+
1812
+[[package]]
1813
+name = "windows-link"
1814
+version = "0.2.1"
1815
+source = "registry+https://github.com/rust-lang/crates.io-index"
1816
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1817
+
1818
+[[package]]
1819
+name = "windows-result"
1820
+version = "0.4.1"
1821
+source = "registry+https://github.com/rust-lang/crates.io-index"
1822
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
1823
+dependencies = [
1824
+ "windows-link",
1825
+]
1826
+
1827
+[[package]]
1828
+name = "windows-strings"
1829
+version = "0.5.1"
1830
+source = "registry+https://github.com/rust-lang/crates.io-index"
1831
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
1832
+dependencies = [
1833
+ "windows-link",
1834
+]
1835
+
1836
+[[package]]
1837
+name = "windows-sys"
1838
+version = "0.52.0"
1839
+source = "registry+https://github.com/rust-lang/crates.io-index"
1840
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1841
+dependencies = [
1842
+ "windows-targets",
1843
+]
1844
+
1845
+[[package]]
1846
+name = "windows-sys"
1847
+version = "0.59.0"
1848
+source = "registry+https://github.com/rust-lang/crates.io-index"
1849
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1850
+dependencies = [
1851
+ "windows-targets",
1852
+]
1853
+
1854
+[[package]]
1855
+name = "windows-sys"
1856
+version = "0.61.2"
1857
+source = "registry+https://github.com/rust-lang/crates.io-index"
1858
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1859
+dependencies = [
1860
+ "windows-link",
1861
+]
1862
+
1863
+[[package]]
1864
+name = "windows-targets"
1865
+version = "0.52.6"
1866
+source = "registry+https://github.com/rust-lang/crates.io-index"
1867
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1868
+dependencies = [
1869
+ "windows_aarch64_gnullvm",
1870
+ "windows_aarch64_msvc",
1871
+ "windows_i686_gnu",
1872
+ "windows_i686_gnullvm",
1873
+ "windows_i686_msvc",
1874
+ "windows_x86_64_gnu",
1875
+ "windows_x86_64_gnullvm",
1876
+ "windows_x86_64_msvc",
1877
+]
1878
+
1879
+[[package]]
1880
+name = "windows_aarch64_gnullvm"
1881
+version = "0.52.6"
1882
+source = "registry+https://github.com/rust-lang/crates.io-index"
1883
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1884
+
1885
+[[package]]
1886
+name = "windows_aarch64_msvc"
1887
+version = "0.52.6"
1888
+source = "registry+https://github.com/rust-lang/crates.io-index"
1889
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1890
+
1891
+[[package]]
1892
+name = "windows_i686_gnu"
1893
+version = "0.52.6"
1894
+source = "registry+https://github.com/rust-lang/crates.io-index"
1895
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1896
+
1897
+[[package]]
1898
+name = "windows_i686_gnullvm"
1899
+version = "0.52.6"
1900
+source = "registry+https://github.com/rust-lang/crates.io-index"
1901
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1902
+
1903
+[[package]]
1904
+name = "windows_i686_msvc"
1905
+version = "0.52.6"
1906
+source = "registry+https://github.com/rust-lang/crates.io-index"
1907
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1908
+
1909
+[[package]]
1910
+name = "windows_x86_64_gnu"
1911
+version = "0.52.6"
1912
+source = "registry+https://github.com/rust-lang/crates.io-index"
1913
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1914
+
1915
+[[package]]
1916
+name = "windows_x86_64_gnullvm"
1917
+version = "0.52.6"
1918
+source = "registry+https://github.com/rust-lang/crates.io-index"
1919
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1920
+
1921
+[[package]]
1922
+name = "windows_x86_64_msvc"
1923
+version = "0.52.6"
1924
+source = "registry+https://github.com/rust-lang/crates.io-index"
1925
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1926
+
1927
+[[package]]
1928
+name = "writeable"
1929
+version = "0.6.4"
1930
+source = "registry+https://github.com/rust-lang/crates.io-index"
1931
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
1932
+
1933
+[[package]]
1934
+name = "yoke"
1935
+version = "0.8.3"
1936
+source = "registry+https://github.com/rust-lang/crates.io-index"
1937
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1938
+dependencies = [
1939
+ "stable_deref_trait",
1940
+ "yoke-derive",
1941
+ "zerofrom",
1942
+]
1943
+
1944
+[[package]]
1945
+name = "yoke-derive"
1946
+version = "0.8.2"
1947
+source = "registry+https://github.com/rust-lang/crates.io-index"
1948
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
1949
+dependencies = [
1950
+ "proc-macro2",
1951
+ "quote",
1952
+ "syn 2.0.119",
1953
+ "synstructure",
1954
+]
1955
+
1956
+[[package]]
1957
+name = "zerofrom"
1958
+version = "0.1.8"
1959
+source = "registry+https://github.com/rust-lang/crates.io-index"
1960
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1961
+dependencies = [
1962
+ "zerofrom-derive",
1963
+]
1964
+
1965
+[[package]]
1966
+name = "zerofrom-derive"
1967
+version = "0.1.7"
1968
+source = "registry+https://github.com/rust-lang/crates.io-index"
1969
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
1970
+dependencies = [
1971
+ "proc-macro2",
1972
+ "quote",
1973
+ "syn 2.0.119",
1974
+ "synstructure",
1975
+]
1976
+
1977
+[[package]]
1978
+name = "zeroize"
1979
+version = "1.9.0"
1980
+source = "registry+https://github.com/rust-lang/crates.io-index"
1981
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
1982
+
1983
+[[package]]
1984
+name = "zerotrie"
1985
+version = "0.2.5"
1986
+source = "registry+https://github.com/rust-lang/crates.io-index"
1987
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
1988
+dependencies = [
1989
+ "displaydoc",
1990
+ "yoke",
1991
+ "zerofrom",
1992
+]
1993
+
1994
+[[package]]
1995
+name = "zerovec"
1996
+version = "0.11.7"
1997
+source = "registry+https://github.com/rust-lang/crates.io-index"
1998
+checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
1999
+dependencies = [
2000
+ "yoke",
2001
+ "zerofrom",
2002
+ "zerovec-derive",
2003
+]
2004
+
2005
+[[package]]
2006
+name = "zerovec-derive"
2007
+version = "0.11.4"
2008
+source = "registry+https://github.com/rust-lang/crates.io-index"
2009
+checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
2010
+dependencies = [
2011
+ "proc-macro2",
2012
+ "quote",
2013
+ "syn 3.0.3",
2014
+]
2015
+
2016
+[[package]]
2017
+name = "zmij"
2018
+version = "1.0.23"
2019
+source = "registry+https://github.com/rust-lang/crates.io-index"
2020
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml
new
+42
@@ -0,0 +1,42 @@
1
+[workspace]
2
+resolver = "2"
3
+members = ["crates/cli", "crates/sigit-api"]
4
+
5
+# The workspace exists for one reason: `sigit-api` is a standalone, typed
6
+# client for the sigit.si JSON API that the desktop app (sigit-app) and the
7
+# agent (sigit) can depend on without pulling in clap, dialoguer, and the rest
8
+# of the terminal surface. `cli` is the thin `si` binary on top of it.
9
+[workspace.package]
10
+version = "1.0.0"
11
+edition = "2021"
12
+authors = ["Seto Elkahfi <seto@sigit.si>"]
13
+license = "Apache-2.0"
14
+repository = "https://github.com/getsigit/sigit-si-cli"
15
+homepage = "https://sigit.si"
16
+
17
+[workspace.dependencies]
18
+anyhow = "1.0"
19
+chrono = { version = "0.4", features = ["serde"] }
20
+clap = { version = "4.5", features = ["derive", "env"] }
21
+console = "0.15"
22
+dialoguer = "0.11"
23
+dirs = "6.0"
24
+git2 = { version = "0.20", default-features = false }
25
+open = "5.3"
26
+reqwest = { version = "0.12", default-features = false, features = [
27
+ "json",
28
+ "rustls-tls-native-roots",
29
+] }
30
+serde = { version = "1", features = ["derive"] }
31
+serde_json = "1"
32
+sigit-api = { version = "1.0.0", path = "crates/sigit-api" }
33
+tabled = { version = "0.20", default-features = false, features = ["std"] }
34
+thiserror = "2.0"
35
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
36
+tracing = "0.1"
37
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
38
+
39
+[profile.release]
40
+lto = "thin"
41
+codegen-units = 1
42
+strip = true
LICENSE
new
+190
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean, as defined by the copyright owner or by the
48
+ Legal Entity authorized to submit on behalf of the copyright owner, any
49
+ work of authorship, including the original version of the Work and any
50
+ modifications or additions to that Work or Derivative Works of the Work.
51
+
52
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
53
+ whom a Contribution has been received by the Licensor and subsequently
54
+ incorporated within the Work.
55
+
56
+ 2. Grant of Copyright License. Subject to the terms and conditions of
57
+ this License, each Contributor hereby grants to You a perpetual,
58
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
59
+ copyright license to reproduce, prepare Derivative Works of,
60
+ publicly display, publicly perform, sublicense, and distribute the
61
+ Work and such Derivative Works in Source or Object form.
62
+
63
+ 3. Grant of Patent License. Subject to the terms and conditions of
64
+ this License, each Contributor hereby grants to You a perpetual,
65
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
66
+ (except as stated in this section) patent license to make, have made,
67
+ use, offer to sell, sell, import, and otherwise transfer the Work,
68
+ where such license applies only to those patent claims licensable
69
+ by such Contributor that are necessarily infringed by their
70
+ Contribution(s) alone or by combination of their Contribution(s)
71
+ with the Work to which such Contribution(s) was submitted. If You
72
+ institute patent litigation against any entity (including a
73
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
74
+ or a Contribution incorporated within the Work constitutes direct
75
+ or contributory patent infringement, then any and all patent licenses
76
+ granted to You under this License for that Work shall terminate
77
+ as of the date such litigation is filed.
78
+
79
+ 4. Redistribution. You may reproduce and distribute copies of the
80
+ Work or Derivative Works thereof in any medium, with or without
81
+ modifications, and in Source or Object form, provided that You
82
+ meet the following conditions:
83
+
84
+ (a) You must give any other recipients of the Work or
85
+ Derivative Works a copy of this License; and
86
+
87
+ (b) You must cause any modified files to carry prominent notices
88
+ stating that You changed the files; and
89
+
90
+ (c) You must retain, in the Source form of any Derivative Works
91
+ that You distribute, all copyright, patent, trademark, and
92
+ attribution notices from the Source form of the Work,
93
+ excluding those notices that do not pertain to any part of
94
+ the Derivative Works; and
95
+
96
+ (d) If the Work includes a "NOTICE" text file as part of its
97
+ distribution, You must include a readable copy of the
98
+ attribution notices contained within such NOTICE file, in
99
+ at least one of the following places: within a NOTICE text
100
+ file distributed as part of the Derivative Works; within
101
+ the Source form or documentation, if provided along with the
102
+ Derivative Works; or, within a display generated by the
103
+ Derivative Works, if and wherever such third-party notices
104
+ normally appear. The contents of the NOTICE file are for
105
+ informational purposes only and do not modify the License.
106
+ You may add Your own attribution notices within Derivative
107
+ Works that You distribute, alongside or as an addendum to
108
+ the NOTICE text from the Work, provided that such additional
109
+ attribution notices cannot be construed as modifying the License.
110
+
111
+ You may add Your own license statement for Your modifications and
112
+ may provide additional grant of rights to use, copy, modify, merge,
113
+ publish, distribute, sublicense, and/or sell copies of the Work,
114
+ and to permit persons to whom the Work is furnished to do so,
115
+ subject to the following conditions:
116
+
117
+ The above copyright notice and this permission notice shall be
118
+ included in all copies or substantial portions of the Work.
119
+
120
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
121
+ any Contribution intentionally submitted for inclusion in the Work
122
+ by You to the Licensor shall be under the terms and conditions of
123
+ this License, without any additional terms or conditions.
124
+ Notwithstanding the above, nothing herein shall supersede or modify
125
+ the terms of any separate license agreement you may have executed
126
+ with Licensor regarding such Contributions.
127
+
128
+ 6. Trademarks. This License does not grant permission to use the trade
129
+ names, trademarks, service marks, or product names of the Licensor,
130
+ except as required for reasonable and customary use in describing the
131
+ origin of the Work and reproducing the content of the NOTICE file.
132
+
133
+ 7. Disclaimer of Warranty. Unless required by applicable law or
134
+ agreed to in writing, Licensor provides the Work (and each
135
+ Contributor provides its Contributions) on an "AS IS" BASIS,
136
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
137
+ implied, including, without limitation, any warranties or conditions
138
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
139
+ PARTICULAR PURPOSE. You are solely responsible for determining the
140
+ appropriateness of using or redistributing the Work and assume any
141
+ risks associated with Your exercise of permissions under this License.
142
+
143
+ 8. Limitation of Liability. In no event and under no legal theory,
144
+ whether in tort (including negligence), contract, or otherwise,
145
+ unless required by applicable law (such as deliberate and grossly
146
+ negligent acts) or agreed to in writing, shall any Contributor be
147
+ liable to You for damages, including any direct, indirect, special,
148
+ incidental, or exemplary damages of any character arising as a
149
+ result of this License or out of the use or inability to use the
150
+ Work (including but not limited to damages for loss of goodwill,
151
+ work stoppage, computer failure or malfunction, or all other
152
+ commercial damages or losses), even if such Contributor has been
153
+ advised of the possibility of such damages.
154
+
155
+ 9. Accepting Warranty or Additional Liability. While redistributing
156
+ the Work or Derivative Works thereof, You may choose to offer,
157
+ and charge a fee for, acceptance of support, warranty, indemnity,
158
+ or other liability obligations and/or rights consistent with this
159
+ License. However, in accepting such obligations, You may act only
160
+ on Your own behalf and on Your sole responsibility, not on behalf
161
+ of any other Contributor, and only if You agree to indemnify,
162
+ defend, and hold each Contributor harmless for any liability
163
+ incurred by, or claims asserted against, such Contributor by reason
164
+ of your accepting any such warranty or additional liability.
165
+
166
+ END OF TERMS AND CONDITIONS
167
+
168
+ APPENDIX: How to apply the Apache License to your work.
169
+
170
+ To apply the Apache License to your work, attach the following
171
+ boilerplate notice, with the fields enclosed by brackets "[]"
172
+ replaced with your own identifying information. (Don't include
173
+ the brackets!) The text should be enclosed in the appropriate
174
+ comment syntax for the file format in use. Also, it is recommended
175
+ that a file or directory name be given on the same line as the
176
+ copyright notice for easier identification within third-party archives.
177
+
178
+ Copyright 2026 PT Sigit Mitra Bangun
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.
Makefile
new
+21
@@ -0,0 +1,21 @@
1
+.PHONY: build test lint fmt check install clean
2
+
3
+build:
4
+ cargo build --workspace
5
+
6
+test:
7
+ cargo test --workspace
8
+
9
+lint:
10
+ cargo clippy --workspace --all-targets -- -D warnings
11
+
12
+fmt:
13
+ cargo fmt --all
14
+
15
+check: fmt lint test
16
+
17
+install:
18
+ cargo install --path crates/cli
19
+
20
+clean:
21
+ cargo clean
README.md
new
+79
@@ -0,0 +1,79 @@
1
+# sigit-si-cli
2
+
3
+`si` is the command line interface for [sigit.si](https://sigit.si) — sign in,
4
+manage repositories, review pull requests, triage issues, search code, and
5
+drive Cloud Sessions, all from the terminal. It's the `gh` of sigit.si.
6
+
7
+## Install
8
+
9
+```sh
10
+cargo install --path crates/cli
11
+```
12
+
13
+This installs a binary named `si`.
14
+
15
+## Usage
16
+
17
+```sh
18
+si auth login # sign in with your sigit.si account
19
+si repo list # your repositories
20
+si repo create my-project --private
21
+
22
+# Inside a repo checkout, si infers OWNER/NAME from the sigit.si git remote —
23
+# --repo is only needed when you're not standing in one.
24
+si pr list
25
+si pr view 42
26
+si pr create --title "Fix the thing" --base main
27
+
28
+si issue list --state open
29
+si issue create --title "Bug: ..." --body "Steps to reproduce..."
30
+
31
+si code search "TODO" --ref main
32
+si code view README.md
33
+
34
+si session new --title "Refactor auth"
35
+si billing show
36
+
37
+si browse # open the current repo on sigit.si
38
+si api repos # raw escape hatch for any endpoint
39
+```
40
+
41
+Pass `--json` to any read command for machine-readable output.
42
+
43
+### Environments
44
+
45
+```sh
46
+si --environment dev repo list # a local sigit.si server (localhost:3000)
47
+SIGIT_HOST=staging.example.com si repo list
48
+```
49
+
50
+Credentials are stored per-environment in `~/.sigit/token` (or
51
+`~/.sigit-dev/token`). `SIGIT_TOKEN` overrides the stored token, which is how
52
+CI authenticates without an interactive login.
53
+
54
+## Workspace layout
55
+
56
+- **`crates/sigit-api`** — a standalone, typed Rust client for the sigit.si
57
+ JSON API. No CLI dependencies (clap, dialoguer, …); usable from any Rust
58
+ project that wants to talk to sigit.si, including the desktop app.
59
+- **`crates/cli`** — the `si` binary: command parsing, terminal rendering, git
60
+ remote resolution, and credential storage, built on top of `sigit-api`.
61
+
62
+Issues, pull requests, and code search have no REST surface on sigit.si yet —
63
+they're served over the same MCP (Model Context Protocol) JSON-RPC endpoint
64
+the AI agent uses. `sigit-api` speaks that transport internally; it's not
65
+visible at the call site.
66
+
67
+## Development
68
+
69
+```sh
70
+make build # cargo build --workspace
71
+make test # cargo test --workspace
72
+make lint # cargo clippy --workspace --all-targets -- -D warnings
73
+make fmt # cargo fmt --all
74
+make check # fmt + lint + test
75
+```
76
+
77
+## License
78
+
79
+Apache-2.0
crates/cli/Cargo.toml
new
+37
@@ -0,0 +1,37 @@
1
+[package]
2
+name = "sigit-si-cli"
3
+description = "si — the sigit.si command line interface."
4
+version.workspace = true
5
+edition.workspace = true
6
+authors.workspace = true
7
+license.workspace = true
8
+repository.workspace = true
9
+homepage.workspace = true
10
+keywords = ["sigit", "git", "cli", "code-review"]
11
+categories = ["command-line-utilities", "development-tools"]
12
+readme = "../../README.md"
13
+
14
+[[bin]]
15
+name = "si"
16
+path = "src/main.rs"
17
+
18
+[dependencies]
19
+anyhow = { workspace = true }
20
+chrono = { workspace = true }
21
+clap = { workspace = true }
22
+console = { workspace = true }
23
+dialoguer = { workspace = true }
24
+dirs = { workspace = true }
25
+git2 = { workspace = true }
26
+open = { workspace = true }
27
+reqwest = { workspace = true }
28
+serde = { workspace = true }
29
+serde_json = { workspace = true }
30
+sigit-api = { workspace = true }
31
+tabled = { workspace = true }
32
+tokio = { workspace = true }
33
+tracing = { workspace = true }
34
+tracing-subscriber = { workspace = true }
35
+
36
+[dev-dependencies]
37
+tempfile = "3"
crates/cli/src/cli.rs
new
+268
@@ -0,0 +1,268 @@
1
+//! The `si` command tree.
2
+
3
+use {clap::Parser, sigit_api::Environment, std::str::FromStr};
4
+
5
+#[derive(Parser)]
6
+#[command(name = "si", version, about = "The sigit.si command line interface.")]
7
+pub struct Cli {
8
+ /// Which sigit.si deployment to talk to. Overridden by SIGIT_HOST.
9
+ #[arg(short, long, global = true, env = "SIGIT_ENVIRONMENT", default_value = "production", value_parser = Environment::from_str)]
10
+ pub environment: Environment,
11
+
12
+ /// Print machine-readable JSON instead of formatted text, where supported.
13
+ #[arg(long, global = true)]
14
+ pub json: bool,
15
+
16
+ #[command(subcommand)]
17
+ pub command: Command,
18
+}
19
+
20
+#[derive(clap::Subcommand)]
21
+pub enum Command {
22
+ /// Sign in, sign out, and inspect your session.
23
+ #[command(subcommand)]
24
+ Auth(AuthCommand),
25
+
26
+ /// Your sigit.si profile.
27
+ Me,
28
+
29
+ /// List, create, and inspect repositories.
30
+ #[command(subcommand)]
31
+ Repo(RepoCommand),
32
+
33
+ /// List, open, and comment on pull requests.
34
+ #[command(subcommand)]
35
+ Pr(PrCommand),
36
+
37
+ /// List, open, and comment on issues.
38
+ #[command(subcommand)]
39
+ Issue(IssueCommand),
40
+
41
+ /// Read files and search code in a repository.
42
+ #[command(subcommand)]
43
+ Code(CodeCommand),
44
+
45
+ /// Manage a repository's deploy webhooks.
46
+ #[command(subcommand)]
47
+ Hook(HookCommand),
48
+
49
+ /// Manage siGit Code Cloud Sessions.
50
+ #[command(subcommand)]
51
+ Session(SessionCommand),
52
+
53
+ /// Plan, usage, and the hosted billing portal.
54
+ #[command(subcommand)]
55
+ Billing(BillingCommand),
56
+
57
+ /// Open something on sigit.si in your browser.
58
+ Browse(BrowseArgs),
59
+
60
+ /// Call the sigit.si API directly. The escape hatch for anything without
61
+ /// a dedicated command.
62
+ Api(ApiArgs),
63
+}
64
+
65
+#[derive(clap::Subcommand)]
66
+pub enum AuthCommand {
67
+ /// Sign in with your sigit.si email and password.
68
+ Login {
69
+ #[arg(long)]
70
+ email: Option<String>,
71
+ },
72
+ /// Sign out and remove the stored token.
73
+ Logout,
74
+ /// Show who is currently signed in.
75
+ Status,
76
+}
77
+
78
+#[derive(clap::Subcommand)]
79
+pub enum RepoCommand {
80
+ /// List your repositories.
81
+ List,
82
+ /// Search all repositories you can see.
83
+ Search {
84
+ /// Case-insensitive substring of the owner or repo name.
85
+ query: Option<String>,
86
+ #[arg(long, default_value_t = 30)]
87
+ limit: u32,
88
+ },
89
+ /// Show a repository's details.
90
+ View {
91
+ /// OWNER/NAME. Defaults to the repo in the current directory.
92
+ repo: Option<String>,
93
+ },
94
+ /// Create a new (empty) repository to push to.
95
+ Create {
96
+ name: String,
97
+ #[arg(long)]
98
+ private: bool,
99
+ #[arg(long)]
100
+ description: Option<String>,
101
+ /// Skip the confirmation prompt.
102
+ #[arg(short, long)]
103
+ yes: bool,
104
+ },
105
+ /// Open a repository in your browser.
106
+ Browse { repo: Option<String> },
107
+}
108
+
109
+#[derive(clap::Subcommand)]
110
+pub enum PrCommand {
111
+ /// List pull requests in a repository.
112
+ List {
113
+ repo: Option<String>,
114
+ #[arg(long, default_value = "open")]
115
+ state: String,
116
+ },
117
+ /// Show a pull request's details, comments, and diff.
118
+ View { number: i64, repo: Option<String> },
119
+ /// Print a pull request's unified diff.
120
+ Diff { number: i64, repo: Option<String> },
121
+ /// Open a pull request from one branch into another.
122
+ Create {
123
+ #[arg(long)]
124
+ title: String,
125
+ #[arg(long)]
126
+ head: Option<String>,
127
+ #[arg(long, default_value = "main")]
128
+ base: String,
129
+ #[arg(long)]
130
+ body: Option<String>,
131
+ repo: Option<String>,
132
+ },
133
+ /// Comment on a pull request.
134
+ Comment {
135
+ number: i64,
136
+ #[arg(long)]
137
+ body: String,
138
+ repo: Option<String>,
139
+ },
140
+}
141
+
142
+#[derive(clap::Subcommand)]
143
+pub enum IssueCommand {
144
+ /// List issues in a repository.
145
+ List {
146
+ repo: Option<String>,
147
+ #[arg(long, default_value = "open")]
148
+ state: String,
149
+ #[arg(long)]
150
+ query: Option<String>,
151
+ },
152
+ /// Show an issue's details and comments.
153
+ View { number: i64, repo: Option<String> },
154
+ /// Open a new issue.
155
+ Create {
156
+ #[arg(long)]
157
+ title: String,
158
+ #[arg(long)]
159
+ body: Option<String>,
160
+ repo: Option<String>,
161
+ },
162
+ /// Comment on an issue.
163
+ Comment {
164
+ number: i64,
165
+ #[arg(long)]
166
+ body: String,
167
+ repo: Option<String>,
168
+ },
169
+}
170
+
171
+#[derive(clap::Subcommand)]
172
+pub enum CodeCommand {
173
+ /// Print a file's contents at a ref.
174
+ View {
175
+ path: String,
176
+ #[arg(long)]
177
+ r#ref: Option<String>,
178
+ repo: Option<String>,
179
+ },
180
+ /// Search a repository's tracked files for a fixed string.
181
+ Search {
182
+ query: String,
183
+ #[arg(long)]
184
+ r#ref: Option<String>,
185
+ #[arg(long, default_value_t = 20)]
186
+ limit: u32,
187
+ repo: Option<String>,
188
+ },
189
+}
190
+
191
+#[derive(clap::Subcommand)]
192
+pub enum HookCommand {
193
+ /// List a repository's webhooks.
194
+ List { repo: Option<String> },
195
+ /// Register a new webhook. Prints the signing secret once — it cannot be
196
+ /// retrieved again.
197
+ Create {
198
+ #[arg(long)]
199
+ url: String,
200
+ #[arg(long, value_delimiter = ',', default_value = "push")]
201
+ events: Vec<String>,
202
+ repo: Option<String>,
203
+ },
204
+ /// Remove a webhook.
205
+ Delete {
206
+ id: i64,
207
+ repo: Option<String>,
208
+ #[arg(short, long)]
209
+ yes: bool,
210
+ },
211
+}
212
+
213
+#[derive(clap::Subcommand)]
214
+pub enum SessionCommand {
215
+ /// List your Cloud Sessions.
216
+ List,
217
+ /// Show a session and its transcript.
218
+ View { id: i64 },
219
+ /// Start a new session.
220
+ New {
221
+ #[arg(long)]
222
+ title: Option<String>,
223
+ #[arg(long)]
224
+ model: Option<String>,
225
+ },
226
+ /// Rename a session or change its model.
227
+ Update {
228
+ id: i64,
229
+ #[arg(long)]
230
+ title: Option<String>,
231
+ #[arg(long)]
232
+ model: Option<String>,
233
+ },
234
+ /// Delete a session.
235
+ Delete {
236
+ id: i64,
237
+ #[arg(short, long)]
238
+ yes: bool,
239
+ },
240
+}
241
+
242
+#[derive(clap::Subcommand)]
243
+pub enum BillingCommand {
244
+ /// Show your current plan and cloud usage.
245
+ Show,
246
+ /// Open the hosted checkout to subscribe or change plans.
247
+ Checkout { plan: String },
248
+ /// Open the hosted billing portal.
249
+ Portal,
250
+}
251
+
252
+#[derive(clap::Args)]
253
+pub struct BrowseArgs {
254
+ /// What to open: a repo, "OWNER/NAME/pull/N", "OWNER/NAME/issue/N", or
255
+ /// left blank for the current repository.
256
+ pub target: Option<String>,
257
+}
258
+
259
+#[derive(clap::Args)]
260
+pub struct ApiArgs {
261
+ /// Path under /api/v1, e.g. "repos" or "billing".
262
+ pub path: String,
263
+ #[arg(short = 'X', long, default_value = "GET")]
264
+ pub method: String,
265
+ /// Raw JSON request body.
266
+ #[arg(long)]
267
+ pub body: Option<String>,
268
+}
crates/cli/src/commands/api.rs
new
+27
@@ -0,0 +1,27 @@
1
+//! `si api <path>` — the escape hatch for any endpoint without a dedicated
2
+//! command, e.g. one the server added since this build of `si`.
3
+
4
+use {
5
+ crate::{cli::ApiArgs, context::Ctx},
6
+ anyhow::{anyhow, Result},
7
+ reqwest::Method,
8
+ std::str::FromStr,
9
+};
10
+
11
+pub async fn run(ctx: &Ctx, args: ApiArgs) -> Result<()> {
12
+ ctx.require_token()?;
13
+
14
+ let method = Method::from_str(&args.method.to_ascii_uppercase())
15
+ .map_err(|_| anyhow!("Unknown HTTP method: {}", args.method))?;
16
+
17
+ let body = match args.body {
18
+ Some(raw) => {
19
+ Some(serde_json::from_str(&raw).map_err(|e| anyhow!("--body is not valid JSON: {e}"))?)
20
+ }
21
+ None => None,
22
+ };
23
+
24
+ let response = ctx.client.raw(method, &args.path, body).await?;
25
+ println!("{}", serde_json::to_string_pretty(&response)?);
26
+ Ok(())
27
+}
crates/cli/src/commands/auth.rs
new
+73
@@ -0,0 +1,73 @@
1
+use {
2
+ crate::{cli::AuthCommand, config, context::Ctx, ui},
3
+ anyhow::{anyhow, Result},
4
+ sigit_api::{account::incomplete_reason, models::AccountStatus},
5
+};
6
+
7
+pub async fn run(ctx: &Ctx, command: AuthCommand) -> Result<()> {
8
+ match command {
9
+ AuthCommand::Login { email } => login(ctx, email).await,
10
+ AuthCommand::Logout => logout(ctx).await,
11
+ AuthCommand::Status => status(ctx).await,
12
+ }
13
+}
14
+
15
+async fn login(ctx: &Ctx, email: Option<String>) -> Result<()> {
16
+ let email = match email {
17
+ Some(email) => email,
18
+ None => ui::prompt::text("Email")?,
19
+ };
20
+ let password = ui::prompt::password("Password")?;
21
+
22
+ match ctx.client.sign_in(&email, &password).await? {
23
+ AccountStatus::Ready { access_token } => {
24
+ let path = config::write_token(ctx.environment, &access_token)?;
25
+ ui::success(format!(
26
+ "Signed in as {}. Token saved to {}.",
27
+ ui::highlight(&email),
28
+ path.display()
29
+ ));
30
+ Ok(())
31
+ }
32
+ AccountStatus::NotFound => Err(anyhow!(
33
+ "No sigit.si account found for {email}. Sign up at https://sigit.si/auth/signup."
34
+ )),
35
+ AccountStatus::Incomplete { status } => Err(anyhow!("{}", incomplete_reason(status))),
36
+ }
37
+}
38
+
39
+async fn logout(ctx: &Ctx) -> Result<()> {
40
+ // A token file surviving from a previous, now-unauthenticated session
41
+ // shouldn't block sign-out — clear it locally either way.
42
+ if ctx.client.token().is_some() {
43
+ let _ = ctx.client.sign_out().await;
44
+ }
45
+ config::clear_token(ctx.environment)?;
46
+ ui::success("Signed out.");
47
+ Ok(())
48
+}
49
+
50
+async fn status(ctx: &Ctx) -> Result<()> {
51
+ if ctx.client.token().is_none() {
52
+ println!("Not signed in to {}. Run `si auth login`.", ctx.environment);
53
+ return Ok(());
54
+ }
55
+
56
+ match ctx.client.user().await {
57
+ Ok(user) => ui::emit(ctx.json, &user, |user| {
58
+ println!(
59
+ "Signed in to {} as {} <{}>.",
60
+ ctx.environment,
61
+ ui::highlight(&user.username),
62
+ user.email
63
+ );
64
+ }),
65
+ Err(e) if e.is_auth_failure() => {
66
+ config::clear_token(ctx.environment)?;
67
+ Err(anyhow!(
68
+ "Your session has expired. Run `si auth login` to sign in again."
69
+ ))
70
+ }
71
+ Err(e) => Err(e.into()),
72
+ }
73
+}
crates/cli/src/commands/billing.rs
new
+39
@@ -0,0 +1,39 @@
1
+use {
2
+ crate::{cli::BillingCommand, context::Ctx, ui},
3
+ anyhow::Result,
4
+};
5
+
6
+pub async fn run(ctx: &Ctx, command: BillingCommand) -> Result<()> {
7
+ ctx.require_token()?;
8
+
9
+ match command {
10
+ BillingCommand::Show => show(ctx).await,
11
+ BillingCommand::Checkout { plan } => checkout(ctx, plan).await,
12
+ BillingCommand::Portal => portal(ctx).await,
13
+ }
14
+}
15
+
16
+async fn show(ctx: &Ctx) -> Result<()> {
17
+ let billing = ctx.client.billing().await?;
18
+ ui::emit(ctx.json, &billing, ui::render::billing)
19
+}
20
+
21
+async fn checkout(ctx: &Ctx, plan: String) -> Result<()> {
22
+ let url = ctx.client.billing_checkout(&plan).await?;
23
+ open_and_report(ctx, &url)
24
+}
25
+
26
+async fn portal(ctx: &Ctx) -> Result<()> {
27
+ let url = ctx.client.billing_portal().await?;
28
+ open_and_report(ctx, &url)
29
+}
30
+
31
+fn open_and_report(ctx: &Ctx, url: &str) -> Result<()> {
32
+ if ctx.json {
33
+ println!("{}", serde_json::json!({ "url": url }));
34
+ return Ok(());
35
+ }
36
+ open::that(url)?;
37
+ ui::note(format!("Opened {url}"));
38
+ Ok(())
39
+}
crates/cli/src/commands/browse.rs
new
+47
@@ -0,0 +1,47 @@
1
+//! `si browse [target]` — open something on sigit.si in the default browser.
2
+//!
3
+//! `target` accepts a bare `OWNER/NAME`, `OWNER/NAME/pull/N`, or
4
+//! `OWNER/NAME/issue/N`; omitted, it falls back to the repository in the
5
+//! current directory.
6
+
7
+use {
8
+ crate::{cli::BrowseArgs, context::Ctx, ui},
9
+ anyhow::{anyhow, Result},
10
+};
11
+
12
+pub async fn run(ctx: &Ctx, args: BrowseArgs) -> Result<()> {
13
+ let url = match args.target {
14
+ Some(target) => resolve_target(ctx, &target)?,
15
+ None => {
16
+ let slug = ctx.resolve_repo(None)?;
17
+ format!("{}/{slug}", ctx.client.base_url())
18
+ }
19
+ };
20
+
21
+ open::that(&url)?;
22
+ ui::note(format!("Opened {url}"));
23
+ Ok(())
24
+}
25
+
26
+/// `OWNER/NAME`, `OWNER/NAME/pull/N`, or `OWNER/NAME/issue/N`.
27
+fn resolve_target(ctx: &Ctx, target: &str) -> Result<String> {
28
+ let trimmed = target.trim().trim_matches('/');
29
+ let parts: Vec<&str> = trimmed.split('/').collect();
30
+
31
+ match parts.as_slice() {
32
+ [owner, name] => Ok(format!("{}/{owner}/{name}", ctx.client.base_url())),
33
+ [owner, name, kind @ ("pull" | "issue"), number] => {
34
+ number
35
+ .parse::<u64>()
36
+ .map_err(|_| anyhow!("Expected a number after {kind}/, got {number:?}."))?;
37
+ Ok(format!(
38
+ "{}/{owner}/{name}/{kind}/{number}",
39
+ ctx.client.base_url()
40
+ ))
41
+ }
42
+ _ => Err(anyhow!(
43
+ "Could not parse {target:?}. Expected OWNER/NAME, OWNER/NAME/pull/N, or \
44
+ OWNER/NAME/issue/N."
45
+ )),
46
+ }
47
+}
crates/cli/src/commands/code.rs
new
+53
@@ -0,0 +1,53 @@
1
+use {
2
+ crate::{cli::CodeCommand, context::Ctx, ui},
3
+ anyhow::Result,
4
+};
5
+
6
+pub async fn run(ctx: &Ctx, command: CodeCommand) -> Result<()> {
7
+ ctx.require_token()?;
8
+
9
+ match command {
10
+ CodeCommand::View { path, r#ref, repo } => view(ctx, path, r#ref, repo).await,
11
+ CodeCommand::Search {
12
+ query,
13
+ r#ref,
14
+ limit,
15
+ repo,
16
+ } => search(ctx, query, r#ref, limit, repo).await,
17
+ }
18
+}
19
+
20
+async fn view(
21
+ ctx: &Ctx,
22
+ path: String,
23
+ git_ref: Option<String>,
24
+ repo: Option<String>,
25
+) -> Result<()> {
26
+ let slug = ctx.resolve_repo(repo)?;
27
+ let file = ctx
28
+ .client
29
+ .file_contents(&slug.to_string(), &path, git_ref.as_deref())
30
+ .await?;
31
+
32
+ if ctx.json {
33
+ println!("{}", serde_json::to_string_pretty(&file)?);
34
+ } else {
35
+ print!("{}", file.content);
36
+ }
37
+ Ok(())
38
+}
39
+
40
+async fn search(
41
+ ctx: &Ctx,
42
+ query: String,
43
+ git_ref: Option<String>,
44
+ limit: u32,
45
+ repo: Option<String>,
46
+) -> Result<()> {
47
+ let slug = ctx.resolve_repo(repo)?;
48
+ let hits = ctx
49
+ .client
50
+ .search_code(&slug.to_string(), &query, git_ref.as_deref(), Some(limit))
51
+ .await?;
52
+ ui::emit(ctx.json, &hits, |hits| ui::render::code_hits(hits))
53
+}
crates/cli/src/commands/hook.rs
new
+56
@@ -0,0 +1,56 @@
1
+use {
2
+ crate::{
3
+ cli::HookCommand,
4
+ context::Ctx,
5
+ ui::{self, prompt},
6
+ },
7
+ anyhow::Result,
8
+};
9
+
10
+pub async fn run(ctx: &Ctx, command: HookCommand) -> Result<()> {
11
+ ctx.require_token()?;
12
+
13
+ match command {
14
+ HookCommand::List { repo } => list(ctx, repo).await,
15
+ HookCommand::Create { url, events, repo } => create(ctx, url, events, repo).await,
16
+ HookCommand::Delete { id, repo, yes } => delete(ctx, id, repo, yes).await,
17
+ }
18
+}
19
+
20
+async fn list(ctx: &Ctx, repo: Option<String>) -> Result<()> {
21
+ let slug = ctx.resolve_repo(repo)?;
22
+ let hooks = ctx.client.hooks(&slug.owner, &slug.name).await?;
23
+ ui::emit(ctx.json, &hooks, |hooks| ui::render::hooks(hooks))
24
+}
25
+
26
+async fn create(ctx: &Ctx, url: String, events: Vec<String>, repo: Option<String>) -> Result<()> {
27
+ let slug = ctx.resolve_repo(repo)?;
28
+ let hook = ctx
29
+ .client
30
+ .create_hook(&slug.owner, &slug.name, &url, &events)
31
+ .await?;
32
+
33
+ ui::emit(ctx.json, &hook, |hook| {
34
+ ui::success(format!("Created webhook #{}.", hook.id));
35
+ if let Some(secret) = &hook.secret {
36
+ println!(
37
+ " {} {}",
38
+ ui::bold("Secret (shown once):"),
39
+ ui::highlight(secret)
40
+ );
41
+ }
42
+ })
43
+}
44
+
45
+async fn delete(ctx: &Ctx, id: i64, repo: Option<String>, yes: bool) -> Result<()> {
46
+ let slug = ctx.resolve_repo(repo)?;
47
+
48
+ if !yes && !prompt::confirm(&format!("Delete webhook #{id} on {slug}?"), false)? {
49
+ ui::note("Cancelled.");
50
+ return Ok(());
51
+ }
52
+
53
+ ctx.client.delete_hook(&slug.owner, &slug.name, id).await?;
54
+ ui::success(format!("Deleted webhook #{id}."));
55
+ Ok(())
56
+}
crates/cli/src/commands/issue.rs
new
+73
@@ -0,0 +1,73 @@
1
+use {
2
+ crate::{cli::IssueCommand, context::Ctx, ui},
3
+ anyhow::{anyhow, Result},
4
+ sigit_api::State,
5
+};
6
+
7
+pub async fn run(ctx: &Ctx, command: IssueCommand) -> Result<()> {
8
+ ctx.require_token()?;
9
+
10
+ match command {
11
+ IssueCommand::List { repo, state, query } => list(ctx, repo, state, query).await,
12
+ IssueCommand::View { number, repo } => view(ctx, number, repo).await,
13
+ IssueCommand::Create { title, body, repo } => create(ctx, title, body, repo).await,
14
+ IssueCommand::Comment { number, body, repo } => comment(ctx, number, body, repo).await,
15
+ }
16
+}
17
+
18
+async fn list(ctx: &Ctx, repo: Option<String>, state: String, query: Option<String>) -> Result<()> {
19
+ let slug = ctx.resolve_repo(repo)?;
20
+ let state = parse_state(&state)?;
21
+ let issues = ctx
22
+ .client
23
+ .issues(&slug.to_string(), state, query.as_deref())
24
+ .await?;
25
+ ui::emit(ctx.json, &issues, |issues| ui::render::issues(issues))
26
+}
27
+
28
+async fn view(ctx: &Ctx, number: i64, repo: Option<String>) -> Result<()> {
29
+ let slug = ctx.resolve_repo(repo)?;
30
+ let issue = ctx.client.issue(&slug.to_string(), number).await?;
31
+ ui::emit(ctx.json, &issue, ui::render::issue_detail)
32
+}
33
+
34
+async fn create(
35
+ ctx: &Ctx,
36
+ title: String,
37
+ body: Option<String>,
38
+ repo: Option<String>,
39
+) -> Result<()> {
40
+ let slug = ctx.resolve_repo(repo)?;
41
+ let created = ctx
42
+ .client
43
+ .create_issue(&slug.to_string(), &title, body.as_deref())
44
+ .await?;
45
+
46
+ ui::emit(ctx.json, &created, |created| {
47
+ ui::success(format!("Opened issue #{}.", created.number));
48
+ println!(" {}", created.url);
49
+ })
50
+}
51
+
52
+async fn comment(ctx: &Ctx, number: i64, body: String, repo: Option<String>) -> Result<()> {
53
+ let slug = ctx.resolve_repo(repo)?;
54
+ let comment = ctx
55
+ .client
56
+ .add_comment(&slug.to_string(), number, &body)
57
+ .await?;
58
+ ui::emit(ctx.json, &comment, |comment| {
59
+ ui::success(format!("Commented on #{number}."));
60
+ println!(" {}", comment.url);
61
+ })
62
+}
63
+
64
+fn parse_state(input: &str) -> Result<State> {
65
+ match input.to_ascii_lowercase().as_str() {
66
+ "open" => Ok(State::Open),
67
+ "closed" => Ok(State::Closed),
68
+ "all" => Ok(State::All),
69
+ other => Err(anyhow!(
70
+ "Unknown state {other:?}. Expected one of: open, closed, all."
71
+ )),
72
+ }
73
+}
crates/cli/src/commands/me.rs
new
+10
@@ -0,0 +1,10 @@
1
+use {
2
+ crate::{context::Ctx, ui},
3
+ anyhow::Result,
4
+};
5
+
6
+pub async fn run(ctx: &Ctx) -> Result<()> {
7
+ ctx.require_token()?;
8
+ let user = ctx.client.user().await?;
9
+ ui::emit(ctx.json, &user, ui::render::user)
10
+}
crates/cli/src/commands/mod.rs
new
+11
@@ -0,0 +1,11 @@
1
+pub mod api;
2
+pub mod auth;
3
+pub mod billing;
4
+pub mod browse;
5
+pub mod code;
6
+pub mod hook;
7
+pub mod issue;
8
+pub mod me;
9
+pub mod pr;
10
+pub mod repo;
11
+pub mod session;
crates/cli/src/commands/pr.rs
new
+107
@@ -0,0 +1,107 @@
1
+use {
2
+ crate::{cli::PrCommand, context::Ctx, git, ui},
3
+ anyhow::{anyhow, Result},
4
+ sigit_api::State,
5
+};
6
+
7
+pub async fn run(ctx: &Ctx, command: PrCommand) -> Result<()> {
8
+ ctx.require_token()?;
9
+
10
+ match command {
11
+ PrCommand::List { repo, state } => list(ctx, repo, state).await,
12
+ PrCommand::View { number, repo } => view(ctx, number, repo).await,
13
+ PrCommand::Diff { number, repo } => diff(ctx, number, repo).await,
14
+ PrCommand::Create {
15
+ title,
16
+ head,
17
+ base,
18
+ body,
19
+ repo,
20
+ } => create(ctx, title, head, base, body, repo).await,
21
+ PrCommand::Comment { number, body, repo } => comment(ctx, number, body, repo).await,
22
+ }
23
+}
24
+
25
+async fn list(ctx: &Ctx, repo: Option<String>, state: String) -> Result<()> {
26
+ let slug = ctx.resolve_repo(repo)?;
27
+ let state = parse_state(&state)?;
28
+ let pulls = ctx.client.pulls(&slug.to_string(), state).await?;
29
+ ui::emit(ctx.json, &pulls, |pulls| ui::render::pulls(pulls))
30
+}
31
+
32
+async fn view(ctx: &Ctx, number: i64, repo: Option<String>) -> Result<()> {
33
+ let slug = ctx.resolve_repo(repo)?;
34
+ let pr = ctx.client.pull(&slug.to_string(), number).await?;
35
+ ui::emit(ctx.json, &pr, ui::render::pull_detail)
36
+}
37
+
38
+async fn diff(ctx: &Ctx, number: i64, repo: Option<String>) -> Result<()> {
39
+ let slug = ctx.resolve_repo(repo)?;
40
+ let pr = ctx.client.pull(&slug.to_string(), number).await?;
41
+
42
+ match pr.diff {
43
+ Some(diff) => {
44
+ print!("{diff}");
45
+ if let Some(note) = pr.diff_note {
46
+ ui::note(note);
47
+ }
48
+ }
49
+ None => {
50
+ ui::note(
51
+ pr.diff_note
52
+ .unwrap_or_else(|| "No diff available.".to_string()),
53
+ );
54
+ }
55
+ }
56
+ Ok(())
57
+}
58
+
59
+async fn create(
60
+ ctx: &Ctx,
61
+ title: String,
62
+ head: Option<String>,
63
+ base: String,
64
+ body: Option<String>,
65
+ repo: Option<String>,
66
+) -> Result<()> {
67
+ let slug = ctx.resolve_repo(repo)?;
68
+ let head = match head {
69
+ Some(head) => head,
70
+ None => git::current_branch(&std::env::current_dir()?)
71
+ .ok_or_else(|| anyhow!("Could not determine the current branch. Pass --head."))?,
72
+ };
73
+
74
+ let created = ctx
75
+ .client
76
+ .create_pull(&slug.to_string(), &title, &head, &base, body.as_deref())
77
+ .await?;
78
+
79
+ ui::emit(ctx.json, &created, |created| {
80
+ ui::success(format!("Opened pull request #{}.", created.number));
81
+ println!(" {}", created.url);
82
+ })
83
+}
84
+
85
+async fn comment(ctx: &Ctx, number: i64, body: String, repo: Option<String>) -> Result<()> {
86
+ let slug = ctx.resolve_repo(repo)?;
87
+ let comment = ctx
88
+ .client
89
+ .add_comment(&slug.to_string(), number, &body)
90
+ .await?;
91
+ ui::emit(ctx.json, &comment, |comment| {
92
+ ui::success(format!("Commented on #{number}."));
93
+ println!(" {}", comment.url);
94
+ })
95
+}
96
+
97
+fn parse_state(input: &str) -> Result<State> {
98
+ match input.to_ascii_lowercase().as_str() {
99
+ "open" => Ok(State::Open),
100
+ "closed" => Ok(State::Closed),
101
+ "merged" => Ok(State::Merged),
102
+ "all" => Ok(State::All),
103
+ other => Err(anyhow!(
104
+ "Unknown state {other:?}. Expected one of: open, closed, merged, all."
105
+ )),
106
+ }
107
+}
crates/cli/src/commands/repo.rs
new
+107
@@ -0,0 +1,107 @@
1
+use {
2
+ crate::{
3
+ cli::RepoCommand,
4
+ context::Ctx,
5
+ ui::{self, prompt},
6
+ },
7
+ anyhow::Result,
8
+ sigit_api::NewRepository,
9
+};
10
+
11
+pub async fn run(ctx: &Ctx, command: RepoCommand) -> Result<()> {
12
+ ctx.require_token()?;
13
+
14
+ match command {
15
+ RepoCommand::List => list(ctx).await,
16
+ RepoCommand::Search { query, limit } => search(ctx, query, limit).await,
17
+ RepoCommand::View { repo } => view(ctx, repo).await,
18
+ RepoCommand::Create {
19
+ name,
20
+ private,
21
+ description,
22
+ yes,
23
+ } => create(ctx, name, private, description, yes).await,
24
+ RepoCommand::Browse { repo } => browse(ctx, repo).await,
25
+ }
26
+}
27
+
28
+async fn list(ctx: &Ctx) -> Result<()> {
29
+ let repos = ctx.client.repos().await?;
30
+ ui::emit(ctx.json, &repos, |repos| ui::render::repos(repos))
31
+}
32
+
33
+async fn search(ctx: &Ctx, query: Option<String>, limit: u32) -> Result<()> {
34
+ let repos = ctx
35
+ .client
36
+ .search_repos(query.as_deref(), Some(limit))
37
+ .await?;
38
+ ui::emit(ctx.json, &repos, |repos| ui::render::repo_summaries(repos))
39
+}
40
+
41
+async fn view(ctx: &Ctx, repo: Option<String>) -> Result<()> {
42
+ let slug = ctx.resolve_repo(repo)?;
43
+
44
+ // `GET /api/v1/repos` only lists the signed-in user's own repos, but
45
+ // `view` should also work on repos owned by someone else — so try the
46
+ // owned list first (it has the fuller `Repository` shape) and fall back
47
+ // to the cross-user MCP search.
48
+ if let Some(repo) = ctx
49
+ .client
50
+ .repos()
51
+ .await?
52
+ .into_iter()
53
+ .find(|r| r.full_name.eq_ignore_ascii_case(&slug.to_string()))
54
+ {
55
+ return ui::emit(ctx.json, &repo, ui::render::repo_detail);
56
+ }
57
+
58
+ let matches = ctx.client.search_repos(Some(&slug.name), None).await?;
59
+ let summary = matches
60
+ .into_iter()
61
+ .find(|r| r.full_name.eq_ignore_ascii_case(&slug.to_string()))
62
+ .ok_or_else(|| anyhow::anyhow!("Repository {slug} was not found."))?;
63
+ ui::emit(ctx.json, &summary, |summary| {
64
+ ui::render::repo_summaries(std::slice::from_ref(summary))
65
+ })
66
+}
67
+
68
+async fn create(
69
+ ctx: &Ctx,
70
+ name: String,
71
+ private: bool,
72
+ description: Option<String>,
73
+ yes: bool,
74
+) -> Result<()> {
75
+ if !yes {
76
+ let confirmed = prompt::confirm(
77
+ &format!(
78
+ "Create {} repository {}?",
79
+ if private { "private" } else { "public" },
80
+ ui::highlight(&name)
81
+ ),
82
+ true,
83
+ )?;
84
+ if !confirmed {
85
+ ui::note("Cancelled.");
86
+ return Ok(());
87
+ }
88
+ }
89
+
90
+ let new_repo = NewRepository::new(&name)
91
+ .private(private)
92
+ .description(description);
93
+ let repo = ctx.client.create_repo(&new_repo).await?;
94
+
95
+ ui::emit(ctx.json, &repo, |repo| {
96
+ ui::success(format!("Created {}.", ui::highlight(&repo.full_name)));
97
+ println!(" {}", repo.clone_url);
98
+ })
99
+}
100
+
101
+async fn browse(ctx: &Ctx, repo: Option<String>) -> Result<()> {
102
+ let slug = ctx.resolve_repo(repo)?;
103
+ let url = format!("{}/{slug}", ctx.client.base_url());
104
+ open::that(&url)?;
105
+ ui::note(format!("Opened {url}"));
106
+ Ok(())
107
+}
crates/cli/src/commands/session.rs
new
+63
@@ -0,0 +1,63 @@
1
+use {
2
+ crate::{
3
+ cli::SessionCommand,
4
+ context::Ctx,
5
+ ui::{self, prompt},
6
+ },
7
+ anyhow::Result,
8
+};
9
+
10
+pub async fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> {
11
+ ctx.require_token()?;
12
+
13
+ match command {
14
+ SessionCommand::List => list(ctx).await,
15
+ SessionCommand::View { id } => view(ctx, id).await,
16
+ SessionCommand::New { title, model } => new(ctx, title, model).await,
17
+ SessionCommand::Update { id, title, model } => update(ctx, id, title, model).await,
18
+ SessionCommand::Delete { id, yes } => delete(ctx, id, yes).await,
19
+ }
20
+}
21
+
22
+async fn list(ctx: &Ctx) -> Result<()> {
23
+ let sessions = ctx.client.sessions().await?;
24
+ ui::emit(ctx.json, &sessions, |sessions| {
25
+ ui::render::sessions(sessions)
26
+ })
27
+}
28
+
29
+async fn view(ctx: &Ctx, id: i64) -> Result<()> {
30
+ let session = ctx.client.session(id).await?;
31
+ ui::emit(ctx.json, &session, ui::render::session_detail)
32
+}
33
+
34
+async fn new(ctx: &Ctx, title: Option<String>, model: Option<String>) -> Result<()> {
35
+ let session = ctx
36
+ .client
37
+ .create_session(title.as_deref(), model.as_deref())
38
+ .await?;
39
+ ui::emit(ctx.json, &session, |session| {
40
+ ui::success(format!("Started session #{}.", session.id));
41
+ })
42
+}
43
+
44
+async fn update(ctx: &Ctx, id: i64, title: Option<String>, model: Option<String>) -> Result<()> {
45
+ let session = ctx
46
+ .client
47
+ .update_session(id, title.as_deref(), model.as_deref())
48
+ .await?;
49
+ ui::emit(ctx.json, &session, |session| {
50
+ ui::success(format!("Updated session #{}.", session.id));
51
+ })
52
+}
53
+
54
+async fn delete(ctx: &Ctx, id: i64, yes: bool) -> Result<()> {
55
+ if !yes && !prompt::confirm(&format!("Delete session #{id}?"), false)? {
56
+ ui::note("Cancelled.");
57
+ return Ok(());
58
+ }
59
+
60
+ ctx.client.delete_session(id).await?;
61
+ ui::success(format!("Deleted session #{id}."));
62
+ Ok(())
63
+}
crates/cli/src/config.rs
new
+107
@@ -0,0 +1,107 @@
1
+//! Where `si` keeps its credentials.
2
+//!
3
+//! One file per environment under `$HOME`: `~/.sigit/token` for production,
4
+//! `~/.sigit-dev/token` for a local server. `SIGIT_TOKEN` overrides both, which
5
+//! is how CI authenticates without a login step.
6
+
7
+use {
8
+ anyhow::{anyhow, Context, Result},
9
+ sigit_api::Environment,
10
+ std::{
11
+ fs,
12
+ path::{Path, PathBuf},
13
+ },
14
+};
15
+
16
+/// Environment variable holding a token, checked before the token file.
17
+pub const TOKEN_ENV: &str = "SIGIT_TOKEN";
18
+
19
+/// `~/.sigit` (or `~/.sigit-dev`), created if absent.
20
+pub fn config_dir(environment: Environment) -> Result<PathBuf> {
21
+ let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not locate your home directory."))?;
22
+ let dir = home.join(environment.config_dir_name());
23
+ fs::create_dir_all(&dir).with_context(|| format!("Could not create {}.", dir.display()))?;
24
+ Ok(dir)
25
+}
26
+
27
+pub fn token_path(environment: Environment) -> Result<PathBuf> {
28
+ Ok(config_dir(environment)?.join("token"))
29
+}
30
+
31
+/// The token for this environment, from `SIGIT_TOKEN` or the token file.
32
+///
33
+/// Returns `None` rather than an error when there is simply no token — "not
34
+/// signed in" is an expected state that callers phrase themselves.
35
+pub fn read_token(environment: Environment) -> Option<String> {
36
+ if let Ok(token) = std::env::var(TOKEN_ENV) {
37
+ let token = token.trim().to_string();
38
+ if !token.is_empty() {
39
+ return Some(token);
40
+ }
41
+ }
42
+
43
+ let path = token_path(environment).ok()?;
44
+ let token = fs::read_to_string(path).ok()?.trim().to_string();
45
+ (!token.is_empty()).then_some(token)
46
+}
47
+
48
+/// Persist a token, readable only by its owner.
49
+pub fn write_token(environment: Environment, token: &str) -> Result<PathBuf> {
50
+ let path = token_path(environment)?;
51
+ fs::write(&path, token.trim())
52
+ .with_context(|| format!("Could not write {}.", path.display()))?;
53
+ restrict_permissions(&path)?;
54
+ Ok(path)
55
+}
56
+
57
+/// Remove the stored token. Succeeds when there was nothing to remove — sign
58
+/// out should be idempotent, including after a token has already expired.
59
+pub fn clear_token(environment: Environment) -> Result<()> {
60
+ let path = token_path(environment)?;
61
+ match fs::remove_file(&path) {
62
+ Ok(()) => Ok(()),
63
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
64
+ Err(e) => Err(e).with_context(|| format!("Could not remove {}.", path.display())),
65
+ }
66
+}
67
+
68
+#[cfg(unix)]
69
+fn restrict_permissions(path: &Path) -> Result<()> {
70
+ use std::os::unix::fs::PermissionsExt;
71
+
72
+ fs::set_permissions(path, fs::Permissions::from_mode(0o600))
73
+ .with_context(|| format!("Could not set permissions on {}.", path.display()))
74
+}
75
+
76
+#[cfg(not(unix))]
77
+fn restrict_permissions(_path: &Path) -> Result<()> {
78
+ // Windows inherits the user-profile ACL, which is already owner-only.
79
+ Ok(())
80
+}
81
+
82
+#[cfg(test)]
83
+mod tests {
84
+ use super::*;
85
+
86
+ #[test]
87
+ fn each_environment_gets_its_own_directory() {
88
+ assert_ne!(
89
+ Environment::Dev.config_dir_name(),
90
+ Environment::Production.config_dir_name()
91
+ );
92
+ }
93
+
94
+ #[cfg(unix)]
95
+ #[test]
96
+ fn a_written_token_is_owner_only() {
97
+ use std::os::unix::fs::PermissionsExt;
98
+
99
+ let dir = tempfile::tempdir().unwrap();
100
+ let path = dir.path().join("token");
101
+ fs::write(&path, "secret").unwrap();
102
+ restrict_permissions(&path).unwrap();
103
+
104
+ let mode = fs::metadata(&path).unwrap().permissions().mode();
105
+ assert_eq!(mode & 0o777, 0o600);
106
+ }
107
+}
crates/cli/src/context.rs
new
+47
@@ -0,0 +1,47 @@
1
+//! Shared state threaded through every command: the API client, the `--json`
2
+//! flag, and repository resolution from the working directory.
3
+
4
+use {
5
+ crate::git::{self, RepoSlug},
6
+ anyhow::{anyhow, Result},
7
+ sigit_api::{Client, Environment},
8
+};
9
+
10
+pub struct Ctx {
11
+ pub client: Client,
12
+ pub environment: Environment,
13
+ pub json: bool,
14
+}
15
+
16
+impl Ctx {
17
+ pub fn new(client: Client, environment: Environment, json: bool) -> Self {
18
+ Self {
19
+ client,
20
+ environment,
21
+ json,
22
+ }
23
+ }
24
+
25
+ /// Every endpoint but sign-in needs a token; fail fast with an actionable
26
+ /// message instead of letting the request round-trip to a 401.
27
+ pub fn require_token(&self) -> Result<()> {
28
+ if self.client.token().is_some() {
29
+ Ok(())
30
+ } else {
31
+ Err(anyhow!("You are not signed in. Run `si auth login` first."))
32
+ }
33
+ }
34
+
35
+ /// Resolve a repository argument: an explicit `OWNER/NAME`, or — when
36
+ /// omitted — the sigit.si remote of the repo in the current directory.
37
+ pub fn resolve_repo(&self, explicit: Option<String>) -> Result<RepoSlug> {
38
+ match explicit {
39
+ Some(input) => RepoSlug::parse(&input),
40
+ None => {
41
+ let cwd = std::env::current_dir()
42
+ .map_err(|e| anyhow!("Could not read the current directory: {e}"))?;
43
+ git::current_repo(&cwd, self.client.base_url())
44
+ }
45
+ }
46
+ }
47
+}
crates/cli/src/git.rs
new
+234
@@ -0,0 +1,234 @@
1
+//! Working-directory git awareness.
2
+//!
3
+//! The point of a `gh`-style CLI is that you rarely have to name the repo — it
4
+//! reads the one you're standing in. This module answers two questions about
5
+//! the current directory: which sigit.si repo does it point at, and what branch
6
+//! is checked out.
7
+
8
+use {
9
+ anyhow::{anyhow, Context, Result},
10
+ git2::Repository as GitRepository,
11
+ std::{fmt, path::Path},
12
+};
13
+
14
+/// An `owner/name` pair identifying a repository on sigit.si.
15
+#[derive(Debug, Clone, PartialEq, Eq)]
16
+pub struct RepoSlug {
17
+ pub owner: String,
18
+ pub name: String,
19
+}
20
+
21
+impl RepoSlug {
22
+ /// Parse the `owner/name` form users type into `--repo`.
23
+ pub fn parse(input: &str) -> Result<Self> {
24
+ let trimmed = input.trim().trim_matches('/');
25
+ let mut parts = trimmed.splitn(2, '/');
26
+ let owner = parts.next().unwrap_or_default().trim();
27
+ let name = parts.next().unwrap_or_default().trim();
28
+
29
+ if owner.is_empty() || name.is_empty() || name.contains('/') {
30
+ return Err(anyhow!(
31
+ "Expected a repository in OWNER/NAME form, got {input:?}."
32
+ ));
33
+ }
34
+
35
+ Ok(Self {
36
+ owner: owner.to_string(),
37
+ name: name.trim_end_matches(".git").to_string(),
38
+ })
39
+ }
40
+}
41
+
42
+impl fmt::Display for RepoSlug {
43
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44
+ write!(f, "{}/{}", self.owner, self.name)
45
+ }
46
+}
47
+
48
+/// Find the sigit.si repository the given directory belongs to.
49
+///
50
+/// Remotes are searched in preference order — `origin`, then `sigit`, then
51
+/// whatever else is configured — and only remotes whose host matches `host` are
52
+/// considered, so a repo with both a GitHub and a sigit.si remote resolves to
53
+/// the sigit.si one.
54
+pub fn current_repo(dir: &Path, host: &str) -> Result<RepoSlug> {
55
+ let repository = GitRepository::discover(dir)
56
+ .context("Not inside a git repository. Pass --repo OWNER/NAME instead.")?;
57
+
58
+ let remotes = repository
59
+ .remotes()
60
+ .context("Could not read this repository's remotes.")?;
61
+
62
+ let names: Vec<String> = remotes.iter().flatten().map(str::to_string).collect();
63
+
64
+ for candidate in preferred_order(&names) {
65
+ let Ok(remote) = repository.find_remote(&candidate) else {
66
+ continue;
67
+ };
68
+ let Some(url) = remote.url() else { continue };
69
+ if let Some(slug) = slug_from_url(url, host) {
70
+ return Ok(slug);
71
+ }
72
+ }
73
+
74
+ Err(anyhow!(
75
+ "No {host} remote found in this repository. Pass --repo OWNER/NAME instead."
76
+ ))
77
+}
78
+
79
+/// The branch currently checked out, or `None` on a detached HEAD.
80
+pub fn current_branch(dir: &Path) -> Option<String> {
81
+ let repository = GitRepository::discover(dir).ok()?;
82
+ let head = repository.head().ok()?;
83
+ if !head.is_branch() {
84
+ return None;
85
+ }
86
+ head.shorthand().map(str::to_string)
87
+}
88
+
89
+/// `origin` first, then `sigit`, then the rest in their configured order.
90
+fn preferred_order(names: &[String]) -> Vec<String> {
91
+ let mut ordered = Vec::with_capacity(names.len());
92
+ for preferred in ["origin", "sigit"] {
93
+ if names.iter().any(|n| n == preferred) {
94
+ ordered.push(preferred.to_string());
95
+ }
96
+ }
97
+ for name in names {
98
+ if !ordered.contains(name) {
99
+ ordered.push(name.clone());
100
+ }
101
+ }
102
+ ordered
103
+}
104
+
105
+/// Pull `owner/name` out of a remote URL, but only when it points at `host`.
106
+///
107
+/// Handles the three shapes git remotes come in: `https://host/owner/repo.git`,
108
+/// `git@host:owner/repo.git`, and `ssh://git@host/owner/repo.git`.
109
+fn slug_from_url(url: &str, host: &str) -> Option<RepoSlug> {
110
+ let url = url.trim();
111
+ let (url_host, path) = split_host_and_path(url)?;
112
+
113
+ if !hosts_match(&url_host, host) {
114
+ return None;
115
+ }
116
+
117
+ RepoSlug::parse(path.trim_matches('/')).ok()
118
+}
119
+
120
+fn split_host_and_path(url: &str) -> Option<(String, String)> {
121
+ if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
122
+ // scheme://[user@]host[:port]/path
123
+ let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
124
+ let (authority, path) = rest.split_once('/')?;
125
+ return Some((authority.to_string(), path.to_string()));
126
+ }
127
+
128
+ // scp-like: [user@]host:path
129
+ let rest = url.split_once('@').map_or(url, |(_, after)| after);
130
+ let (authority, path) = rest.split_once(':')?;
131
+ Some((authority.to_string(), path.to_string()))
132
+}
133
+
134
+/// Compare hosts ignoring the port and a leading `www.`, so a dev remote on
135
+/// `localhost:3000` matches a `localhost` base URL and vice versa.
136
+fn hosts_match(a: &str, b: &str) -> bool {
137
+ fn normalize(host: &str) -> String {
138
+ let host = host.trim().to_ascii_lowercase();
139
+ let host = host
140
+ .rsplit_once("://")
141
+ .map_or(host.as_str(), |(_, rest)| rest);
142
+ let host = host.split('/').next().unwrap_or(host);
143
+ let host = host.split(':').next().unwrap_or(host);
144
+ host.trim_start_matches("www.").to_string()
145
+ }
146
+
147
+ normalize(a) == normalize(b)
148
+}
149
+
150
+#[cfg(test)]
151
+mod tests {
152
+ use super::*;
153
+
154
+ #[test]
155
+ fn slugs_parse_from_the_owner_name_form() {
156
+ let slug = RepoSlug::parse("seto/sigit-si").unwrap();
157
+ assert_eq!(slug.owner, "seto");
158
+ assert_eq!(slug.name, "sigit-si");
159
+ assert_eq!(slug.to_string(), "seto/sigit-si");
160
+ }
161
+
162
+ #[test]
163
+ fn a_trailing_git_suffix_is_dropped() {
164
+ assert_eq!(
165
+ RepoSlug::parse("seto/sigit-si.git").unwrap().name,
166
+ "sigit-si"
167
+ );
168
+ }
169
+
170
+ #[test]
171
+ fn dotted_repo_names_survive() {
172
+ // The server allows names like "Qwen2.5-3B-Instruct-GGUF".
173
+ let slug = RepoSlug::parse("seto/Qwen2.5-3B-Instruct-GGUF").unwrap();
174
+ assert_eq!(slug.name, "Qwen2.5-3B-Instruct-GGUF");
175
+ }
176
+
177
+ #[test]
178
+ fn incomplete_slugs_are_rejected() {
179
+ assert!(RepoSlug::parse("sigit-si").is_err());
180
+ assert!(RepoSlug::parse("seto/").is_err());
181
+ assert!(RepoSlug::parse("/sigit-si").is_err());
182
+ assert!(RepoSlug::parse("a/b/c").is_err());
183
+ }
184
+
185
+ #[test]
186
+ fn https_remotes_resolve() {
187
+ let slug = slug_from_url("https://sigit.si/seto/sigit-si.git", "https://sigit.si").unwrap();
188
+ assert_eq!(slug.to_string(), "seto/sigit-si");
189
+ }
190
+
191
+ #[test]
192
+ fn scp_style_ssh_remotes_resolve() {
193
+ let slug = slug_from_url("git@sigit.si:seto/sigit-si.git", "sigit.si").unwrap();
194
+ assert_eq!(slug.to_string(), "seto/sigit-si");
195
+ }
196
+
197
+ #[test]
198
+ fn ssh_url_remotes_resolve() {
199
+ let slug = slug_from_url("ssh://git@sigit.si/seto/sigit-si.git", "sigit.si").unwrap();
200
+ assert_eq!(slug.to_string(), "seto/sigit-si");
201
+ }
202
+
203
+ #[test]
204
+ fn remotes_on_another_host_are_ignored() {
205
+ // The whole point: a repo mirrored from GitHub must not resolve to its
206
+ // GitHub remote when we asked for the sigit.si one.
207
+ assert!(slug_from_url("git@github.com:seto/sigit-si.git", "sigit.si").is_none());
208
+ assert!(
209
+ slug_from_url("https://github.com/seto/sigit-si.git", "https://sigit.si").is_none()
210
+ );
211
+ }
212
+
213
+ #[test]
214
+ fn dev_remotes_match_regardless_of_port() {
215
+ let slug = slug_from_url(
216
+ "http://localhost:3000/seto/demo.git",
217
+ "http://localhost:3000",
218
+ )
219
+ .unwrap();
220
+ assert_eq!(slug.to_string(), "seto/demo");
221
+ }
222
+
223
+ #[test]
224
+ fn origin_is_tried_before_other_remotes() {
225
+ let names = vec!["upstream".to_string(), "origin".to_string()];
226
+ assert_eq!(preferred_order(&names).first().unwrap(), "origin");
227
+ }
228
+
229
+ #[test]
230
+ fn every_remote_is_still_considered() {
231
+ let names = vec!["upstream".to_string(), "origin".to_string()];
232
+ assert_eq!(preferred_order(&names).len(), 2);
233
+ }
234
+}
crates/cli/src/main.rs
new
+53
@@ -0,0 +1,53 @@
1
+mod cli;
2
+mod commands;
3
+mod config;
4
+mod context;
5
+mod git;
6
+mod ui;
7
+
8
+use {
9
+ anyhow::Result,
10
+ clap::Parser,
11
+ cli::{Cli, Command},
12
+ console::style,
13
+ context::Ctx,
14
+ sigit_api::Client,
15
+};
16
+
17
+#[tokio::main]
18
+async fn main() {
19
+ tracing_subscriber::fmt()
20
+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
21
+ .with_writer(std::io::stderr)
22
+ .init();
23
+
24
+ if let Err(e) = run().await {
25
+ eprintln!("{} {:#}", style("✘").red(), e);
26
+ std::process::exit(1);
27
+ }
28
+}
29
+
30
+async fn run() -> Result<()> {
31
+ let cli = Cli::parse();
32
+
33
+ let mut client = Client::new(cli.environment)?;
34
+ if let Some(token) = config::read_token(cli.environment) {
35
+ client = client.with_token(token);
36
+ }
37
+
38
+ let ctx = Ctx::new(client, cli.environment, cli.json);
39
+
40
+ match cli.command {
41
+ Command::Auth(command) => commands::auth::run(&ctx, command).await,
42
+ Command::Me => commands::me::run(&ctx).await,
43
+ Command::Repo(command) => commands::repo::run(&ctx, command).await,
44
+ Command::Pr(command) => commands::pr::run(&ctx, command).await,
45
+ Command::Issue(command) => commands::issue::run(&ctx, command).await,
46
+ Command::Code(command) => commands::code::run(&ctx, command).await,
47
+ Command::Hook(command) => commands::hook::run(&ctx, command).await,
48
+ Command::Session(command) => commands::session::run(&ctx, command).await,
49
+ Command::Billing(command) => commands::billing::run(&ctx, command).await,
50
+ Command::Browse(args) => commands::browse::run(&ctx, args).await,
51
+ Command::Api(args) => commands::api::run(&ctx, args).await,
52
+ }
53
+}
crates/cli/src/ui/mod.rs
new
+116
@@ -0,0 +1,116 @@
1
+//! Terminal presentation: symbols, colour, and the plain/JSON output switch.
2
+
3
+pub mod prompt;
4
+pub mod render;
5
+
6
+use {
7
+ chrono::{DateTime, Utc},
8
+ console::style,
9
+ serde::Serialize,
10
+};
11
+
12
+pub fn success_symbol() -> String {
13
+ style("✔").green().to_string()
14
+}
15
+
16
+/// Print a completed action.
17
+pub fn success(message: impl AsRef<str>) {
18
+ println!("{} {}", success_symbol(), message.as_ref());
19
+}
20
+
21
+/// Print a non-fatal note. Goes to stderr so it never pollutes piped output.
22
+pub fn note(message: impl AsRef<str>) {
23
+ eprintln!("{}", style(message.as_ref()).dim());
24
+}
25
+
26
+pub fn highlight(text: &str) -> String {
27
+ style(text).green().bold().to_string()
28
+}
29
+
30
+pub fn dim(text: &str) -> String {
31
+ style(text).dim().to_string()
32
+}
33
+
34
+pub fn bold(text: &str) -> String {
35
+ style(text).bold().to_string()
36
+}
37
+
38
+/// Colour an issue/PR state the way the web UI does.
39
+pub fn state_badge(state: &str) -> String {
40
+ match state {
41
+ "open" => style("open").green().to_string(),
42
+ "merged" => style("merged").magenta().to_string(),
43
+ "closed" => style("closed").red().to_string(),
44
+ other => style(other).dim().to_string(),
45
+ }
46
+}
47
+
48
+/// Render a value as either pretty JSON or human output.
49
+///
50
+/// Every read command routes its output through here, so `--json` is uniformly
51
+/// available for scripting without each command reimplementing it.
52
+pub fn emit<T: Serialize>(json: bool, value: &T, render: impl FnOnce(&T)) -> anyhow::Result<()> {
53
+ if json {
54
+ println!("{}", serde_json::to_string_pretty(value)?);
55
+ } else {
56
+ render(value);
57
+ }
58
+ Ok(())
59
+}
60
+
61
+/// A compact "how long ago" for list views, e.g. `3d`, `about 5h`, `just now`.
62
+pub fn relative_time(timestamp: DateTime<Utc>) -> String {
63
+ let elapsed = Utc::now().signed_duration_since(timestamp);
64
+ let seconds = elapsed.num_seconds();
65
+
66
+ // A clock skew between client and server can put a timestamp slightly in
67
+ // the future; read those as "now" rather than printing a negative age.
68
+ if seconds < 60 {
69
+ return "just now".to_string();
70
+ }
71
+
72
+ let minutes = elapsed.num_minutes();
73
+ if minutes < 60 {
74
+ return format!("{minutes}m ago");
75
+ }
76
+ let hours = elapsed.num_hours();
77
+ if hours < 24 {
78
+ return format!("{hours}h ago");
79
+ }
80
+ let days = elapsed.num_days();
81
+ if days < 30 {
82
+ return format!("{days}d ago");
83
+ }
84
+ if days < 365 {
85
+ return format!("{}mo ago", days / 30);
86
+ }
87
+ format!("{}y ago", days / 365)
88
+}
89
+
90
+#[cfg(test)]
91
+mod tests {
92
+ use super::*;
93
+ use chrono::Duration;
94
+
95
+ #[test]
96
+ fn recent_timestamps_read_as_just_now() {
97
+ assert_eq!(relative_time(Utc::now()), "just now");
98
+ }
99
+
100
+ #[test]
101
+ fn future_timestamps_do_not_produce_negative_ages() {
102
+ // Clock skew between the client and server is real; never print "-3m".
103
+ let future = Utc::now() + Duration::minutes(5);
104
+ assert_eq!(relative_time(future), "just now");
105
+ }
106
+
107
+ #[test]
108
+ fn ages_step_through_the_expected_units() {
109
+ let now = Utc::now();
110
+ assert_eq!(relative_time(now - Duration::minutes(5)), "5m ago");
111
+ assert_eq!(relative_time(now - Duration::hours(5)), "5h ago");
112
+ assert_eq!(relative_time(now - Duration::days(5)), "5d ago");
113
+ assert_eq!(relative_time(now - Duration::days(60)), "2mo ago");
114
+ assert_eq!(relative_time(now - Duration::days(800)), "2y ago");
115
+ }
116
+}
crates/cli/src/ui/prompt.rs
new
+47
@@ -0,0 +1,47 @@
1
+//! Interactive prompts, kept in one place so every command fails the same way
2
+//! when stdin isn't a TTY (CI, pipes) instead of hanging.
3
+
4
+use {
5
+ anyhow::{anyhow, Result},
6
+ console::Term,
7
+ dialoguer::{theme::ColorfulTheme, Confirm, Input, Password},
8
+};
9
+
10
+fn require_tty(what: &str) -> Result<()> {
11
+ if Term::stdout().features().is_attended() {
12
+ Ok(())
13
+ } else {
14
+ Err(anyhow!(
15
+ "{what} requires an interactive terminal. Provide it via a flag or environment \
16
+ variable instead when running non-interactively."
17
+ ))
18
+ }
19
+}
20
+
21
+pub fn text(label: &str) -> Result<String> {
22
+ require_tty(label)?;
23
+ Ok(Input::with_theme(&ColorfulTheme::default())
24
+ .with_prompt(label)
25
+ .interact_text()?)
26
+}
27
+
28
+pub fn password(label: &str) -> Result<String> {
29
+ require_tty(label)?;
30
+ Ok(Password::with_theme(&ColorfulTheme::default())
31
+ .with_prompt(label)
32
+ .interact()?)
33
+}
34
+
35
+/// A yes/no confirmation. Non-interactive callers should not reach this at
36
+/// all for destructive actions — check `--yes` first — but reads default to
37
+/// `default` here rather than erroring, since a TTY-less "confirm the details
38
+/// I already showed you" is reasonable to skip.
39
+pub fn confirm(label: &str, default: bool) -> Result<bool> {
40
+ if !Term::stdout().features().is_attended() {
41
+ return Ok(default);
42
+ }
43
+ Ok(Confirm::with_theme(&ColorfulTheme::default())
44
+ .with_prompt(label)
45
+ .default(default)
46
+ .interact()?)
47
+}
crates/cli/src/ui/render.rs
new
+410
@@ -0,0 +1,410 @@
1
+//! Human-readable renderers. Each is the `--json` counterpart of one command's
2
+//! output; see [`crate::ui::emit`].
3
+
4
+use {
5
+ super::{bold, dim, highlight, relative_time, state_badge},
6
+ sigit_api::models::{
7
+ Billing, CloudSession, CodeHit, Comment, Hook, Issue, IssueSummary, PullRequest,
8
+ PullRequestSummary, Repository, RepositorySummary, User,
9
+ },
10
+ tabled::{builder::Builder, settings::Style},
11
+};
12
+
13
+/// Print a table, or a placeholder line when there is nothing to show.
14
+fn table(headers: &[&str], rows: Vec<Vec<String>>, empty: &str) {
15
+ if rows.is_empty() {
16
+ println!("{}", dim(empty));
17
+ return;
18
+ }
19
+
20
+ let mut builder = Builder::default();
21
+ builder.push_record(headers.iter().map(|h| h.to_string()));
22
+ for row in rows {
23
+ builder.push_record(row);
24
+ }
25
+
26
+ let mut table = builder.build();
27
+ table.with(Style::rounded());
28
+ println!("{table}");
29
+}
30
+
31
+/// A `label value` line, skipped when the value is empty.
32
+fn field(label: &str, value: Option<&str>) {
33
+ if let Some(value) = value.map(str::trim).filter(|v| !v.is_empty()) {
34
+ println!(" {:<14}{}", label, value);
35
+ }
36
+}
37
+
38
+fn truncate(text: &str, max: usize) -> String {
39
+ let text = text.replace(['\n', '\r'], " ");
40
+ if text.chars().count() <= max {
41
+ return text;
42
+ }
43
+ let kept: String = text.chars().take(max.saturating_sub(1)).collect();
44
+ format!("{kept}…")
45
+}
46
+
47
+// ── Account ─────────────────────────────────────────────────────────────────
48
+
49
+pub fn user(user: &User) {
50
+ println!();
51
+ println!(" {}", highlight(&user.username));
52
+ field("Name", user.display_name.as_deref());
53
+ field("Email", Some(&user.email));
54
+ field("ID", Some(&user.id.to_string()));
55
+ field(
56
+ "Joined",
57
+ Some(&user.created_at.format("%Y-%m-%d").to_string()),
58
+ );
59
+ println!();
60
+}
61
+
62
+// ── Repositories ────────────────────────────────────────────────────────────
63
+
64
+pub fn repos(repos: &[Repository]) {
65
+ let rows = repos
66
+ .iter()
67
+ .map(|repo| {
68
+ vec![
69
+ repo.full_name.clone(),
70
+ repo.visibility().to_string(),
71
+ truncate(repo.description.as_deref().unwrap_or("—"), 48),
72
+ repo.stars_count.to_string(),
73
+ relative_time(repo.updated_at),
74
+ ]
75
+ })
76
+ .collect();
77
+
78
+ table(
79
+ &[
80
+ "Repository",
81
+ "Visibility",
82
+ "Description",
83
+ "Stars",
84
+ "Updated",
85
+ ],
86
+ rows,
87
+ "You have no repositories yet. Create one with `si repo create <name>`.",
88
+ );
89
+}
90
+
91
+pub fn repo_summaries(repos: &[RepositorySummary]) {
92
+ let rows = repos
93
+ .iter()
94
+ .map(|repo| {
95
+ vec![
96
+ repo.full_name.clone(),
97
+ if repo.private { "private" } else { "public" }.to_string(),
98
+ truncate(repo.description.as_deref().unwrap_or("—"), 56),
99
+ repo.default_branch.clone(),
100
+ ]
101
+ })
102
+ .collect();
103
+
104
+ table(
105
+ &["Repository", "Visibility", "Description", "Branch"],
106
+ rows,
107
+ "No repositories matched.",
108
+ );
109
+}
110
+
111
+pub fn repo_detail(repo: &Repository) {
112
+ println!();
113
+ println!(
114
+ " {} {}",
115
+ highlight(&repo.full_name),
116
+ dim(repo.visibility())
117
+ );
118
+ if let Some(description) = repo.description.as_deref().filter(|d| !d.is_empty()) {
119
+ println!(" {description}");
120
+ }
121
+ println!();
122
+ field("Default branch", Some(&repo.default_branch));
123
+ field("Stars", Some(&repo.stars_count.to_string()));
124
+ field(
125
+ "State",
126
+ Some(if repo.initialized {
127
+ "initialized"
128
+ } else {
129
+ "empty — push something to get started"
130
+ }),
131
+ );
132
+ field("Updated", Some(&relative_time(repo.updated_at)));
133
+ field("Clone", Some(&repo.clone_url));
134
+ field("Web", Some(&repo.web_url));
135
+ println!();
136
+}
137
+
138
+// ── Pull requests ───────────────────────────────────────────────────────────
139
+
140
+pub fn pulls(pulls: &[PullRequestSummary]) {
141
+ let rows = pulls
142
+ .iter()
143
+ .map(|pr| {
144
+ vec![
145
+ format!("#{}", pr.number),
146
+ truncate(&pr.title, 52),
147
+ state_badge(&pr.state),
148
+ format!("{} → {}", pr.head, pr.base),
149
+ ]
150
+ })
151
+ .collect();
152
+
153
+ table(
154
+ &["#", "Title", "State", "Branch"],
155
+ rows,
156
+ "No pull requests match this filter.",
157
+ );
158
+}
159
+
160
+pub fn pull_detail(pr: &PullRequest) {
161
+ println!();
162
+ println!(" {}", bold(&pr.title));
163
+ println!(
164
+ " {} · {} · {} wants to merge {} into {}",
165
+ dim(&format!("#{}", pr.number)),
166
+ state_badge(&pr.state),
167
+ pr.author,
168
+ highlight(&pr.head),
169
+ highlight(&pr.base),
170
+ );
171
+ println!();
172
+
173
+ if let Some(body) = pr.body.as_deref().filter(|b| !b.trim().is_empty()) {
174
+ println!("{body}");
175
+ println!();
176
+ }
177
+
178
+ comments(&pr.comments);
179
+
180
+ if let Some(note) = &pr.diff_note {
181
+ println!(" {}", dim(note));
182
+ }
183
+ let changed = pr.diff.as_deref().map_or(0, count_changed_files);
184
+ if changed > 0 {
185
+ println!(
186
+ " {}",
187
+ dim(&format!(
188
+ "{changed} changed file(s) — see them with `si pr diff {}`",
189
+ pr.number
190
+ ))
191
+ );
192
+ }
193
+ println!(" {}", dim(&pr.url));
194
+ println!();
195
+}
196
+
197
+/// Count `diff --git` headers, which is one per changed file.
198
+fn count_changed_files(diff: &str) -> usize {
199
+ diff.lines()
200
+ .filter(|l| l.starts_with("diff --git "))
201
+ .count()
202
+}
203
+
204
+// ── Issues ──────────────────────────────────────────────────────────────────
205
+
206
+pub fn issues(issues: &[IssueSummary]) {
207
+ let rows = issues
208
+ .iter()
209
+ .map(|issue| {
210
+ vec![
211
+ format!("#{}", issue.number),
212
+ truncate(&issue.title, 60),
213
+ state_badge(&issue.state),
214
+ issue.author.clone(),
215
+ ]
216
+ })
217
+ .collect();
218
+
219
+ table(
220
+ &["#", "Title", "State", "Author"],
221
+ rows,
222
+ "No issues match this filter.",
223
+ );
224
+}
225
+
226
+pub fn issue_detail(issue: &Issue) {
227
+ println!();
228
+ println!(" {}", bold(&issue.title));
229
+ println!(
230
+ " {} · {} · opened by {}",
231
+ dim(&format!("#{}", issue.number)),
232
+ state_badge(&issue.state),
233
+ issue.author
234
+ );
235
+ println!();
236
+
237
+ if let Some(body) = issue.body.as_deref().filter(|b| !b.trim().is_empty()) {
238
+ println!("{body}");
239
+ println!();
240
+ }
241
+
242
+ comments(&issue.comments);
243
+ println!(" {}", dim(&issue.url));
244
+ println!();
245
+}
246
+
247
+pub fn comments(comments: &[Comment]) {
248
+ for comment in comments {
249
+ println!(
250
+ " {} {}",
251
+ highlight(&comment.author),
252
+ dim(&relative_time(comment.created_at))
253
+ );
254
+ for line in comment.body.lines() {
255
+ println!(" {line}");
256
+ }
257
+ println!();
258
+ }
259
+}
260
+
261
+// ── Code search ─────────────────────────────────────────────────────────────
262
+
263
+pub fn code_hits(hits: &[CodeHit]) {
264
+ if hits.is_empty() {
265
+ println!("{}", dim("No matches."));
266
+ return;
267
+ }
268
+
269
+ for hit in hits {
270
+ println!(
271
+ "{}{}{}",
272
+ highlight(&hit.path),
273
+ dim(":"),
274
+ dim(&hit.line.to_string())
275
+ );
276
+ println!(" {}", hit.snippet.trim_end());
277
+ }
278
+}
279
+
280
+// ── Webhooks ────────────────────────────────────────────────────────────────
281
+
282
+pub fn hooks(hooks: &[Hook]) {
283
+ let rows = hooks
284
+ .iter()
285
+ .map(|hook| {
286
+ vec![
287
+ hook.id.to_string(),
288
+ truncate(&hook.url, 52),
289
+ hook.events.join(", "),
290
+ if hook.active { "active" } else { "paused" }.to_string(),
291
+ hook.last_delivered_at
292
+ .map(relative_time)
293
+ .unwrap_or_else(|| "never".to_string()),
294
+ ]
295
+ })
296
+ .collect();
297
+
298
+ table(
299
+ &["ID", "URL", "Events", "State", "Last delivery"],
300
+ rows,
301
+ "This repository has no webhooks.",
302
+ );
303
+}
304
+
305
+// ── Cloud Sessions ──────────────────────────────────────────────────────────
306
+
307
+pub fn sessions(sessions: &[CloudSession]) {
308
+ let rows = sessions
309
+ .iter()
310
+ .map(|session| {
311
+ vec![
312
+ session.id.to_string(),
313
+ truncate(session.title.as_deref().unwrap_or("(untitled)"), 48),
314
+ session.model.clone().unwrap_or_else(|| "—".to_string()),
315
+ session
316
+ .last_message_at
317
+ .map(relative_time)
318
+ .unwrap_or_else(|| "—".to_string()),
319
+ ]
320
+ })
321
+ .collect();
322
+
323
+ table(
324
+ &["ID", "Title", "Model", "Last message"],
325
+ rows,
326
+ "You have no cloud sessions yet. Start one with `si session new`.",
327
+ );
328
+}
329
+
330
+pub fn session_detail(session: &CloudSession) {
331
+ println!();
332
+ println!(
333
+ " {}",
334
+ highlight(session.title.as_deref().unwrap_or("(untitled session)"))
335
+ );
336
+ field("ID", Some(&session.id.to_string()));
337
+ field("Model", session.model.as_deref());
338
+ field("Created", Some(&relative_time(session.created_at)));
339
+ println!();
340
+
341
+ let Some(messages) = &session.messages else {
342
+ return;
343
+ };
344
+ if messages.is_empty() {
345
+ println!("{}", dim(" No messages yet."));
346
+ println!();
347
+ return;
348
+ }
349
+
350
+ for message in messages {
351
+ println!(
352
+ " {} {}",
353
+ highlight(&message.role),
354
+ dim(&relative_time(message.created_at))
355
+ );
356
+ for line in message.content.lines() {
357
+ println!(" {line}");
358
+ }
359
+ println!();
360
+ }
361
+}
362
+
363
+// ── Billing ─────────────────────────────────────────────────────────────────
364
+
365
+pub fn billing(billing: &Billing) {
366
+ println!();
367
+ println!(" {} {}", highlight(&billing.plan), dim(&billing.status));
368
+ field(
369
+ "Cloud",
370
+ Some(if billing.entitled_to_cloud {
371
+ "entitled"
372
+ } else {
373
+ "not included on this plan"
374
+ }),
375
+ );
376
+ field(
377
+ "Usage",
378
+ Some(&format!(
379
+ "{} / {} requests",
380
+ billing.cloud_requests_used, billing.cloud_allowance
381
+ )),
382
+ );
383
+ if let Some(period_end) = billing.current_period_end {
384
+ field("Renews", Some(&period_end.format("%Y-%m-%d").to_string()));
385
+ }
386
+ println!();
387
+}
388
+
389
+#[cfg(test)]
390
+mod tests {
391
+ use super::*;
392
+
393
+ #[test]
394
+ fn long_text_is_truncated_with_an_ellipsis() {
395
+ assert_eq!(truncate("abcdefghij", 5), "abcd…");
396
+ assert_eq!(truncate("abc", 5), "abc");
397
+ }
398
+
399
+ #[test]
400
+ fn newlines_are_flattened_so_a_row_stays_one_line() {
401
+ assert_eq!(truncate("a\nb", 10), "a b");
402
+ }
403
+
404
+ #[test]
405
+ fn changed_files_are_counted_from_diff_headers() {
406
+ let diff = "diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\ndiff --git a/y b/y\n";
407
+ assert_eq!(count_changed_files(diff), 2);
408
+ assert_eq!(count_changed_files(""), 0);
409
+ }
410
+}
crates/sigit-api/Cargo.toml
new
+20
@@ -0,0 +1,20 @@
1
+[package]
2
+name = "sigit-api"
3
+description = "Typed client for the sigit.si JSON API."
4
+version.workspace = true
5
+edition.workspace = true
6
+authors.workspace = true
7
+license.workspace = true
8
+repository.workspace = true
9
+homepage.workspace = true
10
+keywords = ["sigit", "git", "api", "client"]
11
+categories = ["api-bindings"]
12
+readme = "README.md"
13
+
14
+[dependencies]
15
+chrono = { workspace = true }
16
+reqwest = { workspace = true }
17
+serde = { workspace = true }
18
+serde_json = { workspace = true }
19
+thiserror = { workspace = true }
20
+tracing = { workspace = true }
crates/sigit-api/README.md
new
+18
@@ -0,0 +1,18 @@
1
+# sigit-api
2
+
3
+A typed Rust client for the [sigit.si](https://sigit.si) JSON API. Used by the
4
+`si` CLI (`crates/cli` in this workspace) and usable standalone by any Rust
5
+project — the desktop app included.
6
+
7
+```rust
8
+use sigit_api::{Client, Environment};
9
+
10
+let client = Client::new(Environment::Production)?.with_token(token);
11
+let me = client.user().await?;
12
+let repos = client.repos().await?;
13
+```
14
+
15
+See the crate-level docs (`cargo doc --open -p sigit-api`) for the full
16
+surface: accounts, repositories, webhooks, Cloud Sessions, billing, and
17
+issues/pull requests/code search (served over an internal MCP JSON-RPC
18
+bridge).
crates/sigit-api/src/account.rs
new
+42
@@ -0,0 +1,42 @@
1
+//! Sign in, sign out, and the current-user profile.
2
+
3
+use crate::{
4
+ client::Client,
5
+ error::Result,
6
+ models::{AccountStatus, User},
7
+};
8
+
9
+impl Client {
10
+ /// `POST /api/v1/auth/sign_in`
11
+ ///
12
+ /// Answers `200` for all three outcomes — the caller inspects the
13
+ /// [`AccountStatus`] rather than the status code.
14
+ pub async fn sign_in(&self, email: &str, password: &str) -> Result<AccountStatus> {
15
+ let body = serde_json::json!({
16
+ "email": email.trim().to_ascii_lowercase(),
17
+ "password": password,
18
+ });
19
+ self.post("auth/sign_in", &body).await
20
+ }
21
+
22
+ /// `DELETE /api/v1/auth/sign_out` — revokes the token server-side.
23
+ pub async fn sign_out(&self) -> Result<()> {
24
+ self.delete("auth/sign_out").await
25
+ }
26
+
27
+ /// `GET /api/v1/user` — the signed-in user's profile.
28
+ pub async fn user(&self) -> Result<User> {
29
+ self.get("user").await
30
+ }
31
+}
32
+
33
+/// Human-readable explanation for an `AccountStatus::Incomplete` status code,
34
+/// mirroring `account::ErrorCode` in the shared model.
35
+pub fn incomplete_reason(status: u32) -> &'static str {
36
+ match status {
37
+ 1001 => "Your email address is not verified yet. Check your inbox, or run `si auth resend`.",
38
+ 1002 => "Your account is locked.",
39
+ 1003 => "Your account is not active yet.",
40
+ _ => "Your account is not ready to sign in yet. Finish setting it up at https://sigit.si/auth.",
41
+ }
42
+}
crates/sigit-api/src/billing.rs
new
+28
@@ -0,0 +1,28 @@
1
+//! Plan, cloud entitlement, and the hosted checkout / portal links.
2
+
3
+use crate::{
4
+ client::Client,
5
+ error::Result,
6
+ models::{Billing, BillingUrl},
7
+};
8
+
9
+impl Client {
10
+ /// `GET /api/v1/billing`
11
+ pub async fn billing(&self) -> Result<Billing> {
12
+ self.get("billing").await
13
+ }
14
+
15
+ /// `POST /api/v1/billing/checkout` — a Stripe Checkout URL to open in a
16
+ /// browser. Purchases deliberately happen on the web, never in the client.
17
+ pub async fn billing_checkout(&self, plan: &str) -> Result<String> {
18
+ let body = serde_json::json!({ "plan": plan });
19
+ let response: BillingUrl = self.post("billing/checkout", &body).await?;
20
+ Ok(response.url)
21
+ }
22
+
23
+ /// `POST /api/v1/billing/portal` — the Stripe customer portal URL.
24
+ pub async fn billing_portal(&self) -> Result<String> {
25
+ let response: BillingUrl = self.post("billing/portal", &serde_json::json!({})).await?;
26
+ Ok(response.url)
27
+ }
28
+}
crates/sigit-api/src/client.rs
new
+259
@@ -0,0 +1,259 @@
1
+//! The HTTP transport every endpoint module is built on.
2
+
3
+use {
4
+ crate::{
5
+ environment::Environment,
6
+ error::{ApiError, ErrorResponse, Result},
7
+ },
8
+ reqwest::{Method, RequestBuilder, StatusCode},
9
+ serde::{de::DeserializeOwned, Serialize},
10
+ tracing::debug,
11
+};
12
+
13
+const USER_AGENT: &str = concat!("si/", env!("CARGO_PKG_VERSION"));
14
+
15
+/// A configured client for one sigit.si environment.
16
+///
17
+/// Cheap to clone (the inner `reqwest::Client` is an `Arc` over a shared
18
+/// connection pool), so pass it around by value.
19
+#[derive(Debug, Clone)]
20
+pub struct Client {
21
+ http: reqwest::Client,
22
+ environment: Environment,
23
+ api_url: String,
24
+ base_url: String,
25
+ token: Option<String>,
26
+}
27
+
28
+impl Client {
29
+ /// An unauthenticated client. Every endpoint on this API needs a token, so
30
+ /// this is mostly a starting point for [`Client::with_token`].
31
+ pub fn new(environment: Environment) -> Result<Self> {
32
+ let http = reqwest::Client::builder()
33
+ .user_agent(USER_AGENT)
34
+ .build()
35
+ .map_err(|source| ApiError::Transport {
36
+ host: environment.base_url(),
37
+ source,
38
+ })?;
39
+
40
+ Ok(Self {
41
+ http,
42
+ environment,
43
+ api_url: environment.api_url(),
44
+ base_url: environment.base_url(),
45
+ token: None,
46
+ })
47
+ }
48
+
49
+ /// Attach the bearer token sent on every subsequent request.
50
+ pub fn with_token(mut self, token: impl Into<String>) -> Self {
51
+ self.token = Some(token.into());
52
+ self
53
+ }
54
+
55
+ pub fn environment(&self) -> Environment {
56
+ self.environment
57
+ }
58
+
59
+ /// The web origin — the base for browser and `.git` clone URLs.
60
+ pub fn base_url(&self) -> &str {
61
+ &self.base_url
62
+ }
63
+
64
+ pub fn api_url(&self) -> &str {
65
+ &self.api_url
66
+ }
67
+
68
+ pub fn token(&self) -> Option<&str> {
69
+ self.token.as_deref()
70
+ }
71
+
72
+ // ── Verb helpers ────────────────────────────────────────────────────────
73
+
74
+ pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
75
+ self.send(self.request(Method::GET, path)).await
76
+ }
77
+
78
+ pub async fn post<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
79
+ self.send(self.request(Method::POST, path).json(body)).await
80
+ }
81
+
82
+ pub async fn patch<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
83
+ self.send(self.request(Method::PATCH, path).json(body))
84
+ .await
85
+ }
86
+
87
+ /// For endpoints that answer `204 No Content` (sign-out, deletes).
88
+ pub async fn delete(&self, path: &str) -> Result<()> {
89
+ self.send_empty(self.request(Method::DELETE, path)).await
90
+ }
91
+
92
+ /// Escape hatch for `si api` — an arbitrary method and path against the API
93
+ /// root, returning the raw JSON body.
94
+ pub async fn raw(
95
+ &self,
96
+ method: Method,
97
+ path: &str,
98
+ body: Option<serde_json::Value>,
99
+ ) -> Result<serde_json::Value> {
100
+ let mut builder = self.request(method, path);
101
+ if let Some(body) = body {
102
+ builder = builder.json(&body);
103
+ }
104
+
105
+ let response = self.execute(builder).await?;
106
+ let status = response.status();
107
+ let text = response.text().await.unwrap_or_default();
108
+
109
+ // A raw call reports the body as-is, whether or not the status is 2xx —
110
+ // seeing the server's actual error JSON is the point of the command.
111
+ // Only auth failures are translated, so the caller can clear the token.
112
+ if status == StatusCode::UNAUTHORIZED {
113
+ return Err(ApiError::Unauthorized);
114
+ }
115
+ if text.trim().is_empty() {
116
+ return Ok(serde_json::Value::Null);
117
+ }
118
+ serde_json::from_str(&text).map_err(|e| ApiError::Parse(format!("{e}: {text}")))
119
+ }
120
+
121
+ // ── Plumbing ────────────────────────────────────────────────────────────
122
+
123
+ /// Build a request against `<api_url>/<path>`, carrying the bearer token.
124
+ pub fn request(&self, method: Method, path: &str) -> RequestBuilder {
125
+ let url = format!("{}/{}", self.api_url, path.trim_start_matches('/'));
126
+ let builder = self.http.request(method, url);
127
+ match &self.token {
128
+ Some(token) => builder.bearer_auth(token),
129
+ None => builder,
130
+ }
131
+ }
132
+
133
+ /// Send a request and deserialize a JSON body.
134
+ pub async fn send<T: DeserializeOwned>(&self, builder: RequestBuilder) -> Result<T> {
135
+ let response = self.execute(builder).await?;
136
+ let status = response.status();
137
+ let text = response.text().await.unwrap_or_default();
138
+
139
+ if !status.is_success() {
140
+ return Err(Self::error_from(status, &text));
141
+ }
142
+
143
+ serde_json::from_str(&text).map_err(|e| ApiError::Parse(format!("{e}: {text}")))
144
+ }
145
+
146
+ /// Send a request that returns no useful body (`204`, or a `200` we ignore).
147
+ pub async fn send_empty(&self, builder: RequestBuilder) -> Result<()> {
148
+ let response = self.execute(builder).await?;
149
+ let status = response.status();
150
+
151
+ if status.is_success() {
152
+ return Ok(());
153
+ }
154
+
155
+ let text = response.text().await.unwrap_or_default();
156
+ Err(Self::error_from(status, &text))
157
+ }
158
+
159
+ async fn execute(&self, builder: RequestBuilder) -> Result<reqwest::Response> {
160
+ if self.token.is_none() {
161
+ debug!("sending an unauthenticated request to {}", self.api_url);
162
+ }
163
+
164
+ builder.send().await.map_err(|source| ApiError::Transport {
165
+ host: self.base_url.clone(),
166
+ source,
167
+ })
168
+ }
169
+
170
+ /// Turn a non-2xx response into the most specific error we can. The body is
171
+ /// usually an `ErrorResponse`; when it isn't (a proxy's HTML 502, say), fall
172
+ /// back to the status line so the message is still actionable.
173
+ fn error_from(status: StatusCode, body: &str) -> ApiError {
174
+ if status == StatusCode::UNAUTHORIZED {
175
+ return ApiError::Unauthorized;
176
+ }
177
+
178
+ match serde_json::from_str::<ErrorResponse>(body) {
179
+ Ok(error) => ApiError::Status {
180
+ status: status.as_u16(),
181
+ error_code: error.error_code,
182
+ message: error.message,
183
+ },
184
+ Err(_) => ApiError::Status {
185
+ status: status.as_u16(),
186
+ error_code: crate::error::codes::UNKNOWN,
187
+ message: format!(
188
+ "The server returned {}{}",
189
+ status,
190
+ truncate_for_message(body)
191
+ ),
192
+ },
193
+ }
194
+ }
195
+}
196
+
197
+/// Append a short excerpt of an unparseable body, so an HTML error page shows
198
+/// something useful without dumping a full page into the terminal.
199
+fn truncate_for_message(body: &str) -> String {
200
+ let body = body.trim();
201
+ if body.is_empty() {
202
+ return String::new();
203
+ }
204
+
205
+ const MAX: usize = 200;
206
+ let excerpt: String = body.chars().take(MAX).collect();
207
+ if body.chars().count() > MAX {
208
+ format!(": {excerpt}…")
209
+ } else {
210
+ format!(": {excerpt}")
211
+ }
212
+}
213
+
214
+#[cfg(test)]
215
+mod tests {
216
+ use super::*;
217
+
218
+ #[test]
219
+ fn unauthorized_maps_to_the_dedicated_variant() {
220
+ let error = Client::error_from(StatusCode::UNAUTHORIZED, r#"{"error_code":100,"m":"x"}"#);
221
+ assert!(matches!(error, ApiError::Unauthorized));
222
+ }
223
+
224
+ #[test]
225
+ fn error_bodies_keep_the_servers_message_and_code() {
226
+ let body = r#"{"error_code":101,"message":"Repository not found."}"#;
227
+ match Client::error_from(StatusCode::NOT_FOUND, body) {
228
+ ApiError::Status {
229
+ status,
230
+ error_code,
231
+ message,
232
+ } => {
233
+ assert_eq!(status, 404);
234
+ assert_eq!(error_code, 101);
235
+ assert_eq!(message, "Repository not found.");
236
+ }
237
+ other => panic!("expected a Status error, got {other:?}"),
238
+ }
239
+ }
240
+
241
+ #[test]
242
+ fn non_json_bodies_fall_back_to_the_status_line() {
243
+ match Client::error_from(StatusCode::BAD_GATEWAY, "<html>nginx</html>") {
244
+ ApiError::Status { message, .. } => {
245
+ assert!(message.contains("502"), "{message}");
246
+ assert!(message.contains("nginx"), "{message}");
247
+ }
248
+ other => panic!("expected a Status error, got {other:?}"),
249
+ }
250
+ }
251
+
252
+ #[test]
253
+ fn long_html_bodies_are_truncated() {
254
+ let body = "x".repeat(1000);
255
+ let message = truncate_for_message(&body);
256
+ assert!(message.ends_with('…'));
257
+ assert!(message.chars().count() < 250);
258
+ }
259
+}
crates/sigit-api/src/code.rs
new
+64
@@ -0,0 +1,64 @@
1
+//! Reading and searching repository contents, and repo discovery across all
2
+//! repositories the user can see. Served by the MCP tools; see [`crate::mcp`].
3
+
4
+use {
5
+ crate::{
6
+ client::Client,
7
+ error::Result,
8
+ models::{CodeHit, FileContents, RepositorySummary},
9
+ },
10
+ serde_json::json,
11
+};
12
+
13
+impl Client {
14
+ /// Search repositories by an owner/name substring. Unlike
15
+ /// [`Client::repos`], this spans every repo the user can see, not only the
16
+ /// ones they own.
17
+ pub async fn search_repos(
18
+ &self,
19
+ query: Option<&str>,
20
+ limit: Option<u32>,
21
+ ) -> Result<Vec<RepositorySummary>> {
22
+ let mut args = json!({});
23
+ if let Some(query) = query {
24
+ args["query"] = query.into();
25
+ }
26
+ if let Some(limit) = limit {
27
+ args["limit"] = limit.into();
28
+ }
29
+ self.call_tool_as("list_repositories", args).await
30
+ }
31
+
32
+ /// Read one file at a ref, defaulting to the repo's default branch.
33
+ pub async fn file_contents(
34
+ &self,
35
+ repo: &str,
36
+ path: &str,
37
+ git_ref: Option<&str>,
38
+ ) -> Result<FileContents> {
39
+ let mut args = json!({ "repo": repo, "path": path });
40
+ if let Some(git_ref) = git_ref {
41
+ args["ref"] = git_ref.into();
42
+ }
43
+ self.call_tool_as("get_file_contents", args).await
44
+ }
45
+
46
+ /// Fixed-string, case-insensitive search across a repository's tracked
47
+ /// files at a ref.
48
+ pub async fn search_code(
49
+ &self,
50
+ repo: &str,
51
+ query: &str,
52
+ git_ref: Option<&str>,
53
+ limit: Option<u32>,
54
+ ) -> Result<Vec<CodeHit>> {
55
+ let mut args = json!({ "repo": repo, "query": query });
56
+ if let Some(git_ref) = git_ref {
57
+ args["ref"] = git_ref.into();
58
+ }
59
+ if let Some(limit) = limit {
60
+ args["limit"] = limit.into();
61
+ }
62
+ self.call_tool_as("search_code", args).await
63
+ }
64
+}
crates/sigit-api/src/environment.rs
new
+136
@@ -0,0 +1,136 @@
1
+//! Which sigit.si deployment a client talks to, and where its local state lives.
2
+
3
+use std::{fmt, str::FromStr};
4
+
5
+/// The deployment a [`crate::Client`] points at.
6
+///
7
+/// `SIGIT_HOST` overrides the host for either variant, so a client can be aimed
8
+/// at a staging box or a tunnel without a code change:
9
+///
10
+/// ```text
11
+/// SIGIT_HOST=sigit.example.com si repo list
12
+/// SIGIT_HOST=http://localhost:4000 si repo list
13
+/// ```
14
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
15
+pub enum Environment {
16
+ /// A local Rails server (`bin/rails s`), http on port 3000.
17
+ Dev,
18
+ /// https://sigit.si
19
+ #[default]
20
+ Production,
21
+}
22
+
23
+impl Environment {
24
+ /// Scheme + host, no trailing slash — e.g. `https://sigit.si`.
25
+ ///
26
+ /// Both the JSON API and the web UI live on this origin, so it doubles as
27
+ /// the base for browser URLs and `.git` clone URLs.
28
+ pub fn base_url(&self) -> String {
29
+ match std::env::var("SIGIT_HOST") {
30
+ Ok(host) => normalize_host(host.trim(), self.default_scheme()),
31
+ Err(_) => match self {
32
+ Environment::Dev => "http://localhost:3000".to_string(),
33
+ Environment::Production => "https://sigit.si".to_string(),
34
+ },
35
+ }
36
+ }
37
+
38
+ /// Base for JSON API calls — e.g. `https://sigit.si/api/v1`.
39
+ pub fn api_url(&self) -> String {
40
+ format!("{}/api/v1", self.base_url())
41
+ }
42
+
43
+ /// Directory under `$HOME` holding the token and config for this
44
+ /// environment. Dev gets its own so a local login can't clobber the real
45
+ /// one.
46
+ pub fn config_dir_name(&self) -> &'static str {
47
+ match self {
48
+ Environment::Dev => ".sigit-dev",
49
+ Environment::Production => ".sigit",
50
+ }
51
+ }
52
+
53
+ fn default_scheme(&self) -> &'static str {
54
+ match self {
55
+ Environment::Dev => "http",
56
+ Environment::Production => "https",
57
+ }
58
+ }
59
+}
60
+
61
+/// Accept `SIGIT_HOST` with or without a scheme, and strip any trailing slash.
62
+fn normalize_host(host: &str, default_scheme: &str) -> String {
63
+ let with_scheme = if host.contains("://") {
64
+ host.to_string()
65
+ } else {
66
+ format!("{default_scheme}://{host}")
67
+ };
68
+ with_scheme.trim_end_matches('/').to_string()
69
+}
70
+
71
+impl fmt::Display for Environment {
72
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73
+ let name = match self {
74
+ Environment::Dev => "dev",
75
+ Environment::Production => "production",
76
+ };
77
+ f.write_str(name)
78
+ }
79
+}
80
+
81
+impl FromStr for Environment {
82
+ type Err = String;
83
+
84
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
85
+ match s.to_ascii_lowercase().as_str() {
86
+ "dev" | "development" | "local" => Ok(Environment::Dev),
87
+ "production" | "prod" => Ok(Environment::Production),
88
+ other => Err(format!(
89
+ "Unknown environment {other:?}. Valid values are: dev, production."
90
+ )),
91
+ }
92
+ }
93
+}
94
+
95
+#[cfg(test)]
96
+mod tests {
97
+ use super::*;
98
+
99
+ #[test]
100
+ fn api_url_is_the_base_plus_the_version_prefix() {
101
+ // SIGIT_HOST is process-global; this asserts the un-overridden default,
102
+ // which is what every test process sees unless one sets the var.
103
+ if std::env::var("SIGIT_HOST").is_ok() {
104
+ return;
105
+ }
106
+ assert_eq!(Environment::Production.api_url(), "https://sigit.si/api/v1");
107
+ assert_eq!(Environment::Dev.api_url(), "http://localhost:3000/api/v1");
108
+ }
109
+
110
+ #[test]
111
+ fn bare_hosts_get_the_environment_default_scheme() {
112
+ assert_eq!(
113
+ normalize_host("sigit.example.com", "https"),
114
+ "https://sigit.example.com"
115
+ );
116
+ assert_eq!(
117
+ normalize_host("localhost:4000", "http"),
118
+ "http://localhost:4000"
119
+ );
120
+ }
121
+
122
+ #[test]
123
+ fn explicit_schemes_and_trailing_slashes_survive_normalization() {
124
+ assert_eq!(
125
+ normalize_host("http://localhost:4000/", "https"),
126
+ "http://localhost:4000"
127
+ );
128
+ }
129
+
130
+ #[test]
131
+ fn environments_round_trip_through_strings() {
132
+ assert_eq!("dev".parse(), Ok(Environment::Dev));
133
+ assert_eq!("PROD".parse(), Ok(Environment::Production));
134
+ assert!("staging".parse::<Environment>().is_err());
135
+ }
136
+}
crates/sigit-api/src/error.rs
new
+65
@@ -0,0 +1,65 @@
1
+//! Errors surfaced by the sigit.si API client.
2
+
3
+/// The API's error body — `Api::BaseController#render_error` renders every
4
+/// failure as `{ "error_code": <i32>, "message": <string> }`.
5
+#[derive(Debug, Clone, serde::Deserialize)]
6
+pub struct ErrorResponse {
7
+ pub error_code: i32,
8
+ pub message: String,
9
+}
10
+
11
+/// `error_codes::ErrorCode` values the server sends, mirrored from
12
+/// `Api::BaseController`.
13
+pub mod codes {
14
+ pub const UNKNOWN: i32 = 0;
15
+ pub const UNAUTHORIZED: i32 = 100;
16
+ pub const INVALID: i32 = 101;
17
+}
18
+
19
+pub type Result<T> = std::result::Result<T, ApiError>;
20
+
21
+#[derive(Debug, thiserror::Error)]
22
+pub enum ApiError {
23
+ /// The token is missing, expired, or rejected. Callers treat this as
24
+ /// "clear the stored token and ask the user to sign in again", so it stays
25
+ /// a distinct variant rather than folding into [`ApiError::Status`].
26
+ #[error("Your session has expired. Sign in again with `si auth login`.")]
27
+ Unauthorized,
28
+
29
+ /// No token on disk for this environment.
30
+ #[error("You are not signed in. Run `si auth login` first.")]
31
+ NotAuthenticated,
32
+
33
+ /// The server answered with a non-2xx status and an `ErrorResponse` body.
34
+ #[error("{message}")]
35
+ Status {
36
+ status: u16,
37
+ error_code: i32,
38
+ message: String,
39
+ },
40
+
41
+ /// An MCP tool reported an in-band failure (`isError: true`). Issues and
42
+ /// pull requests go over MCP, so this is a normal user-facing error.
43
+ #[error("{0}")]
44
+ Tool(String),
45
+
46
+ /// The request never completed — DNS, TLS, connection refused, timeout.
47
+ #[error("Could not reach {host}: {source}")]
48
+ Transport {
49
+ host: String,
50
+ #[source]
51
+ source: reqwest::Error,
52
+ },
53
+
54
+ /// A 2xx body that didn't match the expected shape.
55
+ #[error("Unexpected response from the server: {0}")]
56
+ Parse(String),
57
+}
58
+
59
+impl ApiError {
60
+ /// True when the caller should discard the stored credentials.
61
+ pub fn is_auth_failure(&self) -> bool {
62
+ matches!(self, ApiError::Unauthorized | ApiError::NotAuthenticated)
63
+ || matches!(self, ApiError::Status { status: 401, .. })
64
+ }
65
+}
crates/sigit-api/src/issues.rs
new
+99
@@ -0,0 +1,99 @@
1
+//! Issues. Served by the MCP tools; see [`crate::mcp`] for why.
2
+
3
+use {
4
+ crate::{
5
+ client::Client,
6
+ error::Result,
7
+ models::{CreatedRef, Issue, IssueSummary},
8
+ },
9
+ serde_json::json,
10
+ std::fmt,
11
+};
12
+
13
+/// Which issues or pull requests to list.
14
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15
+pub enum State {
16
+ #[default]
17
+ Open,
18
+ Closed,
19
+ Merged,
20
+ All,
21
+}
22
+
23
+impl State {
24
+ pub fn as_str(&self) -> &'static str {
25
+ match self {
26
+ State::Open => "open",
27
+ State::Closed => "closed",
28
+ State::Merged => "merged",
29
+ State::All => "all",
30
+ }
31
+ }
32
+}
33
+
34
+impl fmt::Display for State {
35
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36
+ f.write_str(self.as_str())
37
+ }
38
+}
39
+
40
+impl Client {
41
+ /// Issues in `owner/repo`, optionally filtered by a title/body substring.
42
+ pub async fn issues(
43
+ &self,
44
+ repo: &str,
45
+ state: State,
46
+ query: Option<&str>,
47
+ ) -> Result<Vec<IssueSummary>> {
48
+ let mut args = json!({ "repo": repo, "state": state.as_str() });
49
+ if let Some(query) = query {
50
+ args["query"] = query.into();
51
+ }
52
+ self.call_tool_as("list_issues", args).await
53
+ }
54
+
55
+ /// One issue with its comments.
56
+ pub async fn issue(&self, repo: &str, number: i64) -> Result<Issue> {
57
+ self.call_tool_as("get_issue", json!({ "repo": repo, "number": number }))
58
+ .await
59
+ }
60
+
61
+ /// Open a new issue.
62
+ pub async fn create_issue(
63
+ &self,
64
+ repo: &str,
65
+ title: &str,
66
+ body: Option<&str>,
67
+ ) -> Result<CreatedRef> {
68
+ let mut args = json!({ "repo": repo, "title": title });
69
+ if let Some(body) = body {
70
+ args["body"] = body.into();
71
+ }
72
+ self.call_tool_as("create_issue", args).await
73
+ }
74
+
75
+ /// Comment on an issue *or* a pull request — they share one number space.
76
+ pub async fn add_comment(&self, repo: &str, number: i64, body: &str) -> Result<CommentRef> {
77
+ let args = json!({ "repo": repo, "number": number, "body": body });
78
+ self.call_tool_as("add_issue_comment", args).await
79
+ }
80
+}
81
+
82
+/// What `add_issue_comment` returns.
83
+#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
84
+pub struct CommentRef {
85
+ pub id: i64,
86
+ pub url: String,
87
+}
88
+
89
+#[cfg(test)]
90
+mod tests {
91
+ use super::*;
92
+
93
+ #[test]
94
+ fn states_use_the_wire_spelling_the_server_expects() {
95
+ assert_eq!(State::Open.as_str(), "open");
96
+ assert_eq!(State::Merged.as_str(), "merged");
97
+ assert_eq!(State::default(), State::Open);
98
+ }
99
+}
crates/sigit-api/src/lib.rs
new
+41
@@ -0,0 +1,41 @@
1
+//! A typed client for the [sigit.si](https://sigit.si) JSON API.
2
+//!
3
+//! ```no_run
4
+//! use sigit_api::{Client, Environment, issues::State};
5
+//!
6
+//! # async fn example() -> sigit_api::Result<()> {
7
+//! let client = Client::new(Environment::Production)?.with_token("…");
8
+//!
9
+//! let me = client.user().await?;
10
+//! let repos = client.repos().await?;
11
+//! let open = client.pulls("seto/sigit-si", State::Open).await?;
12
+//! # Ok(())
13
+//! # }
14
+//! ```
15
+//!
16
+//! Two transports sit behind one [`Client`]. Accounts, repositories, hooks,
17
+//! Cloud Sessions, and billing are plain REST under `/api/v1`. Issues, pull
18
+//! requests, and code search have no REST surface yet, so they go over the
19
+//! JSON-RPC MCP endpoint at `/api/v1/mcp` — that split is invisible at the call
20
+//! site, and can disappear later without breaking callers.
21
+
22
+pub mod account;
23
+pub mod billing;
24
+pub mod client;
25
+pub mod code;
26
+pub mod environment;
27
+pub mod error;
28
+pub mod issues;
29
+pub mod mcp;
30
+pub mod models;
31
+pub mod pulls;
32
+pub mod repos;
33
+pub mod sessions;
34
+
35
+pub use {
36
+ client::Client,
37
+ environment::Environment,
38
+ error::{ApiError, ErrorResponse, Result},
39
+ issues::State,
40
+ repos::NewRepository,
41
+};
crates/sigit-api/src/mcp.rs
new
+156
@@ -0,0 +1,156 @@
1
+//! JSON-RPC bridge to the MCP endpoint at `POST /api/v1/mcp`.
2
+//!
3
+//! Issues, pull requests, code search, and file reads have no REST surface on
4
+//! sigit.si — they are served as MCP tools. Rather than wait for a second API,
5
+//! the client speaks the same stateless JSON-RPC the agent does and decodes the
6
+//! tool results into ordinary types. The transport is an implementation
7
+//! detail; callers use [`crate::issues`], [`crate::pulls`], and
8
+//! [`crate::code`].
9
+//!
10
+//! The endpoint is stateless (see `Mcp::Server`), so there is no `initialize`
11
+//! handshake to perform — a `tools/call` stands on its own.
12
+
13
+use {
14
+ crate::{
15
+ client::Client,
16
+ error::{ApiError, Result},
17
+ },
18
+ serde::de::DeserializeOwned,
19
+ serde_json::{json, Value},
20
+ std::sync::atomic::{AtomicU64, Ordering},
21
+ tracing::debug,
22
+};
23
+
24
+/// JSON-RPC ids only need to be unique within a connection; a process-wide
25
+/// counter is plenty and keeps `call_tool` callable through `&self`.
26
+static NEXT_ID: AtomicU64 = AtomicU64::new(1);
27
+
28
+impl Client {
29
+ /// Invoke one MCP tool and return its decoded result.
30
+ pub async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value> {
31
+ let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
32
+ let request = json!({
33
+ "jsonrpc": "2.0",
34
+ "id": id,
35
+ "method": "tools/call",
36
+ "params": { "name": name, "arguments": arguments },
37
+ });
38
+
39
+ debug!(tool = name, "calling MCP tool");
40
+
41
+ let builder = self
42
+ .request(reqwest::Method::POST, "mcp")
43
+ .header(reqwest::header::ACCEPT, "application/json")
44
+ .json(&request);
45
+
46
+ let response: Value = self.send(builder).await?;
47
+ decode_tool_result(name, response)
48
+ }
49
+
50
+ /// [`Client::call_tool`], deserialized into a concrete type.
51
+ pub async fn call_tool_as<T: DeserializeOwned>(
52
+ &self,
53
+ name: &str,
54
+ arguments: Value,
55
+ ) -> Result<T> {
56
+ let value = self.call_tool(name, arguments).await?;
57
+ serde_json::from_value(value)
58
+ .map_err(|e| ApiError::Parse(format!("tool {name} returned an unexpected shape: {e}")))
59
+ }
60
+}
61
+
62
+/// Unwrap a JSON-RPC envelope down to the tool's payload.
63
+///
64
+/// Three failure modes are distinct and all reachable: a protocol-level
65
+/// `error` (unknown tool, malformed params), an in-band tool failure
66
+/// (`isError: true`, e.g. "No pull request #9"), and a success whose text block
67
+/// isn't the JSON we expect.
68
+fn decode_tool_result(name: &str, response: Value) -> Result<Value> {
69
+ if let Some(error) = response.get("error") {
70
+ let message = error
71
+ .get("message")
72
+ .and_then(Value::as_str)
73
+ .unwrap_or("The server rejected the request.");
74
+ return Err(ApiError::Tool(message.to_string()));
75
+ }
76
+
77
+ let result = response
78
+ .get("result")
79
+ .ok_or_else(|| ApiError::Parse(format!("tool {name} returned no result")))?;
80
+
81
+ let text = result
82
+ .get("content")
83
+ .and_then(Value::as_array)
84
+ .and_then(|blocks| blocks.first())
85
+ .and_then(|block| block.get("text"))
86
+ .and_then(Value::as_str)
87
+ .ok_or_else(|| ApiError::Parse(format!("tool {name} returned no content")))?;
88
+
89
+ if result.get("isError").and_then(Value::as_bool) == Some(true) {
90
+ return Err(ApiError::Tool(text.to_string()));
91
+ }
92
+
93
+ // Tools serialize their payload as pretty-printed JSON inside a text block.
94
+ // A tool that legitimately returns prose would land in the fallback.
95
+ serde_json::from_str(text).or_else(|_| Ok(Value::String(text.to_string())))
96
+}
97
+
98
+#[cfg(test)]
99
+mod tests {
100
+ use super::*;
101
+
102
+ fn envelope(content: Value, is_error: bool) -> Value {
103
+ json!({
104
+ "jsonrpc": "2.0",
105
+ "id": 1,
106
+ "result": {
107
+ "content": [{ "type": "text", "text": content }],
108
+ "isError": is_error,
109
+ }
110
+ })
111
+ }
112
+
113
+ #[test]
114
+ fn a_successful_call_yields_the_parsed_payload() {
115
+ let response = envelope(json!(r#"[{"number":3,"title":"Fix it"}]"#), false);
116
+ let value = decode_tool_result("list_issues", response).unwrap();
117
+ assert_eq!(value[0]["number"], 3);
118
+ }
119
+
120
+ #[test]
121
+ fn an_in_band_tool_failure_becomes_a_tool_error() {
122
+ let response = envelope(json!("No pull request #9 in a/b."), true);
123
+ match decode_tool_result("get_pull_request", response) {
124
+ Err(ApiError::Tool(message)) => assert!(message.contains("No pull request #9")),
125
+ other => panic!("expected a Tool error, got {other:?}"),
126
+ }
127
+ }
128
+
129
+ #[test]
130
+ fn a_protocol_error_becomes_a_tool_error() {
131
+ let response = json!({
132
+ "jsonrpc": "2.0", "id": 1,
133
+ "error": { "code": -32602, "message": "Unknown tool: nope" }
134
+ });
135
+ match decode_tool_result("nope", response) {
136
+ Err(ApiError::Tool(message)) => assert!(message.contains("Unknown tool")),
137
+ other => panic!("expected a Tool error, got {other:?}"),
138
+ }
139
+ }
140
+
141
+ #[test]
142
+ fn a_non_json_text_block_is_returned_as_a_string() {
143
+ let response = envelope(json!("just prose"), false);
144
+ let value = decode_tool_result("web_search", response).unwrap();
145
+ assert_eq!(value, Value::String("just prose".into()));
146
+ }
147
+
148
+ #[test]
149
+ fn a_missing_result_is_a_parse_error() {
150
+ let response = json!({ "jsonrpc": "2.0", "id": 1 });
151
+ assert!(matches!(
152
+ decode_tool_result("list_issues", response),
153
+ Err(ApiError::Parse(_))
154
+ ));
155
+ }
156
+}
crates/sigit-api/src/models.rs
new
+303
@@ -0,0 +1,303 @@
1
+//! Wire types for the sigit.si API.
2
+//!
3
+//! Field names and nullability mirror the Rails serializers in
4
+//! `app/controllers/api/v1/` and the MCP tool results in
5
+//! `app/services/mcp/tools.rb`. Every struct is `deny_unknown_fields`-free on
6
+//! purpose: the server may add fields, and an older `si` should keep working.
7
+
8
+use {
9
+ chrono::{DateTime, Utc},
10
+ serde::{Deserialize, Serialize},
11
+};
12
+
13
+// ── Account ─────────────────────────────────────────────────────────────────
14
+
15
+/// `GET /api/v1/user`
16
+#[derive(Debug, Clone, Deserialize, Serialize)]
17
+pub struct User {
18
+ pub id: i64,
19
+ pub username: String,
20
+ pub display_name: Option<String>,
21
+ pub avatar_url: Option<String>,
22
+ pub email: String,
23
+ pub created_at: DateTime<Utc>,
24
+}
25
+
26
+/// The result of `POST /api/v1/auth/sign_in`, matching the desktop app's
27
+/// `AccountStatus` enum: the bare string `"NotFound"`, `{"Ready":{…}}`, or
28
+/// `{"Incomplete":{…}}`.
29
+#[derive(Debug, Clone, Deserialize)]
30
+pub enum AccountStatus {
31
+ NotFound,
32
+ Ready { access_token: String },
33
+ Incomplete { status: u32 },
34
+}
35
+
36
+// ── Repositories ────────────────────────────────────────────────────────────
37
+
38
+/// `GET /api/v1/repos`, `POST /api/v1/repos`
39
+#[derive(Debug, Clone, Deserialize, Serialize)]
40
+pub struct Repository {
41
+ pub id: i64,
42
+ pub name: String,
43
+ pub full_name: String,
44
+ pub description: Option<String>,
45
+ pub default_branch: String,
46
+ pub kind: Option<String>,
47
+ pub is_private: bool,
48
+ pub stars_count: i64,
49
+ pub initialized: bool,
50
+ pub updated_at: DateTime<Utc>,
51
+ pub web_url: String,
52
+ pub clone_url: String,
53
+}
54
+
55
+impl Repository {
56
+ pub fn visibility(&self) -> &'static str {
57
+ if self.is_private {
58
+ "private"
59
+ } else {
60
+ "public"
61
+ }
62
+ }
63
+}
64
+
65
+// ── Webhooks ────────────────────────────────────────────────────────────────
66
+
67
+/// `GET/POST /api/v1/repos/:owner/:repo/hooks`
68
+#[derive(Debug, Clone, Deserialize, Serialize)]
69
+pub struct Hook {
70
+ pub id: i64,
71
+ pub url: String,
72
+ pub events: Vec<String>,
73
+ pub active: bool,
74
+ pub last_status_code: Option<i32>,
75
+ pub last_delivered_at: Option<DateTime<Utc>>,
76
+ pub created_at: DateTime<Utc>,
77
+ /// Returned exactly once, by `POST`. It is not retrievable afterwards.
78
+ #[serde(default)]
79
+ pub secret: Option<String>,
80
+}
81
+
82
+// ── Cloud Sessions ──────────────────────────────────────────────────────────
83
+
84
+/// `GET /api/v1/sessions`, `GET /api/v1/sessions/:id`
85
+#[derive(Debug, Clone, Deserialize, Serialize)]
86
+pub struct CloudSession {
87
+ pub id: i64,
88
+ pub title: Option<String>,
89
+ pub model: Option<String>,
90
+ pub last_message_at: Option<DateTime<Utc>>,
91
+ pub created_at: DateTime<Utc>,
92
+ pub updated_at: DateTime<Utc>,
93
+ /// Only present on the single-session `show` response.
94
+ #[serde(default)]
95
+ pub messages: Option<Vec<CloudMessage>>,
96
+}
97
+
98
+#[derive(Debug, Clone, Deserialize, Serialize)]
99
+pub struct CloudMessage {
100
+ pub id: i64,
101
+ pub role: String,
102
+ pub content: String,
103
+ pub created_at: DateTime<Utc>,
104
+}
105
+
106
+/// `POST /api/v1/sessions/:id/messages`
107
+#[derive(Debug, Clone, Deserialize)]
108
+pub struct AppendedMessage {
109
+ pub session: CloudSession,
110
+ pub message: CloudMessage,
111
+}
112
+
113
+// ── Billing ─────────────────────────────────────────────────────────────────
114
+
115
+/// `GET /api/v1/billing`
116
+#[derive(Debug, Clone, Deserialize, Serialize)]
117
+pub struct Billing {
118
+ pub plan: String,
119
+ pub status: String,
120
+ pub entitled_to_cloud: bool,
121
+ pub cloud_requests_used: i64,
122
+ pub cloud_allowance: i64,
123
+ pub current_period_end: Option<DateTime<Utc>>,
124
+}
125
+
126
+/// `POST /api/v1/billing/checkout`, `POST /api/v1/billing/portal`
127
+#[derive(Debug, Clone, Deserialize)]
128
+pub struct BillingUrl {
129
+ pub url: String,
130
+}
131
+
132
+// ── Git credentials ─────────────────────────────────────────────────────────
133
+
134
+/// `POST /api/v1/git_credentials` — a short-lived, read-scoped git password.
135
+#[derive(Debug, Clone, Deserialize)]
136
+pub struct GitCredential {
137
+ pub git_token: String,
138
+ pub username: String,
139
+ pub expires_at: DateTime<Utc>,
140
+}
141
+
142
+// ── MCP-backed resources ────────────────────────────────────────────────────
143
+//
144
+// Issues, pull requests, and code search have no REST surface; they are served
145
+// by the MCP tools at `POST /api/v1/mcp`. These are the tool result shapes.
146
+
147
+#[derive(Debug, Clone, Deserialize, Serialize)]
148
+pub struct RepositorySummary {
149
+ pub full_name: String,
150
+ pub description: Option<String>,
151
+ pub default_branch: String,
152
+ pub private: bool,
153
+ pub url: String,
154
+}
155
+
156
+#[derive(Debug, Clone, Deserialize, Serialize)]
157
+pub struct FileContents {
158
+ pub repo: String,
159
+ pub path: String,
160
+ #[serde(rename = "ref")]
161
+ pub git_ref: String,
162
+ pub size: i64,
163
+ pub content: String,
164
+}
165
+
166
+#[derive(Debug, Clone, Deserialize, Serialize)]
167
+pub struct CodeHit {
168
+ pub path: String,
169
+ pub line: i64,
170
+ pub snippet: String,
171
+}
172
+
173
+#[derive(Debug, Clone, Deserialize, Serialize)]
174
+pub struct Comment {
175
+ pub author: String,
176
+ pub body: String,
177
+ pub created_at: DateTime<Utc>,
178
+}
179
+
180
+#[derive(Debug, Clone, Deserialize, Serialize)]
181
+pub struct IssueSummary {
182
+ pub number: i64,
183
+ pub title: String,
184
+ pub state: String,
185
+ pub author: String,
186
+ pub url: String,
187
+}
188
+
189
+#[derive(Debug, Clone, Deserialize, Serialize)]
190
+pub struct Issue {
191
+ pub number: i64,
192
+ pub title: String,
193
+ pub state: String,
194
+ pub author: String,
195
+ pub body: Option<String>,
196
+ pub url: String,
197
+ #[serde(default)]
198
+ pub comments: Vec<Comment>,
199
+}
200
+
201
+#[derive(Debug, Clone, Deserialize, Serialize)]
202
+pub struct PullRequestSummary {
203
+ pub number: i64,
204
+ pub title: String,
205
+ pub state: String,
206
+ pub head: String,
207
+ pub base: String,
208
+ pub url: String,
209
+}
210
+
211
+#[derive(Debug, Clone, Deserialize, Serialize)]
212
+pub struct PullRequest {
213
+ pub number: i64,
214
+ pub title: String,
215
+ pub state: String,
216
+ pub author: String,
217
+ pub head: String,
218
+ pub base: String,
219
+ pub body: Option<String>,
220
+ pub url: String,
221
+ #[serde(default)]
222
+ pub comments: Vec<Comment>,
223
+ /// `None` when a branch no longer exists; see `diff_note`.
224
+ #[serde(default)]
225
+ pub diff: Option<String>,
226
+ #[serde(default)]
227
+ pub diff_truncated: bool,
228
+ #[serde(default)]
229
+ pub diff_note: Option<String>,
230
+}
231
+
232
+/// What `create_issue` / `create_pull_request` hand back.
233
+#[derive(Debug, Clone, Deserialize, Serialize)]
234
+pub struct CreatedRef {
235
+ pub number: i64,
236
+ pub state: String,
237
+ pub url: String,
238
+}
239
+
240
+#[cfg(test)]
241
+mod tests {
242
+ use super::*;
243
+
244
+ #[test]
245
+ fn account_status_reads_the_bare_not_found_string() {
246
+ let status: AccountStatus = serde_json::from_str(r#""NotFound""#).unwrap();
247
+ assert!(matches!(status, AccountStatus::NotFound));
248
+ }
249
+
250
+ #[test]
251
+ fn account_status_reads_the_ready_envelope() {
252
+ let status: AccountStatus =
253
+ serde_json::from_str(r#"{"Ready":{"access_token":"abc123"}}"#).unwrap();
254
+ match status {
255
+ AccountStatus::Ready { access_token } => assert_eq!(access_token, "abc123"),
256
+ other => panic!("expected Ready, got {other:?}"),
257
+ }
258
+ }
259
+
260
+ #[test]
261
+ fn account_status_reads_the_incomplete_envelope() {
262
+ let status: AccountStatus =
263
+ serde_json::from_str(r#"{"Incomplete":{"status":1001}}"#).unwrap();
264
+ match status {
265
+ AccountStatus::Incomplete { status } => assert_eq!(status, 1001),
266
+ other => panic!("expected Incomplete, got {other:?}"),
267
+ }
268
+ }
269
+
270
+ #[test]
271
+ fn a_hook_without_a_secret_still_parses() {
272
+ // Only the create response carries `secret`; index responses omit it.
273
+ let hook: Hook = serde_json::from_str(
274
+ r#"{"id":1,"url":"https://x.test","events":["push"],"active":true,
275
+ "last_status_code":null,"last_delivered_at":null,
276
+ "created_at":"2026-08-16T10:00:00.000Z"}"#,
277
+ )
278
+ .unwrap();
279
+ assert!(hook.secret.is_none());
280
+ assert!(hook.last_delivered_at.is_none());
281
+ }
282
+
283
+ #[test]
284
+ fn file_contents_maps_the_ref_keyword_field() {
285
+ let file: FileContents = serde_json::from_str(
286
+ r#"{"repo":"a/b","path":"README.md","ref":"main","size":3,"content":"hi\n"}"#,
287
+ )
288
+ .unwrap();
289
+ assert_eq!(file.git_ref, "main");
290
+ }
291
+
292
+ #[test]
293
+ fn a_pull_request_without_diff_fields_parses() {
294
+ // `get_pull_request` omits diff_truncated unless it truncated.
295
+ let pr: PullRequest = serde_json::from_str(
296
+ r#"{"number":7,"title":"t","state":"open","author":"seto","head":"f","base":"main",
297
+ "body":null,"url":"https://sigit.si/a/b/pull/7","comments":[],"diff":"@@"}"#,
298
+ )
299
+ .unwrap();
300
+ assert!(!pr.diff_truncated);
301
+ assert_eq!(pr.diff.as_deref(), Some("@@"));
302
+ }
303
+}
crates/sigit-api/src/pulls.rs
new
+50
@@ -0,0 +1,50 @@
1
+//! Pull requests. Served by the MCP tools; see [`crate::mcp`] for why.
2
+
3
+use {
4
+ crate::{
5
+ client::Client,
6
+ error::Result,
7
+ issues::State,
8
+ models::{CreatedRef, PullRequest, PullRequestSummary},
9
+ },
10
+ serde_json::json,
11
+};
12
+
13
+impl Client {
14
+ /// Pull requests in `owner/repo`.
15
+ pub async fn pulls(&self, repo: &str, state: State) -> Result<Vec<PullRequestSummary>> {
16
+ let args = json!({ "repo": repo, "state": state.as_str() });
17
+ self.call_tool_as("list_pull_requests", args).await
18
+ }
19
+
20
+ /// One pull request with its comments and unified diff. The server
21
+ /// truncates diffs past 100 kB and flags it on the result.
22
+ pub async fn pull(&self, repo: &str, number: i64) -> Result<PullRequest> {
23
+ self.call_tool_as(
24
+ "get_pull_request",
25
+ json!({ "repo": repo, "number": number }),
26
+ )
27
+ .await
28
+ }
29
+
30
+ /// Open a pull request from `head` into `base`.
31
+ pub async fn create_pull(
32
+ &self,
33
+ repo: &str,
34
+ title: &str,
35
+ head: &str,
36
+ base: &str,
37
+ body: Option<&str>,
38
+ ) -> Result<CreatedRef> {
39
+ let mut args = json!({
40
+ "repo": repo,
41
+ "title": title,
42
+ "head": head,
43
+ "base": base,
44
+ });
45
+ if let Some(body) = body {
46
+ args["body"] = body.into();
47
+ }
48
+ self.call_tool_as("create_pull_request", args).await
49
+ }
50
+}
crates/sigit-api/src/repos.rs
new
+102
@@ -0,0 +1,102 @@
1
+//! Repositories and their webhooks.
2
+
3
+use crate::{
4
+ client::Client,
5
+ error::Result,
6
+ models::{GitCredential, Hook, Repository},
7
+};
8
+
9
+/// Fields accepted by `POST /api/v1/repos`.
10
+#[derive(Debug, Clone, serde::Serialize)]
11
+pub struct NewRepository {
12
+ pub name: String,
13
+ pub is_private: bool,
14
+ #[serde(skip_serializing_if = "Option::is_none")]
15
+ pub description: Option<String>,
16
+}
17
+
18
+impl NewRepository {
19
+ pub fn new(name: impl Into<String>) -> Self {
20
+ Self {
21
+ name: name.into(),
22
+ is_private: false,
23
+ description: None,
24
+ }
25
+ }
26
+
27
+ pub fn private(mut self, private: bool) -> Self {
28
+ self.is_private = private;
29
+ self
30
+ }
31
+
32
+ pub fn description(mut self, description: Option<String>) -> Self {
33
+ self.description = description.filter(|d| !d.trim().is_empty());
34
+ self
35
+ }
36
+}
37
+
38
+impl Client {
39
+ /// `GET /api/v1/repos` — the signed-in user's repositories, newest first.
40
+ pub async fn repos(&self) -> Result<Vec<Repository>> {
41
+ self.get("repos").await
42
+ }
43
+
44
+ /// `POST /api/v1/repos` — creates an empty bare repo to push to.
45
+ pub async fn create_repo(&self, repo: &NewRepository) -> Result<Repository> {
46
+ self.post("repos", repo).await
47
+ }
48
+
49
+ /// `GET /api/v1/repos/:owner/:repo/hooks`
50
+ pub async fn hooks(&self, owner: &str, repo: &str) -> Result<Vec<Hook>> {
51
+ self.get(&format!("repos/{owner}/{repo}/hooks")).await
52
+ }
53
+
54
+ /// `POST /api/v1/repos/:owner/:repo/hooks`
55
+ ///
56
+ /// The response is the only place the signing secret ever appears.
57
+ pub async fn create_hook(
58
+ &self,
59
+ owner: &str,
60
+ repo: &str,
61
+ url: &str,
62
+ events: &[String],
63
+ ) -> Result<Hook> {
64
+ let body = serde_json::json!({ "url": url, "events": events });
65
+ self.post(&format!("repos/{owner}/{repo}/hooks"), &body)
66
+ .await
67
+ }
68
+
69
+ /// `DELETE /api/v1/repos/:owner/:repo/hooks/:id`
70
+ pub async fn delete_hook(&self, owner: &str, repo: &str, id: i64) -> Result<()> {
71
+ self.delete(&format!("repos/{owner}/{repo}/hooks/{id}"))
72
+ .await
73
+ }
74
+
75
+ /// `POST /api/v1/git_credentials` — mints a 1-hour, read-scoped git
76
+ /// password. This, not the account token, is what git clones with.
77
+ pub async fn git_credential(&self) -> Result<GitCredential> {
78
+ self.post("git_credentials", &serde_json::json!({})).await
79
+ }
80
+}
81
+
82
+#[cfg(test)]
83
+mod tests {
84
+ use super::*;
85
+
86
+ #[test]
87
+ fn a_blank_description_is_omitted_rather_than_sent_as_empty() {
88
+ let repo = NewRepository::new("demo").description(Some(" ".into()));
89
+ let json = serde_json::to_value(&repo).unwrap();
90
+ assert!(json.get("description").is_none());
91
+ }
92
+
93
+ #[test]
94
+ fn a_real_description_is_sent() {
95
+ let repo = NewRepository::new("demo")
96
+ .private(true)
97
+ .description(Some("A demo repo".into()));
98
+ let json = serde_json::to_value(&repo).unwrap();
99
+ assert_eq!(json["description"], "A demo repo");
100
+ assert_eq!(json["is_private"], true);
101
+ }
102
+}
crates/sigit-api/src/sessions.rs
new
+80
@@ -0,0 +1,80 @@
1
+//! siGit Code Cloud Sessions — named, resumable conversations.
2
+
3
+use crate::{
4
+ client::Client,
5
+ error::Result,
6
+ models::{AppendedMessage, CloudSession},
7
+};
8
+
9
+impl Client {
10
+ /// `GET /api/v1/sessions` — most recently updated first.
11
+ pub async fn sessions(&self) -> Result<Vec<CloudSession>> {
12
+ self.get("sessions").await
13
+ }
14
+
15
+ /// `GET /api/v1/sessions/:id` — the session plus its full transcript.
16
+ pub async fn session(&self, id: i64) -> Result<CloudSession> {
17
+ self.get(&format!("sessions/{id}")).await
18
+ }
19
+
20
+ /// `POST /api/v1/sessions`
21
+ pub async fn create_session(
22
+ &self,
23
+ title: Option<&str>,
24
+ model: Option<&str>,
25
+ ) -> Result<CloudSession> {
26
+ self.post("sessions", &session_body(title, model)).await
27
+ }
28
+
29
+ /// `PATCH /api/v1/sessions/:id`
30
+ pub async fn update_session(
31
+ &self,
32
+ id: i64,
33
+ title: Option<&str>,
34
+ model: Option<&str>,
35
+ ) -> Result<CloudSession> {
36
+ self.patch(&format!("sessions/{id}"), &session_body(title, model))
37
+ .await
38
+ }
39
+
40
+ /// `DELETE /api/v1/sessions/:id`
41
+ pub async fn delete_session(&self, id: i64) -> Result<()> {
42
+ self.delete(&format!("sessions/{id}")).await
43
+ }
44
+
45
+ /// `POST /api/v1/sessions/:id/messages` — append one message.
46
+ pub async fn append_message(
47
+ &self,
48
+ id: i64,
49
+ role: &str,
50
+ content: &str,
51
+ ) -> Result<AppendedMessage> {
52
+ let body = serde_json::json!({ "role": role, "content": content });
53
+ self.post(&format!("sessions/{id}/messages"), &body).await
54
+ }
55
+}
56
+
57
+/// The server treats absent keys as "leave unchanged", so only send what the
58
+/// caller actually supplied.
59
+fn session_body(title: Option<&str>, model: Option<&str>) -> serde_json::Value {
60
+ let mut body = serde_json::Map::new();
61
+ if let Some(title) = title {
62
+ body.insert("title".into(), title.into());
63
+ }
64
+ if let Some(model) = model {
65
+ body.insert("model".into(), model.into());
66
+ }
67
+ serde_json::Value::Object(body)
68
+}
69
+
70
+#[cfg(test)]
71
+mod tests {
72
+ use super::*;
73
+
74
+ #[test]
75
+ fn omitted_fields_stay_out_of_the_update_body() {
76
+ let body = session_body(Some("Refactor auth"), None);
77
+ assert_eq!(body["title"], "Refactor auth");
78
+ assert!(body.get("model").is_none());
79
+ }
80
+}
rust-toolchain.toml
new
+3
@@ -0,0 +1,3 @@
1
+[toolchain]
2
+channel = "stable"
3
+components = ["rustfmt", "clippy"]