09752034 Andy McCall 2 days ago ↶
1
2
3
4
5
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;
13
use spectra_core:: principals:: { identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES , DELEGATION_SCOPE } ;
14
use std:: path:: PathBuf;
15
use std:: time:: Duration;
16
17
pub const KINDS : & [ & str ] = & [ " claude-code" , " antigravity" , " kimi-code" , " codex" , " other" ] ;
18
19
pub struct Add {
20
pub name : String,
21
pub kind : String,
22
pub project : String,
23
pub home : PathBuf,
24
pub uid : Option< u32 > ,
25
pub learn_uid : bool ,
26
pub wait_secs : u64 ,
27
pub owner_home : Option< PathBuf> ,
28
}
29
30
pub async fn add ( ctx : & Ctx, a : Add) -> Result< i32 > {
31
if ! valid_name ( & a.name) {
32
bail! ( " an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64" ) ;
33
}
34
if ! KINDS .contains ( & a.kind.as_str ( ) ) {
35
bail! ( " --kind is one of {}" , KINDS .join ( " , " ) ) ;
36
}
67bfa06a Andy McCall 1 day ago ↶
37
let home = owner_home ( ctx, a.owner_home.clone ( ) ) ;
38
let ( exists, known) = match ctx.owner_client ( & home) .await? {
39
Some ( c) => {
40
let agents = call ( & c, " GET" , " /api/v1/agents" , None ) .await? ;
41
let projects = call ( & c, " GET" , " /api/v1/projects" , None ) .await? ;
42
let has = | v : & serde_json:: Value, n : & str | v.as_array ( ) .is_some_and ( | l | l.iter ( ) .any ( | x | x[ " name" ] = = n ) ) ;
43
( has ( & agents, & a.name) , has ( & projects, & a.project) )
09752034 Andy McCall 2 days ago ↶
44
}
67bfa06a Andy McCall 1 day ago ↶
45
None => {
46
let ledger = Ledger:: open_read_only( & ctx.db ( ) ? ) .await? ;
47
let p = Principals:: load( & ledger) .await? ;
48
let known: Option< String> =
49
sqlx:: query_scalar( " SELECT name FROM projects WHERE name = ?" ) .bind ( & a.project) .fetch_optional ( ledger.pool ( ) ) .await? ;
50
( p.agent_key ( & a.name) .is_some ( ) , known.is_some ( ) )
2fc88a7b Andy McCall 2 days ago ↶
51
}
67bfa06a Andy McCall 1 day ago ↶
52
} ;
53
if exists {
54
bail! ( " an agent named {} already exists (spectra agent list)" , a.name) ;
55
}
56
if ! known {
57
bail! ( " no project named {} (spectra project add PATH --name {})" , a.project, a.project) ;
09752034 Andy McCall 2 days ago ↶
58
}
59
60
let agent = if a.home.join ( " keys" ) .exists ( ) {
61
KeyPair:: load_home( & a.home) .with_context ( | | format! ( " reading the agent's key in {} " , a.home.display ( ) ) ) ?
62
} else {
63
let pair = KeyPair:: create_home( & a.home, & a.name, " " ) ? ;
5d695d86 Andy McCall 2 days ago ↶
64
println! ( " {} {} {} " , ui:: done( " Created" ) , ui:: subject( format! ( " {} 's key" , a.name) ) , ui:: path( a.home.display ( ) ) ) ;
09752034 Andy McCall 2 days ago ↶
65
pair
66
} ;
67
let delegated = | id : & oxy_core:: identity:: Identity| id.delegates_to ( & agent.key, DELEGATION_SCOPE , now_ms ( ) ) ;
68
if ! delegated ( & home_identity ( & home) ? ) {
5d695d86 Andy McCall 2 days ago ↶
69
println! ( " {} {} " , ui:: caution( ) , ui:: warn( format! ( " {} is not yours yet: delegate to its key, as yourself:" , a.name) ) ) ;
09752034 Andy McCall 2 days ago ↶
70
println! (
5d695d86 Andy McCall 2 days ago ↶
71
" {} " ,
09752034 Andy McCall 2 days ago ↶
72
ui:: id( format! (
73
" oxy identity delegate add {} --scope changes --expires 8h" ,
74
a.home.join ( " keys" ) .join ( format! ( " {} .identity" , agent.key.to_hex ( ) ) ) .display ( )
75
) )
76
) ;
5d695d86 Andy McCall 2 days ago ↶
77
let spin = ui:: spinner( & format! (
78
" waiting for your delegation to {} (up to {} s; Ctrl-C stops, and running this again resumes)" ,
79
a.name, a.wait_secs
80
) ) ;
09752034 Andy McCall 2 days ago ↶
81
let deadline = std:: time:: Instant:: now( ) + Duration:: from_secs( a.wait_secs) ;
82
loop {
83
if home_identity ( & home) .is_ok_and ( | id | delegated ( & id) ) {
5d695d86 Andy McCall 2 days ago ↶
84
spin.clear ( ) ;
09752034 Andy McCall 2 days ago ↶
85
break ;
86
}
87
if std:: time:: Instant:: now( ) > = deadline {
5d695d86 Andy McCall 2 days ago ↶
88
spin.clear ( ) ;
09752034 Andy McCall 2 days ago ↶
89
bail! ( " {}'s key is not delegated yet: run the command above, then this again" , a.name) ;
90
}
91
tokio:: time:: sleep( Duration:: from_secs( 2 ) ) .await;
92
}
93
}
571572f5 Andy McCall 2 days ago ↶
94
let w = ctx.owner_writer ( & home) .await? ;
67bfa06a Andy McCall 1 day ago ↶
95
let mut events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
09752034 Andy McCall 2 days ago ↶
96
let owner = KeyPair:: load_home( & home) ? ;
97
let mut p = identity_payload ( & agent.identity) ;
98
p[ " name" ] = a.name.clone ( ) .into ( ) ;
99
p[ " kind" ] = a.kind.into ( ) ;
100
p[ " project" ] = a.project.into ( ) ;
101
p[ " home" ] = a.home.display ( ) .to_string ( ) .into ( ) ;
102
p[ " owner" ] = owner.key.to_hex ( ) .into ( ) ;
103
p[ " owner_name" ] = owner.identity.name.clone ( ) .into ( ) ;
104
p[ " scopes" ] = DEFAULT_SCOPES .into ( ) ;
105
if let Some ( u) = a.uid {
106
p[ " uid" ] = u.into ( ) ;
107
}
108
p[ " learn_uid" ] = a.learn_uid.into ( ) ;
109
let d = owner.identity.delegate ( & agent.key) .context ( " the delegation vanished" ) ? ;
110
p[ " delegation" ] = serde_json:: json! ( { " scope" : d.scope.names ( ) , " expires" : d.expires } ) ;
571572f5 Andy McCall 2 days ago ↶
111
events.push ( signed_event ( & owner, " human" , " agent.added" , p) ) ;
112
w.append ( events) .await? ;
5d695d86 Andy McCall 2 days ago ↶
113
println! ( " {} {} {} {} " , ui:: done( " Added" ) , ui:: subject( & a.name) , ui:: sep( ) , ui:: author( format! ( " {} 's agent" , owner.identity.name) ) ) ;
114
println! ( " {} " , ui:: kv( " key" , ui:: hash( agent.key.to_hex ( ) ) ) ) ;
115
let until = d.expires.map ( | e | format! ( " until {} " , super :: format:: when( e) ) ) .unwrap_or_default ( ) ;
116
println! ( " {} " , ui:: kv( " delegated" , format! ( " {} {until} " , d.scope.names ( ) .join ( " ," ) ) ) ) ;
117
println! ( " {} {} {} " , ui:: note( ) , ui:: dim( " connect its client with" ) , ui:: id( format! ( " spectra mcp --agent {} " , a.name) ) ) ;
09752034 Andy McCall 2 days ago ↶
118
Ok ( 0 )
2fc88a7b Andy McCall 2 days ago ↶
119
}
120
121
122
# [ derive ( sqlx::FromRow ) ]
123
struct AgentRow {
124
name : String,
125
key : String,
126
kind : String,
127
project : String,
128
disabled_ms : Option< i64 > ,
129
presence : Option< String> ,
130
status : Option< String> ,
09752034 Andy McCall 2 days ago ↶
131
}
132
133
pub async fn list ( ctx : & Ctx, json : bool ) -> Result< i32 > {
67bfa06a Andy McCall 1 day ago ↶
134
let out = match ctx.owner_client ( & owner_home ( ctx, None ) ) .await? {
135
Some ( c) => call ( & c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .cloned ( ) .unwrap_or_default ( ) ,
136
None => list_local ( ctx) .await? ,
137
} ;
138
print_list ( out, json)
139
}
140
141
async fn list_local ( ctx : & Ctx) -> Result< Vec< serde_json:: Value> > {
09752034 Andy McCall 2 days ago ↶
142
let ledger = Ledger:: open_read_only( & ctx.db ( ) ? ) .await? ;
143
let p = Principals:: load( & ledger) .await? ;
2fc88a7b Andy McCall 2 days ago ↶
144
let rows: Vec< AgentRow> = sqlx:: query_as(
145
" 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" ,
146
)
147
.fetch_all ( ledger.pool ( ) )
148
.await? ;
09752034 Andy McCall 2 days ago ↶
149
let owner = p.owner ( ) ;
150
let now = now_ms ( ) ;
151
let mut out = Vec :: new( ) ;
2fc88a7b Andy McCall 2 days ago ↶
152
for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows {
09752034 Andy McCall 2 days ago ↶
153
let k = oxy_core:: hash:: ObjectRef:: from_hex( & key) .context ( " a stored key" ) ? ;
154
let state = match p.principal ( & k, now) {
155
Ok ( _ ) => " ok" .to_string ( ) ,
156
Err ( why) => why,
157
} ;
158
let expires = owner.and_then ( | o | o.delegate ( & k) ) .and_then ( | d | d.expires ) ;
159
let who = match p.principal ( & k, now) {
160
Ok ( pr @ Principal:: Agent { .. } ) => pr.display ( ) ,
161
_ => format! ( " {name} · {} 's agent" , owner.map ( | o | o.name.as_str ( ) ) .unwrap_or ( " ?" ) ) ,
162
} ;
2fc88a7b Andy McCall 2 days ago ↶
163
let presence = if disabled.is_some ( ) { " offline" .to_string ( ) } else { presence.unwrap_or_else ( | | " offline" .into ( ) ) } ;
164
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 ↶
165
}
67bfa06a Andy McCall 1 day ago ↶
166
Ok ( out)
167
}
168
169
fn print_list ( out : Vec< serde_json:: Value> , json : bool ) -> Result< i32 > {
09752034 Andy McCall 2 days ago ↶
170
if json {
171
println! ( " {} " , serde_json:: Value:: Array( out) ) ;
172
return Ok ( 0 ) ;
173
}
174
if out.is_empty ( ) {
5d695d86 Andy McCall 2 days ago ↶
175
println! ( " {} {} " , ui:: note( ) , ui:: dim( " no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR" ) ) ;
09752034 Andy McCall 2 days ago ↶
176
}
177
for a in out {
178
let state = a[ " state" ] .as_str ( ) .unwrap_or ( " " ) ;
5d695d86 Andy McCall 2 days ago ↶
179
let until = a[ " delegation_expires" ] .as_u64 ( ) .map ( | e | format! ( " {} until {} " , ui:: sep( ) , super :: format:: when( e) ) ) .unwrap_or_default ( ) ;
180
let display = a[ " display" ] .as_str ( ) .unwrap_or ( " " ) ;
181
let ( name, owner) = display.split_once ( " · " ) .unwrap_or ( ( display, " " ) ) ;
2fc88a7b Andy McCall 2 days ago ↶
182
println! (
183
" {} {} {} {} {} " ,
184
marks:: health( state) ,
185
marks:: presence( a[ " presence" ] .as_str ( ) .unwrap_or ( " offline" ) ) ,
186
ui:: subject( name) ,
187
ui:: sep( ) ,
188
ui:: author( owner)
189
) ;
190
if let Some ( st) = a[ " status" ] .as_str ( ) {
191
println! ( " {} {} " , ui:: note( ) , st) ;
192
}
09752034 Andy McCall 2 days ago ↶
193
println! (
5d695d86 Andy McCall 2 days ago ↶
194
" {} " ,
195
ui:: dim( format! (
196
" {} {} {} {} {} {until} " ,
197
a[ " kind" ] .as_str ( ) .unwrap_or ( " " ) ,
198
ui:: sep( ) ,
199
a[ " project" ] .as_str ( ) .unwrap_or ( " " ) ,
200
ui:: sep( ) ,
201
& a[ " key" ] .as_str ( ) .unwrap_or ( " " ) [ .. 12 ]
202
) )
09752034 Andy McCall 2 days ago ↶
203
) ;
204
if state ! = " ok" {
5d695d86 Andy McCall 2 days ago ↶
205
println! ( " {} {} " , ui:: caution( ) , ui:: warn( state) ) ;
09752034 Andy McCall 2 days ago ↶
206
}
207
}
208
Ok ( 0 )
209
}
210
211
pub async fn disable ( ctx : & Ctx, name : & str , owner_home_flag : Option< PathBuf> ) -> Result< i32 > {
212
let home = owner_home ( ctx, owner_home_flag) ;
571572f5 Andy McCall 2 days ago ↶
213
let w = ctx.owner_writer ( & home) .await? ;
67bfa06a Andy McCall 1 day ago ↶
214
let known = match ( w.ledger ( ) , w.client ( ) ) {
215
( _ , Some ( c) ) => call ( c, " GET" , " /api/v1/agents" , None ) .await? .as_array ( ) .is_some_and ( | l | l.iter ( ) .any ( | x | x[ " name" ] = = name ) ) ,
216
( Some ( l) , None ) => Principals:: load( l) .await? .agent_key ( name) .is_some ( ) ,
217
( None , None ) => false ,
218
} ;
219
if ! known {
220
bail! ( " no agent named {name}" ) ;
221
}
222
let mut events: Vec< _ > = w.refresh_event ( & home) .await? .into_iter ( ) .collect ( ) ;
09752034 Andy McCall 2 days ago ↶
223
let owner = KeyPair:: load_home( & home) ? ;
571572f5 Andy McCall 2 days ago ↶
224
events.push ( signed_event ( & owner, " human" , " agent.disabled" , serde_json:: json! ( { " name" : name } ) ) ) ;
225
w.append ( events) .await? ;
5d695d86 Andy McCall 2 days ago ↶
226
println! ( " {} {} " , ui:: done( " Disabled" ) , ui:: subject( name) ) ;
227
println! ( " {} {} " , ui:: note( ) , ui:: dim( " its key is refused from now on; earlier events keep their signatures" ) ) ;
09752034 Andy McCall 2 days ago ↶
228
Ok ( 0 )
229
}
230
231
pub fn valid_name ( n : & str ) -> bool {
232
! n.is_empty ( )
233
& & n.len ( ) < = 64
234
& & n.bytes ( ) .next ( ) .is_some_and ( | b | b.is_ascii_lowercase ( ) | | b.is_ascii_digit ( ) )
235
& & n.bytes ( ) .all ( | b | b.is_ascii_lowercase ( ) | | b.is_ascii_digit ( ) | | b = = b ' -' )
236
}