@atmorouter/helper 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # AtmoRouter helper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Configure Claude Code or OpenCode for AtmoRouter, discover model IDs, and check an API key without running paid inference. Review configuration changes before applying them; existing settings are merged and backed up.
4
+
5
+ Requires Node.js 22 or newer. No runtime dependencies. Run the versioned package from the official AtmoRouter website:
6
+
7
+ ```sh
8
+ npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz --help
9
+ npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz models --search claude
10
+ npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz configure --target opencode --model YOUR_MODEL_ID
11
+ npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz configure --target claude --model YOUR_MODEL_ID --write
12
+ ```
13
+
14
+ With no command, the interactive wizard searches live text models, previews the selected settings, and asks before applying changes. `configure` is preview-only unless `--write` is supplied. Non-interactive writes additionally require `--yes`. Tests use explicit temporary `--config-dir` folders, never the developer's real agent settings.
15
+
16
+ Targets:
17
+
18
+ | Target | Output |
19
+ |---|---|
20
+ | `claude` | `~/.claude/settings.json`, changing model/base URL variables and preserving other JSON settings |
21
+ | `opencode` | `$XDG_CONFIG_HOME/opencode/opencode.json` or `~/.config/opencode/opencode.json`, using `@ai-sdk/openai-compatible` |
22
+ | `openai` | OpenAI-compatible environment snippet; no file writes |
23
+ | `anthropic` | Anthropic-compatible environment snippet; no file writes |
24
+
25
+ Use `--shell powershell` for PowerShell snippets. `--config-dir PATH` explicitly selects a settings folder. `--offline` skips catalog validation, for previews or isolated tests; it does not establish model availability or agent compatibility. Preview output shows only intended changes, not existing file contents.
26
+
27
+ ## Credentials
28
+
29
+ The helper never saves or prints an API key. Set `ATMOROUTER_API_KEY` privately through your shell or secret manager. OpenCode reads `{env:ATMOROUTER_API_KEY}`. For Claude Code, set `ANTHROPIC_AUTH_TOKEN` to the same value before launching Claude. Settings that already embed Claude credentials or custom provider credential headers require a manual merge, so an older secret cannot silently override your new environment.
30
+
31
+ ```sh
32
+ export ANTHROPIC_AUTH_TOKEN="$ATMOROUTER_API_KEY"
33
+ npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz check-key
34
+ ```
35
+
36
+ `GET /v1/models` is public and **cannot validate a key**. The optional explicit key check sends an empty JSON body to `/v1/chat/completions`. Only HTTP 400 with `model is required` is accepted as proof that the gateway accepted the credential before rejecting the body. No model is selected, no inference is forwarded, and nothing is billed. The check consumes a rate-limit slot. HTTP 401 rejects the key; 429 and all other responses are inconclusive. Requests have a 15-second timeout and refuse redirects. There is no custom API origin option that could send a credential to a different host.
37
+
38
+ ## Applying and restoring settings
39
+
40
+ Existing strict JSON settings are merged, not replaced wholesale. JSONC, invalid JSON, non-object roots, and symlink targets are refused. If an `opencode.jsonc` file exists, use a manual merge instead of creating a competing JSON file. Writes recheck that the file still matches its preview, make a timestamped `.atmorouter-backup-*` copy, and publish the new settings through a temporary file in the same directory. Backups and new files request owner-only permissions (0600 on POSIX); Windows access also depends on directory ACLs.
41
+
42
+ To roll back, stop the agent, compare its current file with the backup, and restore the backup after preserving any intervening edits. For a newly created settings file, remove only the AtmoRouter entries you added; do not delete unrelated settings.
43
+
44
+ The helper does not install agents, execute models, alter shell profiles, create keys, move money, or manage accounts. It does not configure Codex/Responses. A generated config establishes the API connection format; tools, context handling, and agent features remain model/client dependent.
45
+
46
+ ## Checks
47
+
48
+ ```sh
49
+ cd packages/helper
50
+ node --test
51
+ npm pack --dry-run
52
+ ```
53
+
54
+ Source references: [Claude Code gateways](https://code.claude.com/docs/en/llm-gateway), [OpenCode custom providers](https://opencode.ai/docs/providers/). The documented AtmoRouter endpoints are verified against the gateway implementation.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import {parseArgs} from 'node:util';
3
+ import {createInterface} from 'node:readline/promises';
4
+ import {readFile} from 'node:fs/promises';
5
+ import {models,checkKey,planConfiguration,writeConfiguration,environment} from '../lib/helper.mjs';
6
+
7
+ const HELP=`AtmoRouter helper
8
+ Node.js 22+; no runtime dependencies.
9
+
10
+ Commands:
11
+ models [--search TEXT] List live text models; no key required
12
+ check-key Check ATMOROUTER_API_KEY without inference
13
+ configure --target TARGET --model ID Preview settings (default: no write)
14
+ No command Interactive configuration wizard
15
+
16
+ Targets: claude, opencode, openai, anthropic
17
+ Options:
18
+ --write Apply JSON configuration after confirmation
19
+ --yes Confirm writes for non-interactive use
20
+ --config-dir PATH Explicit configuration folder
21
+ --offline Skip catalog lookup; review model ID yourself
22
+ --shell posix|powershell Environment snippet format
23
+ --help Show this help
24
+ --version Show the installed version
25
+
26
+ Keys are read only from ATMOROUTER_API_KEY for check-key. They are never printed
27
+ or stored in generated files. Set ANTHROPIC_AUTH_TOKEN separately for Claude.
28
+ No Responses/Codex, payment, key-creation, or account-management actions.`;
29
+ async function main(){
30
+ const {values,positionals}=parseArgs({allowPositionals:true,options:{target:{type:'string'},model:{type:'string'},search:{type:'string'},'config-dir':{type:'string'},shell:{type:'string',default:'posix'},write:{type:'boolean'},yes:{type:'boolean'},offline:{type:'boolean'},help:{type:'boolean'},version:{type:'boolean'}}});
31
+ if(values.version){console.log(JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')).version);return;}
32
+ if(values.help){console.log(HELP);return;}
33
+ let command=positionals[0],target=values.target,model=values.model,rl;
34
+ try {
35
+ if(positionals.length>1)throw Error('Unexpected command arguments. Use --help.');
36
+ if(command==='check-key'){console.log(await checkKey(process.env.ATMOROUTER_API_KEY));return;}
37
+ if(command==='models'){
38
+ const rows=(await models()).filter(m=>m.id.toLowerCase().includes((values.search || '').toLowerCase()));
39
+ console.log(rows.map(m=>m.id).join('\n')||'No matching text models.');return;
40
+ }
41
+ if(!command){
42
+ if(!process.stdin.isTTY)throw Error('Use configure --target TARGET --model ID for non-interactive use.');
43
+ rl=createInterface({input:process.stdin,output:process.stdout});
44
+ console.log('Preview a connection for Claude Code, OpenCode, or an SDK. No secrets will be saved.');
45
+ target=(await rl.question('Target (claude/opencode/openai/anthropic): ')).trim();
46
+ const query=(await rl.question('Search model IDs: ')).trim().toLowerCase();
47
+ const rows=(await models()).filter(m=>m.id.toLowerCase().includes(query)).slice(0,25);
48
+ if(!rows.length)throw Error('No matching models. Try a different search.');
49
+ rows.forEach((m,i)=>console.log(`${i+1}. ${m.id}`));
50
+ const selected=Number(await rl.question('Model number: '));
51
+ if(!Number.isInteger(selected)||selected<1||selected>rows.length)throw Error('Invalid selection.');
52
+ model=rows[selected-1].id;command='configure';
53
+ } else if(command!=='configure')throw Error('Unknown command. Use --help.');
54
+ if(!target||!model)throw Error('configure requires --target and --model.');
55
+ if(!values.offline&&!rl){if(!(await models()).some(m=>m.id===model))throw Error('Model is not in the live text catalog. Use models to choose one.');}
56
+ if(['openai','anthropic'].includes(target)){
57
+ if(values.write)throw Error('Environment snippets are preview-only. Copy them into your shell.');
58
+ console.log(environment(target,model,values.shell));return;
59
+ }
60
+ const plan=await planConfiguration(target,model,{directory:values['config-dir']});
61
+ // Print only the intended patch. Existing settings may contain secrets.
62
+ console.log('Configuration: '+plan.file+'\nChanges:\n'+JSON.stringify(plan.patch,null,2));
63
+ console.log(target==='claude'?'Before starting Claude, set ANTHROPIC_AUTH_TOKEN to your AtmoRouter key.':'Before starting OpenCode, set ATMOROUTER_API_KEY to your AtmoRouter key.');
64
+ let apply=false;
65
+ if(rl)apply=/^y(es)?$/i.test((await rl.question('Apply these changes? [y/N] ')).trim());
66
+ else if(values.write){
67
+ if(values.yes)apply=true;
68
+ else {if(!process.stdin.isTTY)throw Error('Non-interactive writes require --write --yes. Nothing changed.');rl=createInterface({input:process.stdin,output:process.stdout});apply=/^y(es)?$/i.test((await rl.question('Apply these changes? [y/N] ')).trim());}
69
+ }
70
+ if(!apply){console.log('Preview only. No files changed.');return;}
71
+ const written=await writeConfiguration(plan);console.log('Saved: '+written.file);if(written.backup)console.log('Backup: '+written.backup);
72
+ } finally {rl?.close();}
73
+ }
74
+ main().catch(error=>{
75
+ let message=String(error?.message || 'Operation failed.');
76
+ if(error?.code?.startsWith('ERR_PARSE_ARGS'))message='Unknown or unsupported command options. Do not pass API keys as arguments. Run --help.';
77
+ else if(error instanceof SyntaxError)message='Unexpected catalog JSON. Try again later.';
78
+ else if(error instanceof TypeError || error?.name==='TimeoutError')message='Network operation failed. Check connectivity and retry.';
79
+ if(process.env.ATMOROUTER_API_KEY)message=message.split(process.env.ATMOROUTER_API_KEY).join('[redacted]');
80
+ message=message.replace(/ar-[A-Za-z0-9_-]+/g,'[redacted]');
81
+ console.error('Helper could not complete: '+message);process.exitCode=1;
82
+ });
package/lib/helper.mjs ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import {randomUUID} from 'node:crypto';
5
+
6
+ export const ORIGIN = 'https://atmorouter.dev';
7
+ const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ export function modelId(value) {
9
+ if (typeof value!=='string' || value!==value.trim() || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$/.test(value)) throw Error('Choose a model ID from the catalog; shell characters and whitespace are not allowed.');
10
+ return value;
11
+ }
12
+ export async function models(fetcher = fetch) {
13
+ const response = await fetcher(ORIGIN+'/v1/models', {redirect:'error',signal:AbortSignal.timeout(15000)});
14
+ if (!response.ok) throw Error('Catalog request failed: HTTP '+response.status);
15
+ const body = await response.json();
16
+ if (!Array.isArray(body.data)) throw Error('Unexpected catalog response.');
17
+ return body.data.filter(m => typeof m.id === 'string' && (!m.modality || ['text','chat','multimodal'].includes(m.modality.toLowerCase()))).map(m => ({...m,id:modelId(m.id)}));
18
+ }
19
+ export async function checkKey(key, fetcher = fetch) {
20
+ if (typeof key!=='string' || key.length!==51 || !/^ar-[a-f0-9]{48}$/.test(key)) throw Error('Set ATMOROUTER_API_KEY to your full ar- key. Do not pass secrets as command arguments.');
21
+ const response = await fetcher(ORIGIN+'/v1/chat/completions', {method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),headers:{'Authorization':'Bearer '+key,'Content-Type':'application/json'},body:'{}'});
22
+ const body = await response.json().catch(() => ({}));
23
+ if (response.status===400 && body.error?.message==='model is required') return 'Key accepted by the gateway; empty request rejected before inference or billing.';
24
+ if (response.status===401) throw Error('The key is invalid, revoked, or the account is not eligible.');
25
+ if (response.status===429) throw Error('Key check inconclusive: rate limit reached. Wait before trying again.');
26
+ throw Error('Key check inconclusive: HTTP '+response.status+'. No inference request was sent.');
27
+ }
28
+ export function environment(target, model, shell='posix') {
29
+ modelId(model);
30
+ if (!['openai','anthropic'].includes(target)) throw Error('Environment target must be openai or anthropic.');
31
+ const names = target==='openai' ? ['OPENAI_BASE_URL','OPENAI_API_KEY'] : ['ANTHROPIC_BASE_URL','ANTHROPIC_AUTH_TOKEN'];
32
+ const base = ORIGIN+(target==='openai'?'/v1':'');
33
+ if (shell==='powershell') return `$env:${names[0]} = "${base}"\n$env:${names[1]} = $env:ATMOROUTER_API_KEY\n# Selected model: ${model}`;
34
+ if (shell!=='posix') throw Error('Shell must be posix or powershell.');
35
+ return `export ${names[0]}="${base}"\nexport ${names[1]}="$ATMOROUTER_API_KEY"\n# Selected model: ${model}`;
36
+ }
37
+ export async function planConfiguration(target, model, {directory,home=os.homedir(),env=process.env}={}) {
38
+ modelId(model);
39
+ if (!['claude','opencode'].includes(target)) throw Error('Configuration target must be claude or opencode. Codex/Responses is not supported.');
40
+ const folder=path.resolve(directory || (target==='claude'?path.join(home,'.claude'):path.join(env.XDG_CONFIG_HOME || path.join(home,'.config'),'opencode')));
41
+ const file=path.join(folder,target==='claude'?'settings.json':'opencode.json');
42
+ if (target==='opencode') {
43
+ try { await fs.access(path.join(folder,'opencode.jsonc')); throw Error('An OpenCode JSONC configuration exists. Merge the preview manually; it will not be overwritten.'); } catch(e) { if(e.code!=='ENOENT') throw e; }
44
+ }
45
+ let original=null,settings={};
46
+ try {
47
+ const stat=await fs.lstat(file);
48
+ if(stat.isSymbolicLink()||!stat.isFile()) throw Error('Configuration must be a regular file; links are not overwritten.');
49
+ original=await fs.readFile(file,'utf8');settings=JSON.parse(original);
50
+ if(!isRecord(settings)) throw Error('Configuration root must be a JSON object.');
51
+ } catch(e) {
52
+ if(e.code!=='ENOENT') {
53
+ if(e instanceof SyntaxError) throw Error('Existing configuration is not strict JSON. Merge the preview manually; the file was not changed.');
54
+ throw e;
55
+ }
56
+ }
57
+ let patch,merged;
58
+ if(target==='claude') {
59
+ if(settings.env!==undefined&&!isRecord(settings.env))throw Error('Claude env must be an object. Merge manually.');
60
+ if(['ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN'].some(k=>Object.hasOwn(settings.env || {},k))) throw Error('Claude settings already contain credentials. Resolve those settings manually before switching providers; secrets will not be overwritten.');
61
+ patch={env:{ANTHROPIC_BASE_URL:ORIGIN,ANTHROPIC_MODEL:model,ANTHROPIC_DEFAULT_SONNET_MODEL:model,ANTHROPIC_DEFAULT_OPUS_MODEL:model,ANTHROPIC_DEFAULT_HAIKU_MODEL:model}};
62
+ merged={...settings,env:{...settings.env,...patch.env}};
63
+ } else {
64
+ if(settings.provider!==undefined&&!isRecord(settings.provider))throw Error('OpenCode provider must be an object. Merge manually.');
65
+ const previous=settings.provider?.atmorouter || {};
66
+ if(!isRecord(previous)||(previous.options!==undefined&&!isRecord(previous.options))||(previous.models!==undefined&&!isRecord(previous.models)))throw Error('Existing AtmoRouter provider configuration cannot be merged safely.');
67
+ if(Object.keys(previous.options?.headers || {}).some(k=>/^(authorization|x-api-key)$/i.test(k)))throw Error('Existing provider has custom credential headers. Resolve them manually before switching.');
68
+ patch={provider:{atmorouter:{npm:'@ai-sdk/openai-compatible',name:'AtmoRouter',options:{baseURL:ORIGIN+'/v1',apiKey:'{env:ATMOROUTER_API_KEY}'},models:{[model]:{name:model}}}},model:'atmorouter/'+model};
69
+ merged={...settings,provider:{...settings.provider,atmorouter:{...previous,...patch.provider.atmorouter,options:{...previous.options,...patch.provider.atmorouter.options},models:{...previous.models,...patch.provider.atmorouter.models}}},model:patch.model};
70
+ }
71
+ return {file,patch,original,content:JSON.stringify(merged,null,2)+'\n',target};
72
+ }
73
+ export async function writeConfiguration(plan) {
74
+ let current=null;
75
+ try { const stat=await fs.lstat(plan.file);if(stat.isSymbolicLink()||!stat.isFile())throw Error('Target is no longer a regular file.');current=await fs.readFile(plan.file,'utf8'); } catch(e) {if(e.code!=='ENOENT')throw e;}
76
+ if(current!==plan.original)throw Error('Configuration changed after preview. Run the helper again.');
77
+ await fs.mkdir(path.dirname(plan.file),{recursive:true});
78
+ const stamp=new Date().toISOString().replace(/[:.]/g,'-')+'-'+randomUUID().slice(0,8);
79
+ const backup=plan.original===null?null:plan.file+'.atmorouter-backup-'+stamp;
80
+ if(backup)await fs.writeFile(backup,plan.original,{flag:'wx',mode:0o600});
81
+ const temp=plan.file+'.atmorouter-tmp-'+randomUUID();
82
+ try {await fs.writeFile(temp,plan.content,{flag:'wx',mode:0o600});await fs.rename(temp,plan.file);}finally{await fs.rm(temp,{force:true});}
83
+ return {file:plan.file,backup};
84
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "name": "@atmorouter/helper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Configure AtmoRouter connections for Claude Code and OpenCode",
5
+ "homepage": "https://atmorouter.dev/docs/helper",
6
+ "keywords": ["atmorouter", "claude-code", "opencode", "setup", "cli"],
7
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
8
+ "type": "module",
9
+ "license": "UNLICENSED",
10
+ "engines": { "node": ">=22" },
11
+ "bin": { "atmorouter-helper": "bin/atmorouter-helper.mjs" },
12
+ "files": ["bin", "lib", "README.md"],
13
+ "scripts": { "test": "node --test", "test:package": "node scripts/verify-package.mjs" }
14
+ }