protoagent 0.1.4 → 0.1.5

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 CHANGED
@@ -1,4 +1,7 @@
1
- # ProtoAgent
1
+ ```
2
+ █▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀
3
+ █▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █
4
+ ```
2
5
 
3
6
  A minimal, educational AI coding agent CLI written in TypeScript. It stays small enough to read in an afternoon, but it still has the core pieces you expect from a real coding agent: a streaming tool-use loop, approvals, sessions, MCP, skills, sub-agents, and cost tracking.
4
7
 
@@ -8,7 +11,7 @@ A minimal, educational AI coding agent CLI written in TypeScript. It stays small
8
11
  - **Built-in tools** — Read, write, edit, list, search, run shell commands, manage todos, and fetch web pages with `webfetch`
9
12
  - **Approval system** — Inline confirmation for file writes, file edits, and non-safe shell commands
10
13
  - **Session persistence** — Conversations and TODO state are saved automatically and can be resumed with `--session`
11
- - **Dynamic extensions** — Load skills on demand and add external tools through MCP servers
14
+ <!-- - **Dynamic extensions** — Load skills on demand and add external tools through MCP servers -->
12
15
  - **Sub-agents** — Delegate self-contained tasks to isolated child conversations
13
16
  - **Usage tracking** — Live token, context, and estimated cost display in the TUI
14
17
 
@@ -19,7 +22,12 @@ npm install -g protoagent
19
22
  protoagent
20
23
  ```
21
24
 
22
- On first run, ProtoAgent shows an inline setup flow where you pick a provider/model pair and enter an API key. Config is stored in `~/.local/share/protoagent/config.json` on macOS/Linux and `~/AppData/Local/protoagent/config.json` on Windows.
25
+ On first run, ProtoAgent shows an inline setup flow where you pick a provider/model pair and enter an API key. ProtoAgent stores that selection in `protoagent.jsonc`.
26
+
27
+ Runtime config lookup is simple:
28
+
29
+ - if `<cwd>/.protoagent/protoagent.jsonc` exists, ProtoAgent uses it
30
+ - otherwise it falls back to the shared user config at `~/.config/protoagent/protoagent.jsonc` on macOS/Linux and `~/AppData/Local/protoagent/protoagent.jsonc` on Windows
23
31
 
24
32
  You can also run the standalone wizard directly:
25
33
 
@@ -27,6 +35,29 @@ You can also run the standalone wizard directly:
27
35
  protoagent configure
28
36
  ```
29
37
 
38
+ Or configure a specific target non-interactively:
39
+
40
+ ```bash
41
+ protoagent configure --project --provider openai --model gpt-5-mini
42
+ protoagent configure --user --provider anthropic --model claude-sonnet-4-6
43
+ ```
44
+
45
+ To create a runtime config file for the current project or your shared user config, run:
46
+
47
+ ```bash
48
+ protoagent init
49
+ ```
50
+
51
+ `protoagent init` creates `protoagent.jsonc` in either `<cwd>/.protoagent/protoagent.jsonc` or your shared user config location and prints the exact path it used.
52
+
53
+ For scripts or non-interactive setup, use:
54
+
55
+ ```bash
56
+ protoagent init --project
57
+ protoagent init --user
58
+ protoagent init --project --force
59
+ ```
60
+
30
61
  ## Interactive Commands
31
62
 
32
63
  - `/help` — Show available slash commands
package/dist/App.js CHANGED
@@ -232,7 +232,7 @@ const InlineSetup = ({ onComplete }) => {
232
232
  model: selectedModelId,
233
233
  ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
234
234
  };
235
- writeConfig(newConfig);
235
+ writeConfig(newConfig, 'project');
236
236
  onComplete(newConfig);
237
237
  } })] }));
238
238
  };
@@ -365,8 +365,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
365
365
  });
366
366
  });
367
367
  await loadRuntimeConfig();
368
- // Load config — if none exists, show inline setup
369
- const loadedConfig = readConfig();
368
+ const loadedConfig = readConfig('active');
370
369
  if (!loadedConfig) {
371
370
  setNeedsSetup(true);
372
371
  return;
@@ -420,7 +420,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
420
420
  result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
421
421
  }
422
422
  else {
423
- result = await handleToolCall(name, args, { sessionId });
423
+ result = await handleToolCall(name, args, { sessionId, abortSignal });
424
424
  }
425
425
  logger.debug('Tool result', {
426
426
  tool: name,
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { readFileSync } from 'node:fs';
13
13
  import { render } from 'ink';
14
14
  import { Command } from 'commander';
15
15
  import { App } from './App.js';
16
- import { ConfigureComponent } from './config.js';
16
+ import { ConfigureComponent, InitComponent, readConfig, writeConfig, writeInitConfig } from './config.js';
17
17
  // Get package.json version
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
@@ -33,7 +33,64 @@ program
33
33
  program
34
34
  .command('configure')
35
35
  .description('Configure AI model and API key settings')
36
- .action(() => {
36
+ .option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
37
+ .option('--user', 'Write the shared user protoagent.jsonc')
38
+ .option('--provider <id>', 'Provider id to configure')
39
+ .option('--model <id>', 'Model id to configure')
40
+ .option('--api-key <key>', 'Explicit API key to store in protoagent.jsonc')
41
+ .action((options) => {
42
+ if (options.project || options.user || options.provider || options.model || options.apiKey) {
43
+ if (options.project && options.user) {
44
+ console.error('Choose only one of --project or --user.');
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ if (!options.provider || !options.model) {
49
+ console.error('Non-interactive configure requires --provider and --model.');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const target = options.project ? 'project' : 'user';
54
+ const resultPath = writeConfig({
55
+ provider: options.provider,
56
+ model: options.model,
57
+ ...(typeof options.apiKey === 'string' && options.apiKey.trim() ? { apiKey: options.apiKey.trim() } : {}),
58
+ }, target);
59
+ console.log('Configured ProtoAgent:');
60
+ console.log(resultPath);
61
+ const selected = readConfig(target);
62
+ if (selected) {
63
+ console.log(`${selected.provider} / ${selected.model}`);
64
+ }
65
+ return;
66
+ }
37
67
  render(_jsx(ConfigureComponent, {}));
38
68
  });
69
+ program
70
+ .command('init')
71
+ .description('Create a project-local or shared ProtoAgent runtime config')
72
+ .option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
73
+ .option('--user', 'Write the shared user protoagent.jsonc')
74
+ .option('--force', 'Overwrite an existing target file')
75
+ .action((options) => {
76
+ if (options.project || options.user) {
77
+ if (options.project && options.user) {
78
+ console.error('Choose only one of --project or --user.');
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ const result = writeInitConfig(options.project ? 'project' : 'user', process.cwd(), {
83
+ overwrite: Boolean(options.force),
84
+ });
85
+ const message = result.status === 'created'
86
+ ? 'Created ProtoAgent config:'
87
+ : result.status === 'overwritten'
88
+ ? 'Overwrote ProtoAgent config:'
89
+ : 'ProtoAgent config already exists:';
90
+ console.log(message);
91
+ console.log(result.path);
92
+ return;
93
+ }
94
+ render(_jsx(InitComponent, {}));
95
+ });
39
96
  program.parse(process.argv);
package/dist/config.js CHANGED
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- import { useState, useEffect } from 'react';
5
+ import { useState } from 'react';
6
6
  import { Box, Text } from 'ink';
7
7
  import { Select, TextInput, PasswordInput } from '@inkjs/ui';
8
- import { loadRuntimeConfig } from './runtime-config.js';
8
+ import { parse } from 'jsonc-parser';
9
+ import { getActiveRuntimeConfigPath } from './runtime-config.js';
9
10
  import { getAllProviders, getProvider } from './providers.js';
10
11
  const CONFIG_DIR_MODE = 0o700;
11
12
  const CONFIG_FILE_MODE = 0o600;
@@ -56,80 +57,150 @@ export const getConfigDirectory = () => {
56
57
  }
57
58
  return path.join(homeDir, '.local', 'share', 'protoagent');
58
59
  };
59
- export const getConfigPath = () => {
60
- return path.join(getConfigDirectory(), 'config.json');
61
- };
62
- export const ensureConfigDirectory = () => {
63
- const dir = getConfigDirectory();
64
- if (!existsSync(dir)) {
65
- mkdirSync(dir, { recursive: true, mode: CONFIG_DIR_MODE });
60
+ export const getUserRuntimeConfigDirectory = () => {
61
+ const homeDir = os.homedir();
62
+ if (process.platform === 'win32') {
63
+ return path.join(homeDir, 'AppData', 'Local', 'protoagent');
66
64
  }
67
- hardenPermissions(dir, CONFIG_DIR_MODE);
65
+ return path.join(homeDir, '.config', 'protoagent');
68
66
  };
69
- export const readConfig = () => {
70
- const configPath = getConfigPath();
71
- if (existsSync(configPath)) {
72
- try {
73
- const content = readFileSync(configPath, 'utf8');
74
- const raw = JSON.parse(content);
75
- // Handle legacy format: { provider, model, credentials: { KEY: "..." } }
76
- let apiKey = raw.apiKey;
77
- if (!apiKey && raw.credentials && typeof raw.credentials === 'object') {
78
- const provider = getAllProviders().find((p) => p.id === raw.provider);
79
- if (provider?.apiKeyEnvVar) {
80
- apiKey = raw.credentials[provider.apiKeyEnvVar];
81
- }
82
- // Fallback: grab the first non-empty value
83
- if (!apiKey) {
84
- apiKey = Object.values(raw.credentials).find((v) => typeof v === 'string' && v.length > 0);
85
- }
86
- }
87
- if (!raw.provider || !raw.model) {
88
- return null;
89
- }
90
- return {
91
- provider: raw.provider,
92
- model: raw.model,
93
- apiKey: typeof apiKey === 'string' && apiKey.trim().length > 0 ? apiKey.trim() : undefined,
94
- };
95
- }
96
- catch (error) {
97
- console.error('Error reading config file:', error);
67
+ export const getUserRuntimeConfigPath = () => {
68
+ return path.join(getUserRuntimeConfigDirectory(), 'protoagent.jsonc');
69
+ };
70
+ export const getProjectRuntimeConfigDirectory = (cwd = process.cwd()) => {
71
+ return path.join(cwd, '.protoagent');
72
+ };
73
+ export const getProjectRuntimeConfigPath = (cwd = process.cwd()) => {
74
+ return path.join(getProjectRuntimeConfigDirectory(cwd), 'protoagent.jsonc');
75
+ };
76
+ export const getInitConfigPath = (target, cwd = process.cwd()) => {
77
+ return target === 'project' ? getProjectRuntimeConfigPath(cwd) : getUserRuntimeConfigPath();
78
+ };
79
+ const RUNTIME_CONFIG_TEMPLATE = `{
80
+ // Add project or user-wide ProtoAgent runtime config here.
81
+ // Example uses:
82
+ // - choose the active provider/model by making it the first provider
83
+ // and the first model under that provider
84
+ // - custom providers/models
85
+ // - MCP server definitions
86
+ // - request default parameters
87
+ "providers": {},
88
+ "mcp": {
89
+ "servers": {}
90
+ }
91
+ }
92
+ `;
93
+ function ensureDirectory(targetDir) {
94
+ if (!existsSync(targetDir)) {
95
+ mkdirSync(targetDir, { recursive: true, mode: CONFIG_DIR_MODE });
96
+ }
97
+ hardenPermissions(targetDir, CONFIG_DIR_MODE);
98
+ }
99
+ function isPlainObject(value) {
100
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
101
+ }
102
+ function readRuntimeConfigFileSync(configPath) {
103
+ if (!existsSync(configPath)) {
104
+ return null;
105
+ }
106
+ try {
107
+ const content = readFileSync(configPath, 'utf8');
108
+ const errors = [];
109
+ const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
110
+ if (errors.length > 0 || !isPlainObject(parsed)) {
98
111
  return null;
99
112
  }
113
+ return parsed;
114
+ }
115
+ catch (error) {
116
+ console.error('Error reading runtime config file:', error);
117
+ return null;
118
+ }
119
+ }
120
+ function getConfiguredProviderAndModel(runtimeConfig) {
121
+ for (const [providerId, providerConfig] of Object.entries(runtimeConfig.providers || {})) {
122
+ const modelId = Object.keys(providerConfig.models || {})[0];
123
+ if (!modelId)
124
+ continue;
125
+ const apiKey = typeof providerConfig.apiKey === 'string' && providerConfig.apiKey.trim().length > 0
126
+ ? providerConfig.apiKey.trim()
127
+ : undefined;
128
+ return {
129
+ provider: providerId,
130
+ model: modelId,
131
+ ...(apiKey ? { apiKey } : {}),
132
+ };
100
133
  }
101
134
  return null;
102
- };
103
- export const writeConfig = (config) => {
104
- ensureConfigDirectory();
105
- const configPath = getConfigPath();
106
- const normalizedConfig = {
107
- provider: config.provider,
108
- model: config.model,
135
+ }
136
+ function writeRuntimeConfigFile(configPath, runtimeConfig) {
137
+ ensureDirectory(path.dirname(configPath));
138
+ writeFileSync(configPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, { encoding: 'utf8', mode: CONFIG_FILE_MODE });
139
+ hardenPermissions(configPath, CONFIG_FILE_MODE);
140
+ }
141
+ function upsertSelectedConfig(runtimeConfig, config) {
142
+ const existingProviders = runtimeConfig.providers || {};
143
+ const currentProvider = existingProviders[config.provider] || {};
144
+ const currentModels = currentProvider.models || {};
145
+ const selectedModelConfig = currentModels[config.model] || {};
146
+ const nextProvider = {
147
+ ...currentProvider,
109
148
  ...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
149
+ models: Object.fromEntries([
150
+ [config.model, selectedModelConfig],
151
+ ...Object.entries(currentModels).filter(([modelId]) => modelId !== config.model),
152
+ ]),
153
+ };
154
+ if (!config.apiKey?.trim()) {
155
+ delete nextProvider.apiKey;
156
+ }
157
+ return {
158
+ ...runtimeConfig,
159
+ providers: Object.fromEntries([
160
+ [config.provider, nextProvider],
161
+ ...Object.entries(existingProviders).filter(([providerId]) => providerId !== config.provider),
162
+ ]),
110
163
  };
111
- writeFileSync(configPath, JSON.stringify(normalizedConfig, null, 2), { encoding: 'utf8', mode: CONFIG_FILE_MODE });
164
+ }
165
+ export function writeInitConfig(target, cwd = process.cwd(), options = {}) {
166
+ const configPath = getInitConfigPath(target, cwd);
167
+ const alreadyExists = existsSync(configPath);
168
+ if (alreadyExists) {
169
+ if (!options.overwrite) {
170
+ return { path: configPath, status: 'exists' };
171
+ }
172
+ }
173
+ else {
174
+ ensureDirectory(path.dirname(configPath));
175
+ }
176
+ writeFileSync(configPath, RUNTIME_CONFIG_TEMPLATE, { encoding: 'utf8', mode: CONFIG_FILE_MODE });
112
177
  hardenPermissions(configPath, CONFIG_FILE_MODE);
178
+ return { path: configPath, status: alreadyExists ? 'overwritten' : 'created' };
179
+ }
180
+ export const readConfig = (target = 'active', cwd = process.cwd()) => {
181
+ const configPath = target === 'active' ? getActiveRuntimeConfigPath() : getInitConfigPath(target, cwd);
182
+ if (!configPath) {
183
+ return null;
184
+ }
185
+ const runtimeConfig = readRuntimeConfigFileSync(configPath);
186
+ if (!runtimeConfig) {
187
+ return null;
188
+ }
189
+ return getConfiguredProviderAndModel(runtimeConfig);
113
190
  };
114
- export const InitialLoading = ({ setExistingConfig, setStep }) => {
115
- useEffect(() => {
116
- loadRuntimeConfig()
117
- .then(() => {
118
- const config = readConfig();
119
- if (config) {
120
- setExistingConfig(config);
121
- setStep(1);
122
- }
123
- else {
124
- setStep(2);
125
- }
126
- })
127
- .catch((error) => {
128
- console.error('Error loading runtime config:', error);
129
- setStep(2);
130
- });
131
- }, []);
132
- return _jsx(Text, { children: "Loading configuration..." });
191
+ export function getDefaultConfigTarget(cwd = process.cwd()) {
192
+ const activeConfigPath = getActiveRuntimeConfigPath();
193
+ if (activeConfigPath === getProjectRuntimeConfigPath(cwd)) {
194
+ return 'project';
195
+ }
196
+ return 'user';
197
+ }
198
+ export const writeConfig = (config, target = 'user', cwd = process.cwd()) => {
199
+ const configPath = getInitConfigPath(target, cwd);
200
+ const runtimeConfig = readRuntimeConfigFileSync(configPath) || { providers: {}, mcp: { servers: {} } };
201
+ const nextRuntimeConfig = upsertSelectedConfig(runtimeConfig, config);
202
+ writeRuntimeConfigFile(configPath, nextRuntimeConfig);
203
+ return configPath;
133
204
  };
134
205
  export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
135
206
  const [resetInput, setResetInput] = useState('');
@@ -157,7 +228,7 @@ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setS
157
228
  };
158
229
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
159
230
  };
160
- export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setConfigWritten, }) => {
231
+ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target, setStep, setConfigWritten, }) => {
161
232
  const [errorMessage, setErrorMessage] = useState('');
162
233
  const provider = getProvider(selectedProviderId);
163
234
  const canUseResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
@@ -171,7 +242,7 @@ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setC
171
242
  model: selectedModelId,
172
243
  ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
173
244
  };
174
- writeConfig(newConfig);
245
+ writeConfig(newConfig, target);
175
246
  setConfigWritten(true);
176
247
  setStep(4);
177
248
  };
@@ -182,22 +253,79 @@ export const ConfigResult = ({ configWritten }) => {
182
253
  };
183
254
  export const ConfigureComponent = () => {
184
255
  const [step, setStep] = useState(0);
256
+ const [target, setTarget] = useState(getDefaultConfigTarget());
185
257
  const [existingConfig, setExistingConfig] = useState(null);
186
258
  const [selectedProviderId, setSelectedProviderId] = useState('');
187
259
  const [selectedModelId, setSelectedModelId] = useState('');
188
260
  const [configWritten, setConfigWritten] = useState(false);
261
+ if (step === 0) {
262
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Choose where to configure ProtoAgent:" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: [
263
+ { label: `Project config — ${getProjectRuntimeConfigPath()}`, value: 'project' },
264
+ { label: `Shared user config — ${getUserRuntimeConfigPath()}`, value: 'user' },
265
+ ], onChange: (value) => {
266
+ setTarget(value);
267
+ const existing = readConfig(value);
268
+ setExistingConfig(existing);
269
+ setStep(existing ? 1 : 2);
270
+ } }) })] }));
271
+ }
189
272
  switch (step) {
190
- case 0:
191
- return _jsx(InitialLoading, { setExistingConfig: setExistingConfig, setStep: setStep });
192
273
  case 1:
193
274
  return _jsx(ResetPrompt, { existingConfig: existingConfig, setStep: setStep, setConfigWritten: setConfigWritten });
194
275
  case 2:
195
276
  return (_jsx(ModelSelection, { setSelectedProviderId: setSelectedProviderId, setSelectedModelId: setSelectedModelId, setStep: setStep }));
196
277
  case 3:
197
- return (_jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, setStep: setStep, setConfigWritten: setConfigWritten }));
278
+ return (_jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, target: target, setStep: setStep, setConfigWritten: setConfigWritten }));
198
279
  case 4:
199
280
  return _jsx(ConfigResult, { configWritten: configWritten });
200
281
  default:
201
282
  return _jsx(Text, { children: "Unknown step." });
202
283
  }
203
284
  };
285
+ export const InitComponent = () => {
286
+ const [selectedTarget, setSelectedTarget] = useState(null);
287
+ const [result, setResult] = useState(null);
288
+ const options = [
289
+ {
290
+ label: 'Project config',
291
+ value: 'project',
292
+ description: getProjectRuntimeConfigPath(),
293
+ },
294
+ {
295
+ label: 'Shared user config',
296
+ value: 'user',
297
+ description: getUserRuntimeConfigPath(),
298
+ },
299
+ ];
300
+ const activeTarget = selectedTarget ?? 'project';
301
+ const activeOption = options.find((option) => option.value === activeTarget) ?? options[0];
302
+ if (selectedTarget && !result) {
303
+ const selectedPath = getInitConfigPath(selectedTarget);
304
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Config already exists:" }), _jsx(Text, { children: selectedPath }), _jsx(Text, { children: "Overwrite it? (y/n)" }), _jsx(TextInput, { onSubmit: (answer) => {
305
+ if (answer.trim().toLowerCase() === 'y') {
306
+ setResult(writeInitConfig(selectedTarget, process.cwd(), { overwrite: true }));
307
+ }
308
+ else {
309
+ setResult({ path: selectedPath, status: 'exists' });
310
+ }
311
+ } })] }));
312
+ }
313
+ if (result) {
314
+ const color = result.status === 'exists' ? 'yellow' : 'green';
315
+ const message = result.status === 'created'
316
+ ? 'Created ProtoAgent config:'
317
+ : result.status === 'overwritten'
318
+ ? 'Overwrote ProtoAgent config:'
319
+ : 'ProtoAgent config already exists:';
320
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: color, children: message }), _jsx(Text, { children: result.path })] }));
321
+ }
322
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Create a ProtoAgent runtime config:" }), _jsx(Text, { dimColor: true, children: "Select where to write `protoagent.jsonc`." }), _jsx(Text, { dimColor: true, children: activeOption.description }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: options.map((option) => ({ label: option.label, value: option.value })), onChange: (value) => {
323
+ const target = value;
324
+ const configPath = getInitConfigPath(target);
325
+ if (existsSync(configPath)) {
326
+ setSelectedTarget(target);
327
+ return;
328
+ }
329
+ setResult(writeInitConfig(target));
330
+ } }) })] }));
331
+ };
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { parse, printParseErrorCode } from 'jsonc-parser';
@@ -16,13 +17,26 @@ const DEFAULT_RUNTIME_CONFIG = {
16
17
  mcp: { servers: {} },
17
18
  };
18
19
  let runtimeConfigCache = null;
19
- function getRuntimeConfigPaths() {
20
+ function getProjectRuntimeConfigPath() {
21
+ return path.join(process.cwd(), '.protoagent', 'protoagent.jsonc');
22
+ }
23
+ function getUserRuntimeConfigPath() {
20
24
  const homeDir = os.homedir();
21
- return [
22
- path.join(homeDir, '.config', 'protoagent', 'protoagent.jsonc'),
23
- path.join(homeDir, '.protoagent', 'protoagent.jsonc'),
24
- path.join(process.cwd(), '.protoagent', 'protoagent.jsonc'),
25
- ];
25
+ if (process.platform === 'win32') {
26
+ return path.join(homeDir, 'AppData', 'Local', 'protoagent', 'protoagent.jsonc');
27
+ }
28
+ return path.join(homeDir, '.config', 'protoagent', 'protoagent.jsonc');
29
+ }
30
+ export function getActiveRuntimeConfigPath() {
31
+ const projectPath = getProjectRuntimeConfigPath();
32
+ if (existsSync(projectPath)) {
33
+ return projectPath;
34
+ }
35
+ const userPath = getUserRuntimeConfigPath();
36
+ if (existsSync(userPath)) {
37
+ return userPath;
38
+ }
39
+ return null;
26
40
  }
27
41
  function isPlainObject(value) {
28
42
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -156,16 +170,17 @@ export async function loadRuntimeConfig(forceReload = false) {
156
170
  if (!forceReload && runtimeConfigCache) {
157
171
  return runtimeConfigCache;
158
172
  }
159
- let merged = DEFAULT_RUNTIME_CONFIG;
160
- for (const configPath of getRuntimeConfigPaths()) {
173
+ const configPath = getActiveRuntimeConfigPath();
174
+ let loaded = DEFAULT_RUNTIME_CONFIG;
175
+ if (configPath) {
161
176
  const fileConfig = await readRuntimeConfigFile(configPath);
162
- if (!fileConfig)
163
- continue;
164
- logger.debug(`Loaded runtime config`, { path: configPath });
165
- merged = mergeRuntimeConfig(merged, fileConfig);
177
+ if (fileConfig) {
178
+ logger.debug('Loaded runtime config', { path: configPath });
179
+ loaded = mergeRuntimeConfig(DEFAULT_RUNTIME_CONFIG, fileConfig);
180
+ }
166
181
  }
167
- runtimeConfigCache = merged;
168
- return merged;
182
+ runtimeConfigCache = loaded;
183
+ return loaded;
169
184
  }
170
185
  export function getRuntimeConfig() {
171
186
  return runtimeConfigCache || DEFAULT_RUNTIME_CONFIG;
@@ -114,7 +114,7 @@ async function isSafe(command) {
114
114
  }
115
115
  return validateCommandPaths(tokens);
116
116
  }
117
- export async function runBash(command, timeoutMs = 30_000, sessionId) {
117
+ export async function runBash(command, timeoutMs = 30_000, sessionId, abortSignal) {
118
118
  // Layer 1: hard block
119
119
  if (isDangerous(command)) {
120
120
  return `Error: Command blocked for safety. "${command}" contains a dangerous pattern that cannot be executed.`;
@@ -139,6 +139,7 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
139
139
  let stdout = '';
140
140
  let stderr = '';
141
141
  let timedOut = false;
142
+ let aborted = false;
142
143
  const child = spawn(command, [], {
143
144
  shell: true,
144
145
  cwd: process.cwd(),
@@ -147,13 +148,31 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
147
148
  });
148
149
  child.stdout?.on('data', (data) => { stdout += data.toString(); });
149
150
  child.stderr?.on('data', (data) => { stderr += data.toString(); });
150
- const timer = setTimeout(() => {
151
- timedOut = true;
151
+ const terminateChild = () => {
152
152
  child.kill('SIGTERM');
153
153
  setTimeout(() => child.kill('SIGKILL'), 2000);
154
+ };
155
+ const onAbort = () => {
156
+ aborted = true;
157
+ terminateChild();
158
+ };
159
+ if (abortSignal?.aborted) {
160
+ onAbort();
161
+ }
162
+ else {
163
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
164
+ }
165
+ const timer = setTimeout(() => {
166
+ timedOut = true;
167
+ terminateChild();
154
168
  }, timeoutMs);
155
169
  child.on('close', (code) => {
156
170
  clearTimeout(timer);
171
+ abortSignal?.removeEventListener('abort', onAbort);
172
+ if (aborted) {
173
+ resolve(`Command aborted by user.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
174
+ return;
175
+ }
157
176
  if (timedOut) {
158
177
  resolve(`Command timed out after ${timeoutMs / 1000}s.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
159
178
  return;
@@ -172,6 +191,7 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
172
191
  });
173
192
  child.on('error', (err) => {
174
193
  clearTimeout(timer);
194
+ abortSignal?.removeEventListener('abort', onAbort);
175
195
  resolve(`Error executing command: ${err.message}`);
176
196
  });
177
197
  });
@@ -71,7 +71,7 @@ export async function handleToolCall(toolName, args, context = {}) {
71
71
  case 'search_files':
72
72
  return await searchFiles(args.search_term, args.directory_path, args.case_sensitive, args.file_extensions);
73
73
  case 'bash':
74
- return await runBash(args.command, args.timeout_ms, context.sessionId);
74
+ return await runBash(args.command, args.timeout_ms, context.sessionId, context.abortSignal);
75
75
  case 'todo_read':
76
76
  return readTodos(context.sessionId);
77
77
  case 'todo_write':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",