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 ( ) ;
8d803d85 Andy McCall 1 day ago ↶
119
p[ " kind" ] = a.kind.clone ( ) .into ( ) ;
120
p[ " project" ] = a.project.clone ( ) .into ( ) ;
09752034 Andy McCall 2 days ago ↶
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? ;
8d803d85 Andy McCall 1 day ago ↶
133
134
let folder = match ( w.client ( ) , w.ledger ( ) ) {
135
( Some ( c) , _ ) => call ( c, " GET" , " /api/v1/projects" , None ) .await.ok ( ) .and_then ( | v | {
136
v.as_array ( ) .and_then ( | l | l.iter ( ) .find ( | p | p[ " name" ] = = a.project.as_str ( ) ) .and_then ( | p | p[ " path" ] .as_str ( ) .map ( String :: from) ) )
137
} ) ,
138
( None , Some ( l) ) => sqlx:: query_scalar:: < _ , Option< String> > ( " SELECT path FROM projects WHERE name = ?" )
139
.bind ( & a.project)
140
.fetch_optional ( l.pool ( ) )
141
.await
142
.ok ( )
143
.flatten ( )
144
.flatten ( ) ,
145
_ => None ,
146
} ;
147
super :: auto:: write_label( & a.home, & a.name, & a.kind, & a.project, folder.as_deref ( ) ) ? ;
5d695d86 Andy McCall 2 days ago ↶
148
println! ( " {} {} {} {} " , ui:: done( " Added" ) , ui:: subject( & a.name) , ui:: sep( ) , ui:: author( format! ( " {} 's agent" , owner.identity.name) ) ) ;
149
println! ( " {} " , ui:: kv( " key" , ui:: hash( agent.key.to_hex ( ) ) ) ) ;
98a0fb9f Andy McCall 1 day ago ↶
150
let until = d.expires.map ( | e | format! ( " until {} " , super :: format:: when( e) ) ) .unwrap_or_else ( | | " until you disable it" .into ( ) ) ;
151
println! ( " {} " , ui:: kv( " delegated" , until) ) ;
813b6330 Andy McCall 1 day ago ↶
152
153
let shim = if ctx.remote ( ) ? .is_some ( ) {
154
format! ( " spectra mcp --agent {} --home {} " , a.name, a.home.display ( ) )
155
} else {
156
format! ( " spectra mcp --agent {} " , a.name)
157
} ;
158
println! ( " {} {} {} " , ui:: note( ) , ui:: dim( " connect its client with" ) , ui:: id( shim) ) ;
09752034 Andy McCall 2 days ago ↶
159
Ok ( 0 )
2fc88a7b Andy McCall 2 days ago ↶
160
}
161
162
163
# [ derive ( sqlx::FromRow ) ]
164
struct AgentRow {
165
name : String,
166
key : String,
167
kind : String,
168
project : String,
169
disabled_ms : Option< i64 > ,
170
presence : Option< String> ,
171
status : Option< String> ,
09752034 Andy McCall 2 days ago ↶
172
}
173
174
pub async fn list ( ctx : & Ctx, json : bool ) -> Result< i32 > {
67bfa06a Andy McCall 1 day ago ↶
175
let out = match ctx.owner_client ( & owner_home ( ctx, None ) ) .await? {
176
Some ( c) => call ( & c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .cloned ( ) .unwrap_or_default ( ) ,
177
None => list_local ( ctx) .await? ,
178
} ;
179
print_list ( out, json)
180
}
181
182
async fn list_local ( ctx : & Ctx) -> Result< Vec< serde_json:: Value> > {
09752034 Andy McCall 2 days ago ↶
183
let ledger = Ledger:: open_read_only( & ctx.db ( ) ? ) .await? ;
184
let p = Principals:: load( & ledger) .await? ;
2fc88a7b Andy McCall 2 days ago ↶
185
let rows: Vec< AgentRow> = sqlx:: query_as(
186
" 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" ,
187
)
188
.fetch_all ( ledger.pool ( ) )
189
.await? ;
09752034 Andy McCall 2 days ago ↶
190
let owner = p.owner ( ) ;
191
let now = now_ms ( ) ;
192
let mut out = Vec :: new( ) ;
2fc88a7b Andy McCall 2 days ago ↶
193
for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows {
98a0fb9f Andy McCall 1 day ago ↶
194
let k = spectra_core:: identity:: KeyId:: from_hex( & key) .context ( " a stored key" ) ? ;
09752034 Andy McCall 2 days ago ↶
195
let state = match p.principal ( & k, now) {
196
Ok ( _ ) => " ok" .to_string ( ) ,
197
Err ( why) => why,
198
} ;
199
let expires = owner.and_then ( | o | o.delegate ( & k) ) .and_then ( | d | d.expires ) ;
200
let who = match p.principal ( & k, now) {
201
Ok ( pr @ Principal:: Agent { .. } ) => pr.display ( ) ,
202
_ => format! ( " {name} · {} 's agent" , owner.map ( | o | o.name.as_str ( ) ) .unwrap_or ( " ?" ) ) ,
203
} ;
2fc88a7b Andy McCall 2 days ago ↶
204
let presence = if disabled.is_some ( ) { " offline" .to_string ( ) } else { presence.unwrap_or_else ( | | " offline" .into ( ) ) } ;
205
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 ↶
206
}
67bfa06a Andy McCall 1 day ago ↶
207
Ok ( out)
208
}
209
210
fn print_list ( out : Vec< serde_json:: Value> , json : bool ) -> Result< i32 > {
09752034 Andy McCall 2 days ago ↶
211
if json {
212
println! ( " {} " , serde_json:: Value:: Array( out) ) ;
213
return Ok ( 0 ) ;
214
}
215
if out.is_empty ( ) {
5d695d86 Andy McCall 2 days ago ↶
216
println! ( " {} {} " , ui:: note( ) , ui:: dim( " no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR" ) ) ;
09752034 Andy McCall 2 days ago ↶
217
}
218
for a in out {
219
let state = a[ " state" ] .as_str ( ) .unwrap_or ( " " ) ;
5d695d86 Andy McCall 2 days ago ↶
220
let until = a[ " delegation_expires" ] .as_u64 ( ) .map ( | e | format! ( " {} until {} " , ui:: sep( ) , super :: format:: when( e) ) ) .unwrap_or_default ( ) ;
221
let display = a[ " display" ] .as_str ( ) .unwrap_or ( " " ) ;
222
let ( name, owner) = display.split_once ( " · " ) .unwrap_or ( ( display, " " ) ) ;
2fc88a7b Andy McCall 2 days ago ↶
223
println! (
224
" {} {} {} {} {} " ,
225
marks:: health( state) ,
226
marks:: presence( a[ " presence" ] .as_str ( ) .unwrap_or ( " offline" ) ) ,
227
ui:: subject( name) ,
228
ui:: sep( ) ,
229
ui:: author( owner)
230
) ;
231
if let Some ( st) = a[ " status" ] .as_str ( ) {
232
println! ( " {} {} " , ui:: note( ) , st) ;
233
}
09752034 Andy McCall 2 days ago ↶
234
println! (
5d695d86 Andy McCall 2 days ago ↶
235
" {} " ,
236
ui:: dim( format! (
237
" {} {} {} {} {} {until} " ,
238
a[ " kind" ] .as_str ( ) .unwrap_or ( " " ) ,
239
ui:: sep( ) ,
240
a[ " project" ] .as_str ( ) .unwrap_or ( " " ) ,
241
ui:: sep( ) ,
242
& a[ " key" ] .as_str ( ) .unwrap_or ( " " ) [ .. 12 ]
243
) )
09752034 Andy McCall 2 days ago ↶
244
) ;
245
if state ! = " ok" {
5d695d86 Andy McCall 2 days ago ↶
246
println! ( " {} {} " , ui:: caution( ) , ui:: warn( state) ) ;
09752034 Andy McCall 2 days ago ↶
247
}
248
}
249
Ok ( 0 )
250
}
251
252
pub async fn disable ( ctx : & Ctx, name : & str , owner_home_flag : Option< PathBuf> ) -> Result< i32 > {
253
let home = owner_home ( ctx, owner_home_flag) ;
571572f5 Andy McCall 2 days ago ↶
254
let w = ctx.owner_writer ( & home) .await? ;
67bfa06a Andy McCall 1 day ago ↶
255
let known = match ( w.ledger ( ) , w.client ( ) ) {
256
( _ , Some ( c) ) => call ( c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .is_some_and ( | l | l.iter ( ) .any ( | x | x[ " name" ] = = name ) ) ,
257
( Some ( l) , None ) => Principals:: load( l) .await? .agent_key ( name) .is_some ( ) ,
258
( None , None ) => false ,
259
} ;
260
if ! known {
261
bail! ( " no agent named {name}" ) ;
98a0fb9f Andy McCall 1 day ago ↶
262
}
263
264
if let Some ( key) = agent_key ( & w, name) .await? {
265
if home_identity ( & home) ? .delegate ( & key) .is_some ( ) {
266
spectra_core:: keys:: undelegate( & home, key) ? ;
267
}
67bfa06a Andy McCall 1 day ago ↶
268
}
269
let mut events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
09752034 Andy McCall 2 days ago ↶
270
let owner = KeyPair:: load_home( & home) ? ;
571572f5 Andy McCall 2 days ago ↶
271
events.push ( signed_event ( & owner, " human" , " agent.disabled" , serde_json:: json! ( { " name" : name } ) ) ) ;
272
w.append ( events) .await? ;
5d695d86 Andy McCall 2 days ago ↶
273
println! ( " {} {} " , ui:: done( " Disabled" ) , ui:: subject( name) ) ;
274
println! ( " {} {} " , ui:: note( ) , ui:: dim( " its key is refused from now on; earlier events keep their signatures" ) ) ;
98a0fb9f Andy McCall 1 day ago ↶
275
Ok ( 0 )
276
}
277
278
279
async fn agent_key ( w : & super:: Writer, name : & str ) -> Result< Option< spectra_core:: identity:: KeyId> > {
280
Ok ( match ( w.client ( ) , w.ledger ( ) ) {
281
( Some ( c) , _ ) => call ( c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .and_then ( | l | {
282
l.iter ( ) .find ( | x | x[ " name" ] = = name ) .and_then ( | x | x[ " key" ] .as_str ( ) .and_then ( spectra_core:: identity:: KeyId:: from_hex) )
283
} ) ,
284
( None , Some ( l) ) => Principals:: load( l) .await? .agent_key ( name) ,
285
( None , None ) => None ,
286
} )
287
}
288
87f601cc Andy McCall 1 day ago ↶
289
290
291
pub async fn allow ( ctx : & Ctx, name : & str , channel : & str , remove : bool , owner_home_flag : Option< PathBuf> ) -> Result< i32 > {
292
let channel = channel.trim_start_matches ( ' #' ) ;
293
let scope = format! ( " channel:{channel} " ) ;
294
if ! spectra_core:: principals:: grantable( & scope) {
295
bail! ( " {channel} is not a channel name" ) ;
296
}
297
let home = owner_home ( ctx, owner_home_flag) ;
298
let w = ctx.owner_writer ( & home) .await? ;
299
agent_key ( & w, name) .await? .with_context ( | | format! ( " no agent named {name} " ) ) ? ;
300
let mut events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
301
let owner = KeyPair:: load_home( & home) ? ;
302
events.push ( signed_event ( & owner, " human" , " agent.scoped" , serde_json:: json! ( { " name" : name, " scope" : scope, " grant" : ! remove } ) ) ) ;
303
w.append ( events) .await? ;
304
if remove {
305
println! ( " {} {} {} " , ui:: done( " Removed" ) , ui:: subject( name) , ui:: dim( format! ( " may no longer post in #{channel} " ) ) ) ;
306
} else {
307
println! ( " {} {} {} " , ui:: done( " Allowed" ) , ui:: subject( name) , ui:: dim( format! ( " may post in #{channel} " ) ) ) ;
308
}
309
Ok ( 0 )
310
}
311
98a0fb9f Andy McCall 1 day ago ↶
312
313
314
pub async fn renew ( ctx : & Ctx, name : & str , expires : & str , owner_home_flag : Option< PathBuf> ) -> Result< i32 > {
315
let home = owner_home ( ctx, owner_home_flag) ;
316
let w = ctx.owner_writer ( & home) .await? ;
317
let key = agent_key ( & w, name) .await? .with_context ( | | format! ( " no agent named {name} " ) ) ? ;
318
let expires = expiry ( expires) ? ;
319
spectra_core:: keys:: delegate( & home, key, expires) ? ;
320
let events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
321
w.append ( events) .await? ;
322
let until = expires.map ( | e | format! ( " until {} " , super :: format:: when( e) ) ) .unwrap_or_else ( | | " until you disable it" .into ( ) ) ;
323
println! ( " {} {} {} {} " , ui:: done( " Renewed" ) , ui:: subject( name) , ui:: sep( ) , until) ;
09752034 Andy McCall 2 days ago ↶
324
Ok ( 0 )
325
}
326
327
pub fn valid_name ( n : & str ) -> bool {
328
! n.is_empty ( )
329
& & n.len ( ) < = 64
330
& & n.bytes ( ) .next ( ) .is_some_and ( | b | b.is_ascii_lowercase ( ) | | b.is_ascii_digit ( ) )
331
& & n.bytes ( ) .all ( | b | b.is_ascii_lowercase ( ) | | b.is_ascii_digit ( ) | | b = = b ' -' )
332
}