@sigit / si / commits / 7c2981c

Initial commit

Seto Elkahfi committed Aug 16, 2026 at 12:38 UTC 7c2981c614f3866f3d9d517c5e7d8a698b776472
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"]