@join-pantheon/cli 0.33.0-rc.1 → 0.33.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-peer.js +136 -0
- package/dist/board.js +30 -46
- package/dist/cli.js +89 -50
- package/dist/client.js +16 -0
- package/dist/config.js +7 -1
- package/dist/home-safety.js +195 -0
- package/dist/machine.js +111 -6
- package/dist/service.js +5 -1
- package/dist/uninstall.js +1 -1
- package/dist/win-host.js +59 -7
- package/helper/macos/prebuilt/manifest.json +1 -1
- package/helper/windows/pantheon-notice.ps1 +44 -16
- package/installer/install.ps1 +130 -56
- package/installer/install.sh +15 -1
- package/package.json +1 -1
package/installer/install.ps1
CHANGED
|
@@ -186,8 +186,11 @@ function Install-Pantheon {
|
|
|
186
186
|
# Is a task this user's? Its XML (schtasks /Query /TN <name> /XML) names its
|
|
187
187
|
# principal as DOMAIN\user, a bare user name, or a SID; every one of them must
|
|
188
188
|
# be this user (src/win-host.ts taskIsUsers). A task of the same name that
|
|
189
|
-
# another person on this PC registered is never ended.
|
|
190
|
-
|
|
189
|
+
# another person on this PC registered is never ended. A SID is compared as a
|
|
190
|
+
# SID; a name is first turned into its account's SID ($ToSid) and compared
|
|
191
|
+
# by that, and only by name when Windows cannot say which account it is.
|
|
192
|
+
function Test-PantheonTaskIsMine([string]$Xml, [string]$UserId, [string]$Sid,
|
|
193
|
+
[scriptblock]$ToSid = { param($n) if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return '' }; try { ([Security.Principal.NTAccount]$n).Translate([Security.Principal.SecurityIdentifier]).Value } catch { '' } }) {
|
|
191
194
|
if (-not $Xml) { return $false }
|
|
192
195
|
$ids = @([regex]::Matches(($Xml -replace "`0", ''), '<UserId>([^<]*)</UserId>') | ForEach-Object { $_.Groups[1].Value.Trim().ToLowerInvariant() })
|
|
193
196
|
if ($ids.Count -eq 0) { return $false }
|
|
@@ -195,14 +198,57 @@ function Install-Pantheon {
|
|
|
195
198
|
$s = ([string]$Sid).ToLowerInvariant()
|
|
196
199
|
$bare = if ($u.Contains('\')) { $u.Substring($u.LastIndexOf('\') + 1) } else { $u }
|
|
197
200
|
foreach ($id in $ids) {
|
|
201
|
+
if ($id.StartsWith('s-1-')) { if ($s -and $id -eq $s) { continue }; return $false }
|
|
202
|
+
$idSid = ''
|
|
203
|
+
if ($s) { try { $idSid = ([string](& $ToSid $id)).ToLowerInvariant() } catch { $idSid = '' } }
|
|
204
|
+
if ($idSid) { if ($idSid -eq $s) { continue }; return $false }
|
|
198
205
|
if ($u -and $id -eq $u) { continue }
|
|
199
|
-
if ($
|
|
200
|
-
if ($bare -and -not $id.Contains('\') -and -not $id.StartsWith('s-1-') -and $id -eq $bare) { continue }
|
|
206
|
+
if ($bare -and -not $id.Contains('\') -and $id -eq $bare) { continue }
|
|
201
207
|
return $false
|
|
202
208
|
}
|
|
203
209
|
return $true
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
# What schtasks printed, as text. It writes a task's XML as UTF-16 (with or
|
|
213
|
+
# without a byte order mark); read in the console's code page, a user name
|
|
214
|
+
# with a letter outside ASCII came out as other letters and never matched.
|
|
215
|
+
# So the bytes are decoded by what they hold.
|
|
216
|
+
function ConvertFrom-TaskXmlBytes([byte[]]$Bytes) {
|
|
217
|
+
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
|
218
|
+
if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE) { return [Text.Encoding]::Unicode.GetString($Bytes, 2, $Bytes.Length - 2) }
|
|
219
|
+
if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFE -and $Bytes[1] -eq 0xFF) { return [Text.Encoding]::BigEndianUnicode.GetString($Bytes, 2, $Bytes.Length - 2) }
|
|
220
|
+
if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { return [Text.Encoding]::UTF8.GetString($Bytes, 3, $Bytes.Length - 3) }
|
|
221
|
+
# No mark: UTF-16LE when most odd bytes of the start are zero ('<', 0, '?', 0, ...).
|
|
222
|
+
$n = [Math]::Min($Bytes.Length, 64); $odd = 0; $zeros = 0
|
|
223
|
+
for ($i = 1; $i -lt $n; $i += 2) { $odd++; if ($Bytes[$i] -eq 0) { $zeros++ } }
|
|
224
|
+
if ($odd -gt 0 -and $zeros * 4 -ge $odd * 3) { return [Text.Encoding]::Unicode.GetString($Bytes) }
|
|
225
|
+
try { return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($Bytes) } catch { }
|
|
226
|
+
return [Console]::OutputEncoding.GetString($Bytes)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# A task's XML (schtasks /Query /TN <name> /XML), its bytes read as they are
|
|
230
|
+
# (ConvertFrom-TaskXmlBytes); '' when there is no such task.
|
|
231
|
+
function Get-PantheonTaskXml([string]$Schtasks, [string]$Name) {
|
|
232
|
+
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
233
|
+
$psi.FileName = $Schtasks
|
|
234
|
+
$psi.Arguments = ConvertTo-ArgLine @('/Query', '/TN', $Name, '/XML')
|
|
235
|
+
$psi.UseShellExecute = $false
|
|
236
|
+
$psi.CreateNoWindow = $true
|
|
237
|
+
$psi.RedirectStandardInput = $true
|
|
238
|
+
$psi.RedirectStandardOutput = $true
|
|
239
|
+
$psi.RedirectStandardError = $true
|
|
240
|
+
$p = [System.Diagnostics.Process]::Start($psi)
|
|
241
|
+
$p.StandardInput.Close()
|
|
242
|
+
$buf = New-Object System.IO.MemoryStream
|
|
243
|
+
$copy = $p.StandardOutput.BaseStream.CopyToAsync($buf)
|
|
244
|
+
$err = $p.StandardError.ReadToEndAsync()
|
|
245
|
+
$p.WaitForExit()
|
|
246
|
+
$copy.Wait()
|
|
247
|
+
$null = $err.Result
|
|
248
|
+
if ($p.ExitCode -ne 0) { return '' }
|
|
249
|
+
return ConvertFrom-TaskXmlBytes $buf.ToArray()
|
|
250
|
+
}
|
|
251
|
+
|
|
206
252
|
# Before the runtime folder is swapped: Pantheon's background host (the
|
|
207
253
|
# logon task) runs node.exe from it, and Windows will not rename a folder a
|
|
208
254
|
# running program came from. A teammate's request the worker is running
|
|
@@ -268,6 +314,68 @@ function Install-Pantheon {
|
|
|
268
314
|
return @{ code = $p.ExitCode; out = $out.Result; err = $err.Result }
|
|
269
315
|
}
|
|
270
316
|
|
|
317
|
+
# Hand over to pantheon with the person's own arguments (join <invite>,
|
|
318
|
+
# start): after an install, and also when the update was put off because a
|
|
319
|
+
# teammate's run was in progress -- a join or a start needs a working
|
|
320
|
+
# pantheon, not the update. Sets $State.code to pantheon's exit code.
|
|
321
|
+
function Invoke-PantheonHandoff([string]$nodeExe, [string]$entry, [string[]]$ca, [string[]]$Arguments) {
|
|
322
|
+
if ($null -eq $ca) { $ca = @() }
|
|
323
|
+
# node.exe directly, not the .cmd: PowerShell hands arguments to a .cmd
|
|
324
|
+
# through cmd.exe, which would cut an invite at a & or expand a %. And
|
|
325
|
+
# Start-Process -NoNewWindow, so pantheon gets this console itself: a real
|
|
326
|
+
# terminal for the sign-in questions, not a pipe PowerShell reads from.
|
|
327
|
+
$State.step = 'starting pantheon'
|
|
328
|
+
# With the same --use-system-ca as npm and pantheon.cmd: this first run is the
|
|
329
|
+
# one that signs in, and behind a TLS-inspecting proxy the relay's
|
|
330
|
+
# certificate chains only to the company root in the Windows store.
|
|
331
|
+
$line = ConvertTo-ArgLine (@($ca) + @($entry) + $Arguments)
|
|
332
|
+
$noConsole = $false
|
|
333
|
+
try { $noConsole = [Console]::IsInputRedirected } catch { $noConsole = $false }
|
|
334
|
+
if (-not $noConsole) {
|
|
335
|
+
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru
|
|
336
|
+
$null = $p.Handle # without this, ExitCode is empty once the process is gone
|
|
337
|
+
$p.WaitForExit()
|
|
338
|
+
$State.code = $p.ExitCode
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# No console to type into (CI, input piped in). pantheon still runs: it
|
|
343
|
+
# never asks a question without a terminal (every prompt checks
|
|
344
|
+
# process.stdin.isTTY) and works on flags alone, so a join to a team's own
|
|
345
|
+
# relay, or from a machine already signed in, needs no answers. Its input
|
|
346
|
+
# is an empty file, so nothing can wait on it. What it cannot do here is
|
|
347
|
+
# sign in: on the hosted relay join and start stop at once, before anything
|
|
348
|
+
# is changed, with "sign in first" (or the relay's NOT_SIGNED_IN). When that
|
|
349
|
+
# is how it ended and config.json still has no account, the last line says
|
|
350
|
+
# the one thing to do; any other failure keeps pantheon's own words last.
|
|
351
|
+
$quiet = Join-Path ([IO.Path]::GetTempPath()) ('pantheon-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
352
|
+
New-Item -ItemType Directory -Force -Path $quiet | Out-Null
|
|
353
|
+
try {
|
|
354
|
+
$in = Join-Path $quiet 'in'; $errFile = Join-Path $quiet 'err'
|
|
355
|
+
[IO.File]::WriteAllText($in, '')
|
|
356
|
+
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru -RedirectStandardInput $in -RedirectStandardError $errFile
|
|
357
|
+
$null = $p.Handle
|
|
358
|
+
$p.WaitForExit()
|
|
359
|
+
$State.code = $p.ExitCode
|
|
360
|
+
$said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
|
|
361
|
+
if ($said) { [Console]::Error.Write($said) }
|
|
362
|
+
if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
|
|
363
|
+
$pantheonHome = if ($env:PANTHEON_HOME) { $env:PANTHEON_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
|
|
364
|
+
$signedIn = $false
|
|
365
|
+
try { $signedIn = [bool]((Get-Content -LiteralPath (Join-Path $pantheonHome 'config.json') -Raw | ConvertFrom-Json).account) } catch { $signedIn = $false }
|
|
366
|
+
if (-not $signedIn) {
|
|
367
|
+
# Quoted only where PowerShell needs it, so it reads as typed.
|
|
368
|
+
$show = ($Arguments | ForEach-Object { if ($_ -match '^[A-Za-z0-9_./:=@+,-]+$') { $_ } else { "'" + ($_ -replace "'", "''") + "'" } }) -join ' '
|
|
369
|
+
$what = if ($Arguments[0] -eq 'join') { 'joining' } else { 'creating your team' }
|
|
370
|
+
Say ''
|
|
371
|
+
Say "Pantheon is installed. To finish $what, open PowerShell and run: pantheon $show"
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
Remove-Item -LiteralPath $quiet -Recurse -Force -ErrorAction SilentlyContinue
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
271
379
|
try {
|
|
272
380
|
if ($PantheonVersion -like '*__*') {
|
|
273
381
|
Fail "this copy of install.ps1 has no version filled in. Use the one the relay serves: irm $Site/install.ps1 | iex"
|
|
@@ -457,7 +565,7 @@ function Install-Pantheon {
|
|
|
457
565
|
$stopped = Stop-PantheonHost -NodeDir $nodeDir -HostDir $hostDir -Tasks $tasks `
|
|
458
566
|
-Holders { param($d) Get-FolderHolders $d } -EndTask { param($t) $null = Invoke-Quiet $schtasks @('/End', '/TN', $t) } `
|
|
459
567
|
-RunRecord (Join-Path $pantheonHomeDir 'worker-run.json') `
|
|
460
|
-
-TaskIsMine { param($t) Test-PantheonTaskIsMine (
|
|
568
|
+
-TaskIsMine { param($t) Test-PantheonTaskIsMine (Get-PantheonTaskXml $schtasks $t) $meUser $meSid }
|
|
461
569
|
# A teammate's run still going after the wait: nothing was stopped,
|
|
462
570
|
# and the runtime is left exactly as it is (see the catch below).
|
|
463
571
|
if ($stopped.busy) { throw 'PANTHEON_BUSY:' }
|
|
@@ -617,57 +725,7 @@ function Install-Pantheon {
|
|
|
617
725
|
return
|
|
618
726
|
}
|
|
619
727
|
Say ''
|
|
620
|
-
|
|
621
|
-
# through cmd.exe, which would cut an invite at a & or expand a %. And
|
|
622
|
-
# Start-Process -NoNewWindow, so pantheon gets this console itself: a real
|
|
623
|
-
# terminal for the sign-in questions, not a pipe PowerShell reads from.
|
|
624
|
-
$State.step = 'starting pantheon'
|
|
625
|
-
$line = ConvertTo-ArgLine (@($entry) + $Arguments)
|
|
626
|
-
$noConsole = $false
|
|
627
|
-
try { $noConsole = [Console]::IsInputRedirected } catch { $noConsole = $false }
|
|
628
|
-
if (-not $noConsole) {
|
|
629
|
-
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru
|
|
630
|
-
$null = $p.Handle # without this, ExitCode is empty once the process is gone
|
|
631
|
-
$p.WaitForExit()
|
|
632
|
-
$State.code = $p.ExitCode
|
|
633
|
-
return
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
# No console to type into (CI, input piped in). pantheon still runs: it
|
|
637
|
-
# never asks a question without a terminal (every prompt checks
|
|
638
|
-
# process.stdin.isTTY) and works on flags alone, so a join to a team's own
|
|
639
|
-
# relay, or from a machine already signed in, needs no answers. Its input
|
|
640
|
-
# is an empty file, so nothing can wait on it. What it cannot do here is
|
|
641
|
-
# sign in: on the hosted relay join and start stop at once, before anything
|
|
642
|
-
# is changed, with "sign in first" (or the relay's NOT_SIGNED_IN). When that
|
|
643
|
-
# is how it ended and config.json still has no account, the last line says
|
|
644
|
-
# the one thing to do; any other failure keeps pantheon's own words last.
|
|
645
|
-
$quiet = Join-Path ([IO.Path]::GetTempPath()) ('pantheon-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
646
|
-
New-Item -ItemType Directory -Force -Path $quiet | Out-Null
|
|
647
|
-
try {
|
|
648
|
-
$in = Join-Path $quiet 'in'; $errFile = Join-Path $quiet 'err'
|
|
649
|
-
[IO.File]::WriteAllText($in, '')
|
|
650
|
-
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru -RedirectStandardInput $in -RedirectStandardError $errFile
|
|
651
|
-
$null = $p.Handle
|
|
652
|
-
$p.WaitForExit()
|
|
653
|
-
$State.code = $p.ExitCode
|
|
654
|
-
$said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
|
|
655
|
-
if ($said) { [Console]::Error.Write($said) }
|
|
656
|
-
if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
|
|
657
|
-
$pantheonHome = if ($env:PANTHEON_HOME) { $env:PANTHEON_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
|
|
658
|
-
$signedIn = $false
|
|
659
|
-
try { $signedIn = [bool]((Get-Content -LiteralPath (Join-Path $pantheonHome 'config.json') -Raw | ConvertFrom-Json).account) } catch { $signedIn = $false }
|
|
660
|
-
if (-not $signedIn) {
|
|
661
|
-
# Quoted only where PowerShell needs it, so it reads as typed.
|
|
662
|
-
$show = ($Arguments | ForEach-Object { if ($_ -match '^[A-Za-z0-9_./:=@+,-]+$') { $_ } else { "'" + ($_ -replace "'", "''") + "'" } }) -join ' '
|
|
663
|
-
$what = if ($Arguments[0] -eq 'join') { 'joining' } else { 'creating your team' }
|
|
664
|
-
Say ''
|
|
665
|
-
Say "Pantheon is installed. To finish $what, open PowerShell and run: pantheon $show"
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
} finally {
|
|
669
|
-
Remove-Item -LiteralPath $quiet -Recurse -Force -ErrorAction SilentlyContinue
|
|
670
|
-
}
|
|
728
|
+
Invoke-PantheonHandoff $nodeExe $entry $ca $Arguments
|
|
671
729
|
} catch {
|
|
672
730
|
$m = $_.Exception.Message
|
|
673
731
|
if ($m -like 'PANTHEON_BUSY:*') {
|
|
@@ -675,6 +733,22 @@ function Install-Pantheon {
|
|
|
675
733
|
# is none that runs is the exit code non-zero.
|
|
676
734
|
$have = ''
|
|
677
735
|
if ($installedCli) { try { $have = & $installedCli } catch { $have = '' } }
|
|
736
|
+
if ($have -and $Arguments.Count -gt 0) {
|
|
737
|
+
# The person's join or start still happens, on the Pantheon that is
|
|
738
|
+
# here: it does not touch the runtime folder the run is using.
|
|
739
|
+
Write-Host "Pantheon was not updated to $PantheonVersion because a teammate's run was in progress; Pantheon $have is still installed and working, so your command runs on it now. Run the same line again in a few minutes to update." -ForegroundColor Yellow
|
|
740
|
+
if ($State.lock) { $State.lock.Dispose(); $State.lock = $null }
|
|
741
|
+
$caNow = @()
|
|
742
|
+
try { if ((Invoke-Quiet $nodeExe @('--use-system-ca', '-e', '0')).code -eq 0) { $caNow = @('--use-system-ca') } } catch { $caNow = @() }
|
|
743
|
+
try {
|
|
744
|
+
Say ''
|
|
745
|
+
Invoke-PantheonHandoff $nodeExe $entry $caNow $Arguments
|
|
746
|
+
} catch {
|
|
747
|
+
Write-Host "pantheon install failed: it stopped while starting pantheon ($($_.Exception.Message)). Open a new terminal and run your pantheon command again." -ForegroundColor Red
|
|
748
|
+
$State.code = 1
|
|
749
|
+
}
|
|
750
|
+
return
|
|
751
|
+
}
|
|
678
752
|
$kept = if ($have) { " Pantheon $have is still installed and working." } else { '' }
|
|
679
753
|
Write-Host "Pantheon was not updated because a teammate's run was in progress; run the same line again in a few minutes.$kept" -ForegroundColor Yellow
|
|
680
754
|
$State.code = if ($have) { 0 } else { 1 }
|
package/installer/install.sh
CHANGED
|
@@ -508,6 +508,17 @@ add_block() {
|
|
|
508
508
|
note "the pantheon block in $f has a marker line without its pair, so $f was left as it is; put $BIN_DIR at the front of PATH there yourself"
|
|
509
509
|
return 0
|
|
510
510
|
fi
|
|
511
|
+
# One of each, but the end before the begin (edited by hand): the rewrite
|
|
512
|
+
# below would skip from the begin marker to the end of the file and drop
|
|
513
|
+
# everything after it, so the file is not touched either.
|
|
514
|
+
if [ "${begins:-0}" -eq 1 ]; then
|
|
515
|
+
bline=$(grep -nxF "$BEGIN_MARK" "$f" | head -n 1 | cut -d: -f1)
|
|
516
|
+
eline=$(grep -nxF "$END_MARK" "$f" | head -n 1 | cut -d: -f1)
|
|
517
|
+
if [ "${bline:-0}" -gt "${eline:-0}" ]; then
|
|
518
|
+
note "the pantheon block in $f has its end marker before its begin marker, so $f was left as it is; put $BIN_DIR at the front of PATH there yourself"
|
|
519
|
+
return 0
|
|
520
|
+
fi
|
|
521
|
+
fi
|
|
511
522
|
if [ "${begins:-0}" -eq 1 ]; then
|
|
512
523
|
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '$0 == b { keep = 1 } keep { print } $0 == e { keep = 0 }' "$f" >"$TMP_DIR/old"
|
|
513
524
|
same "$TMP_DIR/old" "$TMP_DIR/block" && return 0
|
|
@@ -531,10 +542,13 @@ add_block() {
|
|
|
531
542
|
}
|
|
532
543
|
|
|
533
544
|
login_shell=$(basename "${SHELL:-sh}")
|
|
534
|
-
# ~/.profile: read by sh and dash logins, by bash when it has no .bash_profile,
|
|
545
|
+
# ~/.profile: read by sh and dash logins, by bash when it has no .bash_profile or .bash_login,
|
|
535
546
|
# and by the `sh -lc` Pantheon itself uses to find programs.
|
|
536
547
|
add_block "$HOME/.profile"
|
|
537
548
|
[ -f "$HOME/.bash_profile" ] && add_block "$HOME/.bash_profile"
|
|
549
|
+
# bash logins read the first of .bash_profile, .bash_login and .profile: with
|
|
550
|
+
# no .bash_profile, an existing .bash_login is the one read, not .profile.
|
|
551
|
+
[ -f "$HOME/.bash_login" ] && add_block "$HOME/.bash_login"
|
|
538
552
|
{ [ -f "$HOME/.bashrc" ] || [ "$login_shell" = bash ]; } && add_block "$HOME/.bashrc"
|
|
539
553
|
{ [ -f "$HOME/.zshrc" ] || [ "$login_shell" = zsh ]; } && add_block "$HOME/.zshrc"
|
|
540
554
|
[ -f "$HOME/.zprofile" ] && add_block "$HOME/.zprofile"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.33.0-rc.
|
|
3
|
+
"version": "0.33.0-rc.3",
|
|
4
4
|
"description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|