@respan/cli 0.2.0 → 0.3.1

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.
@@ -4,7 +4,8 @@ export default class AuthLogin extends BaseCommand {
4
4
  static flags: {
5
5
  'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
6
  'profile-name': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
- 'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ enterprise: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  csv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,26 +1,144 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { password } from '@inquirer/prompts';
2
+ import { select, password } from '@inquirer/prompts';
3
+ import * as http from 'node:http';
4
+ import * as open from 'node:child_process';
3
5
  import { BaseCommand } from '../../lib/base-command.js';
4
- import { setCredential } from '../../lib/config.js';
6
+ import { setCredential, setActiveProfile } from '../../lib/config.js';
5
7
  import { printLoginSuccess } from '../../lib/banner.js';
8
+ import { DEFAULT_BASE_URL, ENTERPRISE_BASE_URL } from '../../lib/auth.js';
9
+ const CALLBACK_PORT = 18392;
10
+ const CALLBACK_PATH = '/callback';
11
+ const LOGIN_TIMEOUT_MS = 120_000;
12
+ const LOGIN_URL_BASE = 'https://platform.respan.ai/login';
13
+ const ENTERPRISE_LOGIN_URL_BASE = 'https://enterprise.respan.ai/login';
14
+ function openBrowser(url) {
15
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
16
+ open.exec(`${cmd} "${url}"`);
17
+ }
18
+ function successHtml() {
19
+ return `<!DOCTYPE html>
20
+ <html>
21
+ <head><meta charset="utf-8"><title>Respan CLI</title>
22
+ <style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0a0a0f;color:#fff}
23
+ .card{text-align:center;padding:2rem}.check{font-size:3rem;margin-bottom:1rem;color:#6483F0}p{color:#aaa;margin-top:0.5rem}</style></head>
24
+ <body><div class="card"><div class="check">&#10003;</div><h2>Login successful!</h2><p>You can close this window and return to the terminal.</p></div></body></html>`;
25
+ }
26
+ function errorHtml(msg) {
27
+ return `<!DOCTYPE html>
28
+ <html>
29
+ <head><meta charset="utf-8"><title>Respan CLI</title>
30
+ <style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0a0a0f;color:#fff}
31
+ .card{text-align:center;padding:2rem}.icon{font-size:3rem;margin-bottom:1rem;color:#f06464}p{color:#aaa;margin-top:0.5rem}</style></head>
32
+ <body><div class="card"><div class="icon">&#10007;</div><h2>Login failed</h2><p>${msg}</p></div></body></html>`;
33
+ }
34
+ function waitForBrowserLogin(enterprise) {
35
+ return new Promise((resolve, reject) => {
36
+ const server = http.createServer((req, res) => {
37
+ const url = new URL(req.url || '/', `http://localhost:${CALLBACK_PORT}`);
38
+ if (url.pathname !== CALLBACK_PATH) {
39
+ res.writeHead(404);
40
+ res.end('Not found');
41
+ return;
42
+ }
43
+ const token = url.searchParams.get('token');
44
+ const refreshToken = url.searchParams.get('refresh_token');
45
+ const email = url.searchParams.get('email') || undefined;
46
+ if (!token || !refreshToken) {
47
+ res.writeHead(400, { 'Content-Type': 'text/html' });
48
+ res.end(errorHtml('Missing token. Please try again.'));
49
+ cleanup();
50
+ reject(new Error('Login callback missing token or refresh_token.'));
51
+ return;
52
+ }
53
+ res.writeHead(200, { 'Content-Type': 'text/html' });
54
+ res.end(successHtml());
55
+ cleanup();
56
+ resolve({ token, refreshToken, email });
57
+ });
58
+ const timeout = setTimeout(() => {
59
+ cleanup();
60
+ reject(new Error('Login timed out after 120 seconds. Please try again.'));
61
+ }, LOGIN_TIMEOUT_MS);
62
+ function cleanup() {
63
+ clearTimeout(timeout);
64
+ server.close();
65
+ }
66
+ server.on('error', (err) => {
67
+ if (err.code === 'EADDRINUSE') {
68
+ reject(new Error(`Port ${CALLBACK_PORT} is in use. Close the other process and try again.`));
69
+ }
70
+ else {
71
+ reject(err);
72
+ }
73
+ });
74
+ server.listen(CALLBACK_PORT, '127.0.0.1', () => {
75
+ const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
76
+ const loginUrlBase = enterprise ? ENTERPRISE_LOGIN_URL_BASE : LOGIN_URL_BASE;
77
+ const loginUrl = new URL(loginUrlBase);
78
+ loginUrl.searchParams.set('mode', 'cli');
79
+ loginUrl.searchParams.set('redirect_uri', redirectUri);
80
+ console.log('');
81
+ console.log(' Opening browser to log in...');
82
+ console.log(` If the browser doesn't open, visit:`);
83
+ console.log(` ${loginUrl.toString()}`);
84
+ console.log('');
85
+ console.log(' Waiting for login (timeout: 120s)...');
86
+ openBrowser(loginUrl.toString());
87
+ });
88
+ });
89
+ }
6
90
  class AuthLogin extends BaseCommand {
7
91
  async run() {
8
92
  const { flags } = await this.parse(AuthLogin);
9
93
  this.globalFlags = flags;
10
- let apiKey = flags['api-key'];
11
- if (!apiKey) {
12
- apiKey = await password({ message: 'Enter your Respan API key:' });
13
- }
14
94
  const profile = flags['profile-name'] || 'default';
15
- setCredential(profile, { type: 'api_key', apiKey, baseUrl: flags['base-url'] });
16
- await printLoginSuccess(undefined, profile);
95
+ setActiveProfile(profile);
96
+ // Step 1: Determine environment (skip if --enterprise flag or --api-key with flag)
97
+ const enterprise = flags.enterprise || (!flags['api-key'] && await select({
98
+ message: 'Select your environment:',
99
+ choices: [
100
+ { name: 'Respan Platform', value: false },
101
+ { name: 'Enterprise', value: true },
102
+ ],
103
+ }));
104
+ const baseUrl = flags['base-url'] || (enterprise ? ENTERPRISE_BASE_URL : DEFAULT_BASE_URL);
105
+ // If --api-key passed directly, skip auth method prompt
106
+ if (flags['api-key']) {
107
+ setCredential(profile, { type: 'api_key', apiKey: flags['api-key'], baseUrl });
108
+ await printLoginSuccess(undefined, profile);
109
+ return;
110
+ }
111
+ // Step 2: Choose auth method
112
+ const method = await select({
113
+ message: 'How would you like to authenticate?',
114
+ choices: [
115
+ { name: 'Browser login (recommended)', value: 'browser' },
116
+ { name: 'API key', value: 'api_key' },
117
+ ],
118
+ });
119
+ if (method === 'api_key') {
120
+ const apiKey = await password({ message: 'Enter your Respan API key:' });
121
+ setCredential(profile, { type: 'api_key', apiKey, baseUrl });
122
+ await printLoginSuccess(undefined, profile);
123
+ return;
124
+ }
125
+ const result = await waitForBrowserLogin(enterprise);
126
+ setCredential(profile, {
127
+ type: 'jwt',
128
+ accessToken: result.token,
129
+ refreshToken: result.refreshToken,
130
+ email: result.email || '',
131
+ baseUrl,
132
+ });
133
+ await printLoginSuccess(result.email, profile);
17
134
  }
18
135
  }
19
136
  AuthLogin.description = 'Log in to Respan';
20
137
  AuthLogin.flags = {
21
138
  ...BaseCommand.baseFlags,
22
- 'api-key': Flags.string({ description: 'API key to store' }),
139
+ 'api-key': Flags.string({ description: 'API key to store (skips interactive prompt)' }),
23
140
  'profile-name': Flags.string({ description: 'Profile name', default: 'default' }),
24
- 'base-url': Flags.string({ description: 'API base URL', default: 'https://api.respan.ai/api' }),
141
+ 'base-url': Flags.string({ description: 'API base URL (auto-detected from login method if not set)' }),
142
+ enterprise: Flags.boolean({ description: 'Use enterprise SSO login', default: false }),
25
143
  };
26
144
  export default AuthLogin;
@@ -4,7 +4,7 @@ class AuthStatus extends BaseCommand {
4
4
  async run() {
5
5
  const { flags } = await this.parse(AuthStatus);
6
6
  this.globalFlags = flags;
7
- const profile = getActiveProfile();
7
+ const profile = flags.profile || getActiveProfile();
8
8
  const cred = getCredential(profile);
9
9
  if (!cred) {
10
10
  this.log('Not authenticated. Run `respan auth login`.');
@@ -8,6 +8,9 @@ export default class LogsList extends BaseCommand {
8
8
  'start-time': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
9
  'end-time': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
10
  filter: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'all-envs': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'is-test': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'include-fields': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
14
  'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
15
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
16
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../lib/base-command.js';
3
+ import { parseFilters, FILTER_SYNTAX_HELP, LOG_FIELDS_HELP, FILTER_EXAMPLES } from '../../lib/filters.js';
3
4
  import { extractPagination, formatPaginationInfo } from '../../lib/pagination.js';
4
5
  class LogsList extends BaseCommand {
5
6
  async run() {
@@ -7,27 +8,23 @@ class LogsList extends BaseCommand {
7
8
  this.globalFlags = flags;
8
9
  try {
9
10
  const client = this.getClient();
10
- const params = {
11
- page_size: flags.limit,
12
- page: flags.page,
13
- };
14
- if (flags['sort-by'])
15
- params.sort_by = flags['sort-by'];
16
- if (flags['start-time'])
17
- params.start_time = flags['start-time'];
18
- if (flags['end-time'])
19
- params.end_time = flags['end-time'];
20
- if (flags.filter && flags.filter.length > 0)
21
- params.filters = flags.filter;
22
- // listSpans requires start_time, end_time, sort_by, operator
11
+ let filters;
12
+ if (flags.filter && flags.filter.length > 0) {
13
+ filters = parseFilters(flags.filter);
14
+ }
23
15
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
24
16
  const data = await this.spin('Fetching logs', () => client.logs.listSpans({
25
- start_time: params.start_time || oneHourAgo,
26
- end_time: params.end_time || new Date().toISOString(),
27
- sort_by: params.sort_by || '-id',
17
+ start_time: flags['start-time'] || oneHourAgo,
18
+ end_time: flags['end-time'] || new Date().toISOString(),
19
+ sort_by: flags['sort-by'] || '-id',
28
20
  operator: '',
29
- page_size: params.page_size,
30
- page: params.page,
21
+ page_size: flags.limit,
22
+ page: flags.page,
23
+ is_test: flags['is-test'],
24
+ all_envs: flags['all-envs'],
25
+ fetch_filters: 'false',
26
+ include_fields: flags['include-fields'],
27
+ filters,
31
28
  }));
32
29
  this.outputResult(data, ['id', 'model', 'prompt_tokens', 'completion_tokens', 'cost', 'latency', 'timestamp']);
33
30
  const pagination = extractPagination(data, flags.page);
@@ -38,14 +35,25 @@ class LogsList extends BaseCommand {
38
35
  }
39
36
  }
40
37
  }
41
- LogsList.description = 'List log spans';
38
+ LogsList.description = `List and filter LLM request logs (spans).
39
+
40
+ Supports pagination, sorting, time range, and server-side filtering.
41
+
42
+ ${FILTER_SYNTAX_HELP}
43
+
44
+ ${LOG_FIELDS_HELP}
45
+
46
+ ${FILTER_EXAMPLES}`;
42
47
  LogsList.flags = {
43
48
  ...BaseCommand.baseFlags,
44
- limit: Flags.integer({ description: 'Number of results per page', default: 50 }),
49
+ limit: Flags.integer({ description: 'Number of results per page (max 1000)', default: 50 }),
45
50
  page: Flags.integer({ description: 'Page number', default: 1 }),
46
- 'sort-by': Flags.string({ description: 'Sort field' }),
51
+ 'sort-by': Flags.string({ description: 'Sort field (prefix with - for descending, e.g. -cost, -latency)' }),
47
52
  'start-time': Flags.string({ description: 'Start time filter (ISO 8601)' }),
48
53
  'end-time': Flags.string({ description: 'End time filter (ISO 8601)' }),
49
- filter: Flags.string({ description: 'Filter expression', multiple: true }),
54
+ filter: Flags.string({ description: 'Filter in field:operator:value format (repeatable)', multiple: true }),
55
+ 'all-envs': Flags.string({ description: 'Include all environments (true/false)' }),
56
+ 'is-test': Flags.string({ description: 'Filter by test (true) or production (false) environment' }),
57
+ 'include-fields': Flags.string({ description: 'Comma-separated fields to include in response' }),
50
58
  };
51
59
  export default LogsList;
@@ -4,6 +4,9 @@ export default class LogsSummary extends BaseCommand {
4
4
  static flags: {
5
5
  'start-time': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
6
  'end-time': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ filter: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'all-envs': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ 'is-test': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
10
  'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
11
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
12
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,12 +1,23 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../lib/base-command.js';
3
+ import { parseFilters, FILTER_SYNTAX_HELP, LOG_FIELDS_HELP, FILTER_EXAMPLES } from '../../lib/filters.js';
3
4
  class LogsSummary extends BaseCommand {
4
5
  async run() {
5
6
  const { flags } = await this.parse(LogsSummary);
6
7
  this.globalFlags = flags;
7
8
  try {
8
9
  const client = this.getClient();
9
- const data = await this.spin('Fetching summary', () => client.logs.getSpansSummary({ start_time: flags['start-time'], end_time: flags['end-time'] }));
10
+ let filters;
11
+ if (flags.filter && flags.filter.length > 0) {
12
+ filters = parseFilters(flags.filter);
13
+ }
14
+ const data = await this.spin('Fetching summary', () => client.logs.getSpansSummary({
15
+ start_time: flags['start-time'],
16
+ end_time: flags['end-time'],
17
+ is_test: flags['is-test'],
18
+ all_envs: flags['all-envs'],
19
+ filters,
20
+ }));
10
21
  this.log(JSON.stringify(data, null, 2));
11
22
  }
12
23
  catch (error) {
@@ -14,10 +25,21 @@ class LogsSummary extends BaseCommand {
14
25
  }
15
26
  }
16
27
  }
17
- LogsSummary.description = 'Get a summary of log spans for a time range';
28
+ LogsSummary.description = `Get aggregated summary statistics for log spans in a time range.
29
+
30
+ Returns total cost, total tokens, request count, and score summaries.
31
+
32
+ ${FILTER_SYNTAX_HELP}
33
+
34
+ ${LOG_FIELDS_HELP}
35
+
36
+ ${FILTER_EXAMPLES}`;
18
37
  LogsSummary.flags = {
19
38
  ...BaseCommand.baseFlags,
20
39
  'start-time': Flags.string({ description: 'Start time (ISO 8601)', required: true }),
21
40
  'end-time': Flags.string({ description: 'End time (ISO 8601)', required: true }),
41
+ filter: Flags.string({ description: 'Filter in field:operator:value format (repeatable)', multiple: true }),
42
+ 'all-envs': Flags.string({ description: 'Include all environments (true/false)' }),
43
+ 'is-test': Flags.string({ description: 'Filter by test (true) or production (false) environment' }),
22
44
  };
23
45
  export default LogsSummary;
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../lib/base-command.js';
3
+ import { parseFilters, FILTER_SYNTAX_HELP, TRACE_FIELDS_HELP, FILTER_EXAMPLES } from '../../lib/filters.js';
3
4
  import { extractPagination, formatPaginationInfo } from '../../lib/pagination.js';
4
5
  class TracesList extends BaseCommand {
5
6
  async run() {
@@ -7,21 +8,22 @@ class TracesList extends BaseCommand {
7
8
  this.globalFlags = flags;
8
9
  try {
9
10
  const client = this.getClient();
10
- const params = {
11
+ let bodyFilters = {};
12
+ if (flags.filter && flags.filter.length > 0) {
13
+ bodyFilters = parseFilters(flags.filter);
14
+ }
15
+ const queryParams = {
11
16
  page_size: flags.limit,
12
17
  page: flags.page,
18
+ sort_by: flags['sort-by'],
13
19
  };
14
- if (flags['sort-by'])
15
- params.sort_by = flags['sort-by'];
16
20
  if (flags['start-time'])
17
- params.start_time = flags['start-time'];
21
+ queryParams.start_time = flags['start-time'];
18
22
  if (flags['end-time'])
19
- params.end_time = flags['end-time'];
23
+ queryParams.end_time = flags['end-time'];
20
24
  if (flags.environment)
21
- params.environment = flags.environment;
22
- if (flags.filter && flags.filter.length > 0)
23
- params.filters = flags.filter;
24
- const data = await this.spin('Fetching traces', () => client.traces.list(params));
25
+ queryParams.environment = flags.environment;
26
+ const data = await this.spin('Fetching traces', () => client.traces.list({ filters: bodyFilters }, { queryParams }));
25
27
  this.outputResult(data, [
26
28
  'trace_unique_id', 'name', 'duration', 'span_count', 'total_cost', 'error_count', 'start_time',
27
29
  ]);
@@ -33,15 +35,23 @@ class TracesList extends BaseCommand {
33
35
  }
34
36
  }
35
37
  }
36
- TracesList.description = 'List traces';
38
+ TracesList.description = `List and filter traces.
39
+
40
+ A trace represents a complete workflow execution containing multiple spans.
41
+
42
+ ${FILTER_SYNTAX_HELP}
43
+
44
+ ${TRACE_FIELDS_HELP}
45
+
46
+ ${FILTER_EXAMPLES}`;
37
47
  TracesList.flags = {
38
48
  ...BaseCommand.baseFlags,
39
49
  limit: Flags.integer({ description: 'Number of results per page', default: 10 }),
40
50
  page: Flags.integer({ description: 'Page number', default: 1 }),
41
- 'sort-by': Flags.string({ description: 'Sort field', default: '-timestamp' }),
51
+ 'sort-by': Flags.string({ description: 'Sort field (prefix with - for descending)', default: '-timestamp' }),
42
52
  'start-time': Flags.string({ description: 'Start time filter (ISO 8601)' }),
43
53
  'end-time': Flags.string({ description: 'End time filter (ISO 8601)' }),
44
54
  environment: Flags.string({ description: 'Environment filter' }),
45
- filter: Flags.string({ description: 'Filter expression', multiple: true }),
55
+ filter: Flags.string({ description: 'Filter in field:operator:value format (repeatable)', multiple: true }),
46
56
  };
47
57
  export default TracesList;
@@ -1,4 +1,6 @@
1
1
  import { Credential } from './config.js';
2
+ export declare const DEFAULT_BASE_URL = "https://api.respan.ai";
3
+ export declare const ENTERPRISE_BASE_URL = "https://endpoint.respan.ai";
2
4
  export interface AuthConfig {
3
5
  apiKey?: string;
4
6
  accessToken?: string;
package/dist/lib/auth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getCredential } from './config.js';
2
- const DEFAULT_BASE_URL = 'https://api.respan.ai/api';
2
+ export const DEFAULT_BASE_URL = 'https://api.respan.ai';
3
+ export const ENTERPRISE_BASE_URL = 'https://endpoint.respan.ai';
3
4
  export function resolveAuth(flags) {
4
5
  if (flags['api-key']) {
5
6
  return { apiKey: flags['api-key'], baseUrl: DEFAULT_BASE_URL };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared filter parser for CLI commands.
3
+ *
4
+ * Parses --filter strings in `field:operator:value` format into the API's
5
+ * `{ field: { operator, value } }` body format.
6
+ *
7
+ * Syntax: field:operator:value
8
+ * - field::value → exact match (empty operator)
9
+ * - field:gt:value → greater than
10
+ * - field:in:a,b,c → value in list
11
+ * - field:isnull:true → null check
12
+ *
13
+ * Numeric values are auto-detected and converted.
14
+ * Multiple --filter flags are merged into a single filters object.
15
+ */
16
+ export declare function parseFilters(filterStrings: string[]): Record<string, {
17
+ operator: string;
18
+ value: unknown[];
19
+ }>;
20
+ /** Filter syntax documentation for use in command descriptions */
21
+ export declare const FILTER_SYNTAX_HELP = "FILTER SYNTAX: field:operator:value\n\nOPERATORS:\n (empty) Exact match model::gpt-4\n not Not equal status_code:not:200\n gt Greater than cost:gt:0.01\n gte Greater than/equal latency:gte:1.0\n lt Less than cost:lt:0.5\n lte Less than/equal prompt_tokens:lte:100\n contains Contains substring error_message:contains:timeout\n icontains Case-insensitive model:icontains:gpt\n startswith Starts with model:startswith:gpt\n endswith Ends with model:endswith:mini\n in Value in list model:in:gpt-4,gpt-4o\n isnull Is null error_message:isnull:true\n iexact Case-insens. exact status:iexact:success";
22
+ export declare const LOG_FIELDS_HELP = "FILTERABLE FIELDS (logs):\n model, status_code, status, cost, latency, prompt_tokens,\n completion_tokens, customer_identifier, custom_identifier,\n thread_identifier, trace_unique_id, span_name, span_workflow_name,\n environment, log_type, error_message, failed, provider_id,\n deployment_name, prompt_name, prompt_id, unique_id, stream,\n temperature, max_tokens, tokens_per_second, time_to_first_token,\n total_request_tokens, metadata__<key>, scores__<evaluator_id>";
23
+ export declare const TRACE_FIELDS_HELP = "FILTERABLE FIELDS (traces):\n trace_unique_id, customer_identifier, environment, span_count,\n llm_call_count, error_count, total_cost, total_tokens,\n total_prompt_tokens, total_completion_tokens, duration,\n span_workflow_name, metadata__<key>";
24
+ export declare const FILTER_EXAMPLES = "EXAMPLES:\n --filter model::gpt-4o --filter cost:gt:0.01\n --filter status_code:not:200\n --filter metadata__env::production\n --filter model:in:gpt-4,gpt-4o";
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Shared filter parser for CLI commands.
3
+ *
4
+ * Parses --filter strings in `field:operator:value` format into the API's
5
+ * `{ field: { operator, value } }` body format.
6
+ *
7
+ * Syntax: field:operator:value
8
+ * - field::value → exact match (empty operator)
9
+ * - field:gt:value → greater than
10
+ * - field:in:a,b,c → value in list
11
+ * - field:isnull:true → null check
12
+ *
13
+ * Numeric values are auto-detected and converted.
14
+ * Multiple --filter flags are merged into a single filters object.
15
+ */
16
+ const VALID_OPERATORS = new Set([
17
+ '', 'not', 'lt', 'lte', 'gt', 'gte',
18
+ 'contains', 'icontains', 'startswith', 'endswith',
19
+ 'in', 'isnull', 'iexact',
20
+ ]);
21
+ function coerceValue(v) {
22
+ if (v === 'true')
23
+ return true;
24
+ if (v === 'false')
25
+ return false;
26
+ const n = Number(v);
27
+ if (!Number.isNaN(n) && v.trim() !== '')
28
+ return n;
29
+ return v;
30
+ }
31
+ export function parseFilters(filterStrings) {
32
+ const result = {};
33
+ for (const raw of filterStrings) {
34
+ // Split on first two colons: field:operator:value
35
+ // field::value means operator is empty string
36
+ const firstColon = raw.indexOf(':');
37
+ if (firstColon === -1) {
38
+ throw new Error(`Invalid filter format: "${raw}". Expected field:operator:value (e.g. model::gpt-4 or cost:gt:0.01)`);
39
+ }
40
+ const field = raw.slice(0, firstColon);
41
+ const rest = raw.slice(firstColon + 1);
42
+ const secondColon = rest.indexOf(':');
43
+ if (secondColon === -1) {
44
+ throw new Error(`Invalid filter format: "${raw}". Expected field:operator:value (e.g. model::gpt-4 or cost:gt:0.01)`);
45
+ }
46
+ const operator = rest.slice(0, secondColon);
47
+ const valueStr = rest.slice(secondColon + 1);
48
+ if (!VALID_OPERATORS.has(operator)) {
49
+ throw new Error(`Unknown filter operator: "${operator}". Valid operators: ${[...VALID_OPERATORS].filter(Boolean).join(', ')} (or empty for exact match)`);
50
+ }
51
+ if (!field) {
52
+ throw new Error(`Filter field cannot be empty: "${raw}"`);
53
+ }
54
+ // For "in" operator, split on comma to create array
55
+ let values;
56
+ if (operator === 'in') {
57
+ values = valueStr.split(',').map((v) => coerceValue(v.trim()));
58
+ }
59
+ else {
60
+ values = [coerceValue(valueStr)];
61
+ }
62
+ result[field] = { operator, value: values };
63
+ }
64
+ return result;
65
+ }
66
+ /** Filter syntax documentation for use in command descriptions */
67
+ export const FILTER_SYNTAX_HELP = `FILTER SYNTAX: field:operator:value
68
+
69
+ OPERATORS:
70
+ (empty) Exact match model::gpt-4
71
+ not Not equal status_code:not:200
72
+ gt Greater than cost:gt:0.01
73
+ gte Greater than/equal latency:gte:1.0
74
+ lt Less than cost:lt:0.5
75
+ lte Less than/equal prompt_tokens:lte:100
76
+ contains Contains substring error_message:contains:timeout
77
+ icontains Case-insensitive model:icontains:gpt
78
+ startswith Starts with model:startswith:gpt
79
+ endswith Ends with model:endswith:mini
80
+ in Value in list model:in:gpt-4,gpt-4o
81
+ isnull Is null error_message:isnull:true
82
+ iexact Case-insens. exact status:iexact:success`;
83
+ export const LOG_FIELDS_HELP = `FILTERABLE FIELDS (logs):
84
+ model, status_code, status, cost, latency, prompt_tokens,
85
+ completion_tokens, customer_identifier, custom_identifier,
86
+ thread_identifier, trace_unique_id, span_name, span_workflow_name,
87
+ environment, log_type, error_message, failed, provider_id,
88
+ deployment_name, prompt_name, prompt_id, unique_id, stream,
89
+ temperature, max_tokens, tokens_per_second, time_to_first_token,
90
+ total_request_tokens, metadata__<key>, scores__<evaluator_id>`;
91
+ export const TRACE_FIELDS_HELP = `FILTERABLE FIELDS (traces):
92
+ trace_unique_id, customer_identifier, environment, span_count,
93
+ llm_call_count, error_count, total_cost, total_tokens,
94
+ total_prompt_tokens, total_completion_tokens, duration,
95
+ span_workflow_name, metadata__<key>`;
96
+ export const FILTER_EXAMPLES = `EXAMPLES:
97
+ --filter model::gpt-4o --filter cost:gt:0.01
98
+ --filter status_code:not:200
99
+ --filter metadata__env::production
100
+ --filter model:in:gpt-4,gpt-4o`;
@@ -61,7 +61,7 @@
61
61
  "description": "Log in to Respan",
62
62
  "flags": {
63
63
  "api-key": {
64
- "description": "API key to store",
64
+ "description": "API key to store (skips interactive prompt)",
65
65
  "name": "api-key",
66
66
  "hasDynamicHelp": false,
67
67
  "multiple": false,
@@ -102,12 +102,17 @@
102
102
  "type": "option"
103
103
  },
104
104
  "base-url": {
105
- "description": "API base URL",
105
+ "description": "API base URL (auto-detected from login method if not set)",
106
106
  "name": "base-url",
107
- "default": "https://api.respan.ai/api",
108
107
  "hasDynamicHelp": false,
109
108
  "multiple": false,
110
109
  "type": "option"
110
+ },
111
+ "enterprise": {
112
+ "description": "Use enterprise SSO login",
113
+ "name": "enterprise",
114
+ "allowNo": false,
115
+ "type": "boolean"
111
116
  }
112
117
  },
113
118
  "hasDynamicHelp": false,
@@ -1728,7 +1733,7 @@
1728
1733
  "logs:list": {
1729
1734
  "aliases": [],
1730
1735
  "args": {},
1731
- "description": "List log spans",
1736
+ "description": "List and filter LLM request logs (spans).\n\nSupports pagination, sorting, time range, and server-side filtering.\n\nFILTER SYNTAX: field:operator:value\n\nOPERATORS:\n (empty) Exact match model::gpt-4\n not Not equal status_code:not:200\n gt Greater than cost:gt:0.01\n gte Greater than/equal latency:gte:1.0\n lt Less than cost:lt:0.5\n lte Less than/equal prompt_tokens:lte:100\n contains Contains substring error_message:contains:timeout\n icontains Case-insensitive model:icontains:gpt\n startswith Starts with model:startswith:gpt\n endswith Ends with model:endswith:mini\n in Value in list model:in:gpt-4,gpt-4o\n isnull Is null error_message:isnull:true\n iexact Case-insens. exact status:iexact:success\n\nFILTERABLE FIELDS (logs):\n model, status_code, status, cost, latency, prompt_tokens,\n completion_tokens, customer_identifier, custom_identifier,\n thread_identifier, trace_unique_id, span_name, span_workflow_name,\n environment, log_type, error_message, failed, provider_id,\n deployment_name, prompt_name, prompt_id, unique_id, stream,\n temperature, max_tokens, tokens_per_second, time_to_first_token,\n total_request_tokens, metadata__<key>, scores__<evaluator_id>\n\nEXAMPLES:\n --filter model::gpt-4o --filter cost:gt:0.01\n --filter status_code:not:200\n --filter metadata__env::production\n --filter model:in:gpt-4,gpt-4o",
1732
1737
  "flags": {
1733
1738
  "api-key": {
1734
1739
  "description": "API key (env: RESPAN_API_KEY)",
@@ -1765,7 +1770,7 @@
1765
1770
  "type": "boolean"
1766
1771
  },
1767
1772
  "limit": {
1768
- "description": "Number of results per page",
1773
+ "description": "Number of results per page (max 1000)",
1769
1774
  "name": "limit",
1770
1775
  "default": 50,
1771
1776
  "hasDynamicHelp": false,
@@ -1781,7 +1786,7 @@
1781
1786
  "type": "option"
1782
1787
  },
1783
1788
  "sort-by": {
1784
- "description": "Sort field",
1789
+ "description": "Sort field (prefix with - for descending, e.g. -cost, -latency)",
1785
1790
  "name": "sort-by",
1786
1791
  "hasDynamicHelp": false,
1787
1792
  "multiple": false,
@@ -1802,11 +1807,32 @@
1802
1807
  "type": "option"
1803
1808
  },
1804
1809
  "filter": {
1805
- "description": "Filter expression",
1810
+ "description": "Filter in field:operator:value format (repeatable)",
1806
1811
  "name": "filter",
1807
1812
  "hasDynamicHelp": false,
1808
1813
  "multiple": true,
1809
1814
  "type": "option"
1815
+ },
1816
+ "all-envs": {
1817
+ "description": "Include all environments (true/false)",
1818
+ "name": "all-envs",
1819
+ "hasDynamicHelp": false,
1820
+ "multiple": false,
1821
+ "type": "option"
1822
+ },
1823
+ "is-test": {
1824
+ "description": "Filter by test (true) or production (false) environment",
1825
+ "name": "is-test",
1826
+ "hasDynamicHelp": false,
1827
+ "multiple": false,
1828
+ "type": "option"
1829
+ },
1830
+ "include-fields": {
1831
+ "description": "Comma-separated fields to include in response",
1832
+ "name": "include-fields",
1833
+ "hasDynamicHelp": false,
1834
+ "multiple": false,
1835
+ "type": "option"
1810
1836
  }
1811
1837
  },
1812
1838
  "hasDynamicHelp": false,
@@ -1828,7 +1854,7 @@
1828
1854
  "logs:summary": {
1829
1855
  "aliases": [],
1830
1856
  "args": {},
1831
- "description": "Get a summary of log spans for a time range",
1857
+ "description": "Get aggregated summary statistics for log spans in a time range.\n\nReturns total cost, total tokens, request count, and score summaries.\n\nFILTER SYNTAX: field:operator:value\n\nOPERATORS:\n (empty) Exact match model::gpt-4\n not Not equal status_code:not:200\n gt Greater than cost:gt:0.01\n gte Greater than/equal latency:gte:1.0\n lt Less than cost:lt:0.5\n lte Less than/equal prompt_tokens:lte:100\n contains Contains substring error_message:contains:timeout\n icontains Case-insensitive model:icontains:gpt\n startswith Starts with model:startswith:gpt\n endswith Ends with model:endswith:mini\n in Value in list model:in:gpt-4,gpt-4o\n isnull Is null error_message:isnull:true\n iexact Case-insens. exact status:iexact:success\n\nFILTERABLE FIELDS (logs):\n model, status_code, status, cost, latency, prompt_tokens,\n completion_tokens, customer_identifier, custom_identifier,\n thread_identifier, trace_unique_id, span_name, span_workflow_name,\n environment, log_type, error_message, failed, provider_id,\n deployment_name, prompt_name, prompt_id, unique_id, stream,\n temperature, max_tokens, tokens_per_second, time_to_first_token,\n total_request_tokens, metadata__<key>, scores__<evaluator_id>\n\nEXAMPLES:\n --filter model::gpt-4o --filter cost:gt:0.01\n --filter status_code:not:200\n --filter metadata__env::production\n --filter model:in:gpt-4,gpt-4o",
1832
1858
  "flags": {
1833
1859
  "api-key": {
1834
1860
  "description": "API key (env: RESPAN_API_KEY)",
@@ -1879,6 +1905,27 @@
1879
1905
  "hasDynamicHelp": false,
1880
1906
  "multiple": false,
1881
1907
  "type": "option"
1908
+ },
1909
+ "filter": {
1910
+ "description": "Filter in field:operator:value format (repeatable)",
1911
+ "name": "filter",
1912
+ "hasDynamicHelp": false,
1913
+ "multiple": true,
1914
+ "type": "option"
1915
+ },
1916
+ "all-envs": {
1917
+ "description": "Include all environments (true/false)",
1918
+ "name": "all-envs",
1919
+ "hasDynamicHelp": false,
1920
+ "multiple": false,
1921
+ "type": "option"
1922
+ },
1923
+ "is-test": {
1924
+ "description": "Filter by test (true) or production (false) environment",
1925
+ "name": "is-test",
1926
+ "hasDynamicHelp": false,
1927
+ "multiple": false,
1928
+ "type": "option"
1882
1929
  }
1883
1930
  },
1884
1931
  "hasDynamicHelp": false,
@@ -2388,7 +2435,7 @@
2388
2435
  "traces:list": {
2389
2436
  "aliases": [],
2390
2437
  "args": {},
2391
- "description": "List traces",
2438
+ "description": "List and filter traces.\n\nA trace represents a complete workflow execution containing multiple spans.\n\nFILTER SYNTAX: field:operator:value\n\nOPERATORS:\n (empty) Exact match model::gpt-4\n not Not equal status_code:not:200\n gt Greater than cost:gt:0.01\n gte Greater than/equal latency:gte:1.0\n lt Less than cost:lt:0.5\n lte Less than/equal prompt_tokens:lte:100\n contains Contains substring error_message:contains:timeout\n icontains Case-insensitive model:icontains:gpt\n startswith Starts with model:startswith:gpt\n endswith Ends with model:endswith:mini\n in Value in list model:in:gpt-4,gpt-4o\n isnull Is null error_message:isnull:true\n iexact Case-insens. exact status:iexact:success\n\nFILTERABLE FIELDS (traces):\n trace_unique_id, customer_identifier, environment, span_count,\n llm_call_count, error_count, total_cost, total_tokens,\n total_prompt_tokens, total_completion_tokens, duration,\n span_workflow_name, metadata__<key>\n\nEXAMPLES:\n --filter model::gpt-4o --filter cost:gt:0.01\n --filter status_code:not:200\n --filter metadata__env::production\n --filter model:in:gpt-4,gpt-4o",
2392
2439
  "flags": {
2393
2440
  "api-key": {
2394
2441
  "description": "API key (env: RESPAN_API_KEY)",
@@ -2441,7 +2488,7 @@
2441
2488
  "type": "option"
2442
2489
  },
2443
2490
  "sort-by": {
2444
- "description": "Sort field",
2491
+ "description": "Sort field (prefix with - for descending)",
2445
2492
  "name": "sort-by",
2446
2493
  "default": "-timestamp",
2447
2494
  "hasDynamicHelp": false,
@@ -2470,7 +2517,7 @@
2470
2517
  "type": "option"
2471
2518
  },
2472
2519
  "filter": {
2473
- "description": "Filter expression",
2520
+ "description": "Filter in field:operator:value format (repeatable)",
2474
2521
  "name": "filter",
2475
2522
  "hasDynamicHelp": false,
2476
2523
  "multiple": true,
@@ -2882,5 +2929,5 @@
2882
2929
  ]
2883
2930
  }
2884
2931
  },
2885
- "version": "0.2.0"
2932
+ "version": "0.3.1"
2886
2933
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@respan/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Respan CLI - manage your LLM observability from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",