09752034 Andy McCall 2 days ago ↶
1
2
98a0fb9f Andy McCall 1 day ago ↶
3
4
5
09752034 Andy McCall 2 days ago ↶
6
67bfa06a Andy McCall 1 day ago ↶
7
use super :: identity:: owner_home;
8
use super :: { call, marks, ui, Ctx} ;
09752034 Andy McCall 2 days ago ↶
9
use anyhow:: { bail, Context, Result } ;
10
use spectra_core:: ids:: now_ms;
11
use spectra_core:: keys:: { home_identity, KeyPair} ;
12
use spectra_core:: ledger:: Ledger;
98a0fb9f Andy McCall 1 day ago ↶
13
use spectra_core:: principals:: { identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES } ;
09752034 Andy McCall 2 days ago ↶
14
use std:: path:: PathBuf;
15
16
pub const KINDS : & [ & str ] = & [ " claude-code" , " antigravity" , " kimi-code" , " codex" , " other" ] ;
17
18
pub struct Add {
19
pub name : String,
20
pub kind : String,
21
pub project : String,
22
pub home : PathBuf,
23
pub uid : Option< u32 > ,
24
pub learn_uid : bool ,
98a0fb9f Andy McCall 1 day ago ↶
25
pub expires : Option< String> ,
26
pub yes : bool ,
09752034 Andy McCall 2 days ago ↶
27
pub owner_home : Option< PathBuf> ,
28
}
29
98a0fb9f Andy McCall 1 day ago ↶
30
31
pub fn expiry ( s : & str ) -> Result< Option< u64 > > {
32
let s = s.trim ( ) ;
33
if s = = " never" {
34
return Ok ( None ) ;
35
}
36
let ( n, unit) = s.split_at ( s.find ( | c : char | ! c.is_ascii_digit ( ) ) .unwrap_or ( s.len ( ) ) ) ;
37
let n: u64 = n.parse ( ) .with_context ( | | format! ( " {s} is not a length of time (30m, 8h, 7d)" ) ) ? ;
38
let ms = match unit {
39
" s" => n * 1000 ,
40
" m" => n * 60_000 ,
41
" h" => n * 3_600_000 ,
42
" d" | " " => n * 86_400_000 ,
43
_ => bail! ( " {s} is not a length of time (30m, 8h, 7d)" ) ,
44
} ;
45
Ok ( Some ( now_ms ( ) + ms) )
46
}
47
48
49
pub fn confirm ( question : & str , yes : bool ) -> Result< ( ) > {
50
use std:: io:: IsTerminal;
51
if yes {
52
return Ok ( ( ) ) ;
53
}
54
if ! std:: io:: stdin( ) .is_terminal ( ) {
55
bail! ( " {question}: not asked, since this is not a terminal (--yes answers for a script)" ) ;
56
}
57
print! ( " {} {} {} " , ui:: glyph( " ?" , " ?" ) , question, ui:: dim( " [y/N]" ) ) ;
58
use std:: io:: Write;
59
std:: io:: stdout( ) .flush ( ) .ok ( ) ;
60
let mut line = String :: new( ) ;
61
std:: io:: stdin( ) .read_line ( & mut line) ? ;
62
if ! matches! ( line.trim ( ) .to_ascii_lowercase ( ) .as_str ( ) , " y" | " yes" ) {
63
bail! ( " not delegated: nothing was changed" ) ;
64
}
65
Ok ( ( ) )
66
}
67
09752034 Andy McCall 2 days ago ↶
68
pub async fn add ( ctx : & Ctx, a : Add) -> Result< i32 > {
69
if ! valid_name ( & a.name) {
70
bail! ( " an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64" ) ;
71
}
72
if ! KINDS .contains ( & a.kind.as_str ( ) ) {
73
bail! ( " --kind is one of {}" , KINDS .join ( " , " ) ) ;
74
}
67bfa06a Andy McCall 1 day ago ↶
75
let home = owner_home ( ctx, a.owner_home.clone ( ) ) ;
76
let ( exists, known) = match ctx.owner_client ( & home) .await? {
77
Some ( c) => {
78
let agents = call ( & c, " GET" , " /api/v1/agents" , None ) .await? ;
79
let projects = call ( & c, " GET" , " /api/v1/projects" , None ) .await? ;
80
let has = | v : & serde_json:: Value, n : & str | v.as_array ( ) .is_some_and ( | l | l.iter ( ) .any ( | x | x[ " name" ] = = n ) ) ;
81
( has ( & agents, & a.name) , has ( & projects, & a.project) )
09752034 Andy McCall 2 days ago ↶
82
}
67bfa06a Andy McCall 1 day ago ↶
83
None => {
84
let ledger = Ledger:: open_read_only( & ctx.db ( ) ? ) .await? ;
85
let p = Principals:: load( & ledger) .await? ;
86
let known: Option< String> =
87
sqlx:: query_scalar( " SELECT name FROM projects WHERE name = ?" ) .bind ( & a.project) .fetch_optional ( ledger.pool ( ) ) .await? ;
88
( p.agent_key ( & a.name) .is_some ( ) , known.is_some ( ) )
2fc88a7b Andy McCall 2 days ago ↶
89
}
67bfa06a Andy McCall 1 day ago ↶
90
} ;
91
if exists {
92
bail! ( " an agent named {} already exists (spectra agent list)" , a.name) ;
93
}
94
if ! known {
95
bail! ( " no project named {} (spectra project add PATH --name {})" , a.project, a.project) ;
09752034 Andy McCall 2 days ago ↶
96
}
97
98
let agent = if a.home.join ( " keys" ) .exists ( ) {
99
KeyPair:: load_home( & a.home) .with_context ( | | format! ( " reading the agent's key in {} " , a.home.display ( ) ) ) ?
100
} else {
101
let pair = KeyPair:: create_home( & a.home, & a.name, " " ) ? ;
5d695d86 Andy McCall 2 days ago ↶
102
println! ( " {} {} {} " , ui:: done( " Created" ) , ui:: subject( format! ( " {} 's key" , a.name) ) , ui:: path( a.home.display ( ) ) ) ;
09752034 Andy McCall 2 days ago ↶
103
pair
104
} ;
98a0fb9f Andy McCall 1 day ago ↶
105
let expires = a.expires.as_deref ( ) .map ( expiry) .transpose ( ) ? .flatten ( ) ;
106
let current = home_identity ( & home) ? ;
107
if current.delegate ( & agent.key) .map ( | d | d.expires ) ! = Some ( expires) {
108
let until = expires.map ( | e | format! ( " until {} " , super :: format:: when( e) ) ) .unwrap_or_else ( | | " until you disable it" .into ( ) ) ;
109
println! ( " {} " , ui:: kv( " agent" , format! ( " {} {} {} " , ui:: subject( & a.name) , ui:: sep( ) , ui:: hash( agent.key.short ( 12 ) ) ) ) ) ;
110
println! ( " {} " , ui:: kv( " acts for" , format! ( " {} {} {until} " , ui:: author( & current.name) , ui:: sep( ) ) ) ) ;
111
confirm ( & format! ( " Let {} act for you" , a.name) , a.yes) ? ;
112
spectra_core:: keys:: delegate( & home, agent.key, expires) ? ;
09752034 Andy McCall 2 days ago ↶
113
}
571572f5 Andy McCall 2 days ago ↶
114
let w = ctx.owner_writer ( & home) .await? ;
67bfa06a Andy McCall 1 day ago ↶
115
let mut events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
09752034 Andy McCall 2 days ago ↶
116
let owner = KeyPair:: load_home( & home) ? ;
117
let mut p = identity_payload ( & agent.identity) ;
118
p[ " name" ] = a.name.clone ( ) .into ( ) ;
119
p[ " kind" ] = a.kind.into ( ) ;
120
p[ " project" ] = a.project.into ( ) ;
121
p[ " home" ] = a.home.display ( ) .to_string ( ) .into ( ) ;
122
p[ " owner" ] = owner.key.to_hex ( ) .into ( ) ;
123
p[ " owner_name" ] = owner.identity.name.clone ( ) .into ( ) ;
124
p[ " scopes" ] = DEFAULT_SCOPES .into ( ) ;
125
if let Some ( u) = a.uid {
126
p[ " uid" ] = u.into ( ) ;
127
}
128
p[ " learn_uid" ] = a.learn_uid.into ( ) ;
129
let d = owner.identity.delegate ( & agent.key) .context ( " the delegation vanished" ) ? ;
98a0fb9f Andy McCall 1 day ago ↶
130
p[ " delegation" ] = serde_json:: json! ( { " expires" : d.expires } ) ;
571572f5 Andy McCall 2 days ago ↶
131
events.push ( signed_event ( & owner, " human" , " agent.added" , p) ) ;
132
w.append ( events) .await? ;
5d695d86 Andy McCall 2 days ago ↶
133
println! ( " {} {} {} {} " , ui:: done( " Added" ) , ui:: subject( & a.name) , ui:: sep( ) , ui:: author( format! ( " {} 's agent" , owner.identity.name) ) ) ;
134
println! ( " {} " , ui:: kv( " key" , ui:: hash( agent.key.to_hex ( ) ) ) ) ;
98a0fb9f Andy McCall 1 day ago ↶
135
let until = d.expires.map ( | e | format! ( " until {} " , super :: format:: when( e) ) ) .unwrap_or_else ( | | " until you disable it" .into ( ) ) ;
136
println! ( " {} " , ui:: kv( " delegated" , until) ) ;
813b6330 Andy McCall 1 day ago ↶
137
138
let shim = if ctx.remote ( ) ? .is_some ( ) {
139
format! ( " spectra mcp --agent {} --home {} " , a.name, a.home.display ( ) )
140
} else {
141
format! ( " spectra mcp --agent {} " , a.name)
142
} ;
143
println! ( " {} {} {} " , ui:: note( ) , ui:: dim( " connect its client with" ) , ui:: id( shim) ) ;
09752034 Andy McCall 2 days ago ↶
144
Ok ( 0 )
2fc88a7b Andy McCall 2 days ago ↶
145
}
146
147
148
# [ derive ( sqlx::FromRow ) ]
149
struct AgentRow {
150
name : String,
151
key : String,
152
kind : String,
153
project : String,
154
disabled_ms : Option< i64 > ,
155
presence : Option< String> ,
156
status : Option< String> ,
09752034 Andy McCall 2 days ago ↶
157
}
158
159
pub async fn list ( ctx : & Ctx, json : bool ) -> Result< i32 > {
67bfa06a Andy McCall 1 day ago ↶
160
let out = match ctx.owner_client ( & owner_home ( ctx, None ) ) .await? {
161
Some ( c) => call ( & c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .cloned ( ) .unwrap_or_default ( ) ,
162
None => list_local ( ctx) .await? ,
163
} ;
164
print_list ( out, json)
165
}
166
167
async fn list_local ( ctx : & Ctx) -> Result< Vec< serde_json:: Value> > {
09752034 Andy McCall 2 days ago ↶
168
let ledger = Ledger:: open_read_only( & ctx.db ( ) ? ) .await? ;
169
let p = Principals:: load( & ledger) .await? ;
2fc88a7b Andy McCall 2 days ago ↶
170
let rows: Vec< AgentRow> = sqlx:: query_as(
171
" SELECT a.name, a.key, a.kind, a.project, a.disabled_ms, p.state AS presence, p.status_text AS status FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name" ,
172
)
173
.fetch_all ( ledger.pool ( ) )
174
.await? ;
09752034 Andy McCall 2 days ago ↶
175
let owner = p.owner ( ) ;
176
let now = now_ms ( ) ;
177
let mut out = Vec :: new( ) ;
2fc88a7b Andy McCall 2 days ago ↶
178
for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows {
98a0fb9f Andy McCall 1 day ago ↶
179
let k = spectra_core:: identity:: KeyId:: from_hex( & key) .context ( " a stored key" ) ? ;
09752034 Andy McCall 2 days ago ↶
180
let state = match p.principal ( & k, now) {
181
Ok ( _ ) => " ok" .to_string ( ) ,
182
Err ( why) => why,
183
} ;
184
let expires = owner.and_then ( | o | o.delegate ( & k) ) .and_then ( | d | d.expires ) ;
185
let who = match p.principal ( & k, now) {
186
Ok ( pr @ Principal:: Agent { .. } ) => pr.display ( ) ,
187
_ => format! ( " {name} · {} 's agent" , owner.map ( | o | o.name.as_str ( ) ) .unwrap_or ( " ?" ) ) ,
188
} ;
2fc88a7b Andy McCall 2 days ago ↶
189
let presence = if disabled.is_some ( ) { " offline" .to_string ( ) } else { presence.unwrap_or_else ( | | " offline" .into ( ) ) } ;
190
out.push ( serde_json:: json! ( { " name" : name, " display" : who, " key" : key, " kind" : kind, " project" : project, " state" : state, " presence" : presence, " status" : status, " delegation_expires" : expires, " disabled" : disabled.is_some ( ) } ) ) ;
09752034 Andy McCall 2 days ago ↶
191
}
67bfa06a Andy McCall 1 day ago ↶
192
Ok ( out)
193
}
194
195
fn print_list ( out : Vec< serde_json:: Value> , json : bool ) -> Result< i32 > {
09752034 Andy McCall 2 days ago ↶
196
if json {
197
println! ( " {} " , serde_json:: Value:: Array( out) ) ;
198
return Ok ( 0 ) ;
199
}
200
if out.is_empty ( ) {
5d695d86 Andy McCall 2 days ago ↶
201
println! ( " {} {} " , ui:: note( ) , ui:: dim( " no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR" ) ) ;
09752034 Andy McCall 2 days ago ↶
202
}
203
for a in out {
204
let state = a[ " state" ] .as_str ( ) .unwrap_or ( " " ) ;
5d695d86 Andy McCall 2 days ago ↶
205
let until = a[ " delegation_expires" ] .as_u64 ( ) .map ( | e | format! ( " {} until {} " , ui:: sep( ) , super :: format:: when( e) ) ) .unwrap_or_default ( ) ;
206
let display = a[ " display" ] .as_str ( ) .unwrap_or ( " " ) ;
207
let ( name, owner) = display.split_once ( " · " ) .unwrap_or ( ( display, " " ) ) ;
2fc88a7b Andy McCall 2 days ago ↶
208
println! (
209
" {} {} {} {} {} " ,
210
marks:: health( state) ,
211
marks:: presence( a[ " presence" ] .as_str ( ) .unwrap_or ( " offline" ) ) ,
212
ui:: subject( name) ,
213
ui:: sep( ) ,
214
ui:: author( owner)
215
) ;
216
if let Some ( st) = a[ " status" ] .as_str ( ) {
217
println! ( " {} {} " , ui:: note( ) , st) ;
218
}
09752034 Andy McCall 2 days ago ↶
219
println! (
5d695d86 Andy McCall 2 days ago ↶
220
" {} " ,
221
ui:: dim( format! (
222
" {} {} {} {} {} {until} " ,
223
a[ " kind" ] .as_str ( ) .unwrap_or ( " " ) ,
224
ui:: sep( ) ,
225
a[ " project" ] .as_str ( ) .unwrap_or ( " " ) ,
226
ui:: sep( ) ,
227
& a[ " key" ] .as_str ( ) .unwrap_or ( " " ) [ .. 12 ]
228
) )
09752034 Andy McCall 2 days ago ↶
229
) ;
230
if state ! = " ok" {
5d695d86 Andy McCall 2 days ago ↶
231
println! ( " {} {} " , ui:: caution( ) , ui:: warn( state) ) ;
09752034 Andy McCall 2 days ago ↶
232
}
233
}
234
Ok ( 0 )
235
}
236
237
pub async fn disable ( ctx : & Ctx, name : & str , owner_home_flag : Option< PathBuf> ) -> Result< i32 > {
238
let home = owner_home ( ctx, owner_home_flag) ;
571572f5 Andy McCall 2 days ago ↶
239
let w = ctx.owner_writer ( & home) .await? ;
67bfa06a Andy McCall 1 day ago ↶
240
let known = match ( w.ledger ( ) , w.client ( ) ) {
241
( _ , Some ( c) ) => call ( c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .is_some_and ( | l | l.iter ( ) .any ( | x | x[ " name" ] = = name ) ) ,
242
( Some ( l) , None ) => Principals:: load( l) .await? .agent_key ( name) .is_some ( ) ,
243
( None , None ) => false ,
244
} ;
245
if ! known {
246
bail! ( " no agent named {name}" ) ;
98a0fb9f Andy McCall 1 day ago ↶
247
}
248
249
if let Some ( key) = agent_key ( & w, name) .await? {
250
if home_identity ( & home) ? .delegate ( & key) .is_some ( ) {
251
spectra_core:: keys:: undelegate( & home, key) ? ;
252
}
67bfa06a Andy McCall 1 day ago ↶
253
}
254
let mut events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
09752034 Andy McCall 2 days ago ↶
255
let owner = KeyPair:: load_home( & home) ? ;
571572f5 Andy McCall 2 days ago ↶
256
events.push ( signed_event ( & owner, " human" , " agent.disabled" , serde_json:: json! ( { " name" : name } ) ) ) ;
257
w.append ( events) .await? ;
5d695d86 Andy McCall 2 days ago ↶
258
println! ( " {} {} " , ui:: done( " Disabled" ) , ui:: subject( name) ) ;
259
println! ( " {} {} " , ui:: note( ) , ui:: dim( " its key is refused from now on; earlier events keep their signatures" ) ) ;
98a0fb9f Andy McCall 1 day ago ↶
260
Ok ( 0 )
261
}
262
263
264
async fn agent_key ( w : & super:: Writer, name : & str ) -> Result< Option< spectra_core:: identity:: KeyId> > {
265
Ok ( match ( w.client ( ) , w.ledger ( ) ) {
266
( Some ( c) , _ ) => call ( c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .and_then ( | l | {
267
l.iter ( ) .find ( | x | x[ " name" ] = = name ) .and_then ( | x | x[ " key" ] .as_str ( ) .and_then ( spectra_core:: identity:: KeyId:: from_hex) )
268
} ) ,
269
( None , Some ( l) ) => Principals:: load( l) .await? .agent_key ( name) ,
270
( None , None ) => None ,
271
} )
272
}
273
274
275
276
pub async fn renew ( ctx : & Ctx, name : & str , expires : & str , owner_home_flag : Option< PathBuf> ) -> Result< i32 > {
277
let home = owner_home ( ctx, owner_home_flag) ;
278
let w = ctx.owner_writer ( & home) .await? ;
279
let key = agent_key ( & w, name) .await? .with_context ( | | format! ( " no agent named {name} " ) ) ? ;
280
let expires = expiry ( expires) ? ;
281
spectra_core:: keys:: delegate( & home, key, expires) ? ;
282
let events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
283
w.append ( events) .await? ;
284
let until = expires.map ( | e | format! ( " until {} " , super :: format:: when( e) ) ) .unwrap_or_else ( | | " until you disable it" .into ( ) ) ;
285
println! ( " {} {} {} {} " , ui:: done( " Renewed" ) , ui:: subject( name) , ui:: sep( ) , until) ;
09752034 Andy McCall 2 days ago ↶
286
Ok ( 0 )
287
}
288
289
pub fn valid_name ( n : & str ) -> bool {
290
! n.is_empty ( )
291
& & n.len ( ) < = 64
292
& & n.bytes ( ) .next ( ) .is_some_and ( | b | b.is_ascii_lowercase ( ) | | b.is_ascii_digit ( ) )
293
& & n.bytes ( ) .all ( | b | b.is_ascii_lowercase ( ) | | b.is_ascii_digit ( ) | | b = = b ' -' )
294
}