Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/eppie-cli-agent-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ JSON responses use a normalized envelope:

In `--non-interactive=true --output=json` mode, structured responses are emitted on `stdout` without a preceding stack trace on `stderr` for handled command failures such as `unhandledException`.

In that mode the prompts are gone, so before each value it reads as a single line the run emits `{"type":"status","code":"inputRequired","data":{"input":"<name>"}}`, naming the value being read. A command therefore emits several envelopes even in the ordinary case: parse `stdout` line by line and act on the last one. Text output carries no announcements. The message body of `send` is the exception: it is read to end-of-stream rather than as a single line and is not announced, so write it straight after the vault password and close `stdin`.

`data.input` is one of nine names: `vaultPassword` for any run with `--unlock-password-stdin=true` and for `open`; `newVaultPassword` for `init` and `restore`; `seedPhrase` and `restorePath` for `restore`; `accountAddress` and `accountPassword` for `add-account -t proton` without `--input-json-stdin`, followed in that same run by `twoFactorCode`, `mailboxPassword` and `humanVerificationToken` whenever Proton asks for them. Nothing else is announced -- `add-account -t email` takes its values as one payload or refuses, and a vault password is never confirmed in this mode.

`--output=json` requires `--non-interactive=true`. Reading from a console writes the prompt and the typed characters to `stdout`, which cannot share the stream with the envelopes, so the run is refused with `interactiveInputNotSupported` and exit code `1` before any command starts, leaving nothing half-done.

In non-interactive mode, do not use `open` as part of the agent workflow. It does not establish reusable state for later process launches. For stateful non-interactive commands, use `--unlock-password-stdin=true` instead.

For all agent examples in this file, `<account>` means the account address returned by `list-accounts` in `data[].address`. Prefer that address string for `-a` instead of the numeric `id`, unless a command explicitly documents another identifier format.
Expand Down Expand Up @@ -543,6 +549,7 @@ Notes:
- `twoFactorCode` is required only if the Proton flow asks for it
- `humanVerificationToken` is required only if the Proton flow asks for it; see `Proton human verification (captcha)`
- if the mailbox password is the same as the account password, repeat the same value in `mailboxPassword`
- structured input is fixed for the whole run, so a value Proton rejects is never resent: the command stops with `authorizationCanceled` instead of replaying the same `twoFactorCode`, `mailboxPassword`, or `humanVerificationToken`
- invalid structured input returns one of these machine-readable errors:
- `structuredStandardInputInvalidJson`
- `structuredStandardInputMissingProperty`
Expand All @@ -553,10 +560,10 @@ Proton can require human verification during `add-account -t proton`. The CLI ca
JSON mode emits:

```json
{"type":"status","code":"humanVerificationRequired","data":{"verificationUri":"https://account.proton.me/api/core/v4/captcha?Token=<challenge-token>"}}
{"type":"status","code":"humanVerificationRequired","data":{"verificationUri":"https://mail-api.proton.me/core/v4/captcha?Token=<challenge-token>","helpUri":"<address of this section>"}}
```

Text mode prints the same address together with a link to this section.
Text mode prints the same two addresses. `data.helpUri` always points at this section, so a run can be diagnosed without the command line at hand.

How to obtain the token:
1. Open `verificationUri` in a web browser.
Expand All @@ -577,6 +584,8 @@ Notes:
- the token is single-use; a new challenge requires a new token
- the token survives a process restart until it is consumed, so it can be prepared in one run and used in the next
- the captcha page cannot be embedded in a local page; open the address directly
- an empty line is never sent to Proton, for `humanVerificationToken`, `twoFactorCode` and `mailboxPassword` alike, so watch for stray newlines in a `stdin` payload; the first two are trimmed before that check, while a `mailboxPassword` of only spaces counts as a real password and is sent
- a missing or blank token in `--input-json-stdin` gives `structuredStandardInputMissingProperty`; a rejected one gives `unsuccessfulAttempt`, a replacement `humanVerificationRequired`, then `authorizationCanceled`; a line-based `stdin` that ends first gives `standardInputEnded`. Each exits `1`, and a redirected `stdin` is never asked twice

## Reference: recommended agent workflow

Expand Down Expand Up @@ -626,6 +635,10 @@ When `--output=json` is enabled, handle responses by `type` first:
| --- | --- | --- |
| `invalidPassword` | vault password was rejected | stop; do not retry automatically with the same password |
| `humanVerificationRequired` | Proton asked for a captcha and `data.verificationUri` holds the challenge address | solve the captcha, then retry with `humanVerificationToken` in the structured input; see `Proton human verification (captcha)` |
| `inputRequired` | the run is waiting for `data.input` on standard input | supply that value; not an outcome -- another envelope follows |
| `interactiveInputNotSupported` | `--output=json` was used without `--non-interactive=true` on a command that reads a value | add `--non-interactive=true` and supply the values on `stdin` |
| `authorizationCanceled` | a value was rejected and could not be replaced, so the login was abandoned | supply a corrected value and run again; do not replay the rejected one |
| `standardInputEnded` | `stdin` ran out before a required value was read | extend the `stdin` payload with the missing value |
| `structuredStandardInputInvalidJson` | structured payload is not valid JSON | fix serialization and retry once with corrected JSON |
| `structuredStandardInputMissingProperty` | required JSON property is missing or empty | provide the missing property and retry once |
| `unhandledException` | command failed and returned exception details | inspect `data.exceptionType` and command context; do not retry blindly |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public async Task ProcessWhenNoStartupCommandIsProvidedInNonInteractiveModeOutpu

Assert.Multiple(() =>
{
AssertProcessSucceeded(result);
AssertProcessFailed(result);
Assert.That(result.StandardOutput, Does.Contain(TestConstants.InteractiveMenu));
Assert.That(result.StandardOutput, Does.Contain(TestConstants.NonInteractiveOption));
Assert.That(result.StandardOutput, Does.Not.Contain(TestConstants.InteractivePrompt));
Expand Down Expand Up @@ -205,7 +205,7 @@ public async Task ProcessWhenLockedStartupCommandRunsInNonInteractiveJsonModeWit

Assert.Multiple(() =>
{
AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode);
AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode, expectSuccess: false);
Assert.That(warningData.GetProperty(TestConstants.JsonCommandNameProperty).GetString(), Is.EqualTo(TestConstants.ListAccountsCommand));
});
}
Expand All @@ -224,7 +224,7 @@ public async Task ProcessWhenLockedStartupCommandRunsInNonInteractiveJsonModeWit

Assert.Multiple(() =>
{
AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode);
AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.StartupCommandRequiresUnlockPasswordFromStandardInputCode, expectSuccess: false);
Assert.That(warningData.GetProperty(TestConstants.JsonCommandNameProperty).GetString(), Is.EqualTo(TestConstants.ListAccountsCommand));
});
}
Expand All @@ -243,7 +243,7 @@ public async Task ProcessWhenLockedStartupCommandRunsInNonInteractiveJsonModeWit
unlockPasswordFromStandardInput: true,
nonInteractive: true).ConfigureAwait(false);

AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.InvalidPasswordCode);
AssertJsonTypeAndCode(listAccountsResult, TestConstants.JsonWarningType, TestConstants.InvalidPasswordCode, expectSuccess: false);
}

[Test]
Expand All @@ -255,7 +255,7 @@ public async Task ProcessWhenOpenRunsInNonInteractiveTextModeOnUninitializedAppl

Assert.Multiple(() =>
{
AssertProcessSucceeded(result);
AssertProcessFailed(result);
AssertContainsAllTerms(result.StandardOutput, TestConstants.InitializedCode);
Assert.That(result.StandardOutput, Does.Not.Contain(TestConstants.InteractivePrompt));
});
Expand All @@ -274,7 +274,7 @@ public async Task ProcessWhenOpenRunsInJsonModeAfterResetOutputsStructuredUninit

using JsonCommandResult openResult = await harness.RunJsonCommandAsync(TestConstants.OpenCommand, nonInteractive: true).ConfigureAwait(false);

AssertJsonTypeAndCode(openResult, TestConstants.JsonWarningType, TestConstants.UninitializedCode);
AssertJsonTypeAndCode(openResult, TestConstants.JsonWarningType, TestConstants.UninitializedCode, expectSuccess: false);
}

[Test]
Expand Down Expand Up @@ -320,7 +320,7 @@ public async Task ProcessWhenResetRunsInNonInteractiveModeWithoutAssumeYesShowsD

Assert.Multiple(() =>
{
AssertProcessSucceeded(resetResult);
AssertProcessFailed(resetResult);
Assert.That(resetResult.StandardOutput, Does.Contain(TestConstants.AssumeYesOption));
Assert.That(resetResult.StandardOutput, Does.Not.Contain("An error has occurred:"));
Assert.That(resetResult.StandardOutput, Does.Not.Contain("System.InvalidOperationException"));
Expand All @@ -339,7 +339,7 @@ public async Task ProcessWhenResetRunsInNonInteractiveJsonModeWithoutAssumeYesOu

Assert.Multiple(() =>
{
AssertJsonTypeAndCode(resetResult, TestConstants.JsonWarningType, TestConstants.CommandRequiresAssumeYesInNonInteractiveModeCode);
AssertJsonTypeAndCode(resetResult, TestConstants.JsonWarningType, TestConstants.CommandRequiresAssumeYesInNonInteractiveModeCode, expectSuccess: false);
Assert.That(resetResult.RootElement.GetProperty(TestConstants.JsonMessageProperty).GetString(), Does.Contain(TestConstants.AssumeYesOption));
});
}
Expand Down Expand Up @@ -390,7 +390,7 @@ public async Task ProcessWhenOpenRunsInJsonModeOnUninitializedApplicationOutputs

Assert.Multiple(() =>
{
AssertJsonTypeAndCode(result, TestConstants.JsonWarningType, TestConstants.UninitializedCode);
AssertJsonTypeAndCode(result, TestConstants.JsonWarningType, TestConstants.UninitializedCode, expectSuccess: false);
Assert.That(GetJsonMessage(result), Does.Contain("hasn't been initialized yet"));
});
}
Expand Down Expand Up @@ -425,7 +425,7 @@ public async Task ProcessWhenLockedStartupCommandIsLaunchedInNonInteractiveModeW

Assert.Multiple(() =>
{
AssertProcessSucceeded(listAccountsResult);
AssertProcessFailed(listAccountsResult);
Assert.That(listAccountsResult.StandardOutput, Does.Contain(TestConstants.UnlockPasswordStdinOption));
Assert.That(listAccountsResult.StandardOutput, Does.Not.Contain("There are no accounts yet."));
});
Expand Down Expand Up @@ -469,7 +469,7 @@ public async Task ProcessWhenUnlockPasswordFromStandardInputIsEnabledInNonIntera
}

[Test]
public async Task ProcessWhenAddProtonAccountRunsInNonInteractiveModeOmitsAccountAddressPrompt()
public async Task ProcessWhenAddProtonAccountRunsInNonInteractiveModeWithoutInputFailsWithoutPrompting()
{
using ApplicationCommandTestHarness harness = ApplicationCommandTestHarness.Create("eppie-cli-proton-non-interactive-");

Expand All @@ -485,7 +485,8 @@ public async Task ProcessWhenAddProtonAccountRunsInNonInteractiveModeOmitsAccoun

Assert.Multiple(() =>
{
AssertProcessSucceeded(addAccountResult);
AssertProcessFailed(addAccountResult);
AssertContainsAllTerms(addAccountResult.StandardOutput, TestConstants.StandardInputEndedCode);
AssertOutputOmitsSetupPrompts(addAccountResult.StandardOutput);
});
}
Expand Down Expand Up @@ -598,7 +599,7 @@ public async Task ProcessWhenUnhandledExceptionRunsInNonInteractiveJsonModeKeeps

Assert.Multiple(() =>
{
AssertProcessSucceeded(sendResult);
AssertProcessFailed(sendResult.ProcessResult);
Assert.That(sendResult.ProcessResult.StandardError, Is.Empty);
Assert.That(sendResult.RootElement.GetProperty(TestConstants.JsonTypeProperty).GetString(), Is.EqualTo(TestConstants.JsonErrorType));
Assert.That(sendResult.RootElement.GetProperty(TestConstants.JsonCodeProperty).GetString(), Is.EqualTo(TestConstants.UnhandledExceptionCode));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,16 @@ internal static JsonCommandResult Create(ProcessResult processResult)
{
ArgumentNullException.ThrowIfNull(processResult);

string[] envelopes = processResult.StandardOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

if (envelopes.Length == 0)
{
Assert.Fail($"The command produced no output.{Environment.NewLine}Exit code: {processResult.ExitCode}");
}

try
{
return new JsonCommandResult(processResult, JsonDocument.Parse(processResult.StandardOutput));
return new JsonCommandResult(processResult, JsonDocument.Parse(envelopes[^1]));
}
catch (JsonException ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// //
// ---------------------------------------------------------------------------- //

using Eppie.CLI.Exceptions;
using Eppie.CLI.Menu;
using Eppie.CLI.Services;
using Eppie.CLI.Tests.TestDoubles;
Expand Down Expand Up @@ -147,13 +148,83 @@ public async Task ExecuteAsyncWhenStartupCommandIsNotRunPassesCancelableNonCance
});
}

[Test]
public async Task ExecuteAsyncWhenStartupCommandThrowsUnexpectedExceptionWritesUnhandledExceptionAndStopsApplication()
{
FakeApplicationMenu applicationMenu = new();
using FakeHostApplicationLifetime lifetime = new();
InvalidOperationException failure = new("the core failed outside a controlled outcome");
FakeStartupCommandRunner startupCommandRunner = new() { TryRunException = failure };
FakeApplicationOutputWriter outputWriter = new();
using TestApplicationMenuLoop loop = new(lifetime, TestApplicationFactory.CreateLaunchOptionsOptions(), startupCommandRunner, outputWriter, applicationMenu);

await loop.RunAsync(CancellationToken.None).ConfigureAwait(false);

Assert.Multiple(() =>
{
Assert.That(startupCommandRunner.TryRunCallCount, Is.EqualTo(1));
Assert.That(applicationMenu.LoopCallCount, Is.Zero);
Assert.That(lifetime.StopApplicationCallCount, Is.EqualTo(1));
Assert.That(outputWriter.LastOutput, Is.TypeOf<UnhandledExceptionOutput>());
Assert.That(((UnhandledExceptionOutput)outputWriter.LastOutput!).Exception, Is.SameAs(failure));
});
}

[Test]
public async Task ExecuteAsyncWhenTheRunIsCanceledStopsWithoutReportingAnError()
{
FakeApplicationMenu applicationMenu = new();
using FakeHostApplicationLifetime lifetime = new();
using CancellationTokenSource cancellationTokenSource = new();
FakeStartupCommandRunner startupCommandRunner = new()
{
OnTryRun = cancellationTokenSource.Cancel,
TryRunException = new OperationCanceledException()
};
FakeApplicationOutputWriter outputWriter = new();
using TestApplicationMenuLoop loop = new(lifetime, TestApplicationFactory.CreateLaunchOptionsOptions(), startupCommandRunner, outputWriter, applicationMenu);

await loop.RunAsync(cancellationTokenSource.Token).ConfigureAwait(false);

Assert.Multiple(() =>
{
Assert.That(outputWriter.Outputs, Is.Empty);
Assert.That(applicationMenu.LoopCallCount, Is.Zero);
Assert.That(lifetime.StopApplicationCallCount, Is.EqualTo(1));
});
}

[Test]
public async Task ExecuteAsyncWhenAPromptIsCanceledByTheUserStopsWithoutReportingAnError()
{
FakeApplicationMenu applicationMenu = new();
using FakeHostApplicationLifetime lifetime = new();
FakeStartupCommandRunner startupCommandRunner = new() { TryRunException = new InputCanceledByUserException() };
FakeApplicationOutputWriter outputWriter = new();
using TestApplicationMenuLoop loop = new(lifetime, TestApplicationFactory.CreateLaunchOptionsOptions(), startupCommandRunner, outputWriter, applicationMenu);

await loop.RunAsync(CancellationToken.None).ConfigureAwait(false);

Assert.Multiple(() =>
{
Assert.That(outputWriter.Outputs, Is.Empty);
Assert.That(applicationMenu.LoopCallCount, Is.Zero);
Assert.That(lifetime.StopApplicationCallCount, Is.EqualTo(1));
});
}

private sealed class TestApplicationMenuLoop(
IHostApplicationLifetime lifetime,
IOptions<ApplicationLaunchOptions> launchOptions,
IStartupCommandRunner startupCommandRunner,
IApplicationOutputWriter outputWriter,
IApplicationMenu applicationMenu)
: ApplicationMenuLoop(NullLogger<ApplicationMenuLoop>.Instance, lifetime, launchOptions, startupCommandRunner, outputWriter, applicationMenu)
: ApplicationMenuLoop(NullLogger<ApplicationMenuLoop>.Instance,
lifetime,
launchOptions,
startupCommandRunner,
new ApplicationFailureHandler(NullLogger<ApplicationFailureHandler>.Instance, launchOptions, outputWriter),
applicationMenu)
{
internal Task RunAsync(CancellationToken cancellationToken)
{
Expand All @@ -165,11 +236,18 @@ private sealed class FakeStartupCommandRunner : IStartupCommandRunner
{
internal int TryRunCallCount { get; private set; }
internal bool TryRunResult { get; init; }
internal Exception? TryRunException { get; init; }
internal Action? OnTryRun { get; init; }

public Task<bool> TryRunAsync(CancellationToken cancellationToken)
{
TryRunCallCount++;
return Task.FromResult(TryRunResult);

OnTryRun?.Invoke();

return TryRunException is null
? Task.FromResult(TryRunResult)
: Task.FromException<bool>(TryRunException);
}
}

Expand Down
Loading
Loading