Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/catalog-provider-api-key-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pythoughts/pythinker-code': patch
---

Prompt for an API key when connecting a catalog provider whose environment variable is not set, instead of failing with "Environment variable is not set or is empty". Applies to `/login`, `/provider`, and `pythinker provider catalog add`, which now also accepts `--api-key <key>`.
5 changes: 5 additions & 0 deletions .changeset/homebrew-update-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pythoughts/pythinker-code': patch
---

Explain in `/update` and the startup update notice that Homebrew installs do not auto-update, and point to the native installer for automatic background updates.
5 changes: 5 additions & 0 deletions .changeset/native-install-script-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pythoughts/pythinker-code': patch
---

Point the native install scripts at the published release assets.
5 changes: 5 additions & 0 deletions .changeset/old-node-launch-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pythoughts/pythinker-code': patch
---

Show a clear requirement message with the native-installer alternative when the CLI is launched on Node.js older than 26.4, instead of failing with a cryptic flag error.
2 changes: 1 addition & 1 deletion .github/workflows/install-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ jobs:

- name: Check PowerShell syntax
run: |
pwsh -NoProfile -Command '[void][System.Management.Automation.Language.Parser]::ParseFile("apps/pythinker-web/public/install.ps1", [ref]$null, [ref]$errs); if ($errs.Count) { $errs; exit 1 }'
pwsh -NoProfile -Command '$errs = $null; [void][System.Management.Automation.Language.Parser]::ParseFile("apps/pythinker-web/public/install.ps1", [ref]$null, [ref]$errs); if ($errs.Count) { $errs; exit 1 }'
27 changes: 3 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,30 +227,9 @@ See the [configuration docs](https://pythoughts-labs.github.io/pythinker-code/co

Pythinker Code is a **pnpm monorepo**. The CLI consumes capabilities through the SDK and never depends directly on internal engine packages.

```mermaid
flowchart LR
subgraph Apps
CLI["pythinker-code<br/>(CLI / TUI)"]
WEB["pythinker-web<br/>(Browser UI)"]
DASH["dashboard<br/>(Session replay)"]
end

subgraph Packages
SDK["node-sdk"]
CORE["agent-core"]
ANYLLM["Any LLM<br/>(provider abstraction)"]
KAOS["kaos<br/>(Execution env)"]
SERVER["server<br/>(REST + WebSocket)"]
end

CLI --> SDK
WEB --> SERVER
DASH --> SERVER
SDK --> CORE
SERVER --> CORE
CORE --> ANYLLM
CORE --> KAOS
```
<p align="center">
<img src="docs/media/Architecture.webp" alt="Pythinker Code architecture" width="836" />
</p>

| Package | Role |
|---------|------|
Expand Down
1 change: 1 addition & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ nd = "nd" # ndJsonStream, `Nd` cron interval token
dows = "dows" # formatDows — days-of-week (cron)
fo = "fo" # `/FO` flag of Windows schtasks
pn = "pn" # "PNGs" tokenized as PN by the checker
iterm = "iterm" # iTerm2 terminal app identifier
37 changes: 23 additions & 14 deletions apps/pythinker-code/src/cli/sub/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface CatalogListOptions {
}

interface CatalogAddOptions {
readonly apiKey?: string;
readonly apiKeyEnv?: string;
readonly defaultModel?: string;
readonly url?: string;
Expand Down Expand Up @@ -331,19 +332,24 @@ export async function handleCatalogAdd(
deps.exit(1);
}

const literalApiKey = opts.apiKey?.trim();
const apiKeyEnvVar = (opts.apiKeyEnv ?? entry.env?.[0])?.trim();
if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) {
deps.stderr.write(
`Provider "${providerId}" does not declare an API key environment variable.\n`,
);
deps.exit(1);
}
const apiKey = deps.env[apiKeyEnvVar]?.trim();
if (apiKey === undefined || apiKey.length === 0) {
deps.stderr.write(
`Environment variable "${apiKeyEnvVar}" is not set or is empty.\n`,
);
deps.exit(1);
let useEnvVar = false;
if (literalApiKey === undefined || literalApiKey.length === 0) {
if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) {
deps.stderr.write(
`Provider "${providerId}" does not declare an API key environment variable. Pass --api-key <key>.\n`,
);
deps.exit(1);
}
const envValue = deps.env[apiKeyEnvVar]?.trim();
if (envValue === undefined || envValue.length === 0) {
deps.stderr.write(
`Environment variable "${apiKeyEnvVar}" is not set or is empty. Set it or pass --api-key <key>.\n`,
);
deps.exit(1);
}
useEnvVar = true;
}

const models = catalogProviderModels(entry);
Expand Down Expand Up @@ -386,7 +392,8 @@ export async function handleCatalogAdd(
catalogUrl: url,
wire,
baseUrl,
apiKeyEnvVar,
apiKey: useEnvVar ? undefined : literalApiKey,
apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined,
models,
selectedModelId: opts.defaultModel ?? '',
thinking: false,
Expand Down Expand Up @@ -519,17 +526,19 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
catalog
.command('add <providerId>')
.description('Import a known provider from the catalog by id.')
.option('--api-key <key>', 'Provider API key to store in config.toml (takes precedence over --api-key-env).')
.option('--api-key-env <name>', 'Environment variable containing the provider API key.')
.option('--default-model <modelId>', 'Mark the imported model as default_model after import.')
.option('--url <url>', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`)
.action(
async (
providerId: string,
options: { apiKeyEnv?: string; defaultModel?: string; url?: string },
options: { apiKey?: string; apiKeyEnv?: string; defaultModel?: string; url?: string },
) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () =>
handleCatalogAdd(resolved, providerId, {
apiKey: options.apiKey,
apiKeyEnv: options.apiKeyEnv,
defaultModel: options.defaultModel,
url: options.url,
Expand Down
17 changes: 15 additions & 2 deletions apps/pythinker-code/src/cli/update/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,17 @@ export function renderManualUpdateMessage(
sourceDesc = 'unsupported package manager or layout.';
break;
}
const homebrewHint =
source === 'homebrew'
? `Homebrew installs do not auto-update. For automatic background updates, ` +
`switch to the native installer: ${NATIVE_INSTALL_COMMAND_UNIX}\n`
: '';
return (
`A newer version of ${NPM_PACKAGE_NAME} is available ` +
`(${currentVersion} -> ${target.version}).\n` +
`Detected install source: ${sourceDesc}\n` +
`To update manually, run: ${installCommand}\n`
`To update manually, run: ${installCommand}\n` +
homebrewHint
);
}

Expand Down Expand Up @@ -717,7 +723,12 @@ export type ManualUpdateResult =
| { readonly status: 'check-failed'; readonly message: string }
| { readonly status: 'started'; readonly version: string }
| { readonly status: 'in-progress'; readonly version: string }
| { readonly status: 'manual'; readonly version: string; readonly command: string };
| {
readonly status: 'manual';
readonly version: string;
readonly command: string;
readonly source: InstallSource;
};

/**
* Explicit user-requested update (TUI `/update`). Unlike the passive
Expand Down Expand Up @@ -746,6 +757,7 @@ export async function startManualUpdate(
status: 'manual',
version: target.version,
command: installCommandFor(source, target.version, platform),
source,
};
}

Expand All @@ -763,6 +775,7 @@ export async function startManualUpdate(
status: 'manual',
version: target.version,
command: installCommandFor(source, target.version, platform),
source,
};
}

Expand Down
27 changes: 27 additions & 0 deletions apps/pythinker-code/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ const FFI_FLAG = '--experimental-ffi';
const FFI_WARNING_FLAG = '--disable-warning=ExperimentalWarning';
const FFI_CHILD_ENV = 'PYTHINKER_CODE_FFI_CHILD';
const REQUIRED_RUNTIME = 'Node.js 26.4.0 or newer with experimental FFI support';
const MINIMUM_NODE = [26, 4, 0] as const;
const NATIVE_INSTALL_HINT =
'Alternatively, use the native installer (no Node.js required): https://code.pythinker.com';

/**
* Older Node (e.g. 24 LTS) has no `--experimental-ffi`, so the re-exec below
* would die with a cryptic `bad option` error. npm installs the package on any
* Node version (engines is only a warning for consumers), so guard here with
* an actionable message instead.
*/
function isRuntimeTooOld(): boolean {
const parts = process.versions.node.split('.').map(Number);
const [major = 0, minor = 0, patch = 0] = parts;
const [reqMajor, reqMinor, reqPatch] = MINIMUM_NODE;
if (major !== reqMajor) return major < reqMajor;
if (minor !== reqMinor) return minor < reqMinor;
return patch < reqPatch;
}

function isFfiProcess(): boolean {
// Only execArgv decides: a stale env marker must never bypass the FFI re-exec.
Expand Down Expand Up @@ -66,6 +84,15 @@ function launchWindowsFallback(
}

async function launch(): Promise<void> {
if (isRuntimeTooOld()) {
process.stderr.write(
`Pythinker Code requires ${REQUIRED_RUNTIME}; you are running Node.js ${process.versions.node}.\n` +
`${NATIVE_INSTALL_HINT}\n`,
);
process.exitCode = 1;
return;
}

if (isFfiProcess()) {
await import(new URL('./main.mjs', import.meta.url).href);
return;
Expand Down
29 changes: 18 additions & 11 deletions apps/pythinker-code/src/tui/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,18 +250,24 @@ export async function connectCatalogProvider(
return;
}

const baseUrl = catalogBaseUrl(catalogEntry, wire);
const platformName = displayName ?? catalogEntry.name ?? providerId;

const apiKeyEnvVar = catalogEntry.env?.[0]?.trim();
if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) {
host.showError(`Catalog provider "${providerId}" does not declare an API key environment variable.`);
return;
}
if (process.env[apiKeyEnvVar]?.trim().length === 0 || process.env[apiKeyEnvVar] === undefined) {
host.showError(`Environment variable "${apiKeyEnvVar}" is not set or is empty.`);
return;
const envVarHasValue =
apiKeyEnvVar !== undefined &&
apiKeyEnvVar.length > 0 &&
(process.env[apiKeyEnvVar]?.trim().length ?? 0) > 0;
let apiKey: string | undefined;
if (!envVarHasValue) {
const subtitleLines = [
...(baseUrl === undefined ? [] : [`${'base_url'.padEnd(12)}${baseUrl}`]),
`${'saved to'.padEnd(12)}~/.pythinker-code/config.toml`,
];
apiKey = await promptApiKey(host, platformName, subtitleLines);
if (apiKey === undefined) return;
}

const baseUrl = catalogBaseUrl(catalogEntry, wire);
const platformName = displayName ?? catalogEntry.name ?? providerId;
const models = catalogProviderModels(catalogEntry);
if (models.length === 0) {
host.showError('No models available for this platform.');
Expand All @@ -282,7 +288,8 @@ export async function connectCatalogProvider(
catalogUrl: DEFAULT_CATALOG_URL,
wire,
baseUrl,
apiKeyEnvVar,
apiKey,
apiKeyEnvVar: envVarHasValue ? apiKeyEnvVar : undefined,
models,
selectedModelId: selection.model.id,
thinking: selection.effort !== 'off',
Expand All @@ -296,7 +303,7 @@ export async function connectCatalogProvider(
});

await host.authFlow.refreshConfigAfterLogin();
host.track('login', { provider: providerId, method: 'api_key_env' });
host.track('login', { provider: providerId, method: envVarHasValue ? 'api_key_env' : 'api_key' });
host.showStatus(`Setup complete: ${platformName} · ${selection.model.id}`);
}

Expand Down
10 changes: 8 additions & 2 deletions apps/pythinker-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {

import { handleDoctor } from '#/cli/sub/doctor';
import { startManualUpdate } from '#/cli/update/preflight';
import { PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app';
import { NATIVE_INSTALL_COMMAND_UNIX, PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app';
import { openUrl } from '#/utils/open-url';
import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel';
import { buildStatusReportLines } from '../components/messages/status-panel';
Expand Down Expand Up @@ -323,7 +323,13 @@ export async function handleUpdateCommand(
);
return;
case 'manual':
host.showNotice(`Update available — v${result.version}`, `Run: ${result.command}`);
host.showNotice(
`Update available — v${result.version}`,
result.source === 'homebrew'
? `Homebrew installs do not auto-update. Run: ${result.command}\n` +
`For automatic background updates, switch to the native installer: ${NATIVE_INSTALL_COMMAND_UNIX}`
: `Run: ${result.command}`,
);
return;
case 'check-failed':
host.showError(`Update check failed: ${result.message}`);
Expand Down
59 changes: 59 additions & 0 deletions apps/pythinker-code/test/cli/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,4 +1009,63 @@ describe('pythinker provider catalog add', () => {
'CUSTOM_ANTHROPIC_API_KEY',
);
});

it('stores a literal --api-key when the environment variable is unset', async () => {
mockRegistryFetch(CATALOG_BODY);
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
const { deps, exitCodes } = makeDeps(harness, { env: {} });

await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' }));

expect(exitCodes).toEqual([]);
expect(current().providers['anthropic']?.apiKey).toBe('sk-literal');
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
});

it('prefers a literal --api-key over a set environment variable', async () => {
mockRegistryFetch(CATALOG_BODY);
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
const { deps, exitCodes } = makeDeps(harness, {
env: { ANTHROPIC_API_KEY: 'from-env' },
});

await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' }));

expect(exitCodes).toEqual([]);
expect(current().providers['anthropic']?.apiKey).toBe('sk-literal');
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
});

it('stores a literal --api-key even when the catalog declares no credential name', async () => {
mockRegistryFetch({
anthropic: { ...CATALOG_BODY.anthropic, env: undefined },
});
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
const { deps, exitCodes } = makeDeps(harness, { env: {} });

await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' }));

expect(exitCodes).toEqual([]);
expect(current().providers['anthropic']?.apiKey).toBe('sk-literal');
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
});

it('routes --api-key through Commander', async () => {
mockRegistryFetch(CATALOG_BODY);
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
const { deps, exitCodes } = makeDeps(harness, { env: {} });
const program = new Command('pythinker');
registerProviderCommand(program, deps);

await tryRun(() =>
program.parseAsync(
['node', 'pythinker', 'provider', 'catalog', 'add', 'anthropic', '--api-key', 'sk-flag'],
{ from: 'node' },
),
);

expect(exitCodes).toEqual([]);
expect(current().providers['anthropic']?.apiKey).toBe('sk-flag');
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions apps/pythinker-code/test/cli/update/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,7 @@ describe('startManualUpdate', () => {
status: 'manual',
version: '0.5.0',
command: 'brew upgrade pythinker-code',
source: 'homebrew',
});
expect(mocks.spawn).not.toHaveBeenCalled();
});
Expand Down
Loading
Loading