trucontext 0.1.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.
package/src/auth.js ADDED
@@ -0,0 +1,197 @@
1
+ // auth.js — PKCE Authorization Code flow for CLI login
2
+
3
+ import { createHash, randomBytes } from 'crypto';
4
+ import { createServer } from 'http';
5
+ import open from 'open';
6
+ import {
7
+ COGNITO_DOMAIN,
8
+ CLI_CLIENT_ID,
9
+ CALLBACK_PORT,
10
+ CALLBACK_URL,
11
+ getCredentials,
12
+ saveCredentials,
13
+ } from './config.js';
14
+
15
+ function base64url(buffer) {
16
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
17
+ }
18
+
19
+ function generateCodeVerifier() {
20
+ return base64url(randomBytes(32));
21
+ }
22
+
23
+ function generateCodeChallenge(verifier) {
24
+ return base64url(createHash('sha256').update(verifier).digest());
25
+ }
26
+
27
+ /**
28
+ * Run the PKCE login flow:
29
+ * 1. Start localhost server
30
+ * 2. Open browser to Cognito authorize endpoint
31
+ * 3. Capture auth code from redirect
32
+ * 4. Exchange for tokens
33
+ * 5. Save credentials
34
+ */
35
+ export async function performLogin({ provider } = {}) {
36
+ const codeVerifier = generateCodeVerifier();
37
+ const codeChallenge = generateCodeChallenge(codeVerifier);
38
+ const state = base64url(randomBytes(16));
39
+
40
+ return new Promise((resolve, reject) => {
41
+ const server = createServer(async (req, res) => {
42
+ const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
43
+
44
+ if (url.pathname !== '/callback') {
45
+ res.writeHead(404);
46
+ res.end('Not found');
47
+ return;
48
+ }
49
+
50
+ const code = url.searchParams.get('code');
51
+ const returnedState = url.searchParams.get('state');
52
+ const error = url.searchParams.get('error');
53
+
54
+ if (error) {
55
+ res.writeHead(200, { 'Content-Type': 'text/html' });
56
+ res.end('<html><body><h2>Login failed</h2><p>You can close this window.</p></body></html>');
57
+ server.close();
58
+ reject(new Error(`Auth error: ${error}`));
59
+ return;
60
+ }
61
+
62
+ if (returnedState !== state) {
63
+ res.writeHead(200, { 'Content-Type': 'text/html' });
64
+ res.end('<html><body><h2>Invalid state</h2><p>Please try again.</p></body></html>');
65
+ server.close();
66
+ reject(new Error('State mismatch — possible CSRF attack'));
67
+ return;
68
+ }
69
+
70
+ try {
71
+ // Exchange auth code for tokens
72
+ const tokenRes = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
75
+ body: new URLSearchParams({
76
+ grant_type: 'authorization_code',
77
+ client_id: CLI_CLIENT_ID,
78
+ code,
79
+ redirect_uri: CALLBACK_URL,
80
+ code_verifier: codeVerifier,
81
+ }),
82
+ });
83
+
84
+ const tokens = await tokenRes.json();
85
+
86
+ if (!tokenRes.ok || tokens.error) {
87
+ throw new Error(tokens.error_description || tokens.error || 'Token exchange failed');
88
+ }
89
+
90
+ // Decode ID token payload (no verification needed client-side)
91
+ const payload = JSON.parse(
92
+ Buffer.from(tokens.id_token.split('.')[1], 'base64').toString()
93
+ );
94
+
95
+ const creds = {
96
+ refreshToken: tokens.refresh_token,
97
+ idToken: tokens.id_token,
98
+ accessToken: tokens.access_token,
99
+ expiresAt: Date.now() + tokens.expires_in * 1000,
100
+ email: payload.email || payload['cognito:username'],
101
+ sub: payload.sub,
102
+ };
103
+
104
+ saveCredentials(creds);
105
+
106
+ res.writeHead(302, { Location: 'https://app.trucontext.ai' });
107
+ res.end();
108
+
109
+ server.close();
110
+ resolve(creds);
111
+ } catch (err) {
112
+ res.writeHead(200, { 'Content-Type': 'text/html' });
113
+ res.end(`<html><body><h2>Login failed</h2><p>${err.message}</p></body></html>`);
114
+ server.close();
115
+ reject(err);
116
+ }
117
+ });
118
+
119
+ server.listen(CALLBACK_PORT, () => {
120
+ const authorizeUrl = new URL(`${COGNITO_DOMAIN}/oauth2/authorize`);
121
+ authorizeUrl.searchParams.set('response_type', 'code');
122
+ authorizeUrl.searchParams.set('client_id', CLI_CLIENT_ID);
123
+ authorizeUrl.searchParams.set('redirect_uri', CALLBACK_URL);
124
+ authorizeUrl.searchParams.set('scope', 'email openid profile');
125
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge);
126
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
127
+ authorizeUrl.searchParams.set('state', state);
128
+
129
+ // Skip hosted UI and go straight to provider if specified
130
+ if (provider) {
131
+ authorizeUrl.searchParams.set('identity_provider', provider);
132
+ }
133
+
134
+ open(authorizeUrl.toString());
135
+ });
136
+
137
+ // Timeout after 2 minutes
138
+ setTimeout(() => {
139
+ server.close();
140
+ reject(new Error('Login timed out — no callback received within 2 minutes'));
141
+ }, 120000);
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Refresh tokens using the stored refresh token.
147
+ * Returns the new ID token.
148
+ */
149
+ export async function refreshTokens() {
150
+ const creds = getCredentials();
151
+ if (!creds?.refreshToken) {
152
+ throw new Error('Not logged in. Run: trucontext login');
153
+ }
154
+
155
+ const res = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
158
+ body: new URLSearchParams({
159
+ grant_type: 'refresh_token',
160
+ client_id: CLI_CLIENT_ID,
161
+ refresh_token: creds.refreshToken,
162
+ }),
163
+ });
164
+
165
+ const data = await res.json();
166
+
167
+ if (!res.ok || data.error) {
168
+ throw new Error(data.error_description || data.error || 'Token refresh failed. Run: trucontext login');
169
+ }
170
+
171
+ saveCredentials({
172
+ ...creds,
173
+ idToken: data.id_token,
174
+ accessToken: data.access_token,
175
+ expiresAt: Date.now() + data.expires_in * 1000,
176
+ // Cognito does NOT return a new refresh_token on refresh — keep the original
177
+ });
178
+
179
+ return data.id_token;
180
+ }
181
+
182
+ /**
183
+ * Get a valid ID token, refreshing if needed.
184
+ */
185
+ export async function ensureFreshToken() {
186
+ const creds = getCredentials();
187
+ if (!creds) {
188
+ throw new Error('Not logged in. Run: trucontext login');
189
+ }
190
+
191
+ // Refresh if within 60 seconds of expiry
192
+ if (Date.now() > (creds.expiresAt || 0) - 60000) {
193
+ return await refreshTokens();
194
+ }
195
+
196
+ return creds.idToken;
197
+ }
package/src/client.js ADDED
@@ -0,0 +1,77 @@
1
+ // client.js — HTTP client for both API planes
2
+ // Auto-refreshes JWT, attaches active app header.
3
+
4
+ import { ensureFreshToken } from './auth.js';
5
+ import { getActiveApp, DATA_PLANE_URL, CONTROL_PLANE_URL } from './config.js';
6
+
7
+ async function request(baseUrl, method, path, body, retry = true) {
8
+ const token = await ensureFreshToken();
9
+ const activeApp = getActiveApp();
10
+
11
+ const headers = {
12
+ 'Authorization': `Bearer ${token}`,
13
+ 'Content-Type': 'application/json',
14
+ };
15
+ if (activeApp) {
16
+ headers['X-TruContext-App'] = activeApp;
17
+ }
18
+
19
+ const ac = new AbortController();
20
+ const timeout = setTimeout(() => ac.abort(), 15000);
21
+
22
+ try {
23
+ const res = await fetch(`${baseUrl}${path}`, {
24
+ method,
25
+ headers,
26
+ body: body ? JSON.stringify(body) : undefined,
27
+ signal: ac.signal,
28
+ });
29
+
30
+ // Retry once on 401 after refresh
31
+ if (res.status === 401 && retry) {
32
+ const newToken = await ensureFreshToken();
33
+ headers['Authorization'] = `Bearer ${newToken}`;
34
+ const retryRes = await fetch(`${baseUrl}${path}`, {
35
+ method,
36
+ headers,
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ signal: ac.signal,
39
+ });
40
+ return handleResponse(retryRes);
41
+ }
42
+
43
+ return handleResponse(res);
44
+ } finally {
45
+ clearTimeout(timeout);
46
+ }
47
+ }
48
+
49
+ async function handleResponse(res) {
50
+ const text = await res.text();
51
+ let data;
52
+ try {
53
+ data = JSON.parse(text);
54
+ } catch {
55
+ data = { raw: text };
56
+ }
57
+
58
+ if (!res.ok) {
59
+ const msg = data?.error || data?.message || `HTTP ${res.status}`;
60
+ const err = new Error(msg);
61
+ err.status = res.status;
62
+ err.data = data;
63
+ throw err;
64
+ }
65
+
66
+ return data;
67
+ }
68
+
69
+ // --- Public API ---
70
+
71
+ export function dataPlane(method, path, body) {
72
+ return request(DATA_PLANE_URL, method, path, body);
73
+ }
74
+
75
+ export function controlPlane(method, path, body) {
76
+ return request(CONTROL_PLANE_URL, method, path, body);
77
+ }
@@ -0,0 +1,48 @@
1
+ import chalk from 'chalk';
2
+ import { controlPlane } from '../client.js';
3
+ import { getActiveApp, setActiveApp } from '../config.js';
4
+
5
+ export async function appsCommand() {
6
+ try {
7
+ const res = await controlPlane('GET', '/apps');
8
+ const apps = res.data?.apps || [];
9
+ const activeApp = getActiveApp();
10
+
11
+ if (apps.length === 0) {
12
+ console.log(chalk.yellow('No apps. Create one in the dashboard.'));
13
+ return;
14
+ }
15
+
16
+ for (const app of apps) {
17
+ const active = app.appId === activeApp ? chalk.green(' (active)') : '';
18
+ console.log(`${chalk.bold(app.name)}${active}`);
19
+ console.log(` ${chalk.dim(app.appId)} — ${app.status}`);
20
+ }
21
+ } catch (err) {
22
+ console.error(chalk.red(`Failed: ${err.message}`));
23
+ process.exit(1);
24
+ }
25
+ }
26
+
27
+ export async function useCommand(appId) {
28
+ try {
29
+ const res = await controlPlane('GET', '/apps');
30
+ const apps = res.data?.apps || [];
31
+ const app = apps.find(a => a.appId === appId || a.name.toLowerCase() === appId.toLowerCase());
32
+
33
+ if (!app) {
34
+ console.error(chalk.red(`App not found: ${appId}`));
35
+ console.log(chalk.dim('Available apps:'));
36
+ for (const a of apps) {
37
+ console.log(` ${a.name} ${chalk.dim(a.appId)}`);
38
+ }
39
+ process.exit(1);
40
+ }
41
+
42
+ setActiveApp(app.appId);
43
+ console.log(chalk.green(`Active app: ${chalk.bold(app.name)} (${app.appId})`));
44
+ } catch (err) {
45
+ console.error(chalk.red(`Failed: ${err.message}`));
46
+ process.exit(1);
47
+ }
48
+ }
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { dataPlane } from '../client.js';
3
+
4
+ export async function contextsListCommand() {
5
+ try {
6
+ const res = await dataPlane('GET', '/v1/contexts');
7
+ const contexts = res.data?.contexts || [];
8
+
9
+ if (contexts.length === 0) {
10
+ console.log(chalk.yellow('No contexts. Create one with: trucontext contexts create <name>'));
11
+ return;
12
+ }
13
+
14
+ for (const ctx of contexts) {
15
+ console.log(`${chalk.bold(ctx.name)} ${chalk.dim(ctx.contextId)}`);
16
+ if (ctx.metadata && Object.keys(ctx.metadata).length > 0) {
17
+ console.log(` ${chalk.dim(JSON.stringify(ctx.metadata))}`);
18
+ }
19
+ }
20
+ } catch (err) {
21
+ console.error(chalk.red(`Failed: ${err.message}`));
22
+ process.exit(1);
23
+ }
24
+ }
25
+
26
+ export async function contextsCreateCommand(name, options) {
27
+ try {
28
+ const body = { name };
29
+ if (options.metadata) {
30
+ try {
31
+ body.metadata = JSON.parse(options.metadata);
32
+ } catch {
33
+ console.error(chalk.red('Invalid metadata JSON'));
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ const res = await dataPlane('POST', '/v1/contexts', body);
39
+ const ctx = res.data;
40
+ console.log(chalk.green(`Created: ${chalk.bold(ctx.name)}`));
41
+ console.log(chalk.dim(`Context ID: ${ctx.contextId}`));
42
+ } catch (err) {
43
+ console.error(chalk.red(`Failed: ${err.message}`));
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ export async function contextsDeleteCommand(contextId) {
49
+ try {
50
+ await dataPlane('DELETE', `/v1/contexts/${contextId}`);
51
+ console.log(chalk.green(`Deleted: ${contextId}`));
52
+ } catch (err) {
53
+ console.error(chalk.red(`Failed: ${err.message}`));
54
+ process.exit(1);
55
+ }
56
+ }
@@ -0,0 +1,141 @@
1
+ import chalk from 'chalk';
2
+ import { dataPlane } from '../client.js';
3
+
4
+ export async function entitiesListCommand(options) {
5
+ try {
6
+ const params = new URLSearchParams();
7
+ if (options.type) params.set('type', options.type);
8
+ if (options.limit) params.set('limit', options.limit);
9
+ const qs = params.toString();
10
+ const path = `/v1/entities${qs ? `?${qs}` : ''}`;
11
+
12
+ const res = await dataPlane('GET', path);
13
+ const entities = res.data?.entities || [];
14
+
15
+ if (entities.length === 0) {
16
+ console.log(chalk.yellow('No entities found.'));
17
+ return;
18
+ }
19
+
20
+ for (const e of entities) {
21
+ console.log(`${chalk.bold(e.entityId)} ${chalk.dim(`[${e.type}]`)}`);
22
+ if (e.recipe_id) console.log(` recipe: ${chalk.dim(e.recipe_id)}`);
23
+ if (e.properties && Object.keys(e.properties).length > 0) {
24
+ console.log(` properties: ${chalk.dim(JSON.stringify(e.properties))}`);
25
+ }
26
+ if (e.heartbeat_enabled !== undefined) console.log(` heartbeat: ${chalk.dim(e.heartbeat_enabled)}`);
27
+ if (e.confidence !== undefined) console.log(` confidence: ${chalk.dim(e.confidence)}`);
28
+ if (e.temporal !== undefined) console.log(` temporal: ${chalk.dim(e.temporal)}`);
29
+ }
30
+ } catch (err) {
31
+ console.error(chalk.red(`Failed: ${err.message}`));
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ export async function entitiesGetCommand(entityId) {
37
+ try {
38
+ const res = await dataPlane('GET', `/v1/entities/${entityId}`);
39
+ const e = res.data || res;
40
+
41
+ console.log(`${chalk.bold(e.entityId)} ${chalk.dim(`[${e.type}]`)}`);
42
+ if (e.recipe_id) console.log(` recipe: ${chalk.dim(e.recipe_id)}`);
43
+ if (e.properties && Object.keys(e.properties).length > 0) {
44
+ console.log(` properties: ${chalk.dim(JSON.stringify(e.properties))}`);
45
+ }
46
+ if (e.heartbeat_enabled !== undefined) console.log(` heartbeat: ${chalk.dim(e.heartbeat_enabled)}`);
47
+ if (e.confidence !== undefined) console.log(` confidence: ${chalk.dim(e.confidence)}`);
48
+ if (e.temporal !== undefined) console.log(` temporal: ${chalk.dim(e.temporal)}`);
49
+ } catch (err) {
50
+ console.error(chalk.red(`Failed: ${err.message}`));
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ export async function entitiesCreateCommand(options) {
56
+ try {
57
+ if (!options.id) {
58
+ console.error(chalk.red('--id is required'));
59
+ process.exit(1);
60
+ }
61
+ if (!options.type) {
62
+ console.error(chalk.red('--type is required'));
63
+ process.exit(1);
64
+ }
65
+
66
+ const body = {
67
+ entityId: options.id,
68
+ type: options.type,
69
+ };
70
+
71
+ if (options.properties) {
72
+ try {
73
+ body.properties = JSON.parse(options.properties);
74
+ } catch {
75
+ console.error(chalk.red('Invalid properties JSON'));
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ if (options.recipe) body.recipe_id = options.recipe;
81
+ if (options.heartbeat === false) body.heartbeat_enabled = false;
82
+ if (options.confidence !== undefined) body.confidence = parseFloat(options.confidence);
83
+ if (options.temporal !== undefined) body.temporal = options.temporal;
84
+
85
+ if (options.context?.length > 0) {
86
+ body.contexts = options.context.map(entry => {
87
+ const colonIdx = entry.indexOf(':');
88
+ if (colonIdx === -1) {
89
+ return { context_id: entry, relationship: 'ABOUT' };
90
+ }
91
+ return {
92
+ context_id: entry.slice(0, colonIdx),
93
+ relationship: entry.slice(colonIdx + 1),
94
+ };
95
+ });
96
+ }
97
+
98
+ const res = await dataPlane('POST', '/v1/entities', body);
99
+ const e = res.data || res;
100
+ console.log(chalk.green(`Created: ${chalk.bold(e.entityId || options.id)}`));
101
+ } catch (err) {
102
+ console.error(chalk.red(`Failed: ${err.message}`));
103
+ process.exit(1);
104
+ }
105
+ }
106
+
107
+ export async function entitiesUpdateCommand(entityId, options) {
108
+ try {
109
+ const body = {};
110
+
111
+ if (options.properties) {
112
+ try {
113
+ body.properties = JSON.parse(options.properties);
114
+ } catch {
115
+ console.error(chalk.red('Invalid properties JSON'));
116
+ process.exit(1);
117
+ }
118
+ }
119
+
120
+ if (options.recipe) body.recipe_id = options.recipe;
121
+ if (options.heartbeat !== undefined) body.heartbeat_enabled = options.heartbeat;
122
+ if (options.confidence !== undefined) body.confidence = parseFloat(options.confidence);
123
+ if (options.temporal !== undefined) body.temporal = options.temporal;
124
+
125
+ await dataPlane('PUT', `/v1/entities/${entityId}`, body);
126
+ console.log(chalk.green(`Updated: ${entityId}`));
127
+ } catch (err) {
128
+ console.error(chalk.red(`Failed: ${err.message}`));
129
+ process.exit(1);
130
+ }
131
+ }
132
+
133
+ export async function entitiesDeleteCommand(entityId) {
134
+ try {
135
+ await dataPlane('DELETE', `/v1/entities/${entityId}`);
136
+ console.log(chalk.green(`Deleted: ${entityId}`));
137
+ } catch (err) {
138
+ console.error(chalk.red(`Failed: ${err.message}`));
139
+ process.exit(1);
140
+ }
141
+ }
@@ -0,0 +1,59 @@
1
+ import chalk from 'chalk';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { basename } from 'path';
4
+ import { dataPlane } from '../client.js';
5
+
6
+ export async function ingestCommand(source, options) {
7
+ try {
8
+ let body;
9
+
10
+ if (existsSync(source)) {
11
+ const content = readFileSync(source);
12
+ const filename = basename(source);
13
+ const ext = filename.split('.').pop().toLowerCase();
14
+
15
+ const textExts = ['txt', 'md', 'csv', 'html', 'json'];
16
+ const isText = textExts.includes(ext);
17
+
18
+ body = {
19
+ content: isText ? content.toString('utf-8') : content.toString('base64'),
20
+ content_type: isText ? 'text/plain' : `application/${ext}`,
21
+ encoding: isText ? undefined : 'base64',
22
+ metadata: { original_filename: filename, source_medium: 'cli' },
23
+ };
24
+ } else {
25
+ body = {
26
+ content: source,
27
+ content_type: 'text/plain',
28
+ metadata: { source_medium: 'cli' },
29
+ };
30
+ }
31
+
32
+ if (options.confidence !== undefined) body.confidence = parseFloat(options.confidence);
33
+ if (options.temporal !== undefined) body.temporal = options.temporal;
34
+
35
+ // Build contexts array from --context flags: "entity-id:RELATIONSHIP"
36
+ if (options.context?.length > 0) {
37
+ body.contexts = options.context.map(entry => {
38
+ const colonIdx = entry.indexOf(':');
39
+ if (colonIdx === -1) {
40
+ return { context_id: entry, relationship: 'ABOUT' };
41
+ }
42
+ return {
43
+ context_id: entry.slice(0, colonIdx),
44
+ relationship: entry.slice(colonIdx + 1),
45
+ };
46
+ });
47
+ }
48
+
49
+ const res = await dataPlane('POST', '/v1/ingest', body);
50
+ console.log(chalk.green('Accepted'));
51
+ console.log(chalk.dim(`Content ID: ${res.contentId}`));
52
+ if (res.status === 'upload_required') {
53
+ console.log(chalk.yellow(`Large file — upload to: ${res.uploadUrl}`));
54
+ }
55
+ } catch (err) {
56
+ console.error(chalk.red(`Ingest failed: ${err.message}`));
57
+ process.exit(1);
58
+ }
59
+ }
@@ -0,0 +1,81 @@
1
+ import chalk from 'chalk';
2
+ import input from '@inquirer/input';
3
+ import select from '@inquirer/select';
4
+ import { controlPlane } from '../client.js';
5
+ import { setActiveApp } from '../config.js';
6
+
7
+ export async function initCommand(name, options) {
8
+ // Step 1: App name
9
+ if (!name) {
10
+ name = await input({ message: 'App name:' });
11
+ }
12
+
13
+ // Step 2: Description (required for schema generation)
14
+ let description = options.description;
15
+ if (!description) {
16
+ description = await input({
17
+ message: 'Describe your app (what data will it store, who uses it):',
18
+ });
19
+ }
20
+
21
+ // Step 3: Authorship model
22
+ const authorship = await select({
23
+ message: 'Who creates content in your app?',
24
+ choices: [
25
+ { name: 'Multiple users contribute content', value: 'multi', description: 'e.g. team wikis, social apps, collaboration tools' },
26
+ { name: 'Single author / personal use', value: 'single', description: 'e.g. personal notes, solo projects' },
27
+ { name: 'The app itself generates content', value: 'app', description: 'e.g. automated pipelines, bots, scrapers' },
28
+ ],
29
+ });
30
+
31
+ // Step 4: Create the app
32
+ console.log(chalk.dim(`\nCreating app "${name}"...`));
33
+
34
+ try {
35
+ const res = await controlPlane('POST', '/apps', { name, description });
36
+ const app = res.data;
37
+ setActiveApp(app.appId);
38
+
39
+ console.log(chalk.green(`App created: ${app.appId}`));
40
+
41
+ // Step 5: Generate schema
42
+ console.log(chalk.dim('Generating schema from description...'));
43
+
44
+ const schemaRes = await controlPlane('POST', '/schema/generate', {
45
+ description,
46
+ authorship_model: authorship,
47
+ });
48
+ const schema = schemaRes.data?.schema;
49
+
50
+ if (schema) {
51
+ // Step 6: Save schema to app
52
+ await controlPlane('PUT', `/apps/${app.appId}/schema`, schema);
53
+
54
+ console.log(chalk.green('Schema generated and saved.\n'));
55
+
56
+ // Show a summary
57
+ if (schema.entity_types) {
58
+ console.log(chalk.bold('Entity types:'));
59
+ for (const et of schema.entity_types) {
60
+ console.log(` ${chalk.cyan(et.name)} ${chalk.dim(et.description || '')}`);
61
+ }
62
+ }
63
+ if (schema.edge_types) {
64
+ console.log(chalk.bold('\nRelationships:'));
65
+ for (const edge of schema.edge_types) {
66
+ console.log(` ${chalk.dim(edge.from)} ${chalk.yellow(`—${edge.name}→`)} ${chalk.dim(edge.to)}`);
67
+ }
68
+ }
69
+ } else {
70
+ console.log(chalk.yellow('Schema generation returned empty — you can generate one later:'));
71
+ console.log(chalk.cyan(' trucontext schema generate -d "your description"'));
72
+ }
73
+
74
+ console.log(chalk.dim(`\nReady to go:`));
75
+ console.log(chalk.cyan(' trucontext ingest <file>'));
76
+ console.log(chalk.cyan(' trucontext query "your question"'));
77
+ } catch (err) {
78
+ console.error(chalk.red(`Failed: ${err.message}`));
79
+ process.exit(1);
80
+ }
81
+ }