@agentic-kit/cli 0.8.2 → 0.9.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/README.md +15 -2
- package/account-store.d.ts +19 -0
- package/account-store.js +59 -0
- package/api-key.d.ts +51 -0
- package/api-key.js +96 -0
- package/auth-error.d.ts +4 -0
- package/auth-error.js +114 -0
- package/auth.d.ts +28 -0
- package/auth.js +119 -0
- package/backend-store.d.ts +19 -0
- package/backend-store.js +64 -0
- package/commands.d.ts +3 -0
- package/commands.js +112 -0
- package/config.d.ts +16 -0
- package/config.js +53 -1
- package/credentials.d.ts +11 -0
- package/credentials.js +32 -0
- package/db-tools.d.ts +2 -2
- package/db-tools.js +14 -18
- package/esm/account-store.d.ts +19 -0
- package/esm/account-store.js +54 -0
- package/esm/api-key.d.ts +51 -0
- package/esm/api-key.js +88 -0
- package/esm/auth-error.d.ts +4 -0
- package/esm/auth-error.js +108 -0
- package/esm/auth.d.ts +28 -0
- package/esm/auth.js +114 -0
- package/esm/backend-store.d.ts +19 -0
- package/esm/backend-store.js +58 -0
- package/esm/commands.d.ts +3 -0
- package/esm/commands.js +109 -0
- package/esm/config.d.ts +16 -0
- package/esm/config.js +51 -0
- package/esm/credentials.d.ts +11 -0
- package/esm/credentials.js +28 -0
- package/esm/db-tools.d.ts +2 -2
- package/esm/db-tools.js +14 -18
- package/esm/index.js +24 -1
- package/index.js +23 -0
- package/package.json +7 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
2
|
+
'ECONNREFUSED',
|
|
3
|
+
'ECONNRESET',
|
|
4
|
+
'ENOTFOUND',
|
|
5
|
+
'EAI_AGAIN',
|
|
6
|
+
'EHOSTUNREACH',
|
|
7
|
+
'ENETUNREACH',
|
|
8
|
+
'ETIMEDOUT',
|
|
9
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
10
|
+
'UND_ERR_SOCKET'
|
|
11
|
+
]);
|
|
12
|
+
function errorCode(err) {
|
|
13
|
+
if (!err || typeof err !== 'object')
|
|
14
|
+
return undefined;
|
|
15
|
+
const { code, cause } = err;
|
|
16
|
+
if (typeof code === 'string')
|
|
17
|
+
return code;
|
|
18
|
+
if (cause && typeof cause === 'object') {
|
|
19
|
+
const causeCode = cause.code;
|
|
20
|
+
if (typeof causeCode === 'string')
|
|
21
|
+
return causeCode;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function isNetworkError(err) {
|
|
26
|
+
const code = errorCode(err);
|
|
27
|
+
if (code !== undefined)
|
|
28
|
+
return NETWORK_ERROR_CODES.has(code);
|
|
29
|
+
if (err instanceof AggregateError)
|
|
30
|
+
return true;
|
|
31
|
+
return err instanceof TypeError && /fetch failed/i.test(err.message);
|
|
32
|
+
}
|
|
33
|
+
function extractGraphqlMessages(err) {
|
|
34
|
+
const messages = [];
|
|
35
|
+
const codes = [];
|
|
36
|
+
const visited = new Set();
|
|
37
|
+
const visit = (value) => {
|
|
38
|
+
if (!value || typeof value !== 'object' || visited.has(value))
|
|
39
|
+
return;
|
|
40
|
+
visited.add(value);
|
|
41
|
+
const record = value;
|
|
42
|
+
if (typeof record.message === 'string' && record.message.trim() !== '') {
|
|
43
|
+
const message = record.message.trim().replace(/^GraphQL Error:\s*/u, '');
|
|
44
|
+
if (message.length > 0)
|
|
45
|
+
messages.push(message);
|
|
46
|
+
}
|
|
47
|
+
const extensions = record.extensions && typeof record.extensions === 'object'
|
|
48
|
+
? record.extensions
|
|
49
|
+
: undefined;
|
|
50
|
+
if (typeof extensions?.code === 'string')
|
|
51
|
+
codes.push(extensions.code);
|
|
52
|
+
if (Array.isArray(record.errors))
|
|
53
|
+
record.errors.forEach(visit);
|
|
54
|
+
if (record.cause)
|
|
55
|
+
visit(record.cause);
|
|
56
|
+
if (record.extensions)
|
|
57
|
+
visit(record.extensions);
|
|
58
|
+
if (record.detail)
|
|
59
|
+
visit(record.detail);
|
|
60
|
+
if (record.data)
|
|
61
|
+
visit(record.data);
|
|
62
|
+
if (record.response)
|
|
63
|
+
visit(record.response);
|
|
64
|
+
if (record.result)
|
|
65
|
+
visit(record.result);
|
|
66
|
+
if (record.payload)
|
|
67
|
+
visit(record.payload);
|
|
68
|
+
};
|
|
69
|
+
visit(err);
|
|
70
|
+
if (messages.length === 0) {
|
|
71
|
+
for (const code of codes) {
|
|
72
|
+
if (code === 'UNAUTHENTICATED')
|
|
73
|
+
messages.push('Your session has expired. Sign in again.');
|
|
74
|
+
if (code === 'FORBIDDEN') {
|
|
75
|
+
messages.push('The server rejected this request. Check your account permissions.');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...new Set(messages)];
|
|
80
|
+
}
|
|
81
|
+
export function describeAuthError(err, endpoint) {
|
|
82
|
+
if (isNetworkError(err)) {
|
|
83
|
+
return `Could not reach the server at ${endpoint}. Check that it is running, or pick a different backend.`;
|
|
84
|
+
}
|
|
85
|
+
const graphqlMessages = extractGraphqlMessages(err);
|
|
86
|
+
if (graphqlMessages.length > 0)
|
|
87
|
+
return graphqlMessages.join('; ');
|
|
88
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
89
|
+
if (message.trim() !== '' && message !== '[object Object]')
|
|
90
|
+
return message;
|
|
91
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
92
|
+
return `Authentication failed unexpectedly (${name}).`;
|
|
93
|
+
}
|
|
94
|
+
export const AUTH_TIMEOUT_MS = 10_000;
|
|
95
|
+
export function withAuthTimeout(promise, endpoint, ms = AUTH_TIMEOUT_MS) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
reject(new Error(`The server at ${endpoint} did not respond within ${Math.round(ms / 1000)}s. Check the backend settings and try again.`));
|
|
99
|
+
}, ms);
|
|
100
|
+
promise.then(value => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
resolve(value);
|
|
103
|
+
}, (err) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
package/esm/auth.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ConfigStore } from 'appstash';
|
|
2
|
+
import { AccountSession } from './account-store';
|
|
3
|
+
export type ApiKeyRefreshStatus = 'ok' | 'reminted' | 'reauth-required' | 'unavailable' | 'signed-out';
|
|
4
|
+
/**
|
|
5
|
+
* Ensure the stored session carries a usable, not-about-to-expire API key.
|
|
6
|
+
* Never throws: returns a status the caller can surface. A mint/re-mint needs
|
|
7
|
+
* the fresh-password step-up window, so it succeeds right after a sign-in and
|
|
8
|
+
* degrades to 'reauth-required' when attempted cold.
|
|
9
|
+
*/
|
|
10
|
+
export declare function refreshApiKeyIfNeeded({ store, context, authEndpoint }: {
|
|
11
|
+
store: ConfigStore;
|
|
12
|
+
/** Backend context the session is filed under; defaults to the active one. */
|
|
13
|
+
context?: string;
|
|
14
|
+
authEndpoint: string;
|
|
15
|
+
}): Promise<ApiKeyRefreshStatus>;
|
|
16
|
+
export declare function signIn({ store, context, authEndpoint, email, password }: {
|
|
17
|
+
store: ConfigStore;
|
|
18
|
+
context?: string;
|
|
19
|
+
authEndpoint: string;
|
|
20
|
+
email: string;
|
|
21
|
+
password: string;
|
|
22
|
+
}): Promise<AccountSession>;
|
|
23
|
+
export declare function signOut({ store, context, authEndpoint }: {
|
|
24
|
+
store: ConfigStore;
|
|
25
|
+
/** Backend context the session is filed under; defaults to the active one. */
|
|
26
|
+
context?: string;
|
|
27
|
+
authEndpoint: string;
|
|
28
|
+
}): Promise<boolean>;
|
package/esm/auth.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { auth } from '@constructive-io/sdk';
|
|
2
|
+
import { clearSession, loadSession, saveSession } from './account-store';
|
|
3
|
+
import { API_KEY_YEARS, buildCreateApiKeyInput, classifyApiKeyError, needsRemint, parseMintedKey, remintApiKey } from './api-key';
|
|
4
|
+
import { describeAuthError, withAuthTimeout } from './auth-error';
|
|
5
|
+
const SELECT = {
|
|
6
|
+
result: {
|
|
7
|
+
select: { userId: true, accessToken: true, accessTokenExpiresAt: true }
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
const CREATE_KEY_SELECT = {
|
|
11
|
+
result: { select: { apiKey: true, keyId: true, expiresAt: true } }
|
|
12
|
+
};
|
|
13
|
+
const REVOKE_KEY_SELECT = { result: true };
|
|
14
|
+
function authedClient(endpoint, bearer) {
|
|
15
|
+
return auth.createClient({
|
|
16
|
+
endpoint,
|
|
17
|
+
headers: { Authorization: `Bearer ${bearer}` }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async function mint(client) {
|
|
21
|
+
const data = (await client.mutation
|
|
22
|
+
.createApiKey({ input: buildCreateApiKeyInput({ years: API_KEY_YEARS }) }, { select: CREATE_KEY_SELECT })
|
|
23
|
+
.unwrap());
|
|
24
|
+
return parseMintedKey(data?.createApiKey?.result);
|
|
25
|
+
}
|
|
26
|
+
async function revoke(client, keyId) {
|
|
27
|
+
await client.mutation.revokeApiKey({ input: { keyId } }, { select: REVOKE_KEY_SELECT }).unwrap();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Ensure the stored session carries a usable, not-about-to-expire API key.
|
|
31
|
+
* Never throws: returns a status the caller can surface. A mint/re-mint needs
|
|
32
|
+
* the fresh-password step-up window, so it succeeds right after a sign-in and
|
|
33
|
+
* degrades to 'reauth-required' when attempted cold.
|
|
34
|
+
*/
|
|
35
|
+
export async function refreshApiKeyIfNeeded({ store, context, authEndpoint }) {
|
|
36
|
+
const session = loadSession(store, context);
|
|
37
|
+
if (!session)
|
|
38
|
+
return 'signed-out';
|
|
39
|
+
const hasKey = !!session.apiKey;
|
|
40
|
+
const due = needsRemint({
|
|
41
|
+
apiKeyExpiresAt: session.apiKeyExpiresAt,
|
|
42
|
+
now: Date.now()
|
|
43
|
+
});
|
|
44
|
+
if (hasKey && !due)
|
|
45
|
+
return 'ok';
|
|
46
|
+
const client = authedClient(authEndpoint, session.accessToken);
|
|
47
|
+
try {
|
|
48
|
+
const minted = await remintApiKey({
|
|
49
|
+
currentKeyId: session.keyId,
|
|
50
|
+
revoke: (keyId) => revoke(client, keyId),
|
|
51
|
+
mint: () => mint(client)
|
|
52
|
+
});
|
|
53
|
+
saveSession(store, {
|
|
54
|
+
...session,
|
|
55
|
+
apiKey: minted.apiKey,
|
|
56
|
+
keyId: minted.keyId,
|
|
57
|
+
apiKeyExpiresAt: minted.apiKeyExpiresAt
|
|
58
|
+
}, context);
|
|
59
|
+
return 'reminted';
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const kind = classifyApiKeyError(err);
|
|
63
|
+
if (kind === 'step-up-required' || kind === 'not-authenticated')
|
|
64
|
+
return 'reauth-required';
|
|
65
|
+
console.warn(`[agent] API key refresh failed (${kind}): ${describeAuthError(err, authEndpoint)}`);
|
|
66
|
+
return 'unavailable';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function signIn({ store, context, authEndpoint, email, password }) {
|
|
70
|
+
const trimmedEmail = email.trim();
|
|
71
|
+
if (!trimmedEmail || !password)
|
|
72
|
+
throw new Error('Email and password are required.');
|
|
73
|
+
const client = auth.createClient({ endpoint: authEndpoint });
|
|
74
|
+
const request = client.mutation
|
|
75
|
+
.signIn({ input: { email: trimmedEmail, password } }, { select: SELECT })
|
|
76
|
+
.unwrap();
|
|
77
|
+
let data;
|
|
78
|
+
try {
|
|
79
|
+
data = (await withAuthTimeout(request, authEndpoint));
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new Error(describeAuthError(err, authEndpoint));
|
|
83
|
+
}
|
|
84
|
+
const record = data?.signIn?.result;
|
|
85
|
+
if (!record?.accessToken || !record.userId) {
|
|
86
|
+
throw new Error('Authentication returned no access token (MFA may be required).');
|
|
87
|
+
}
|
|
88
|
+
saveSession(store, {
|
|
89
|
+
userId: record.userId,
|
|
90
|
+
email: trimmedEmail,
|
|
91
|
+
accessToken: record.accessToken,
|
|
92
|
+
...(record.accessTokenExpiresAt ? { accessTokenExpiresAt: record.accessTokenExpiresAt } : {}),
|
|
93
|
+
signedInAt: Date.now()
|
|
94
|
+
}, context);
|
|
95
|
+
// Mint the long-lived API key inside the fresh step-up window. Best-effort: a
|
|
96
|
+
// mint failure leaves a valid signed-in session that lacks a key until re-auth.
|
|
97
|
+
await refreshApiKeyIfNeeded({ store, context, authEndpoint });
|
|
98
|
+
return loadSession(store, context);
|
|
99
|
+
}
|
|
100
|
+
export async function signOut({ store, context, authEndpoint }) {
|
|
101
|
+
const session = loadSession(store, context);
|
|
102
|
+
if (!session)
|
|
103
|
+
return false;
|
|
104
|
+
if (session.keyId) {
|
|
105
|
+
try {
|
|
106
|
+
await revoke(authedClient(authEndpoint, session.accessToken), session.keyId);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.warn(`[agent] API key revoke on sign-out failed: ${describeAuthError(err, authEndpoint)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
clearSession(store, context);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ConfigStore } from 'appstash';
|
|
2
|
+
export interface BackendConfig {
|
|
3
|
+
apiEndpoint: string;
|
|
4
|
+
authEndpoint: string;
|
|
5
|
+
modulesEndpoint: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const BACKEND_PRESETS: Record<string, BackendConfig>;
|
|
8
|
+
/** Context name used for endpoints that match no preset. */
|
|
9
|
+
export declare const CUSTOM_CONTEXT = "custom";
|
|
10
|
+
/**
|
|
11
|
+
* A chosen backend is a named context in the shared store: the preset name when
|
|
12
|
+
* the endpoints match one, `custom` otherwise. Credentials hang off the context,
|
|
13
|
+
* so signing in against localnet and devnet keeps two independent sessions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function contextNameFor(config: BackendConfig): string;
|
|
16
|
+
/** Endpoints of the active context, or null when no backend has been chosen. */
|
|
17
|
+
export declare function loadBackendConfig(store: ConfigStore): BackendConfig | null;
|
|
18
|
+
/** Persist the endpoints as a context and make it active. */
|
|
19
|
+
export declare function saveBackendConfig(store: ConfigStore, config: BackendConfig): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const BACKEND_PRESETS = {
|
|
2
|
+
localnet: {
|
|
3
|
+
apiEndpoint: 'http://api.localhost:3000/graphql',
|
|
4
|
+
authEndpoint: 'http://auth.localhost:3000/graphql',
|
|
5
|
+
modulesEndpoint: 'http://modules.localhost:3000/graphql'
|
|
6
|
+
},
|
|
7
|
+
devnet: {
|
|
8
|
+
apiEndpoint: 'https://api.launchql.dev/graphql',
|
|
9
|
+
authEndpoint: 'https://auth.launchql.dev/graphql',
|
|
10
|
+
modulesEndpoint: 'https://modules.launchql.dev/graphql'
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** Context name used for endpoints that match no preset. */
|
|
14
|
+
export const CUSTOM_CONTEXT = 'custom';
|
|
15
|
+
/**
|
|
16
|
+
* A chosen backend is a named context in the shared store: the preset name when
|
|
17
|
+
* the endpoints match one, `custom` otherwise. Credentials hang off the context,
|
|
18
|
+
* so signing in against localnet and devnet keeps two independent sessions.
|
|
19
|
+
*/
|
|
20
|
+
export function contextNameFor(config) {
|
|
21
|
+
for (const [name, preset] of Object.entries(BACKEND_PRESETS)) {
|
|
22
|
+
if (preset.apiEndpoint === config.apiEndpoint &&
|
|
23
|
+
preset.authEndpoint === config.authEndpoint &&
|
|
24
|
+
preset.modulesEndpoint === config.modulesEndpoint) {
|
|
25
|
+
return name;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return CUSTOM_CONTEXT;
|
|
29
|
+
}
|
|
30
|
+
function toBackendConfig(targets) {
|
|
31
|
+
const apiEndpoint = targets?.api?.endpoint;
|
|
32
|
+
const authEndpoint = targets?.auth?.endpoint;
|
|
33
|
+
const modulesEndpoint = targets?.modules?.endpoint;
|
|
34
|
+
if (!apiEndpoint || !authEndpoint || !modulesEndpoint)
|
|
35
|
+
return null;
|
|
36
|
+
return { apiEndpoint, authEndpoint, modulesEndpoint };
|
|
37
|
+
}
|
|
38
|
+
/** Endpoints of the active context, or null when no backend has been chosen. */
|
|
39
|
+
export function loadBackendConfig(store) {
|
|
40
|
+
const ctx = store.getCurrentContext();
|
|
41
|
+
if (!ctx)
|
|
42
|
+
return null;
|
|
43
|
+
return toBackendConfig(ctx.targets);
|
|
44
|
+
}
|
|
45
|
+
/** Persist the endpoints as a context and make it active. */
|
|
46
|
+
export function saveBackendConfig(store, config) {
|
|
47
|
+
const name = contextNameFor(config);
|
|
48
|
+
store.createContext(name, {
|
|
49
|
+
endpoint: config.apiEndpoint,
|
|
50
|
+
targets: {
|
|
51
|
+
api: { endpoint: config.apiEndpoint },
|
|
52
|
+
auth: { endpoint: config.authEndpoint },
|
|
53
|
+
modules: { endpoint: config.modulesEndpoint }
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
store.setCurrentContext(name);
|
|
57
|
+
return name;
|
|
58
|
+
}
|
package/esm/commands.d.ts
CHANGED
|
@@ -2,4 +2,7 @@ import { AgentCliConfig } from './config';
|
|
|
2
2
|
export declare function skillsList(config: AgentCliConfig): Promise<void>;
|
|
3
3
|
export declare function skillsUpdate(config: AgentCliConfig): Promise<void>;
|
|
4
4
|
export declare function init(config: AgentCliConfig, argv: Record<string, unknown>): Promise<void>;
|
|
5
|
+
export declare function login(config: AgentCliConfig, argv: Record<string, unknown>): Promise<void>;
|
|
6
|
+
export declare function logout(config: AgentCliConfig): Promise<void>;
|
|
7
|
+
export declare function whoami(config: AgentCliConfig): void;
|
|
5
8
|
export declare function usage(): void;
|
package/esm/commands.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { deriveSubdomainEndpoint } from '@agentic-kit/pi';
|
|
1
2
|
import { Inquirerer } from 'inquirerer';
|
|
3
|
+
import { loadSession } from './account-store';
|
|
4
|
+
import { signIn, signOut } from './auth';
|
|
5
|
+
import { BACKEND_PRESETS, contextNameFor, loadBackendConfig, saveBackendConfig } from './backend-store';
|
|
2
6
|
import { defaultManifest, saveManifestFile } from './config';
|
|
3
7
|
import { assembleSkills } from './skills';
|
|
4
8
|
const log = (msg) => console.log(`[agent] ${msg}`);
|
|
@@ -39,6 +43,108 @@ export async function init(config, argv) {
|
|
|
39
43
|
log(`wrote ${config.manifestFile}`);
|
|
40
44
|
log(`local overlay dir (highest precedence): ${config.overlayDir}`);
|
|
41
45
|
}
|
|
46
|
+
function maskKey(token) {
|
|
47
|
+
if (token.length <= 10)
|
|
48
|
+
return '****';
|
|
49
|
+
return `${token.slice(0, 6)}...${token.slice(-4)}`;
|
|
50
|
+
}
|
|
51
|
+
export async function login(config, argv) {
|
|
52
|
+
if (!process.stdin.isTTY && !(argv.email && argv.password)) {
|
|
53
|
+
throw new Error('agent login is interactive and needs a terminal. For headless use, set CONSTRUCTIVE_USER_ID, CONSTRUCTIVE_ACCESS_TOKEN, and CONSTRUCTIVE_API_KEY.');
|
|
54
|
+
}
|
|
55
|
+
const saved = loadBackendConfig(config.store);
|
|
56
|
+
const prompter = new Inquirerer({ noTty: !process.stdin.isTTY });
|
|
57
|
+
try {
|
|
58
|
+
const answers = await prompter.prompt(argv, [
|
|
59
|
+
{
|
|
60
|
+
type: 'list',
|
|
61
|
+
name: 'backend',
|
|
62
|
+
message: 'Backend',
|
|
63
|
+
options: [...Object.keys(BACKEND_PRESETS), 'custom'],
|
|
64
|
+
default: saved ? contextNameFor(saved) : 'localnet'
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: 'text',
|
|
68
|
+
name: 'apiUrl',
|
|
69
|
+
message: 'API GraphQL endpoint (e.g. https://api.example.com/graphql)',
|
|
70
|
+
default: saved?.apiEndpoint,
|
|
71
|
+
when: (a) => a.backend === 'custom'
|
|
72
|
+
},
|
|
73
|
+
{ type: 'text', name: 'email', message: 'Email' },
|
|
74
|
+
{ type: 'password', name: 'password', message: 'Password' }
|
|
75
|
+
]);
|
|
76
|
+
let backend;
|
|
77
|
+
if (answers.backend === 'custom') {
|
|
78
|
+
const apiUrl = String(answers.apiUrl ?? '').trim();
|
|
79
|
+
const authEndpoint = deriveSubdomainEndpoint(apiUrl, 'auth');
|
|
80
|
+
const modulesEndpoint = deriveSubdomainEndpoint(apiUrl, 'modules');
|
|
81
|
+
if (!apiUrl || !authEndpoint || !modulesEndpoint) {
|
|
82
|
+
throw new Error(`Invalid API endpoint URL: ${apiUrl || '(empty)'}`);
|
|
83
|
+
}
|
|
84
|
+
backend = { apiEndpoint: apiUrl, authEndpoint, modulesEndpoint };
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
backend = BACKEND_PRESETS[String(answers.backend)];
|
|
88
|
+
if (!backend)
|
|
89
|
+
throw new Error(`Unknown backend preset: ${answers.backend}`);
|
|
90
|
+
}
|
|
91
|
+
// File the session under the backend's context name, then commit the backend
|
|
92
|
+
// itself: a failed sign-in must leave neither behind.
|
|
93
|
+
const contextName = contextNameFor(backend);
|
|
94
|
+
const session = await signIn({
|
|
95
|
+
store: config.store,
|
|
96
|
+
context: contextName,
|
|
97
|
+
authEndpoint: backend.authEndpoint,
|
|
98
|
+
email: String(answers.email ?? ''),
|
|
99
|
+
password: String(answers.password ?? '')
|
|
100
|
+
});
|
|
101
|
+
saveBackendConfig(config.store, backend);
|
|
102
|
+
log(`signed in as ${session.email}`);
|
|
103
|
+
log(`backend: ${backend.apiEndpoint}`);
|
|
104
|
+
if (session.apiKey) {
|
|
105
|
+
log(`API key ${maskKey(session.apiKey)} (expires ${session.apiKeyExpiresAt ?? 'unknown'})`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
log('warning: API key mint failed — db tools stay signed out. Run `agent login` again to retry.');
|
|
109
|
+
}
|
|
110
|
+
log(`session stored in context ${contextName}`);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
prompter.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function logout(config) {
|
|
117
|
+
const backend = loadBackendConfig(config.store) ?? BACKEND_PRESETS.localnet;
|
|
118
|
+
const wasSignedIn = await signOut({
|
|
119
|
+
store: config.store,
|
|
120
|
+
authEndpoint: backend.authEndpoint
|
|
121
|
+
});
|
|
122
|
+
if (wasSignedIn)
|
|
123
|
+
log('signed out — API key revoked and session cleared.');
|
|
124
|
+
else
|
|
125
|
+
log('not signed in.');
|
|
126
|
+
}
|
|
127
|
+
export function whoami(config) {
|
|
128
|
+
const session = loadSession(config.store);
|
|
129
|
+
if (!session) {
|
|
130
|
+
log('not signed in — run `agent login`');
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const backend = loadBackendConfig(config.store);
|
|
135
|
+
log(`signed in as ${session.email}`);
|
|
136
|
+
log(`user id: ${session.userId}`);
|
|
137
|
+
log(`backend: ${backend?.apiEndpoint ?? 'unknown'}`);
|
|
138
|
+
if (session.apiKey) {
|
|
139
|
+
log(`API key: ${maskKey(session.apiKey)} (expires ${session.apiKeyExpiresAt ?? 'unknown'})`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
log('API key: none — db tools stay signed out. Run `agent login` to mint one.');
|
|
143
|
+
}
|
|
144
|
+
if (session.accessTokenExpiresAt)
|
|
145
|
+
log(`access token expires: ${session.accessTokenExpiresAt}`);
|
|
146
|
+
log(`context: ${config.store.getCurrentContext()?.name ?? 'none'}`);
|
|
147
|
+
}
|
|
42
148
|
export function usage() {
|
|
43
149
|
console.log(`agent — the pi coding agent with the Constructive harness baked in
|
|
44
150
|
|
|
@@ -46,6 +152,9 @@ Usage:
|
|
|
46
152
|
agent [pi options...] start an interactive session (pi TUI)
|
|
47
153
|
agent -p "prompt" one-shot print mode (pi)
|
|
48
154
|
agent init configure the skills source (repo + pin)
|
|
155
|
+
agent login sign in to the Constructive platform
|
|
156
|
+
agent logout revoke the API key and clear the session
|
|
157
|
+
agent whoami show the signed-in account
|
|
49
158
|
agent skills list resolve + list the effective skill set
|
|
50
159
|
agent skills update re-fetch the base release and re-materialize
|
|
51
160
|
agent help show this help
|
package/esm/config.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { HarnessDirs, SkillsManifest } from '@agentic-kit/harness';
|
|
2
|
+
import { ConfigStore } from 'appstash';
|
|
2
3
|
export declare const DEFAULT_SKILLS_REPO = "constructive-io/constructive-skills";
|
|
4
|
+
/**
|
|
5
|
+
* Directory identity shared with the csdk CLI and the desktop app, so a sign-in
|
|
6
|
+
* here is a sign-in there. `agent` is only the tool's own name (env prefixes,
|
|
7
|
+
* help text).
|
|
8
|
+
*/
|
|
9
|
+
export declare const STASH_NAME = "constructive";
|
|
10
|
+
export declare const TOOL_NAME = "agent";
|
|
3
11
|
export declare const DEFAULT_SKILLS_PIN = "main";
|
|
4
12
|
/** Layer names understood by the assembler. */
|
|
5
13
|
export declare const BASE_LAYER = "constructive-skills";
|
|
@@ -12,6 +20,8 @@ export interface AgentCliConfig {
|
|
|
12
20
|
overlayDir: string;
|
|
13
21
|
/** Path of the user-editable manifest: `<config>/skills-manifest.json`. */
|
|
14
22
|
manifestFile: string;
|
|
23
|
+
/** Shared contexts + credentials store (endpoints and signed-in session). */
|
|
24
|
+
store: ConfigStore;
|
|
15
25
|
manifest: SkillsManifest;
|
|
16
26
|
skillsRepo: string;
|
|
17
27
|
skillsPin: string;
|
|
@@ -22,6 +32,12 @@ interface ManifestFile {
|
|
|
22
32
|
manifest?: SkillsManifest;
|
|
23
33
|
}
|
|
24
34
|
export declare function defaultManifest(): SkillsManifest;
|
|
35
|
+
/**
|
|
36
|
+
* Move a pre-shared-store sign-in (`agent/account.json` +
|
|
37
|
+
* `agent/backend-config.json`) into the store, once. The originals are renamed
|
|
38
|
+
* rather than deleted, so a downgrade still finds them.
|
|
39
|
+
*/
|
|
40
|
+
export declare function importLegacyAgentFiles(store: ConfigStore, legacyDir: string): void;
|
|
25
41
|
export declare function loadConfig(baseDir?: string): AgentCliConfig;
|
|
26
42
|
export declare function saveManifestFile(config: AgentCliConfig, file: ManifestFile): void;
|
|
27
43
|
export {};
|
package/esm/config.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { harnessDirs } from '@agentic-kit/harness';
|
|
2
|
+
import { createConfigStore } from 'appstash';
|
|
2
3
|
import * as fs from 'fs';
|
|
3
4
|
import * as path from 'path';
|
|
5
|
+
import { saveSession } from './account-store';
|
|
6
|
+
import { saveBackendConfig } from './backend-store';
|
|
4
7
|
export const DEFAULT_SKILLS_REPO = 'constructive-io/constructive-skills';
|
|
8
|
+
/**
|
|
9
|
+
* Directory identity shared with the csdk CLI and the desktop app, so a sign-in
|
|
10
|
+
* here is a sign-in there. `agent` is only the tool's own name (env prefixes,
|
|
11
|
+
* help text).
|
|
12
|
+
*/
|
|
13
|
+
export const STASH_NAME = 'constructive';
|
|
14
|
+
export const TOOL_NAME = 'agent';
|
|
5
15
|
export const DEFAULT_SKILLS_PIN = 'main';
|
|
6
16
|
/** Layer names understood by the assembler. */
|
|
7
17
|
export const BASE_LAYER = 'constructive-skills';
|
|
@@ -11,13 +21,53 @@ export function defaultManifest() {
|
|
|
11
21
|
sources: [{ name: BASE_LAYER }, { name: OVERLAY_LAYER }]
|
|
12
22
|
};
|
|
13
23
|
}
|
|
24
|
+
function readLegacyFile(file) {
|
|
25
|
+
if (!fs.existsSync(file))
|
|
26
|
+
return null;
|
|
27
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Move a pre-shared-store sign-in (`agent/account.json` +
|
|
31
|
+
* `agent/backend-config.json`) into the store, once. The originals are renamed
|
|
32
|
+
* rather than deleted, so a downgrade still finds them.
|
|
33
|
+
*/
|
|
34
|
+
export function importLegacyAgentFiles(store, legacyDir) {
|
|
35
|
+
const accountFile = path.join(legacyDir, 'account.json');
|
|
36
|
+
const backendFile = path.join(legacyDir, 'backend-config.json');
|
|
37
|
+
if (!fs.existsSync(accountFile) && !fs.existsSync(backendFile))
|
|
38
|
+
return;
|
|
39
|
+
const backend = readLegacyFile(backendFile);
|
|
40
|
+
if (backend?.apiEndpoint && backend.authEndpoint && backend.modulesEndpoint) {
|
|
41
|
+
saveBackendConfig(store, backend);
|
|
42
|
+
}
|
|
43
|
+
const legacy = readLegacyFile(accountFile);
|
|
44
|
+
if (legacy?.token && legacy.userId) {
|
|
45
|
+
const session = {
|
|
46
|
+
userId: legacy.userId,
|
|
47
|
+
email: legacy.email ?? '',
|
|
48
|
+
accessToken: legacy.token,
|
|
49
|
+
accessTokenExpiresAt: legacy.accessTokenExpiresAt,
|
|
50
|
+
apiKey: legacy.apiKey,
|
|
51
|
+
keyId: legacy.keyId,
|
|
52
|
+
apiKeyExpiresAt: legacy.apiKeyExpiresAt,
|
|
53
|
+
signedInAt: legacy.signedInAt ?? Date.now()
|
|
54
|
+
};
|
|
55
|
+
saveSession(store, session);
|
|
56
|
+
}
|
|
57
|
+
for (const file of [accountFile, backendFile]) {
|
|
58
|
+
if (fs.existsSync(file))
|
|
59
|
+
fs.renameSync(file, `${file}.migrated`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
14
62
|
export function loadConfig(baseDir) {
|
|
15
63
|
const dirs = harnessDirs('constructive', baseDir);
|
|
16
64
|
const agentDir = path.join(dirs.stash.data, 'agent');
|
|
17
65
|
const overlayDir = path.join(dirs.stash.config, 'skills-overlay');
|
|
18
66
|
const manifestFile = path.join(dirs.stash.config, 'skills-manifest.json');
|
|
67
|
+
const store = createConfigStore(TOOL_NAME, { stashName: STASH_NAME, baseDir });
|
|
19
68
|
fs.mkdirSync(agentDir, { recursive: true });
|
|
20
69
|
fs.mkdirSync(overlayDir, { recursive: true });
|
|
70
|
+
importLegacyAgentFiles(store, path.join(dirs.stash.config, 'agent'));
|
|
21
71
|
let file = {};
|
|
22
72
|
if (fs.existsSync(manifestFile)) {
|
|
23
73
|
file = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
@@ -27,6 +77,7 @@ export function loadConfig(baseDir) {
|
|
|
27
77
|
agentDir,
|
|
28
78
|
overlayDir,
|
|
29
79
|
manifestFile,
|
|
80
|
+
store,
|
|
30
81
|
manifest: file.manifest ?? defaultManifest(),
|
|
31
82
|
skillsRepo: process.env.AGENT_SKILLS_REPO ?? file.repo ?? DEFAULT_SKILLS_REPO,
|
|
32
83
|
skillsPin: process.env.AGENT_SKILLS_PIN ?? file.pin ?? DEFAULT_SKILLS_PIN
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ResolvedAccount = {
|
|
2
|
+
userId: string;
|
|
3
|
+
accessToken: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
};
|
|
6
|
+
export type ResolvedBackendConfig = {
|
|
7
|
+
apiEndpoint?: string;
|
|
8
|
+
modulesEndpoint?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveAccount(): ResolvedAccount | null;
|
|
11
|
+
export declare function resolveBackendConfig(): ResolvedBackendConfig | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { loadSession } from './account-store';
|
|
2
|
+
import { loadBackendConfig } from './backend-store';
|
|
3
|
+
import { loadConfig } from './config';
|
|
4
|
+
// Both resolvers run on every tool call and re-read the store, so a login or
|
|
5
|
+
// logout mid-session is picked up without a restart. CONSTRUCTIVE_* env vars
|
|
6
|
+
// win over the store (the CI/headless path); tokens never enter process.env
|
|
7
|
+
// from here.
|
|
8
|
+
export function resolveAccount() {
|
|
9
|
+
const userId = process.env.CONSTRUCTIVE_USER_ID;
|
|
10
|
+
const accessToken = process.env.CONSTRUCTIVE_ACCESS_TOKEN;
|
|
11
|
+
if (userId && accessToken) {
|
|
12
|
+
return { userId, accessToken, apiKey: process.env.CONSTRUCTIVE_API_KEY };
|
|
13
|
+
}
|
|
14
|
+
const session = loadSession(loadConfig(process.env.AGENT_HOME).store);
|
|
15
|
+
if (!session)
|
|
16
|
+
return null;
|
|
17
|
+
return { userId: session.userId, accessToken: session.accessToken, apiKey: session.apiKey };
|
|
18
|
+
}
|
|
19
|
+
export function resolveBackendConfig() {
|
|
20
|
+
const apiEndpoint = process.env.CONSTRUCTIVE_API_ENDPOINT;
|
|
21
|
+
const modulesEndpoint = process.env.CONSTRUCTIVE_MODULES_ENDPOINT;
|
|
22
|
+
if (apiEndpoint || modulesEndpoint)
|
|
23
|
+
return { apiEndpoint, modulesEndpoint };
|
|
24
|
+
const stored = loadBackendConfig(loadConfig(process.env.AGENT_HOME).store);
|
|
25
|
+
if (!stored)
|
|
26
|
+
return undefined;
|
|
27
|
+
return { apiEndpoint: stored.apiEndpoint, modulesEndpoint: stored.modulesEndpoint };
|
|
28
|
+
}
|
package/esm/db-tools.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AgentCliConfig } from './config';
|
|
2
2
|
/**
|
|
3
|
-
* Environment variables the
|
|
4
|
-
* Without
|
|
3
|
+
* Environment variables that override the stored session at tool-call time.
|
|
4
|
+
* Without env vars or a stored session the tools load but report "not signed in".
|
|
5
5
|
*/
|
|
6
6
|
export declare const HOST_ENV_VARS: readonly ["CONSTRUCTIVE_USER_ID", "CONSTRUCTIVE_ACCESS_TOKEN", "CONSTRUCTIVE_API_KEY", "CONSTRUCTIVE_API_ENDPOINT", "CONSTRUCTIVE_MODULES_ENDPOINT"];
|
|
7
7
|
/**
|