@slothmoney/agent-cli 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-07-23
4
+
5
+ - Add `auth login`, `auth status`, and `auth logout`.
6
+ - Store PATs in native OS credential storage, separated by API origin.
7
+ - Keep `SLOTH_AGENT_TOKEN` as the highest-precedence option for CI and
8
+ headless systems.
9
+ - Validate imported PATs before saving and report live authentication status
10
+ without exposing credentials.
11
+
3
12
  ## 0.1.0 - 2026-07-18
4
13
 
5
14
  - Publish the first installable Sloth Agent CLI.
package/README.md CHANGED
@@ -15,22 +15,62 @@ sloth-agent --version
15
15
  For a one-off pinned run:
16
16
 
17
17
  ```bash
18
- npm exec --yes --package=@slothmoney/agent-cli@0.1.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.2.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
22
22
 
23
23
  Create a personal access token in Sloth Money under
24
- **Settings > Developer access**. Load it from your environment or secret
25
- manager:
24
+ **Settings > Developer access**, then save it in your operating system's
25
+ secure credential store:
26
+
27
+ ```bash
28
+ sloth-agent auth login
29
+ ```
30
+
31
+ The prompt hides the token. The CLI validates it before replacing any
32
+ credential already stored for the selected API origin.
33
+
34
+ For a non-interactive import, pass the token through stdin or import it from
35
+ the environment:
36
+
37
+ ```bash
38
+ printf '%s' "$SLOTH_AGENT_TOKEN" | sloth-agent auth login --token-stdin
39
+ sloth-agent auth login --from-env
40
+ ```
41
+
42
+ Never put a token in a command argument. For CI and headless systems, keep
43
+ using an environment secret:
26
44
 
27
45
  ```bash
28
46
  export SLOTH_AGENT_TOKEN="sloth_pat_v1_..."
29
47
  ```
30
48
 
31
- Do not paste the token into prompts, chat, source control, shared logs, or
32
- assignment files. The CLI reads the token from the environment, sends it only
33
- as an HTTPS bearer token, and never stores it.
49
+ `SLOTH_AGENT_TOKEN` always overrides a stored credential.
50
+
51
+ To migrate from an existing environment-only setup:
52
+
53
+ ```bash
54
+ sloth-agent auth login --from-env
55
+ unset SLOTH_AGENT_TOKEN
56
+ sloth-agent auth status
57
+ sloth-agent categories
58
+ ```
59
+
60
+ Check the active credential with a live API request:
61
+
62
+ ```bash
63
+ sloth-agent auth status
64
+ ```
65
+
66
+ This updates the PAT's `lastUsedAt` value. To remove the local credential:
67
+
68
+ ```bash
69
+ sloth-agent auth logout
70
+ ```
71
+
72
+ Logout does not unset `SLOTH_AGENT_TOKEN` or revoke the PAT. Revoke a PAT
73
+ remotely in **Sloth Money Settings > Developer access**.
34
74
 
35
75
  ## Commands
36
76
 
@@ -94,14 +134,18 @@ set `SLOTH_AGENT_API_BASE_URL=http://localhost:4000` or pass
94
134
  `--base-url http://localhost:4000`. Non-local HTTP origins are rejected so a
95
135
  token cannot be sent over an unencrypted connection.
96
136
 
137
+ Stored credentials are separated by normalized API origin. One credential is
138
+ stored per origin; log out and log in again to switch accounts on the same
139
+ origin.
140
+
97
141
  Command results are JSON on stdout. Diagnostics are written to stderr.
98
142
 
99
143
  | Exit code | Meaning |
100
144
  | --- | --- |
101
145
  | `0` | Success |
102
- | `1` | API, network, response-validation, or partial assignment failure |
103
- | `2` | Invalid command, option, URL, date, or assignment input |
104
- | `3` | Missing required configuration |
146
+ | `1` | API, network, credential-store, response-validation, or partial assignment failure |
147
+ | `2` | Invalid command, option, URL, date, auth input, or assignment input |
148
+ | `3` | No credential or native secure storage is unavailable |
105
149
 
106
150
  Assignment writes are best-effort. A response containing any failed assignment
107
151
  returns exit code `1` while preserving the complete API response on stdout.
package/dist/args.js CHANGED
@@ -122,6 +122,46 @@ function parseTransactions(args) {
122
122
  function withBaseUrl(value, baseUrl) {
123
123
  return baseUrl === undefined ? value : { ...value, baseUrl };
124
124
  }
125
+ function parseAuth(args, baseUrl) {
126
+ const authCommand = args.shift();
127
+ if (!authCommand) {
128
+ throw new UsageError('auth requires login, status, or logout');
129
+ }
130
+ if (authCommand === 'login') {
131
+ let input;
132
+ for (const argument of args) {
133
+ if (argument === '--token-stdin') {
134
+ if (input === 'stdin') {
135
+ throw new UsageError('--token-stdin may only be provided once');
136
+ }
137
+ if (input === 'environment') {
138
+ throw new UsageError('--token-stdin and --from-env are mutually exclusive');
139
+ }
140
+ input = 'stdin';
141
+ }
142
+ else if (argument === '--from-env') {
143
+ if (input === 'environment') {
144
+ throw new UsageError('--from-env may only be provided once');
145
+ }
146
+ if (input === 'stdin') {
147
+ throw new UsageError('--token-stdin and --from-env are mutually exclusive');
148
+ }
149
+ input = 'environment';
150
+ }
151
+ else {
152
+ throw new UsageError(`Unknown auth login option: ${argument}`);
153
+ }
154
+ }
155
+ return withBaseUrl({ command: 'auth-login', input: input ?? 'prompt' }, baseUrl);
156
+ }
157
+ if (authCommand === 'status' || authCommand === 'logout') {
158
+ if (args.length > 0) {
159
+ throw new UsageError(`Unknown auth ${authCommand} option: ${args[0]}`);
160
+ }
161
+ return withBaseUrl({ command: `auth-${authCommand}` }, baseUrl);
162
+ }
163
+ throw new UsageError(`Unknown auth command: ${authCommand}`);
164
+ }
125
165
  export function parseArgs(argv) {
126
166
  if (argv.includes('--help') || argv.includes('-h'))
127
167
  return { command: 'help' };
@@ -131,6 +171,9 @@ export function parseArgs(argv) {
131
171
  const command = args.shift();
132
172
  if (!command)
133
173
  return { command: 'help' };
174
+ if (command === 'auth') {
175
+ return parseAuth(args, baseUrl);
176
+ }
134
177
  if (command === 'categories') {
135
178
  if (args.length > 0) {
136
179
  throw new UsageError(`Unknown categories option: ${args[0]}`);
package/dist/cli.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import { parseArgs, resolveBaseUrl, } from './args.js';
3
3
  import { parseApiResponse, validateAssignmentPayload, } from './contracts.js';
4
+ import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
4
5
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
5
- export const CLI_VERSION = '0.1.0';
6
+ export const CLI_VERSION = '0.2.0';
6
7
  const REQUEST_TIMEOUT_MS = 30_000;
7
8
  export function usageText() {
8
9
  return [
9
10
  'Sloth Agent CLI',
10
11
  '',
11
12
  'Usage:',
13
+ ' sloth-agent auth login [--token-stdin | --from-env] [--base-url URL]',
14
+ ' sloth-agent auth status [--base-url URL]',
15
+ ' sloth-agent auth logout [--base-url URL]',
12
16
  ' sloth-agent categories [--base-url URL]',
13
17
  ' sloth-agent transactions [--uncategorized[=true|false]] [--limit N]',
14
18
  ' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
@@ -26,15 +30,63 @@ export function usageText() {
26
30
  function writeJson(write, data) {
27
31
  write(`${JSON.stringify(data, null, 2)}\n`);
28
32
  }
29
- function requireToken(environment) {
30
- const token = environment.SLOTH_AGENT_TOKEN;
31
- if (!token?.trim())
32
- throw new ConfigError('SLOTH_AGENT_TOKEN is required');
33
- return token;
34
- }
35
33
  function redact(value, token) {
36
34
  return token ? value.split(token).join('[REDACTED]') : value;
37
35
  }
36
+ function environmentToken(environment) {
37
+ const token = environment.SLOTH_AGENT_TOKEN;
38
+ return token?.trim() ? token : undefined;
39
+ }
40
+ async function defaultReadSecret() {
41
+ const { default: password } = await import('@inquirer/password');
42
+ return password({
43
+ message: 'Personal access token:',
44
+ mask: '*',
45
+ }, {
46
+ output: process.stderr,
47
+ });
48
+ }
49
+ async function defaultReadStdin() {
50
+ const chunks = [];
51
+ for await (const chunk of process.stdin) {
52
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
53
+ }
54
+ return Buffer.concat(chunks).toString('utf8');
55
+ }
56
+ function validateLoginToken(value) {
57
+ if (!value.startsWith('sloth_pat_v1_') || /\s/.test(value)) {
58
+ throw new UsageError('Personal access token must use the sloth_pat_v1_ prefix and contain no whitespace');
59
+ }
60
+ return value;
61
+ }
62
+ function stripStdinLineEnding(value) {
63
+ if (value.endsWith('\r\n'))
64
+ return value.slice(0, -2);
65
+ if (value.endsWith('\n'))
66
+ return value.slice(0, -1);
67
+ return value;
68
+ }
69
+ async function loadCredentialStore(getCredentialStore) {
70
+ try {
71
+ return await getCredentialStore();
72
+ }
73
+ catch (error) {
74
+ if (error instanceof CliError)
75
+ throw error;
76
+ throw secureStorageUnavailableError();
77
+ }
78
+ }
79
+ async function resolveCredential(environment, origin, getCredentialStore) {
80
+ const token = environmentToken(environment);
81
+ if (token)
82
+ return { source: 'environment', token };
83
+ const credentialStore = await loadCredentialStore(getCredentialStore);
84
+ const storedToken = await credentialStore.get(origin);
85
+ if (!storedToken) {
86
+ throw new ConfigError(`No credential found for ${origin}. Run "sloth-agent auth login" or set SLOTH_AGENT_TOKEN.`);
87
+ }
88
+ return { source: 'keychain', token: storedToken };
89
+ }
38
90
  function readAssignmentFile(filePath) {
39
91
  try {
40
92
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -74,7 +126,7 @@ async function parseHttpResponse(response, token) {
74
126
  }
75
127
  catch {
76
128
  if (response.ok)
77
- throw new ApiError('Agent API returned invalid JSON');
129
+ throw new ApiError('Agent API returned invalid JSON', response.status);
78
130
  }
79
131
  }
80
132
  if (!response.ok) {
@@ -84,10 +136,39 @@ async function parseHttpResponse(response, token) {
84
136
  && typeof data.error === 'string')
85
137
  ? data.error
86
138
  : `Agent API request failed with status ${response.status}`;
87
- throw new ApiError(redact(message, token));
139
+ throw new ApiError(redact(message, token), response.status);
88
140
  }
89
141
  return data;
90
142
  }
143
+ function requestHeaders(token) {
144
+ return {
145
+ Accept: 'application/json',
146
+ Authorization: `Bearer ${token}`,
147
+ 'User-Agent': `sloth-agent/${CLI_VERSION}`,
148
+ };
149
+ }
150
+ async function validateCredentialRemotely(fetchImplementation, origin, token) {
151
+ const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
152
+ method: 'GET',
153
+ headers: requestHeaders(token),
154
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
155
+ });
156
+ parseApiResponse('categories', await parseHttpResponse(response, token));
157
+ }
158
+ function maskedTokenSuffix(token) {
159
+ return token.length > 4 ? `…${token.slice(-4)}` : '…';
160
+ }
161
+ function classifyRemoteStatus(error) {
162
+ if (error instanceof ApiError) {
163
+ if (error.status === 401)
164
+ return 'invalid_or_expired';
165
+ if (error.status === 402)
166
+ return 'payment_required';
167
+ if (error.status === 403)
168
+ return 'insufficient_scope';
169
+ }
170
+ return 'unreachable';
171
+ }
91
172
  function hasFailures(value) {
92
173
  if (!value || typeof value !== 'object' || !('failed' in value))
93
174
  return false;
@@ -96,6 +177,11 @@ function hasFailures(value) {
96
177
  export async function runCli(argv = process.argv.slice(2), options = {}) {
97
178
  const environment = options.env ?? process.env;
98
179
  const fetchImplementation = options.fetch ?? globalThis.fetch;
180
+ const getCredentialStore = options.getCredentialStore ?? createSystemCredentialStore;
181
+ const isInteractive = options.isInteractive
182
+ ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
183
+ const readSecret = options.readSecret ?? defaultReadSecret;
184
+ const readStdin = options.readStdin ?? defaultReadStdin;
99
185
  const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
100
186
  const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
101
187
  let token;
@@ -109,13 +195,72 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
109
195
  writeStdout(`${CLI_VERSION}\n`);
110
196
  return 0;
111
197
  }
112
- token = requireToken(environment);
113
198
  const baseUrl = resolveBaseUrl(environment, parsed.baseUrl);
114
- const headers = {
115
- Accept: 'application/json',
116
- Authorization: `Bearer ${token}`,
117
- 'User-Agent': `sloth-agent/${CLI_VERSION}`,
118
- };
199
+ if (parsed.command === 'auth-login') {
200
+ if (parsed.input === 'prompt' && !isInteractive) {
201
+ throw new UsageError('Interactive login requires a TTY. Use --token-stdin or --from-env.');
202
+ }
203
+ let rawToken;
204
+ if (parsed.input === 'environment') {
205
+ const tokenFromEnvironment = environmentToken(environment);
206
+ if (!tokenFromEnvironment) {
207
+ throw new ConfigError('SLOTH_AGENT_TOKEN is required with --from-env');
208
+ }
209
+ rawToken = tokenFromEnvironment;
210
+ }
211
+ else {
212
+ rawToken = await (parsed.input === 'stdin' ? readStdin() : readSecret());
213
+ if (parsed.input === 'stdin')
214
+ rawToken = stripStdinLineEnding(rawToken);
215
+ }
216
+ token = validateLoginToken(rawToken);
217
+ await validateCredentialRemotely(fetchImplementation, baseUrl, token);
218
+ const credentialStore = await loadCredentialStore(getCredentialStore);
219
+ await credentialStore.set(baseUrl, token);
220
+ const environmentOverrideActive = environmentToken(environment) !== undefined;
221
+ writeJson(writeStdout, {
222
+ activeSource: environmentOverrideActive ? 'environment' : 'keychain',
223
+ environmentOverrideActive,
224
+ origin: baseUrl,
225
+ stored: true,
226
+ });
227
+ return 0;
228
+ }
229
+ if (parsed.command === 'auth-status') {
230
+ const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
231
+ token = credential.token;
232
+ let remoteStatus = 'valid';
233
+ let exitCode = 0;
234
+ try {
235
+ await validateCredentialRemotely(fetchImplementation, baseUrl, token);
236
+ }
237
+ catch (error) {
238
+ remoteStatus = classifyRemoteStatus(error);
239
+ exitCode = 1;
240
+ }
241
+ writeJson(writeStdout, {
242
+ origin: baseUrl,
243
+ remoteStatus,
244
+ source: credential.source,
245
+ tokenSuffix: maskedTokenSuffix(token),
246
+ });
247
+ return exitCode;
248
+ }
249
+ if (parsed.command === 'auth-logout') {
250
+ const credentialStore = await loadCredentialStore(getCredentialStore);
251
+ const localCredentialRemoved = await credentialStore.delete(baseUrl);
252
+ writeJson(writeStdout, {
253
+ environmentOverrideActive: environmentToken(environment) !== undefined,
254
+ localCredentialRemoved,
255
+ origin: baseUrl,
256
+ remoteRevoked: false,
257
+ revocationInstructions: 'Revoke the token in Sloth Money Settings > Developer access.',
258
+ });
259
+ return 0;
260
+ }
261
+ const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
262
+ token = credential.token;
263
+ const headers = requestHeaders(token);
119
264
  if (parsed.command === 'assign') {
120
265
  const payload = validateAssignmentPayload(readAssignmentFile(parsed.input));
121
266
  const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
@@ -0,0 +1,43 @@
1
+ import { ApiError, ConfigError, } from './errors.js';
2
+ const KEYCHAIN_SERVICE = 'app.slothmoney.agent-cli';
3
+ export function secureStorageUnavailableError() {
4
+ return new ConfigError('Native secure credential storage is unavailable. Set SLOTH_AGENT_TOKEN instead.');
5
+ }
6
+ export async function createSystemCredentialStore() {
7
+ let keytar;
8
+ try {
9
+ const module = await import('@github/keytar');
10
+ keytar = ('default' in module
11
+ ? module.default
12
+ : module);
13
+ }
14
+ catch {
15
+ throw secureStorageUnavailableError();
16
+ }
17
+ return {
18
+ delete: async (origin) => {
19
+ try {
20
+ return await keytar.deletePassword(KEYCHAIN_SERVICE, origin);
21
+ }
22
+ catch {
23
+ throw new ApiError('Failed to delete the credential from native secure storage');
24
+ }
25
+ },
26
+ get: async (origin) => {
27
+ try {
28
+ return await keytar.getPassword(KEYCHAIN_SERVICE, origin);
29
+ }
30
+ catch {
31
+ throw secureStorageUnavailableError();
32
+ }
33
+ },
34
+ set: async (origin, token) => {
35
+ try {
36
+ await keytar.setPassword(KEYCHAIN_SERVICE, origin, token);
37
+ }
38
+ catch {
39
+ throw new ApiError('Failed to save the credential to native secure storage');
40
+ }
41
+ },
42
+ };
43
+ }
package/dist/errors.js CHANGED
@@ -16,7 +16,9 @@ export class ConfigError extends CliError {
16
16
  }
17
17
  }
18
18
  export class ApiError extends CliError {
19
- constructor(message) {
19
+ status;
20
+ constructor(message, status) {
20
21
  super(message, 1);
22
+ this.status = status;
21
23
  }
22
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,5 +50,9 @@
50
50
  "typescript": "^5.8.3",
51
51
  "typescript-eslint": "^8.35.1",
52
52
  "vitest": "^3.2.4"
53
+ },
54
+ "dependencies": {
55
+ "@github/keytar": "7.10.6",
56
+ "@inquirer/password": "4.0.18"
53
57
  }
54
58
  }