@hzlmy2002/web-market 0.1.2 → 0.1.3
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 +77 -14
- package/dist/api.js +13 -5
- package/dist/cli.js +36 -12
- package/dist/credentials.d.ts +14 -0
- package/dist/credentials.js +34 -0
- package/dist/install.d.ts +3 -1
- package/dist/install.js +21 -4
- package/dist/interactive.d.ts +10 -0
- package/dist/interactive.js +43 -0
- package/dist/oauth.d.ts +24 -0
- package/dist/oauth.js +213 -0
- package/dist/schema.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/uninstall.d.ts +4 -0
- package/dist/uninstall.js +109 -0
- package/docs/contract.md +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,26 +1,67 @@
|
|
|
1
1
|
# AIsa Web Market
|
|
2
2
|
|
|
3
|
-
Node/TypeScript stdio MCP for focused website competitive analysis. Five tools call **AIsa REST APIs directly**, with
|
|
3
|
+
Node/TypeScript stdio MCP for focused website competitive analysis. Five tools call **AIsa REST APIs directly**, with OAuth or API Key Bearer authentication. No dependency on the Python aisa-mcp server or model calls inside tools.
|
|
4
|
+
|
|
5
|
+
Version 0.1.3 adds OAuth setup and interactive uninstall. The commands below targeting 0.1.3 become available after this version is published; use the source commands before publication.
|
|
6
|
+
|
|
7
|
+
## Install with your agent
|
|
8
|
+
|
|
9
|
+
Copy the following prompt into Codex or another agent that can run commands on your computer. It uses the latest published npm package.
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
Install and configure AIsa Web Market on this computer for me using the published npm package: @hzlmy2002/web-market.
|
|
13
|
+
|
|
14
|
+
I am not technical. Please complete the installation yourself and involve me only when necessary.
|
|
15
|
+
|
|
16
|
+
1. Check whether Node.js 22 or newer and npm are installed. If missing, install them using an official method suitable for my operating system. Explain any system permission I need to approve.
|
|
17
|
+
2. From my home directory, run this command in an interactive terminal:
|
|
18
|
+
npx -y @hzlmy2002/web-market@latest setup
|
|
19
|
+
3. Let setup detect and configure the supported agent clients. Reuse existing installations and credentials, and preserve my other settings.
|
|
20
|
+
4. If OAuth opens, tell me to finish signing in through my browser. If automatic callback fails, guide me to paste the complete callback URL directly into the setup terminal. If necessary, use the API Key fallback with hidden terminal input. Never ask me to paste credentials or callback URLs into this conversation.
|
|
21
|
+
5. Verify that the MCP server starts, its five tools are available, and its Skill is installed. Do not make paid API calls just to test installation.
|
|
22
|
+
|
|
23
|
+
Use the published package's help output if needed. Do not clone the source repository or assume unreleased features are available.
|
|
24
|
+
|
|
25
|
+
Keep going until installation is complete or a step genuinely requires my attention. If you cannot access my local computer or an interactive terminal, explain the limitation and give me the smallest possible next step.
|
|
26
|
+
|
|
27
|
+
Finish with a brief explanation of which clients were configured and whether I need to restart or refresh them.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start (current source)
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm ci
|
|
34
|
+
npm run build
|
|
35
|
+
node dist/cli.js setup
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Setup detects all supported clients and starts OAuth when no credentials are available. Finish in the browser, paste the complete callback URL into the terminal, or type `key` for API Key fallback. Existing credentials are reused. To remove the installation:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
node dist/cli.js uninstall
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The first menu entry is `0` for all installations; `1`, `2`, etc. select clients, and Enter cancels. Removing the last client clears local OAuth credentials, managed configuration, Skills and installation state, and attempts refresh-token revocation. Files belonging to other tools are retained.
|
|
4
45
|
|
|
5
46
|
## Install with npx
|
|
6
47
|
|
|
7
48
|
Requires Node 22 or newer. Run one of these commands in a terminal:
|
|
8
49
|
|
|
9
50
|
```sh
|
|
10
|
-
npx -y @hzlmy2002/web-market@0.1.
|
|
11
|
-
npx -y @hzlmy2002/web-market@0.1.
|
|
12
|
-
npx -y @hzlmy2002/web-market@0.1.
|
|
51
|
+
npx -y @hzlmy2002/web-market@0.1.3 setup --client codex
|
|
52
|
+
npx -y @hzlmy2002/web-market@0.1.3 setup --client claude-code
|
|
53
|
+
npx -y @hzlmy2002/web-market@0.1.3 setup --client hermes
|
|
13
54
|
```
|
|
14
55
|
|
|
15
|
-
This installs the Skill and registers a version-pinned `npx -y @hzlmy2002/web-market@0.1.
|
|
56
|
+
This installs the Skill and registers a version-pinned `npx -y @hzlmy2002/web-market@0.1.3 serve` MCP command. Clearing the npm cache does not invalidate the configured path. Setup reuses credentials or starts OAuth automatically; paste a callback URL or type `key` for API Key fallback. Restart or refresh the client after setup. Run `npx -y @hzlmy2002/web-market@0.1.3 uninstall` for interactive removal.
|
|
16
57
|
|
|
17
58
|
When testing the published release from this source repository, first change to another directory (for example, `cd ~`). npm exec/npx can select the current project when its name and version match the requested package, but a source checkout has no installed `aisa-web-market` command link. This results in `sh: aisa-web-market: command not found`. Alternatively, use the local setup command below after building.
|
|
18
59
|
|
|
19
60
|
## Automatic client detection
|
|
20
61
|
|
|
21
|
-
Run `npx -y @hzlmy2002/web-market@0.1.
|
|
62
|
+
Run `npx -y @hzlmy2002/web-market@0.1.3 setup` to install for every detected client. From a built source checkout, use `node dist/cli.js setup`. Detection checks `.codex/`, `.claude/` or `.claude.json`, and `.hermes/` in the user's home directory (or `--home`). These are usage traces, not proof that the executable is still installed. Shared `.agents/` directories alone do not count as detection.
|
|
22
63
|
|
|
23
|
-
Use `--client` to select a single client or install before its first run. If nothing is detected, setup explains how to proceed without creating client configurations. Custom client configuration roots are not discovered automatically. A
|
|
64
|
+
Use `--client` to select a single client or install before its first run. If nothing is detected, setup explains how to proceed without creating client configurations. Custom client configuration roots are not discovered automatically. A new OAuth login or API Key fallback is requested once per setup and shared across clients needing credentials; existing client keys remain in place. Installation results are reported per client; a failure does not undo successful installations, and any failure produces a nonzero exit status. Run `uninstall` for an interactive selection of managed installations; `--client` remains available for scripts.
|
|
24
65
|
|
|
25
66
|
Automatic detection is available starting with version 0.1.2. Windows uses the same home-directory markers, but the full workflow has not yet been tested on a Windows machine.
|
|
26
67
|
|
|
@@ -34,9 +75,23 @@ npm test
|
|
|
34
75
|
npm run build
|
|
35
76
|
```
|
|
36
77
|
|
|
37
|
-
Setup
|
|
78
|
+
Setup reuses existing credentials. When none are available, it starts **OAuth automatically**, without a method-selection prompt. Type `key` while waiting for the callback to switch to hidden API Key input. OAuth failure or cancellation also falls back to API Key input; press Ctrl+C there to cancel setup. Use `--auth` to explicitly switch an existing installation:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
node dist/cli.js setup --auth oauth
|
|
82
|
+
node dist/cli.js setup --auth oauth --no-browser
|
|
83
|
+
node dist/cli.js setup --auth key
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
OAuth dynamically registers a public client with Clerk, uses PKCE S256, opens the browser and receives a loopback callback. The authorization URL is also printed. You may paste the **complete callback URL** into the terminal instead. On a different device, the localhost page may fail to load: copy its full URL from the address bar and paste it in the original terminal. `--no-browser` does not launch a browser or bind a local port; it uses manual paste only. Login times out after five minutes. Paste callbacks only into the setup terminal, never into an agent conversation.
|
|
38
87
|
|
|
39
|
-
|
|
88
|
+
OAuth credentials are saved in `~/.aisa/oauth.json` (under `--home` when supplied), using atomic replacement and restrictive file permissions where supported. Client configurations contain only `AISA_AUTH_FILE`, pointing to that file. All installed clients share one login. The runtime refreshes expiring tokens with a cross-process file lock; it never opens a browser during MCP tool calls. A failed refresh requires `setup --auth oauth`. A provider that does not issue a refresh token requires login again after expiry. Removing the last managed client deletes local OAuth credentials and attempts refresh-token revocation. Partial uninstall keeps credentials for remaining clients. Switching to API Key preserves that OAuth file too.
|
|
89
|
+
|
|
90
|
+
API Key input is hidden and saved in the selected client's `env.AISA_API_KEY`. Existing client keys are preserved unless `--auth` explicitly selects a method. An environment key is reused without copying it by default; explicit `--auth key` saves it to the selected clients. Setup does not edit shell profiles or Windows environment settings. Headless OAuth still requires an interactive terminal for pasting; unattended installs should use `AISA_API_KEY`.
|
|
91
|
+
|
|
92
|
+
The browser launcher supports macOS, Linux and Windows; the Windows workflow has not been tested on a Windows machine. Real Clerk discovery, public-client registration and authorization redirect have been checked. Token exchange and refresh are covered by simulated integration tests; end-to-end account authorization still needs live verification.
|
|
93
|
+
|
|
94
|
+
The normal command is `node dist/cli.js setup`. To target a specific client before its first run:
|
|
40
95
|
|
|
41
96
|
```sh
|
|
42
97
|
node dist/cli.js setup --client codex
|
|
@@ -54,14 +109,16 @@ Setup installs the Skill and merges a user-level MCP entry using the absolute No
|
|
|
54
109
|
|
|
55
110
|
Refresh/restart the client after installation if the skill is not visible. Setup follows the actual user's home directory; use `--home /absolute/path` for an isolated profile or testing. Symlinked installation paths are refused to avoid writing outside the selected location. For Hermes custom profiles, pass the matching profile home explicitly; otherwise setup targets `~/.hermes`.
|
|
56
111
|
|
|
57
|
-
To use MCP-only configuration, set `command` to your Node executable and `args` to the absolute path to `dist/cli.js`, followed by `serve`.
|
|
112
|
+
To use MCP-only configuration, set `command` to your Node executable and `args` to the absolute path to `dist/cli.js`, followed by `serve`. Run OAuth setup first, or pass `AISA_API_KEY` through the client environment. To install the skill during server startup, add `--install-skills --client codex` (or another supported client). This is an explicit opt-in, runs idempotently, and writes status only to stderr; the first session may require a refresh for skill discovery.
|
|
58
113
|
|
|
59
114
|
```sh
|
|
60
115
|
node dist/cli.js status
|
|
61
|
-
node dist/cli.js uninstall
|
|
116
|
+
node dist/cli.js uninstall
|
|
62
117
|
```
|
|
63
118
|
|
|
64
|
-
|
|
119
|
+
Interactive uninstall lists managed client installations and accepts numbers (such as `1,2`), `0` for all, or Enter / Ctrl+C to cancel. `0` is displayed first. It describes the removal scope before selection. The menu is also available with no remaining managed clients so orphan OAuth credentials can be cleaned. Non-interactive callers must specify `--client`.
|
|
120
|
+
|
|
121
|
+
Uninstall removes only matching owned files and the matching MCP entry. It preserves other tools and settings. A modified owned file stops uninstall for review. It removes empty managed Skill directories and, after the last removal, the local OAuth file and empty installer directories. Other user files are preserved. Remote revocation failures are reported; local deletion does not guarantee removal of the DCR application record or immediate invalidation of already-issued access tokens. To update, rebuild this checkout and rerun setup.
|
|
65
122
|
|
|
66
123
|
## Tools
|
|
67
124
|
|
|
@@ -95,13 +152,19 @@ Generates `plugins/{codex,claude-code,hermes}/aisa-web-market/`, each with the s
|
|
|
95
152
|
|
|
96
153
|
- Codex: `.codex-plugin/plugin.json` plus `.mcp.json`; distribute using the client's local/plugin marketplace workflow.
|
|
97
154
|
- Claude Code: `.claude-plugin/plugin.json` plus `.mcp.json`; local test with `claude --plugin-dir /absolute/path/to/plugins/claude-code/aisa-web-market`.
|
|
98
|
-
- Hermes: Agent Plugins v1 `plugin.json` plus `mcp.json`; requires a version supporting portable packages. Enable after installing through Hermes.
|
|
155
|
+
- Hermes: Agent Plugins v1 `plugin.json` plus `mcp.json`; requires a version supporting portable packages. Enable after installing through Hermes. Use the shared local OAuth login or configure AISA_API_KEY in the Hermes process environment.
|
|
99
156
|
|
|
100
157
|
Prefer either plugin installation or direct setup for a client, to avoid duplicate skills and tools. Plugin host discovery has not been tested in interactive Codex/Claude/Hermes sessions. The bundled runtime is tested over real stdio, and setup configuration is tested in isolated profiles.
|
|
101
158
|
|
|
102
159
|
## Development and release
|
|
103
160
|
|
|
104
|
-
`npm test` exercises API contracts, calculations, failures, installer preservation and real stdio protocol exchange without network calls or credits. `npm pack` creates an installable npm archive. The scoped package is configured for public npm publication. npm-installed setup registers a pinned npx command; setup from a source checkout registers its local Node entry. Plugin bundles remain a separate distribution option.
|
|
161
|
+
`npm test` exercises API contracts, calculations, failures, installer preservation and real stdio protocol exchange without external network calls or credits (OAuth tests use a temporary loopback listener). `npm pack` creates an installable npm archive. The scoped package is configured for public npm publication. npm-installed setup registers a pinned npx command; setup from a source checkout registers its local Node entry. Plugin bundles remain a separate distribution option.
|
|
162
|
+
|
|
163
|
+
npm publishes the version in `package.json`, not a Git tag. Each published name/version pair is immutable. The root version in `package-lock.json`, runtime version strings and pinned setup command must stay aligned. A Git tag is optional release bookkeeping; this repository does not require one for manual publishing.
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
npm publish --access public
|
|
167
|
+
```
|
|
105
168
|
|
|
106
169
|
`docs/contract.md` records the endpoint mapping and limits. `docs/upstream-snapshot.json` captures only the required operation definitions and examples from the supplied docs checkout. Run `node scripts/check-contract.mjs /path/to/docs/openapi/similarweb.json` to detect drift in those operations. Live API verification requires an explicitly selected funded account and month; no production calls are made by the test suite.
|
|
107
170
|
|
package/dist/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { accessToken } from './oauth.js';
|
|
1
2
|
export class ApiError extends Error {
|
|
2
3
|
code;
|
|
3
4
|
retryable;
|
|
@@ -21,20 +22,27 @@ export function safeError(error) {
|
|
|
21
22
|
export class ApiClient {
|
|
22
23
|
key;
|
|
23
24
|
fetcher;
|
|
24
|
-
constructor(key = process.env.AISA_API_KEY, fetcher = fetch) {
|
|
25
|
+
constructor(key = process.env.AISA_AUTH_FILE ? undefined : process.env.AISA_API_KEY, fetcher = fetch) {
|
|
25
26
|
this.key = key;
|
|
26
27
|
this.fetcher = fetcher;
|
|
27
28
|
}
|
|
28
29
|
async get(path, query, cap, signal) {
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
let token;
|
|
31
|
+
try {
|
|
32
|
+
token = this.key?.trim() || await accessToken();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new ApiError('authentication_required', 'OAuth credentials could not be refreshed. Run setup --auth oauth to sign in again.');
|
|
36
|
+
}
|
|
37
|
+
if (!token)
|
|
38
|
+
throw new ApiError('missing_credentials', 'Run setup to sign in with OAuth or configure AISA_API_KEY.');
|
|
31
39
|
if (!Object.values(paths).includes(path))
|
|
32
40
|
throw new ApiError('invalid_input', 'Unsupported API operation.');
|
|
33
41
|
const url = new URL('https://api.aisa.one/apis/v1' + path);
|
|
34
42
|
for (const [k, v] of Object.entries(query))
|
|
35
43
|
if (v !== undefined)
|
|
36
44
|
url.searchParams.set(k, String(v));
|
|
37
|
-
const headers = { Authorization: `Bearer ${
|
|
45
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
|
|
38
46
|
if (cap !== undefined)
|
|
39
47
|
headers['X-AISA-Max-Price-USD'] = String(cap);
|
|
40
48
|
try {
|
|
@@ -90,7 +98,7 @@ export class ApiClient {
|
|
|
90
98
|
}
|
|
91
99
|
const codes = {
|
|
92
100
|
400: ['invalid_input', 'AIsa rejected the query scope or parameters.', false],
|
|
93
|
-
401: ['unauthorized', 'AIsa rejected the
|
|
101
|
+
401: ['unauthorized', 'AIsa rejected the credentials. Run setup --auth oauth to sign in again, or check AISA_API_KEY.', false],
|
|
94
102
|
402: ['payment_required', 'AIsa requires sufficient credit and the appropriate subscription. Check your AIsa account.', false],
|
|
95
103
|
403: ['forbidden', 'The account cannot access this data scope.', false],
|
|
96
104
|
404: ['not_found', 'HTTP 404 returned by AIsa or its data provider. The response does not establish whether the route is unavailable or the requested resource/data was not found.', false],
|
package/dist/cli.js
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { setupCredentialResolver } from './credentials.js';
|
|
3
|
+
import { cleanupUninstall } from './uninstall.js';
|
|
4
|
+
import { chooseUninstall } from './interactive.js';
|
|
5
|
+
import { authFile, readSession } from './oauth.js';
|
|
3
6
|
import { parseArgs } from 'node:util';
|
|
4
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
8
|
import { createServer } from './server.js';
|
|
6
|
-
import { detectClients, install } from './install.js';
|
|
9
|
+
import { detectClients, installedClients, install } from './install.js';
|
|
7
10
|
async function main() {
|
|
8
|
-
const { values, positionals } = parseArgs({ allowPositionals: true, options: { client: { type: 'string' }, home: { type: 'string' }, 'install-skills': { type: 'boolean' }, help: { type: 'boolean' } } });
|
|
11
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: { client: { type: 'string' }, home: { type: 'string' }, auth: { type: 'string' }, 'no-browser': { type: 'boolean' }, 'install-skills': { type: 'boolean' }, help: { type: 'boolean' } } });
|
|
9
12
|
const command = positionals[0] ?? 'serve';
|
|
10
13
|
if (values.help) {
|
|
11
|
-
console.log('aisa-web-market serve [--install-skills --client codex|claude-code|hermes]\naisa-web-market setup [--client codex|claude-code|hermes] [--home directory]\naisa-web-market uninstall --client codex|claude-code|hermes [--home directory]\naisa-web-market status\nsetup detects all supported clients in your home directory when --client is omitted.
|
|
14
|
+
console.log('aisa-web-market serve [--install-skills --client codex|claude-code|hermes]\naisa-web-market setup [--client codex|claude-code|hermes] [--home directory] [--auth oauth|key] [--no-browser]\naisa-web-market uninstall [--client codex|claude-code|hermes] [--home directory]\naisa-web-market status\nsetup detects all supported clients in your home directory when --client is omitted. setup reuses existing credentials or starts OAuth automatically; type key during login to use an API Key. --auth forces a method. --no-browser prints the OAuth URL without opening it; paste the complete callback URL after login.');
|
|
12
15
|
return;
|
|
13
16
|
}
|
|
14
17
|
if (positionals.length > 1)
|
|
15
18
|
throw Error('Unexpected positional argument.');
|
|
16
19
|
if (command === 'status') {
|
|
17
|
-
|
|
20
|
+
const key = !process.env.AISA_AUTH_FILE && process.env.AISA_API_KEY?.trim();
|
|
21
|
+
const oauth = key ? undefined : await readSession(process.env.AISA_AUTH_FILE || authFile(values.home));
|
|
22
|
+
console.log(JSON.stringify({ credential_configured: Boolean(key || oauth), authentication: key ? 'key' : oauth ? 'oauth' : 'none', version: '0.1.3' }));
|
|
18
23
|
return;
|
|
19
24
|
}
|
|
20
25
|
if (command === 'setup' || command === 'uninstall') {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
26
|
+
let clients;
|
|
27
|
+
if (values.client)
|
|
28
|
+
clients = [values.client];
|
|
29
|
+
else if (command === 'uninstall') {
|
|
30
|
+
const installed = await installedClients(values.home);
|
|
31
|
+
const selected = await chooseUninstall(installed);
|
|
32
|
+
if (!selected) {
|
|
33
|
+
console.log('Uninstall cancelled.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
clients = selected;
|
|
37
|
+
console.error(`Uninstalling from: ${clients.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
clients = await detectClients(values.home);
|
|
41
|
+
if (!clients.length && command === 'setup')
|
|
25
42
|
throw Error('No supported clients detected. Run a client once, or use setup --client codex|claude-code|hermes.');
|
|
26
|
-
if (!values.client)
|
|
43
|
+
if (!values.client && command === 'setup')
|
|
27
44
|
console.error(`Detected clients: ${clients.join(', ')}`);
|
|
28
|
-
const resolveKey =
|
|
45
|
+
const resolveKey = setupCredentialResolver({ home: values.home, auth: values.auth, noBrowser: values['no-browser'] });
|
|
29
46
|
for (const client of clients) {
|
|
30
47
|
try {
|
|
31
48
|
console.log(JSON.stringify(await install(client, { home: values.home, remove: command === 'uninstall', resolveKey: command === 'setup' ? resolveKey : undefined })));
|
|
@@ -35,7 +52,14 @@ async function main() {
|
|
|
35
52
|
process.exitCode = 1;
|
|
36
53
|
}
|
|
37
54
|
}
|
|
38
|
-
|
|
55
|
+
if (command === 'uninstall') {
|
|
56
|
+
const cleanup = await cleanupUninstall(values.home);
|
|
57
|
+
if (cleanup.warning)
|
|
58
|
+
console.error(cleanup.warning);
|
|
59
|
+
console.log(cleanup.credentialsCleared ? 'Uninstalled. Local OAuth login and installation state cleared.' : 'Selected clients removed. OAuth login retained for remaining installations.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
console.log('Client configuration preserves existing values; serialization may reformat comments. OAuth credentials are shared locally; API Keys are passed through client configuration or the environment. Restart or refresh the client to load skills.');
|
|
39
63
|
return;
|
|
40
64
|
}
|
|
41
65
|
if (command !== 'serve')
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
import { login } from './oauth.js';
|
|
1
2
|
export declare function promptKey(): Promise<string>;
|
|
2
3
|
export declare function resolveSetupKey(savedKey?: string): Promise<string | undefined>;
|
|
3
4
|
export declare function sharedSetupKeyResolver(resolve?: typeof resolveSetupKey): (savedKey?: string) => Promise<string | undefined>;
|
|
5
|
+
export type SetupCredential = string | {
|
|
6
|
+
oauthFile: string;
|
|
7
|
+
} | undefined;
|
|
8
|
+
export declare function setupCredentialResolver(options: {
|
|
9
|
+
home?: string;
|
|
10
|
+
auth?: string;
|
|
11
|
+
noBrowser?: boolean;
|
|
12
|
+
}, io?: {
|
|
13
|
+
login: typeof login;
|
|
14
|
+
promptKey: typeof promptKey;
|
|
15
|
+
interactive: () => boolean;
|
|
16
|
+
report: (message: string) => void;
|
|
17
|
+
}): (savedKey?: string) => Promise<SetupCredential>;
|
package/dist/credentials.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { emitKeypressEvents } from 'node:readline';
|
|
2
|
+
import { authFile, readSession, login } from './oauth.js';
|
|
2
3
|
// Read from the terminal without echoing credentials or consuming MCP stdin.
|
|
3
4
|
export async function promptKey() {
|
|
4
5
|
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
@@ -69,3 +70,36 @@ export function sharedSetupKeyResolver(resolve = resolveSetupKey) {
|
|
|
69
70
|
return requested ??= resolve();
|
|
70
71
|
};
|
|
71
72
|
}
|
|
73
|
+
export function setupCredentialResolver(options, io = {
|
|
74
|
+
login, promptKey,
|
|
75
|
+
interactive: () => Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
76
|
+
report: (message) => { process.stderr.write(message + '\n'); },
|
|
77
|
+
}) {
|
|
78
|
+
if (options.auth && !['oauth', 'key'].includes(options.auth))
|
|
79
|
+
throw Error('Choose --auth oauth or key.');
|
|
80
|
+
let pending;
|
|
81
|
+
const file = authFile(options.home);
|
|
82
|
+
return (savedKey) => {
|
|
83
|
+
if (!options.auth && savedKey?.trim())
|
|
84
|
+
return Promise.resolve(savedKey);
|
|
85
|
+
if (!options.auth && process.env.AISA_API_KEY?.trim())
|
|
86
|
+
return Promise.resolve(undefined);
|
|
87
|
+
return pending ??= (async () => {
|
|
88
|
+
if (!options.auth && await readSession(file))
|
|
89
|
+
return { oauthFile: file };
|
|
90
|
+
if (options.auth === 'key')
|
|
91
|
+
return process.env.AISA_API_KEY?.trim() || await io.promptKey();
|
|
92
|
+
if (!io.interactive())
|
|
93
|
+
throw Error('No credentials. Run setup in an interactive terminal for OAuth, or set AISA_API_KEY.');
|
|
94
|
+
try {
|
|
95
|
+
await io.login(file, options.noBrowser);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
io.report(error instanceof Error ? error.message : 'OAuth login failed.');
|
|
99
|
+
io.report('Use an API Key instead (Ctrl+C to cancel setup).');
|
|
100
|
+
return await io.promptKey();
|
|
101
|
+
}
|
|
102
|
+
return { oauthFile: file };
|
|
103
|
+
})();
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/install.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { SetupCredential } from './credentials.js';
|
|
1
2
|
export type Client = 'codex' | 'claude-code' | 'hermes';
|
|
2
3
|
export declare function detectClients(home?: string): Promise<Client[]>;
|
|
4
|
+
export declare function installedClients(home?: string): Promise<Client[]>;
|
|
3
5
|
export declare const packageRoot: string;
|
|
4
6
|
export declare function launchEntry(client: Client, root?: string): {
|
|
5
7
|
env_vars?: string[] | undefined;
|
|
@@ -10,7 +12,7 @@ export declare function install(client: Client, options?: {
|
|
|
10
12
|
home?: string;
|
|
11
13
|
remove?: boolean;
|
|
12
14
|
skillsOnly?: boolean;
|
|
13
|
-
resolveKey?: (savedKey?: string) => Promise<
|
|
15
|
+
resolveKey?: (savedKey?: string) => Promise<SetupCredential>;
|
|
14
16
|
}): Promise<{
|
|
15
17
|
client: Client;
|
|
16
18
|
skill: string;
|
package/dist/install.js
CHANGED
|
@@ -26,6 +26,21 @@ export async function detectClients(home = homedir()) {
|
|
|
26
26
|
}
|
|
27
27
|
return [...found];
|
|
28
28
|
}
|
|
29
|
+
// Only installations with our ownership record are offered for removal.
|
|
30
|
+
export async function installedClients(home = homedir()) {
|
|
31
|
+
const found = [];
|
|
32
|
+
for (const client of ['codex', 'claude-code', 'hermes']) {
|
|
33
|
+
try {
|
|
34
|
+
if ((await fs.lstat(path.resolve(home, '.aisa/web-market', `${client}.json`))).isFile())
|
|
35
|
+
found.push(client);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
29
44
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
45
|
const skillName = 'aisa-web-market';
|
|
31
46
|
// npm/npx installations live in node_modules. Persist a version-pinned registry
|
|
@@ -33,7 +48,7 @@ const skillName = 'aisa-web-market';
|
|
|
33
48
|
// local Node entry so development changes remain immediately testable.
|
|
34
49
|
export function launchEntry(client, root = packageRoot) {
|
|
35
50
|
const command = root.split(path.sep).includes('node_modules')
|
|
36
|
-
? { command: process.platform === 'win32' ? 'npx.cmd' : 'npx', args: ['-y', '@hzlmy2002/web-market@0.1.
|
|
51
|
+
? { command: process.platform === 'win32' ? 'npx.cmd' : 'npx', args: ['-y', '@hzlmy2002/web-market@0.1.3', 'serve'] }
|
|
37
52
|
: { command: process.execPath, args: [path.join(root, 'dist/cli.js'), 'serve'] };
|
|
38
53
|
return { ...command, ...(client === 'codex' ? { env_vars: ['AISA_API_KEY'] } : {}) };
|
|
39
54
|
}
|
|
@@ -117,7 +132,7 @@ export async function install(client, options = {}) {
|
|
|
117
132
|
const root = skillRoot(client, home);
|
|
118
133
|
const entry = launchEntry(client);
|
|
119
134
|
const writes = new Map();
|
|
120
|
-
const next = { version: '0.1.
|
|
135
|
+
const next = { version: '0.1.3', files: {}, config: previous.config, configHash: previous.configHash };
|
|
121
136
|
const bundled = await skillFiles(path.join(packageRoot, 'skills', skillName));
|
|
122
137
|
for (const relative of new Set([...Object.keys(bundled), ...Object.keys(previous.files)])) {
|
|
123
138
|
if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..'))
|
|
@@ -160,6 +175,8 @@ export async function install(client, options = {}) {
|
|
|
160
175
|
if (existing && !owned)
|
|
161
176
|
throw Error('MCP entry has been modified; preserving it.');
|
|
162
177
|
delete config[cfg.key][skillName];
|
|
178
|
+
if (!Object.keys(config[cfg.key]).length)
|
|
179
|
+
delete config[cfg.key];
|
|
163
180
|
}
|
|
164
181
|
else {
|
|
165
182
|
if (existing && !owned && JSON.stringify(existing) !== JSON.stringify(entry))
|
|
@@ -168,14 +185,14 @@ export async function install(client, options = {}) {
|
|
|
168
185
|
const apiKey = options.resolveKey ? await options.resolveKey(savedKey) : savedKey;
|
|
169
186
|
const configured = { ...entry };
|
|
170
187
|
if (apiKey) {
|
|
171
|
-
configured.env = { AISA_API_KEY: apiKey };
|
|
188
|
+
configured.env = typeof apiKey === 'string' ? { AISA_API_KEY: apiKey } : { AISA_AUTH_FILE: apiKey.oauthFile };
|
|
172
189
|
delete configured.env_vars;
|
|
173
190
|
}
|
|
174
191
|
config[cfg.key][skillName] = configured;
|
|
175
192
|
delete next.config;
|
|
176
193
|
next.configHash = hash(JSON.stringify(configured));
|
|
177
194
|
}
|
|
178
|
-
writes.set(cfg.file, cfg.stringify(config));
|
|
195
|
+
writes.set(cfg.file, options.remove && !Object.keys(config).length ? undefined : cfg.stringify(config));
|
|
179
196
|
}
|
|
180
197
|
writes.set(stateFile, options.remove ? undefined : JSON.stringify(next, null, 2));
|
|
181
198
|
const backups = new Map();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Client } from './install.js';
|
|
2
|
+
export declare function parseSelection(answer: string, clients: Client[]): Client[];
|
|
3
|
+
export declare function chooseUninstall(clients: Client[], io?: {
|
|
4
|
+
input: NodeJS.ReadStream & {
|
|
5
|
+
fd: 0;
|
|
6
|
+
};
|
|
7
|
+
output: NodeJS.WriteStream & {
|
|
8
|
+
fd: 2;
|
|
9
|
+
};
|
|
10
|
+
}): Promise<Client[] | undefined>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
export function parseSelection(answer, clients) {
|
|
3
|
+
const value = answer.trim().toLowerCase();
|
|
4
|
+
if (!value)
|
|
5
|
+
return [];
|
|
6
|
+
if (value === '0')
|
|
7
|
+
return [...clients];
|
|
8
|
+
const parts = value.split(/[\s,]+/);
|
|
9
|
+
if (parts.some(part => !/^[1-9]\d*$/.test(part) || Number(part) > clients.length))
|
|
10
|
+
throw Error('Enter listed numbers (for example 1,2), 0 for all, or press Enter to cancel.');
|
|
11
|
+
return [...new Set(parts.map(part => clients[Number(part) - 1]))];
|
|
12
|
+
}
|
|
13
|
+
export async function chooseUninstall(clients, io = { input: process.stdin, output: process.stderr }) {
|
|
14
|
+
if (!io.input.isTTY)
|
|
15
|
+
throw Error('Interactive uninstall requires a terminal. For scripts, use uninstall --client codex|claude-code|hermes.');
|
|
16
|
+
io.output.write('Remove AIsa Web Market MCP configuration and installed Skills from:\n');
|
|
17
|
+
io.output.write(' 0. Uninstall all and clear OAuth login / installation state\n');
|
|
18
|
+
clients.forEach((client, index) => io.output.write(` ${index + 1}. ${client}\n`));
|
|
19
|
+
io.output.write(' Enter / Ctrl+C: Cancel\nRemoving the last client also clears OAuth login and installation state. Modified files will be preserved.\n');
|
|
20
|
+
const rl = createInterface(io);
|
|
21
|
+
try {
|
|
22
|
+
return await new Promise((resolve, reject) => {
|
|
23
|
+
rl.once('SIGINT', () => resolve(undefined));
|
|
24
|
+
rl.once('close', () => resolve(undefined));
|
|
25
|
+
rl.once('error', reject);
|
|
26
|
+
const ask = () => rl.question('Choose client numbers, 0 for all, or Enter to cancel: ', answer => {
|
|
27
|
+
try {
|
|
28
|
+
const selected = parseSelection(answer, clients);
|
|
29
|
+
resolve(answer.trim() === '0' || selected.length ? selected : undefined);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
io.output.write(`${error.message}\n`);
|
|
33
|
+
ask();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
ask();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
rl.close();
|
|
41
|
+
io.input.pause();
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const authFile: (home?: string) => string;
|
|
2
|
+
export interface Session {
|
|
3
|
+
client_id: string;
|
|
4
|
+
access_token: string;
|
|
5
|
+
refresh_token?: string;
|
|
6
|
+
expires_at: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function readSession(file?: string): Promise<Session | undefined>;
|
|
9
|
+
export declare function saveSession(file: string, session: Session): Promise<void>;
|
|
10
|
+
export declare function tokenSession(client_id: string, body: any, previous?: Session): Session;
|
|
11
|
+
export declare function accessToken(file?: string, fetcher?: typeof fetch): Promise<string | undefined>;
|
|
12
|
+
export declare function callbackCode(raw: string, redirect: string, state: string): string;
|
|
13
|
+
declare function openBrowser(url: string): void;
|
|
14
|
+
export declare function login(file: string, noBrowser?: boolean, io?: {
|
|
15
|
+
input: NodeJS.ReadStream & {
|
|
16
|
+
fd: 0;
|
|
17
|
+
};
|
|
18
|
+
output: NodeJS.WriteStream & {
|
|
19
|
+
fd: 2;
|
|
20
|
+
};
|
|
21
|
+
fetcher: typeof fetch;
|
|
22
|
+
launch: typeof openBrowser;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
export {};
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createHash, randomBytes, randomUUID, randomInt } from 'node:crypto';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
const issuer = 'https://clerk.aisa.one';
|
|
9
|
+
export const authFile = (home = homedir()) => path.resolve(home, '.aisa/oauth.json');
|
|
10
|
+
export async function readSession(file = process.env.AISA_AUTH_FILE || authFile()) {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
13
|
+
if (typeof value.client_id !== 'string' || typeof value.access_token !== 'string' || !Number.isFinite(value.expires_at)
|
|
14
|
+
|| (value.refresh_token !== undefined && typeof value.refresh_token !== 'string'))
|
|
15
|
+
throw Error();
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code === 'ENOENT')
|
|
20
|
+
return undefined;
|
|
21
|
+
throw Error('Cannot read OAuth credentials. Run setup --auth oauth to sign in again.');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function saveSession(file, session) {
|
|
25
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
26
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
27
|
+
try {
|
|
28
|
+
await fs.writeFile(tmp, JSON.stringify(session), { flag: 'wx', mode: 0o600 });
|
|
29
|
+
await fs.rename(tmp, file);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await fs.rm(tmp, { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function request(url, init, fetcher = fetch) {
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = await fetcher(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(20_000) });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw Error('OAuth service unavailable. Retry setup or use --auth key.');
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw Error(`OAuth request failed (HTTP ${response.status}). Run setup --auth oauth to sign in again.`);
|
|
45
|
+
try {
|
|
46
|
+
return await response.json();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw Error('OAuth service returned invalid JSON.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function tokenSession(client_id, body, previous) {
|
|
53
|
+
if (body.token_type?.toLowerCase() !== 'bearer' || typeof body.access_token !== 'string' || !body.access_token
|
|
54
|
+
|| !Number.isFinite(body.expires_in) || body.expires_in <= 0
|
|
55
|
+
|| (body.refresh_token !== undefined && typeof body.refresh_token !== 'string'))
|
|
56
|
+
throw Error('OAuth service returned an invalid token response.');
|
|
57
|
+
return { client_id, access_token: body.access_token, refresh_token: body.refresh_token || previous?.refresh_token, expires_at: Date.now() + body.expires_in * 1000 };
|
|
58
|
+
}
|
|
59
|
+
// A file lock serializes refreshes across the three clients as well as concurrent tools.
|
|
60
|
+
export async function accessToken(file = process.env.AISA_AUTH_FILE || authFile(), fetcher = fetch) {
|
|
61
|
+
const current = await readSession(file);
|
|
62
|
+
if (!current)
|
|
63
|
+
return undefined;
|
|
64
|
+
if (current.expires_at > Date.now() + 60_000)
|
|
65
|
+
return current.access_token;
|
|
66
|
+
const lock = `${file}.lock`;
|
|
67
|
+
let fd;
|
|
68
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
fd = await fs.open(lock, 'wx', 0o600);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e.code !== 'EEXIST')
|
|
75
|
+
throw e;
|
|
76
|
+
await new Promise(r => setTimeout(r, 250));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!fd)
|
|
80
|
+
throw Error('OAuth credentials are busy. If a process crashed, remove oauth.json.lock after stopping the other AIsa processes.');
|
|
81
|
+
try {
|
|
82
|
+
const latest = await readSession(file);
|
|
83
|
+
if (!latest)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (latest.expires_at > Date.now() + 60_000)
|
|
86
|
+
return latest.access_token;
|
|
87
|
+
if (!latest.refresh_token)
|
|
88
|
+
throw Error('OAuth session expired. Run setup --auth oauth to sign in again.');
|
|
89
|
+
const body = await request(`${issuer}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'refresh_token', client_id: latest.client_id, refresh_token: latest.refresh_token }) }, fetcher);
|
|
90
|
+
const next = tokenSession(latest.client_id, body, latest);
|
|
91
|
+
await saveSession(file, next);
|
|
92
|
+
return next.access_token;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await fd.close();
|
|
96
|
+
await fs.rm(lock, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function callbackCode(raw, redirect, state) {
|
|
100
|
+
let url;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(raw.trim());
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw Error('Paste the complete callback URL, including code and state.');
|
|
106
|
+
}
|
|
107
|
+
const expected = new URL(redirect);
|
|
108
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname)
|
|
109
|
+
throw Error('Callback URL does not match this login attempt.');
|
|
110
|
+
if (url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== state)
|
|
111
|
+
throw Error('Callback state does not match this login attempt.');
|
|
112
|
+
if (url.searchParams.has('iss') && url.searchParams.get('iss') !== issuer)
|
|
113
|
+
throw Error('Callback issuer does not match AIsa.');
|
|
114
|
+
if (url.searchParams.has('error'))
|
|
115
|
+
throw Error('OAuth authorization was declined. Run setup again or choose --auth key.');
|
|
116
|
+
const code = url.searchParams.get('code');
|
|
117
|
+
if (!code || url.searchParams.getAll('code').length !== 1)
|
|
118
|
+
throw Error('Callback URL has no unique authorization code.');
|
|
119
|
+
return code;
|
|
120
|
+
}
|
|
121
|
+
function openBrowser(url) {
|
|
122
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32'
|
|
123
|
+
? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
|
|
124
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
125
|
+
child.on('error', () => { process.stderr.write('Could not open a browser. Open the printed URL on another device.\n'); });
|
|
126
|
+
child.unref();
|
|
127
|
+
}
|
|
128
|
+
export async function login(file, noBrowser = false, io = { input: process.stdin, output: process.stderr, fetcher: fetch, launch: openBrowser }) {
|
|
129
|
+
const { input, output, fetcher, launch } = io;
|
|
130
|
+
if (!input.isTTY)
|
|
131
|
+
throw Error('OAuth setup requires an interactive terminal. Use AISA_API_KEY for unattended setup.');
|
|
132
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
133
|
+
const state = randomBytes(32).toString('base64url');
|
|
134
|
+
let redirect = '';
|
|
135
|
+
let complete = () => { };
|
|
136
|
+
const server = createServer((req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
const code = callbackCode(new URL(req.url || '/', redirect).href, redirect, state);
|
|
139
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' }).end('AIsa authorization received. You can return to the terminal.');
|
|
140
|
+
complete(code);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Invalid callback. Return to the terminal or retry authorization.');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
let lines;
|
|
147
|
+
let timer;
|
|
148
|
+
try {
|
|
149
|
+
if (noBrowser) {
|
|
150
|
+
// Manual mode also works where binding a local port is forbidden.
|
|
151
|
+
redirect = `http://127.0.0.1:${randomInt(49152, 65536)}/callback`;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); })
|
|
155
|
+
.catch(() => { throw Error('Cannot listen for OAuth callback. Retry setup --auth oauth --no-browser to paste the callback manually.'); });
|
|
156
|
+
const address = server.address();
|
|
157
|
+
if (!address || typeof address === 'string')
|
|
158
|
+
throw Error('Could not start OAuth callback listener.');
|
|
159
|
+
redirect = `http://127.0.0.1:${address.port}/callback`;
|
|
160
|
+
}
|
|
161
|
+
const metadata = await request(`${issuer}/.well-known/oauth-authorization-server`, undefined, fetcher);
|
|
162
|
+
if (metadata.issuer !== issuer || metadata.registration_endpoint !== `${issuer}/oauth/register`
|
|
163
|
+
|| metadata.authorization_endpoint !== `${issuer}/oauth/authorize` || metadata.token_endpoint !== `${issuer}/oauth/token`
|
|
164
|
+
|| !metadata.code_challenge_methods_supported?.includes('S256'))
|
|
165
|
+
throw Error('Unexpected AIsa OAuth discovery metadata.');
|
|
166
|
+
const client = await request(metadata.registration_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'AIsa Scene Agents', redirect_uris: [redirect], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', scope: 'profile email offline_access' }) }, fetcher);
|
|
167
|
+
if (!client.client_id || client.token_endpoint_auth_method !== 'none')
|
|
168
|
+
throw Error('OAuth registration did not return a public client.');
|
|
169
|
+
const url = new URL(metadata.authorization_endpoint);
|
|
170
|
+
url.search = new URLSearchParams({ client_id: client.client_id, redirect_uri: redirect, response_type: 'code', scope: 'profile email offline_access', state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }).toString();
|
|
171
|
+
const code = await new Promise((resolve, reject) => {
|
|
172
|
+
complete = resolve;
|
|
173
|
+
timer = setTimeout(() => reject(Error('OAuth login timed out after 5 minutes. Run setup again.')), 300_000);
|
|
174
|
+
lines = createInterface({ input: input, output: output });
|
|
175
|
+
lines.on('line', line => {
|
|
176
|
+
if (!line.trim())
|
|
177
|
+
return;
|
|
178
|
+
if (line.trim().toLowerCase() === 'key') {
|
|
179
|
+
reject(Error('Switched to API Key.'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
resolve(callbackCode(line, redirect, state));
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
output.write(`${e.message}\nPaste callback URL: `);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
lines.once('SIGINT', () => reject(Error('OAuth login cancelled.')));
|
|
190
|
+
lines.once('close', () => reject(Error('OAuth input closed. Run setup again.')));
|
|
191
|
+
output.write(`Open this URL to sign in:\n${url.href}\n\nWaiting for browser callback. Alternatively paste the COMPLETE callback URL here, or type key to use an API Key.\nOn another device, localhost may fail to load; copy the URL from its address bar.\nPaste callback URL: `);
|
|
192
|
+
if (!noBrowser)
|
|
193
|
+
launch(url.href);
|
|
194
|
+
});
|
|
195
|
+
lines?.close();
|
|
196
|
+
input.pause();
|
|
197
|
+
if (timer)
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
const body = await request(metadata.token_endpoint, { method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code', client_id: client.client_id, code, redirect_uri: redirect, code_verifier: verifier }) }, fetcher);
|
|
200
|
+
await saveSession(file, tokenSession(client.client_id, body));
|
|
201
|
+
output.write('\nOAuth login saved.\n');
|
|
202
|
+
if (!body.refresh_token)
|
|
203
|
+
output.write('No refresh token was issued; sign in again when this session expires.\n');
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (timer)
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
lines?.close();
|
|
209
|
+
input.pause();
|
|
210
|
+
server.close();
|
|
211
|
+
server.closeAllConnections();
|
|
212
|
+
}
|
|
213
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -160,9 +160,9 @@ export declare const output: z.ZodObject<{
|
|
|
160
160
|
calculation: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
161
161
|
warnings: z.ZodArray<z.ZodString, "many">;
|
|
162
162
|
}, "strip", z.ZodTypeAny, {
|
|
163
|
+
scope: Record<string, unknown>;
|
|
163
164
|
data: Record<string, unknown>[];
|
|
164
165
|
schema_version: "1.0.0";
|
|
165
|
-
scope: Record<string, unknown>;
|
|
166
166
|
derived: Record<string, unknown>[];
|
|
167
167
|
completeness: {
|
|
168
168
|
status: "complete" | "partial" | "empty";
|
|
@@ -185,9 +185,9 @@ export declare const output: z.ZodObject<{
|
|
|
185
185
|
calculation: Record<string, unknown>;
|
|
186
186
|
warnings: string[];
|
|
187
187
|
}, {
|
|
188
|
+
scope: Record<string, unknown>;
|
|
188
189
|
data: Record<string, unknown>[];
|
|
189
190
|
schema_version: "1.0.0";
|
|
190
|
-
scope: Record<string, unknown>;
|
|
191
191
|
derived: Record<string, unknown>[];
|
|
192
192
|
completeness: {
|
|
193
193
|
status: "complete" | "partial" | "empty";
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ApiClient, safeError } from './api.js';
|
|
|
3
3
|
import { inputs, output } from './schema.js';
|
|
4
4
|
import { execute, descriptions } from './tools.js';
|
|
5
5
|
export function createServer(api = new ApiClient()) {
|
|
6
|
-
const server = new McpServer({ name: 'aisa-web-market', version: '0.1.
|
|
6
|
+
const server = new McpServer({ name: 'aisa-web-market', version: '0.1.3' }, { instructions: 'Website competitive analysis using five AIsa tools. Use the installed aisa-web-market skill for scenario guidance. Compare identical scope and preserve missing-data evidence. API calls are billed. Credentials come from local OAuth login or AISA_API_KEY. If authentication fails, ask the user to run setup in their terminal; never request credentials or callback URLs in conversation.' });
|
|
7
7
|
for (const name of Object.keys(inputs)) {
|
|
8
8
|
server.registerTool(name, { description: descriptions[name], inputSchema: inputs[name], outputSchema: output.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true } }, async (args, extra) => {
|
|
9
9
|
try {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { installedClients } from './install.js';
|
|
5
|
+
import { authFile, readSession } from './oauth.js';
|
|
6
|
+
async function prune(dir) {
|
|
7
|
+
try {
|
|
8
|
+
const stat = await fs.lstat(dir);
|
|
9
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
10
|
+
return;
|
|
11
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true }))
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
await prune(path.join(dir, entry.name));
|
|
14
|
+
await fs.rmdir(dir);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code))
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function empty(dir) {
|
|
22
|
+
try {
|
|
23
|
+
await fs.rmdir(dir);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code))
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function cleanupUninstall(home = homedir(), fetcher = fetch) {
|
|
31
|
+
home = path.resolve(home);
|
|
32
|
+
const dir = path.join(home, '.aisa/web-market');
|
|
33
|
+
for (const target of [path.join(home, '.aisa'), dir, authFile(home)]) {
|
|
34
|
+
try {
|
|
35
|
+
if ((await fs.lstat(target)).isSymbolicLink())
|
|
36
|
+
throw Error('Refusing symlinked uninstall credential/state path.');
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== 'ENOENT')
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await fs.mkdir(dir, { recursive: true });
|
|
44
|
+
const lock = path.join(dir, 'install.lock');
|
|
45
|
+
let fd;
|
|
46
|
+
try {
|
|
47
|
+
fd = await fs.open(lock, 'wx', 0o600);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw Error('Another setup is active; retry uninstall after it finishes.');
|
|
51
|
+
}
|
|
52
|
+
let cleared = false;
|
|
53
|
+
let warning;
|
|
54
|
+
try {
|
|
55
|
+
const remaining = await installedClients(home);
|
|
56
|
+
for (const [client, root] of [['codex', '.agents'], ['claude-code', '.claude'], ['hermes', '.hermes']]) {
|
|
57
|
+
if (remaining.includes(client))
|
|
58
|
+
continue;
|
|
59
|
+
await prune(path.join(home, root, 'skills/aisa-web-market'));
|
|
60
|
+
await empty(path.join(home, root, 'skills'));
|
|
61
|
+
await empty(path.join(home, root));
|
|
62
|
+
}
|
|
63
|
+
if (!remaining.length) {
|
|
64
|
+
const file = authFile(home);
|
|
65
|
+
const tokenLock = `${file}.lock`;
|
|
66
|
+
let tokenFd;
|
|
67
|
+
try {
|
|
68
|
+
tokenFd = await fs.open(tokenLock, 'wx', 0o600);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw Error('OAuth credentials are in use; stop AIsa processes and retry uninstall.');
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
let session;
|
|
75
|
+
try {
|
|
76
|
+
session = await readSession(file);
|
|
77
|
+
}
|
|
78
|
+
catch { /* Corrupt credentials still need local cleanup. */ }
|
|
79
|
+
if (session?.refresh_token) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetcher('https://clerk.aisa.one/oauth/token/revoke', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000), body: new URLSearchParams({ token: session.refresh_token, token_type_hint: 'refresh_token', client_id: session.client_id }) });
|
|
82
|
+
if (!response.ok)
|
|
83
|
+
throw Error();
|
|
84
|
+
await response.body?.cancel();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
warning = 'Local OAuth credentials removed, but server-side revocation could not be confirmed.';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await fs.rm(file, { force: true });
|
|
91
|
+
cleared = true;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await tokenFd.close();
|
|
95
|
+
await fs.rm(tokenLock, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await fd.close();
|
|
101
|
+
await fs.rm(lock, { force: true });
|
|
102
|
+
}
|
|
103
|
+
if (cleared) {
|
|
104
|
+
await empty(dir);
|
|
105
|
+
await empty(path.join(home, '.aisa'));
|
|
106
|
+
await empty(path.join(home, '.codex'));
|
|
107
|
+
}
|
|
108
|
+
return { credentialsCleared: cleared, warning };
|
|
109
|
+
}
|
package/docs/contract.md
CHANGED
|
@@ -18,7 +18,9 @@ Traffic algorithm traffic-derived-v1 compares requested endpoints only, leaves g
|
|
|
18
18
|
|
|
19
19
|
Bearer, request headers and arbitrary upstream error bodies are not returned. 402 is payment_required because the gateway can use it for subscription or credit restrictions. No per-endpoint prices are hardcoded. max_price_usd is divided among planned calls; tiny budgets that round to zero fail before requests. A network interruption is not automatically replayed.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
OAuth login uses Clerk DCR and PKCE with shared local credentials and automatic refresh. Token exchange and refresh have simulated integration coverage; live account authorization remains unverified.
|
|
22
|
+
|
|
23
|
+
Remaining integration limits: no automatic publication-date discovery, no organic/nonbranded keyword filters, no live price estimation, no durable query cache, no interactive-client evals. See live-verification-2026-09-05.md for test-account evidence. HTTP bearer requests and sample parsing are verified using fixtures, including the docs' example envelopes.
|
|
22
24
|
|
|
23
25
|
404 diagnostics: only the known gateway error `api endpoint not found` maps to unavailable_operation. Other 404 responses map to not_found without assuming route disablement, account permissions or missing domain data. Tool errors include http_status when known; arbitrary upstream messages are never forwarded. The aisa-mcp catalogue includes website-top-geographies but explicitly excludes website/traffic-geography and website/folders based on its 2026-08-26 production checks. This historical catalogue is not a live availability guarantee.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hzlmy2002/web-market",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Focused website competitive analysis: five MCP tools and a portable skill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc",
|
|
20
|
-
"test": "npm run build && node --test tests/*.test.mjs",
|
|
20
|
+
"test": "npm run build && node --import ./tests/isolate-env.mjs --test tests/*.test.mjs",
|
|
21
21
|
"check": "tsc --noEmit",
|
|
22
22
|
"prepack": "npm run build",
|
|
23
23
|
"plugins": "npm run build && node scripts/package-plugins.mjs"
|