Skip to content
Merged
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
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ here rather than left to be discovered.

### Notes

Nothing in this release has been verified against a live Databricks workspace. See
[docs/compatibility.md](docs/compatibility.md), which records what was actually tested rather than
what is expected to work.
Verified against a live Azure Databricks workspace on 2026-08-01: `agents list`, `ask`, every
output format, `pack run`, `export last` and `feedback last`. `chat`, chunked and external-link
results, visualizations and `QUERY_RESULT_EXPIRED` recovery were **not** exercised live and remain
covered by contract tests only. [docs/compatibility.md](docs/compatibility.md) records exactly what
was run against what.
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ Status: in development.
- [x] Question Packs: schema, validation, runner, Markdown reports
- [x] Output formats and stable exit codes
- [x] CI, release pipeline, SBOM, provenance
- [ ] Verification against a live Azure Databricks workspace
- [x] Verification against a live Azure Databricks workspace
- [ ] CLI snapshot tests across terminal widths, unicode and `NO_COLOR`
- [ ] Command reference and authentication guide
- [ ] Live coverage for `chat`, chunked results and `QUERY_RESULT_EXPIRED` recovery
- [x] Command reference and authentication guide

## v0.2 — automation

Expand Down
42 changes: 42 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,48 @@ one pass instead of repeated guessing.

See the [Question Pack guide](question-packs.md).

## `lakespeak export [last]`

Exports the last answer's query result without opening a chat session — the scriptable
counterpart to chat's `/export`.

```bash
lakespeak export last --output revenue.csv
lakespeak export last # to stdout
```

| Option | Meaning |
|---|---|
| `--output`, `-o` | File to write. Defaults to stdout |
| `--force` | Overwrite the output file if it exists |

The result is **re-fetched from Databricks** rather than cached locally: a local copy of governed
data would have none of the governance, and Databricks is already the system of record. That means
an expired cached result fails here rather than silently returning stale rows.

If the result is incomplete — truncated by Databricks, or continuing beyond the rows this version
reads — the command says so on stderr. It never writes a partial export silently.

## `lakespeak feedback [last] --rating <r>`

Rates the last answer without opening a chat session.

```bash
lakespeak feedback last --rating negative --comment "Included cancelled orders."
lakespeak feedback last --rating positive
```

| Option | Meaning |
|---|---|
| `--rating`, `-r` | `positive`, `negative`, or `none`. Required |
| `--comment`, `-c` | Free-text comment sent to Databricks |

Databricks rejects a comment alongside a `none` rating. LakeSpeak refuses that combination before
sending, exiting `2`, rather than surfacing an HTTP 400 that reads like a transport fault.

Both commands read a pointer file written by the last `ask`. It records identifiers only — Agent,
conversation, message and attachment ids — and never a question, an answer, or a row.

## `lakespeak auth check`

Verifies that a profile resolves, that a token can be obtained, and that the workspace answers.
Expand Down
3 changes: 1 addition & 2 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,10 @@ Entra ID access token for resource `2ff814a6-3304-4ab8-85cb-cd0e6f879c1d`, suppl
| `ask --format json` | Valid UTF-8, versioned schema, on stdout only |
| `ask --format csv` | Clean CSV on stdout with diagnostics on stderr, verified via `2>/dev/null` |
| `ask` against an unknown Agent | Real listing lookup, exit 2 |
| `pack run` | Two questions, Markdown report written, exit 0 |
| `pack run` | Two questions, Markdown report written, exit 0. One of the two came back as a Genie clarifying question rather than an answer — see [limitations](limitations.md) |
| Decimal fidelity | `4500000.00`, `3350000.50`, `1780000.25` reached CSV, JSON and Markdown byte-identical to what Databricks returned |
| Column types | `DECIMAL(22,2)` — precision and scale preserved, which `type_name` alone would have lost |
| Non-ASCII | `€` intact in a file-written report |

| `export last` | Re-fetched the result from Databricks and wrote correct CSV |
| `feedback last` | Positive rating with a comment accepted |
| Opt-in live suite | 8 tests green, including follow-up conversations, feedback and cancellation |
Expand Down
7 changes: 1 addition & 6 deletions src/LakeSpeak.Cli/Commands/AgentsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ private static async Task<int> ListAsync(CliHost host, CancellationToken cancell
host.Output.WriteResultLine("id,title");
foreach (var agent in agents)
{
host.Output.WriteResultLine($"{agent.AgentId},{Escape(agent.Title)}");
host.Output.WriteResultLine($"{CsvWriter.EscapeField(agent.AgentId)},{CsvWriter.EscapeField(agent.Title)}");
}

break;
Expand All @@ -72,9 +72,4 @@ private static async Task<int> ListAsync(CliHost host, CancellationToken cancell

return ExitCode.Success;
}

private static string Escape(string value) =>
value.AsSpan().ContainsAny(",\"\r\n")
? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
: value;
}
6 changes: 2 additions & 4 deletions src/LakeSpeak.Cli/Commands/CliHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,8 @@ internal static CliHost Create(ParseResult parseResult)

var services = new ServiceCollection();

// --verbose was declared and registered but never read, so it did nothing while the help
// text promised diagnostics "with credentials always redacted" — a guarantee about
// behaviour that did not exist. The client already logs through ILogger; it simply had no
// sink. Every record is scrubbed on its way out, which is what makes the promise true.
// Every record is scrubbed on its way out by RedactingStderrLoggerProvider, which is what
// makes the --verbose help text's redaction promise true rather than aspirational.
if (parseResult.GetValue(GlobalOptions.Verbose))
{
services.AddLogging(builder => builder
Expand Down
6 changes: 4 additions & 2 deletions src/LakeSpeak.Cli/ExitCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ public static class ExitCode
GenieFailureKind.UnsupportedResult => MalformedResponse,
GenieFailureKind.Network => Unexpected,
GenieFailureKind.Unexpected => Unexpected,
// GenieFailureKind is this project's own closed set, so an unhandled arm is a bug to
// catch at compile time rather than a platform value to tolerate at runtime.
// GenieFailureKind is this project's own closed set, so an unmapped value is a bug rather
// than a platform value to tolerate. This arm makes the switch exhaustive to the
// compiler, which means a NEW member will not raise CS8509 — the gap is caught by
// ExitCodeTests.Every_failure_kind_is_mapped in CI, not at build time.
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unmapped failure kind."),
};
}
3 changes: 0 additions & 3 deletions src/LakeSpeak.Genie/Authentication/DatabricksProfiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,6 @@ public static IReadOnlyList<DatabricksProfile> Load(string? path = null)
return profiles;
}

public static DatabricksProfile? Find(string profileName, string? path = null) =>
Load(path).FirstOrDefault(p => p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));

/// <summary>
/// Resolves the workspace host, in the documented precedence order: explicit value, then
/// <c>DATABRICKS_HOST</c>, then the named profile, then the DEFAULT profile.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ namespace LakeSpeak.Genie.Authentication;
/// </summary>
/// <remarks>
/// The token is fetched per request rather than captured once, so a refresh by the provider takes
/// effect without rebuilding the client. It is attached here and nowhere else: no call site
/// handles a raw token, which is what keeps the credential out of URLs, logs and argument vectors
/// by construction rather than by review.
/// effect without rebuilding the client. This is the only place in the HTTP pipeline that touches
/// a raw token, so no request-building code can put one in a URL or a log by accident.
/// <c>auth check</c> also calls the provider directly, but only to report the token's length.
/// </remarks>
public sealed class GenieAuthenticationHandler(IGenieTokenProvider tokenProvider) : DelegatingHandler
{
Expand Down
10 changes: 0 additions & 10 deletions src/LakeSpeak.Genie/Wire/GenieWireModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,16 +294,6 @@ internal sealed record ExternalLinkWire
public long? RowCount { get; init; }
}

internal sealed record DownloadHandleWire
{
[JsonPropertyName("download_id")]
public string? DownloadId { get; init; }

// Bearer-equivalent. Never logged; see DiagnosticRedaction.
[JsonPropertyName("download_id_signature")]
public string? DownloadIdSignature { get; init; }
}

internal sealed record FeedbackRequestWire
{
[JsonPropertyName("rating")]
Expand Down
9 changes: 9 additions & 0 deletions src/LakeSpeak.Rendering/ResultWriters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ public static string Write(GenieQueryResult result)
return builder.ToString();
}

/// <summary>
/// Escapes a single CSV field, including the spreadsheet-formula guard.
/// </summary>
/// <remarks>
/// Public so no other writer reimplements it. A second copy of this rule is how one CSV
/// path ends up defused and another does not.
/// </remarks>
public static string EscapeField(string? value) => Quote(value);

private static string Quote(string? value)
{
// A SQL NULL becomes an empty unquoted field, which is how every CSV reader
Expand Down
19 changes: 19 additions & 0 deletions tests/LakeSpeak.Genie.Tests/OutputFidelityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ public void Csv_defuses_a_formula_hidden_behind_leading_whitespace(string value)
csv.ShouldContain("'");
}

[Theory]
[InlineData("=1+1")]
[InlineData("\t=cmd|'/c calc'!A1")]
[InlineData("has,comma")]
[InlineData(null)]
public void The_shared_escaper_carries_the_same_rules_as_the_row_writer(string? value)
{
// Arrange — every CSV path must go through one escaper. A second copy is how one path
// ends up defused against formula injection and another does not, which is exactly what
// happened to `agents list --format csv`.
var viaRowWriter = CsvWriter.Write(Result(("payload", value))).Split('\n')[1].TrimEnd('\r');

// Act
var viaSharedHelper = CsvWriter.EscapeField(value);

// Assert
viaSharedHelper.ShouldBe(viaRowWriter);
}

[Fact]
public void Csv_quotes_values_containing_delimiters_and_quotes()
{
Expand Down
Loading