@kaelio/ktx 0.10.0 → 0.11.0

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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
4
+ import { SLACK_HELP_FOOTER, writeErrorCommunityHint } from './community-cta.js';
4
5
  import { registerCompletionCommands } from './commands/completion-commands.js';
5
6
  import { registerConnectionCommands } from './commands/connection-commands.js';
6
7
  import { registerIngestCommands } from './commands/ingest-commands.js';
@@ -168,6 +169,7 @@ function createBaseProgram(info, io) {
168
169
  .helpOption('-h, --help', 'Show this help text')
169
170
  .configureHelp({ showGlobalOptions: true })
170
171
  .showHelpAfterError()
172
+ .addHelpText('after', `\n${SLACK_HELP_FOOTER}`)
171
173
  .exitOverride()
172
174
  .configureOutput({
173
175
  writeOut: (chunk) => io.stdout.write(chunk),
@@ -433,6 +435,7 @@ export async function runCommanderKtxCli(argv, io, deps, info, options) {
433
435
  io,
434
436
  });
435
437
  io.stderr.write(`${formatCliError(error)}\n`);
438
+ writeErrorCommunityHint(io, 'error');
436
439
  return 1;
437
440
  }
438
441
  }
@@ -458,6 +461,7 @@ export async function runCommanderKtxCli(argv, io, deps, info, options) {
458
461
  }
459
462
  else {
460
463
  io.stderr.write(`${formatCliError(error)}\n`);
464
+ writeErrorCommunityHint(io, 'error');
461
465
  exitCode = 1;
462
466
  }
463
467
  }
@@ -49,5 +49,7 @@ export declare function runInitForCommander(args: {
49
49
  }, io: KtxCliIo): Promise<number>;
50
50
  /** @internal */
51
51
  export declare function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageInfo): (source: "uncaughtException" | "unhandledRejection", error: unknown) => Promise<void>;
52
+ /** @internal */
53
+ export declare function writeGlobalExceptionToStderr(io: KtxCliIo, error: unknown): void;
52
54
  export declare function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackageInfo): () => void;
53
55
  export declare function runKtxCli(argv?: string[], io?: KtxCliIo, deps?: KtxCliDeps): Promise<number>;
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { profileMark, profileSpan } from './startup-profile.js';
3
3
  import { assertCliVersion } from './release-version.js';
4
+ import { writeErrorCommunityHint } from './community-cta.js';
4
5
  profileMark('module:cli-runtime');
5
6
  const requirePackageJson = createRequire(import.meta.url);
6
7
  export function getKtxCliPackageInfo() {
@@ -88,6 +89,16 @@ export function createGlobalExceptionReporter(io, info) {
88
89
  await shutdownTelemetryEmitter();
89
90
  };
90
91
  }
92
+ /** @internal */
93
+ export function writeGlobalExceptionToStderr(io, error) {
94
+ if (error instanceof Error && error.stack) {
95
+ io.stderr.write(`${error.stack}\n`);
96
+ }
97
+ else {
98
+ io.stderr.write(`${String(error)}\n`);
99
+ }
100
+ writeErrorCommunityHint(io, 'crash');
101
+ }
91
102
  export function installGlobalExceptionHandlers(io, info) {
92
103
  const report = createGlobalExceptionReporter(io, info);
93
104
  const handle = (source, error) => {
@@ -98,12 +109,7 @@ export function installGlobalExceptionHandlers(io, info) {
98
109
  catch {
99
110
  // Best-effort: preserve Node's process termination behavior.
100
111
  }
101
- if (error instanceof Error && error.stack) {
102
- io.stderr.write(`${error.stack}\n`);
103
- }
104
- else {
105
- io.stderr.write(`${String(error)}\n`);
106
- }
112
+ writeGlobalExceptionToStderr(io, error);
107
113
  process.exit(1);
108
114
  })();
109
115
  };
@@ -0,0 +1,11 @@
1
+ import type { KtxCliIo } from './cli-runtime.js';
2
+ type ErrorCtaVariant = 'error' | 'crash';
3
+ /** @internal */
4
+ export declare const SLACK_HELP_FOOTER = "Community & support: https://ktx.sh/slack";
5
+ /** @internal */
6
+ export declare const SLACK_SETUP_NOTE: {
7
+ readonly title: "Community";
8
+ readonly body: "Questions or feedback? Join the ktx Slack: https://ktx.sh/slack";
9
+ };
10
+ export declare function writeErrorCommunityHint(io: KtxCliIo, variant: ErrorCtaVariant): void;
11
+ export {};
@@ -0,0 +1,19 @@
1
+ import { isWritableTtyOutput } from './io/tty.js';
2
+ import { dim } from './io/symbols.js';
3
+ import { SLACK_URL } from './links.js';
4
+ /** @internal */
5
+ export const SLACK_HELP_FOOTER = `Community & support: ${SLACK_URL}`;
6
+ /** @internal */
7
+ export const SLACK_SETUP_NOTE = {
8
+ title: 'Community',
9
+ body: `Questions or feedback? Join the ktx Slack: ${SLACK_URL}`,
10
+ };
11
+ export function writeErrorCommunityHint(io, variant) {
12
+ if (!isWritableTtyOutput(io.stderr)) {
13
+ return;
14
+ }
15
+ const line = variant === 'crash'
16
+ ? `This may be a bug - report it or ask in the ktx community: ${SLACK_URL}`
17
+ : `Stuck? The ktx community can help: ${SLACK_URL}`;
18
+ io.stderr.write(`${dim(line)}\n`);
19
+ }
@@ -1,2 +1,13 @@
1
1
  import { type SimpleGit } from 'simple-git';
2
- export declare function createSimpleGit(baseDir: string): SimpleGit;
2
+ /**
3
+ * Create a simple-git client scoped to `baseDir`. When an identity is provided, ktx's own
4
+ * commits carry it through the GIT_AUTHOR and GIT_COMMITTER environment variables instead of
5
+ * relying on repo-local or global git config. This keeps commits working when the project
6
+ * directory is an existing repo ktx did not create and the machine has no configured git
7
+ * identity (e.g. a fresh Mac with no ~/.gitconfig), without mutating the user's repo config.
8
+ * Explicit `--author` flags on individual commits still take precedence over GIT_AUTHOR_NAME.
9
+ */
10
+ export declare function createSimpleGit(baseDir: string, identity?: {
11
+ name: string;
12
+ email: string;
13
+ }): SimpleGit;
@@ -21,6 +21,21 @@ function sanitizedGitEnv(env = process.env) {
21
21
  }
22
22
  return sanitized;
23
23
  }
24
- export function createSimpleGit(baseDir) {
25
- return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(sanitizedGitEnv());
24
+ /**
25
+ * Create a simple-git client scoped to `baseDir`. When an identity is provided, ktx's own
26
+ * commits carry it through the GIT_AUTHOR and GIT_COMMITTER environment variables instead of
27
+ * relying on repo-local or global git config. This keeps commits working when the project
28
+ * directory is an existing repo ktx did not create and the machine has no configured git
29
+ * identity (e.g. a fresh Mac with no ~/.gitconfig), without mutating the user's repo config.
30
+ * Explicit `--author` flags on individual commits still take precedence over GIT_AUTHOR_NAME.
31
+ */
32
+ export function createSimpleGit(baseDir, identity) {
33
+ const env = sanitizedGitEnv();
34
+ if (identity?.name && identity.email) {
35
+ env.GIT_AUTHOR_NAME = identity.name;
36
+ env.GIT_AUTHOR_EMAIL = identity.email;
37
+ env.GIT_COMMITTER_NAME = identity.name;
38
+ env.GIT_COMMITTER_EMAIL = identity.email;
39
+ }
40
+ return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(env);
26
41
  }
@@ -47,8 +47,12 @@ export class GitService {
47
47
  // Ensure config directory exists
48
48
  await fs.mkdir(this.configDir, { recursive: true });
49
49
  this.logger.log(`Config directory ensured at: ${this.configDir}`);
50
- // Initialize simple-git
51
- this.git = createSimpleGit(this.configDir);
50
+ // Initialize simple-git. Carry ktx's identity in the environment so commits succeed even
51
+ // when this repo already exists and the machine has no configured git identity.
52
+ this.git = createSimpleGit(this.configDir, {
53
+ name: this.config.git.userName,
54
+ email: this.config.git.userEmail,
55
+ });
52
56
  // Initialize git repository
53
57
  await this.initialize();
54
58
  }
@@ -58,9 +62,6 @@ export class GitService {
58
62
  const isRepo = await this.git.checkIsRepo();
59
63
  if (!isRepo) {
60
64
  await this.git.init();
61
- const gitConfig = this.config.git;
62
- await this.git.addConfig('user.name', gitConfig.userName);
63
- await this.git.addConfig('user.email', gitConfig.userEmail);
64
65
  this.logger.log('Initialized git repository');
65
66
  }
66
67
  // Keep any auto-maintenance triggered by writes in-process. Detached maintenance can
@@ -81,7 +82,11 @@ export class GitService {
81
82
  }
82
83
  catch (error) {
83
84
  this.logger.error('Failed to initialize git repository', error);
84
- throw new Error('Failed to initialize git repository');
85
+ // Preserve the underlying git error: the generic message alone is undiagnosable in
86
+ // telemetry and unactionable for the user. The exception reporter walks `cause` and
87
+ // redacts secrets before send.
88
+ const detail = error instanceof Error ? error.message : String(error);
89
+ throw new Error(`Failed to initialize git repository: ${detail}`, { cause: error });
85
90
  }
86
91
  }
87
92
  async commitFile(filePath, commitMessage, author, authorEmail) {
@@ -740,7 +745,10 @@ export class GitService {
740
745
  */
741
746
  forWorktree(workdir) {
742
747
  const scoped = new GitService(this.config, this.logger);
743
- scoped.git = createSimpleGit(workdir);
748
+ scoped.git = createSimpleGit(workdir, {
749
+ name: this.config.git.userName,
750
+ email: this.config.git.userEmail,
751
+ });
744
752
  scoped.configDir = workdir;
745
753
  return scoped;
746
754
  }
@@ -0,0 +1,9 @@
1
+ import type { Writable } from 'node:stream';
2
+ import type { KtxCliIo } from '../cli-runtime.js';
3
+ type KtxCliOutput = (KtxCliIo['stdout'] | KtxCliIo['stderr']) & {
4
+ isTTY?: boolean;
5
+ columns?: number;
6
+ on?: unknown;
7
+ };
8
+ export declare function isWritableTtyOutput(output: KtxCliOutput): output is KtxCliOutput & Writable;
9
+ export {};
package/dist/io/tty.js ADDED
@@ -0,0 +1,5 @@
1
+ export function isWritableTtyOutput(output) {
2
+ return (output.isTTY === true &&
3
+ typeof output.on === 'function' &&
4
+ typeof output.columns !== 'undefined');
5
+ }
@@ -0,0 +1 @@
1
+ export declare const SLACK_URL = "https://ktx.sh/slack";
package/dist/links.js ADDED
@@ -0,0 +1 @@
1
+ export const SLACK_URL = 'https://ktx.sh/slack';
@@ -9,14 +9,10 @@ import { markKtxSetupStateStepComplete } from './context/project/setup-config.js
9
9
  import { serializeKtxProjectConfig } from './context/project/config.js';
10
10
  import { strToU8, zipSync } from 'fflate';
11
11
  import { errorMessage, writePrefixedLines } from './clack.js';
12
+ import { isWritableTtyOutput } from './io/tty.js';
12
13
  import { createKtxSetupPromptAdapter, createKtxSetupUiAdapter, } from './setup-prompts.js';
13
14
  import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
14
15
  const MCP_DAEMON_REQUIRED_NOTICE = 'mcp-daemon-required';
15
- function isWritableTtyOutput(output) {
16
- return (output.isTTY === true &&
17
- typeof output.on === 'function' &&
18
- typeof output.columns !== 'undefined');
19
- }
20
16
  function writeSetupInfo(io, message) {
21
17
  if (isWritableTtyOutput(io.stdout)) {
22
18
  log.info(message, { output: io.stdout });
@@ -1,4 +1,5 @@
1
1
  import { autocomplete, autocompleteMultiselect, cancel, confirm, intro, isCancel, log, multiselect, note, select, text, } from '@clack/prompts';
2
+ import { isWritableTtyOutput } from './io/tty.js';
2
3
  import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
3
4
  import { revealPassword } from './reveal-password-prompt.js';
4
5
  import { withSetupInterruptConfirmation } from './setup-interrupt.js';
@@ -101,11 +102,6 @@ export function createKtxSetupPromptAdapter(options) {
101
102
  },
102
103
  };
103
104
  }
104
- function isWritableTtyOutput(output) {
105
- return (output.isTTY === true &&
106
- typeof output.on === 'function' &&
107
- typeof output.columns !== 'undefined');
108
- }
109
105
  export function createKtxSetupUiAdapter() {
110
106
  return {
111
107
  intro(title, io) {
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type KtxCliIo } from './cli-runtime.js';
2
2
  import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js';
3
+ import type { CommandOutcome } from './telemetry/index.js';
3
4
  import { type KtxAgentScope, type KtxAgentTarget, type KtxSetupAgentsDeps, runKtxSetupAgentsStep } from './setup-agents.js';
4
5
  import { type KtxSetupDatabaseDriver, type KtxSetupDatabasesDeps, runKtxSetupDatabasesStep } from './setup-databases.js';
5
6
  import { type KtxSetupEmbeddingsDeps, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
@@ -125,6 +126,7 @@ export interface KtxSetupDeps {
125
126
  entryMenuDeps?: KtxSetupEntryMenuDeps;
126
127
  setupUi?: KtxSetupUiAdapter;
127
128
  }
129
+ type TelemetrySetupStep = 'project' | 'runtime' | 'models' | 'embeddings' | 'databases' | 'sources' | 'context' | 'agents' | 'demo-tour';
128
130
  export interface KtxSetupEntryMenuPromptAdapter {
129
131
  select(options: {
130
132
  message: string;
@@ -135,6 +137,28 @@ export interface KtxSetupEntryMenuPromptAdapter {
135
137
  export interface KtxSetupEntryMenuDeps {
136
138
  prompts?: KtxSetupEntryMenuPromptAdapter;
137
139
  }
140
+ interface SetupCommandAnnotation {
141
+ outcome: CommandOutcome;
142
+ errorClass?: string;
143
+ errorDetail?: string;
144
+ }
145
+ /**
146
+ * Single source of truth for how a non-ready setup step ends: the process exit
147
+ * code and the telemetry annotation are both derived from one classification,
148
+ * so they can never disagree. A genuine failure (`error`) exits non-zero; an
149
+ * abort — the user leaving an interactive wizard — exits 0, matching the entry
150
+ * menu's "Exit", a project cancellation, and a confirmed Ctrl+C.
151
+ */
152
+ /** @internal */
153
+ export declare function setupTerminalOutcome(input: {
154
+ status: 'failed' | 'missing-input' | 'cancelled';
155
+ step: TelemetrySetupStep;
156
+ interactive: boolean;
157
+ errorDetail?: string;
158
+ }): {
159
+ exitCode: number;
160
+ annotation: SetupCommandAnnotation;
161
+ };
138
162
  export interface ReadKtxSetupStatusOptions {
139
163
  cliVersion?: string;
140
164
  env?: NodeJS.ProcessEnv;
@@ -146,3 +170,4 @@ export declare function formatKtxSetupCompletionSummary(status: KtxSetupStatus,
146
170
  agentNextActions?: string;
147
171
  }): string;
148
172
  export declare function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps?: KtxSetupDeps): Promise<number>;
173
+ export {};
package/dist/setup.js CHANGED
@@ -6,6 +6,7 @@ import { ktxLocalStateDbPath } from './context/project/local-state-db.js';
6
6
  import { loadKtxProject } from './context/project/project.js';
7
7
  import { readKtxSetupState } from './context/project/setup-config.js';
8
8
  import { getKtxCliPackageInfo } from './cli-runtime.js';
9
+ import { SLACK_SETUP_NOTE } from './community-cta.js';
9
10
  import { formatNextStepLines, formatSetupNextStepLines } from './next-steps.js';
10
11
  import { runtimeInstallPolicyFromFlags } from './managed-python-command.js';
11
12
  import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js';
@@ -36,6 +37,61 @@ function setupTelemetryOutcome(status) {
36
37
  return 'skipped';
37
38
  return 'abandoned';
38
39
  }
40
+ /**
41
+ * Classify a terminal non-ready setup status into the `command` telemetry
42
+ * outcome. The setup flow is the decision-maker and knows the difference:
43
+ * - `failed` is a genuine error; attach a step-scoped reason so the dashboard
44
+ * shows an actionable signature instead of a blank.
45
+ * - `missing-input` from a *non-interactive* run is an automation error
46
+ * (required flags absent and no prompt was possible); attach a reason too.
47
+ * - `missing-input` from an interactive prompt, or a project `cancelled`, is the
48
+ * user backing out of the wizard — an abort, not a failure. Keep it out of
49
+ * error telemetry so it stops inflating the error count.
50
+ *
51
+ * `interactive` must reflect whether a prompt could actually be shown — input
52
+ * is enabled AND a TTY is attached. `inputMode: 'auto'` alone is not enough: a
53
+ * piped/CI run without `--no-input` is still non-interactive, and steps such as
54
+ * the project step return `missing-input` ("pass --yes …") there without ever
55
+ * prompting. Treating that as an abort would make a broken automation run exit
56
+ * 0, so it must classify as an error.
57
+ *
58
+ * Reasons are synthetic, step-scoped strings (no user input), so they satisfy
59
+ * the telemetry privacy rules. The step's own `errorDetail`, when present, has
60
+ * already been vetted for the `setup_step` event and is safe to reuse.
61
+ */
62
+ function setupCommandOutcomeAnnotation(input) {
63
+ if (input.status === 'failed') {
64
+ return {
65
+ outcome: 'error',
66
+ errorClass: 'KtxSetupStepFailed',
67
+ errorDetail: input.errorDetail ?? `${input.step} setup step failed`,
68
+ };
69
+ }
70
+ if (input.status === 'missing-input' && !input.interactive) {
71
+ return {
72
+ outcome: 'error',
73
+ errorClass: 'KtxSetupMissingInput',
74
+ errorDetail: `${input.step} setup step requires input not provided in a non-interactive run`,
75
+ };
76
+ }
77
+ return { outcome: 'aborted' };
78
+ }
79
+ /**
80
+ * Single source of truth for how a non-ready setup step ends: the process exit
81
+ * code and the telemetry annotation are both derived from one classification,
82
+ * so they can never disagree. A genuine failure (`error`) exits non-zero; an
83
+ * abort — the user leaving an interactive wizard — exits 0, matching the entry
84
+ * menu's "Exit", a project cancellation, and a confirmed Ctrl+C.
85
+ */
86
+ /** @internal */
87
+ export function setupTerminalOutcome(input) {
88
+ const annotation = setupCommandOutcomeAnnotation(input);
89
+ return { exitCode: annotation.outcome === 'error' ? 1 : 0, annotation };
90
+ }
91
+ async function annotateSetupCommandOutcome(annotation) {
92
+ const { annotateCommandOutcome } = await import('./telemetry/index.js');
93
+ annotateCommandOutcome(annotation);
94
+ }
39
95
  async function recordSetupStep(input) {
40
96
  const { emitTelemetryEvent } = await import('./telemetry/index.js');
41
97
  await emitTelemetryEvent({
@@ -325,6 +381,10 @@ async function runKtxSetupInner(args, io, deps = {}) {
325
381
  args.inputMode !== 'disabled' &&
326
382
  !args.agents &&
327
383
  (io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined);
384
+ // A prompt is only possible when input is enabled AND a TTY is attached. A
385
+ // piped/CI `ktx setup` without `--no-input` is still `inputMode: 'auto'` but
386
+ // cannot prompt, so its `missing-input` is an automation error, not an abort.
387
+ const interactive = args.inputMode !== 'disabled' && io.stdout.isTTY === true;
328
388
  setupLoop: while (true) {
329
389
  entryAction = undefined;
330
390
  if (canShowEntryMenu) {
@@ -363,7 +423,13 @@ async function runKtxSetupInner(args, io, deps = {}) {
363
423
  continue;
364
424
  }
365
425
  if (projectResult.status !== 'ready') {
366
- return projectResult.status === 'cancelled' ? 0 : 1;
426
+ const terminal = setupTerminalOutcome({
427
+ status: projectResult.status,
428
+ step: 'project',
429
+ interactive,
430
+ });
431
+ await annotateSetupCommandOutcome(terminal.annotation);
432
+ return terminal.exitCode;
367
433
  }
368
434
  const agentsRequested = args.agents || entryAction === 'agents';
369
435
  const currentStatus = await readKtxSetupStatus(projectResult.projectDir, { cliVersion: args.cliVersion });
@@ -576,11 +642,15 @@ async function runKtxSetupInner(args, io, deps = {}) {
576
642
  cliVersion: args.cliVersion,
577
643
  ...(stepResult.errorDetail ? { errorDetail: stepResult.errorDetail } : {}),
578
644
  });
579
- if (stepResult.status === 'failed') {
580
- return 1;
581
- }
582
- if (stepResult.status === 'missing-input') {
583
- return 1;
645
+ if (stepResult.status === 'failed' || stepResult.status === 'missing-input') {
646
+ const terminal = setupTerminalOutcome({
647
+ status: stepResult.status,
648
+ step,
649
+ interactive,
650
+ ...(stepResult.errorDetail ? { errorDetail: stepResult.errorDetail } : {}),
651
+ });
652
+ await annotateSetupCommandOutcome(terminal.annotation);
653
+ return terminal.exitCode;
584
654
  }
585
655
  if (stepResult.status === 'back') {
586
656
  const previousIndex = previousNavigableStepIndex(stepIndex);
@@ -630,5 +700,6 @@ async function runKtxSetupInner(args, io, deps = {}) {
630
700
  }).join('\n'), 'What you can do next', io);
631
701
  }
632
702
  }
703
+ setupUi.note(SLACK_SETUP_NOTE.body, SLACK_SETUP_NOTE.title, io);
633
704
  return 0;
634
705
  }
@@ -6,6 +6,9 @@ interface CommandSpan {
6
6
  hasProject: boolean;
7
7
  attachProjectGroup: boolean;
8
8
  startedAt: number;
9
+ annotatedOutcome?: CommandOutcome;
10
+ annotatedErrorClass?: string;
11
+ annotatedErrorDetail?: string;
9
12
  }
10
13
  export interface CompletedCommandSpan {
11
14
  commandPath: string[];
@@ -19,6 +22,27 @@ export interface CompletedCommandSpan {
19
22
  projectGroupAttached: boolean;
20
23
  }
21
24
  export declare function beginCommandSpan(input: CommandSpan): void;
25
+ /**
26
+ * Let a command action record the true outcome and reason on the active span.
27
+ *
28
+ * The Commander wrapper can only derive an outcome from a thrown error or the
29
+ * process exit code, so a command that exits non-zero *without throwing* (e.g.
30
+ * `ktx setup` when the user abandons the wizard) lands as `outcome: 'error'`
31
+ * with no `errorClass`/`errorDetail` — an unactionable blank in the dashboard.
32
+ * The action is the decision-maker: it can mark the run `aborted`, or attach a
33
+ * scrubbed reason so the next occurrence is self-diagnosing. A later thrown
34
+ * error still wins (see {@link completeCommandSpan}), since that is the most
35
+ * authoritative signal and also feeds the `$exception` stream. No-ops when no
36
+ * span is active so call sites stay safe in tests and bare-help paths.
37
+ *
38
+ * Values are emitted verbatim and must already satisfy the telemetry privacy
39
+ * rules — pass synthetic or already-scrubbed strings, never raw user input.
40
+ */
41
+ export declare function annotateCommandOutcome(input: {
42
+ outcome?: CommandOutcome;
43
+ errorClass?: string;
44
+ errorDetail?: string;
45
+ }): void;
22
46
  export declare function completeCommandSpan(input: {
23
47
  completedAt: number;
24
48
  outcome: CommandOutcome;
@@ -3,18 +3,52 @@ let activeCommandSpan;
3
3
  export function beginCommandSpan(input) {
4
4
  activeCommandSpan = input;
5
5
  }
6
+ /**
7
+ * Let a command action record the true outcome and reason on the active span.
8
+ *
9
+ * The Commander wrapper can only derive an outcome from a thrown error or the
10
+ * process exit code, so a command that exits non-zero *without throwing* (e.g.
11
+ * `ktx setup` when the user abandons the wizard) lands as `outcome: 'error'`
12
+ * with no `errorClass`/`errorDetail` — an unactionable blank in the dashboard.
13
+ * The action is the decision-maker: it can mark the run `aborted`, or attach a
14
+ * scrubbed reason so the next occurrence is self-diagnosing. A later thrown
15
+ * error still wins (see {@link completeCommandSpan}), since that is the most
16
+ * authoritative signal and also feeds the `$exception` stream. No-ops when no
17
+ * span is active so call sites stay safe in tests and bare-help paths.
18
+ *
19
+ * Values are emitted verbatim and must already satisfy the telemetry privacy
20
+ * rules — pass synthetic or already-scrubbed strings, never raw user input.
21
+ */
22
+ export function annotateCommandOutcome(input) {
23
+ if (!activeCommandSpan) {
24
+ return;
25
+ }
26
+ if (input.outcome !== undefined) {
27
+ activeCommandSpan.annotatedOutcome = input.outcome;
28
+ }
29
+ if (input.errorClass !== undefined) {
30
+ activeCommandSpan.annotatedErrorClass = input.errorClass;
31
+ }
32
+ if (input.errorDetail !== undefined) {
33
+ activeCommandSpan.annotatedErrorDetail = input.errorDetail;
34
+ }
35
+ }
6
36
  export function completeCommandSpan(input) {
7
37
  const span = activeCommandSpan;
8
38
  activeCommandSpan = undefined;
9
39
  if (!span) {
10
40
  return undefined;
11
41
  }
12
- const errorClass = input.error ? scrubErrorClass(input.error) : undefined;
13
- const errorDetail = input.error ? formatErrorDetail(input.error) : undefined;
42
+ // Precedence: a thrown error is authoritative; otherwise an action's own
43
+ // annotation; otherwise the wrapper's exit-code-derived outcome.
44
+ const thrown = Boolean(input.error);
45
+ const outcome = thrown ? input.outcome : (span.annotatedOutcome ?? input.outcome);
46
+ const errorClass = thrown ? scrubErrorClass(input.error) : span.annotatedErrorClass;
47
+ const errorDetail = thrown ? formatErrorDetail(input.error) : span.annotatedErrorDetail;
14
48
  return {
15
49
  commandPath: span.commandPath,
16
50
  durationMs: Math.max(0, input.completedAt - span.startedAt),
17
- outcome: input.outcome,
51
+ outcome,
18
52
  ...(errorClass ? { errorClass } : {}),
19
53
  ...(errorDetail ? { errorDetail } : {}),
20
54
  flagsPresent: span.flagsPresent,
@@ -1,9 +1,9 @@
1
1
  import { type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
2
- import { beginCommandSpan, completeCommandSpan, type CommandOutcome, type CompletedCommandSpan } from './command-hook.js';
2
+ import { annotateCommandOutcome, beginCommandSpan, completeCommandSpan, type CommandOutcome, type CompletedCommandSpan } from './command-hook.js';
3
3
  import { shutdownTelemetryEmitter } from './emitter.js';
4
4
  import { reportException, type ExceptionContext } from './exception.js';
5
5
  import { type TelemetryCommonEnvelope, type TelemetryEventName, type TelemetryEventProperties } from './events.js';
6
- export { beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
6
+ export { annotateCommandOutcome, beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
7
7
  export type { CommandOutcome, CompletedCommandSpan, ExceptionContext };
8
8
  export declare function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise<void>;
9
9
  type TelemetryEventFields<Name extends TelemetryEventName> = Omit<TelemetryEventProperties<Name>, keyof TelemetryCommonEnvelope>;
@@ -1,12 +1,12 @@
1
1
  import { getKtxCliPackageInfo } from '../cli-runtime.js';
2
2
  import { loadKtxProject } from '../context/project/project.js';
3
- import { beginCommandSpan, completeCommandSpan, } from './command-hook.js';
3
+ import { annotateCommandOutcome, beginCommandSpan, completeCommandSpan, } from './command-hook.js';
4
4
  import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
5
5
  import { reportException } from './exception.js';
6
6
  import { buildCommonEnvelope, buildTelemetryEvent, } from './events.js';
7
7
  import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
8
8
  import { buildProjectStackSnapshotFields } from './project-snapshot.js';
9
- export { beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
9
+ export { annotateCommandOutcome, beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
10
10
  export async function showTelemetryNoticeIfNeeded(io, packageInfo) {
11
11
  const identity = await loadTelemetryIdentity({
12
12
  stderr: io.stderr,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaelio/ktx",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Standalone ktx context layer for data agents",
5
5
  "author": {
6
6
  "name": "Kaelio",