@hzlmy2002/web-market 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 ADDED
@@ -0,0 +1,100 @@
1
+ # AIsa Web Market
2
+
3
+ Node/TypeScript stdio MCP for focused website competitive analysis. Five tools call **AIsa REST APIs directly**, with user-supplied Bearer authentication. No dependency on the Python aisa-mcp server, OAuth, or model calls inside tools.
4
+
5
+ ## Install with npx
6
+
7
+ Requires Node 22 or newer. Set AISA_API_KEY in the environment of the process launching your MCP client, then run one of:
8
+
9
+ ```sh
10
+ npx -y @hzlmy2002/web-market@0.1.0 setup --client codex
11
+ npx -y @hzlmy2002/web-market@0.1.0 setup --client claude-code
12
+ npx -y @hzlmy2002/web-market@0.1.0 setup --client hermes
13
+ ```
14
+
15
+ This installs the Skill and registers a version-pinned `npx -y @hzlmy2002/web-market@0.1.0 serve` MCP command. Clearing the npm cache does not invalidate the configured path: npx downloads the pinned release again when necessary. Restart or refresh the client to discover the Skill. Credentials must still reach the MCP process; the installer never stores your key. Use `uninstall --client ...` to remove an unchanged managed installation.
16
+
17
+ ## Local setup
18
+
19
+ Requires Node 22 or newer.
20
+
21
+ ```sh
22
+ npm ci
23
+ npm test
24
+ npm run build
25
+ ```
26
+
27
+ Set `AISA_API_KEY` in the environment of the process launching your MCP client. It is an AIsa API key; never put it in a Skill or commit it. Desktop clients started outside your shell may need the variable configured explicitly in their MCP settings. The installer does not read or persist the key.
28
+
29
+ Run one of these from this checkout:
30
+
31
+ ```sh
32
+ node dist/cli.js setup --client codex
33
+ node dist/cli.js setup --client claude-code
34
+ node dist/cli.js setup --client hermes
35
+ ```
36
+
37
+ Setup installs the Skill and merges a user-level MCP entry using the absolute Node executable and checkout path. Keep this checkout in place or rerun setup after moving it. Existing settings are preserved semantically; TOML/YAML comments and formatting can change. Unowned existing Skill files are preserved, even if their contents match the bundle. Setup refuses conflicting MCP entries and modified skills. It stages changes in memory, uses atomic writes and rolls back ordinary write failures. It uses a lock against concurrent AIsa installers; close the client's settings editor during setup. This is not a filesystem transaction across process crashes.
38
+
39
+ | Client | Skill directory | MCP config |
40
+ |---|---|---|
41
+ | Codex | `~/.agents/skills/aisa-web-market` | `~/.codex/config.toml` |
42
+ | Claude Code | `~/.claude/skills/aisa-web-market` | `~/.claude.json` |
43
+ | Hermes | `~/.hermes/skills/aisa-web-market` | `~/.hermes/config.yaml` |
44
+
45
+ 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`.
46
+
47
+ To use MCP-only configuration, set `command` to your Node executable and `args` to the absolute path to `dist/cli.js`, followed by `serve`. 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.
48
+
49
+ ```sh
50
+ node dist/cli.js status
51
+ node dist/cli.js uninstall --client codex
52
+ ```
53
+
54
+ 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 may leave empty directories. To update, rebuild this checkout and rerun setup.
55
+
56
+ ## Tools
57
+
58
+ | Tool | Behavior |
59
+ |---|---|
60
+ | `AIsa_similar_sites` | One seed; explicit supported end month; exact 3-month window; up to 20 candidates |
61
+ | `AIsa_traffic_engagement` | 1–5 domains, explicit 1–12 monthly buckets, visits and engagement; aligned missing cells and growth |
62
+ | `AIsa_geography` | Latest worldwide snapshot, up to 10 countries per domain, 1–5 domains |
63
+ | `AIsa_website_keywords` | Single-domain keyword sample, explicit month, up to 20 rows |
64
+ | `AIsa_keyword_gap` | Target and 1–3 competitors, matching scope, deterministic sample classifications |
65
+
66
+ Country is `us` or `ww` in this release. Keyword filtering by organic/paid or branded/nonbranded is not exposed by the pinned API contract. `competition` is not relabeled as difficulty. No search volume is invented. Domain URLs are normalized; duplicate, IP, local and invalid hosts are rejected.
67
+
68
+ Every tool supports optional `max_price_usd`, a **total tool-call ceiling**, divided downward across planned requests and passed as `X-AISA-Max-Price-USD`. This relies on AIsa gateway enforcement. It is not a local price estimate or a conversation-wide budget. No automatic retries; timed-out requests may already have been billed. Up to three requests run concurrently, each with a 20-second timeout and 2 MB response cap; a tool invocation has a 60-second deadline. No persistent data cache is used.
69
+
70
+ Dates are explicit for traffic, keywords and Similar Sites. The public API does not expose a universally verified availability endpoint for these datasets, so this release does not guess the latest published month or perform additional billed probing. Geography uses the server's latest available snapshot and reports its scope. A later release can add availability discovery once a stable public contract exists.
71
+
72
+ Example user requests:
73
+
74
+ - “Compare a.com and b.com traffic, worldwide, April–June 2026.”
75
+ - “Compare target.com and rival.com keyword samples for June 2026 in the US.”
76
+ - “Find sites similar to a.com for the supported window ending July 2026.”
77
+
78
+ ## Plugin bundles
79
+
80
+ ```sh
81
+ npm run plugins
82
+ ```
83
+
84
+ Generates `plugins/{codex,claude-code,hermes}/aisa-web-market/`, each with the same skills and a self-contained JS runtime (Node still required). No npm download is needed when starting a generated plugin. Packaging itself does not install anything into your clients or publish a marketplace entry.
85
+
86
+ - Codex: `.codex-plugin/plugin.json` plus `.mcp.json`; distribute using the client's local/plugin marketplace workflow.
87
+ - Claude Code: `.claude-plugin/plugin.json` plus `.mcp.json`; local test with `claude --plugin-dir /absolute/path/to/plugins/claude-code/aisa-web-market`.
88
+ - Hermes: Agent Plugins v1 `plugin.json` plus `mcp.json`; requires a version supporting portable packages. Enable after installing through Hermes. Configure AISA_API_KEY in the Hermes process environment.
89
+
90
+ 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.
91
+
92
+ ## Development and release
93
+
94
+ `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.
95
+
96
+ `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.
97
+
98
+ ## Live verification
99
+
100
+ The five tools and all four underlying API paths were exercised with an authorized test account on 2026-09-05 (9 requests). See [the report](docs/live-verification-2026-09-05.md). `estk.me` returned provider Data not found for July 2026 traffic/geography; wikipedia.org succeeded. The exact no-data signature now returns empty success with an explanatory warning. The captured responses also run offline in regression tests. This does not guarantee every domain or month has data.
package/dist/api.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export type Row = Record<string, any>;
2
+ export declare class ApiError extends Error {
3
+ code: string;
4
+ retryable: boolean;
5
+ status?: number | undefined;
6
+ constructor(code: string, message: string, retryable?: boolean, status?: number | undefined);
7
+ }
8
+ export declare const paths: {
9
+ readonly traffic: "/similarweb/website/traffic-engagement";
10
+ readonly similar: "/similarweb/website/similar-sites";
11
+ readonly keywords: "/similarweb/search/website-keywords";
12
+ readonly geography: "/similarweb/website-top-geographies";
13
+ };
14
+ export type Endpoint = typeof paths[keyof typeof paths];
15
+ export interface Transport {
16
+ get(path: Endpoint, query: Row, cap?: number, signal?: AbortSignal): Promise<Row>;
17
+ }
18
+ export declare function safeError(error: unknown): ApiError;
19
+ export declare class ApiClient implements Transport {
20
+ private key;
21
+ private fetcher;
22
+ constructor(key?: string | undefined, fetcher?: typeof fetch);
23
+ get(path: Endpoint, query: Row, cap?: number, signal?: AbortSignal): Promise<Row>;
24
+ }
package/dist/api.js ADDED
@@ -0,0 +1,137 @@
1
+ export class ApiError extends Error {
2
+ code;
3
+ retryable;
4
+ status;
5
+ constructor(code, message, retryable = false, status) {
6
+ super(message);
7
+ this.code = code;
8
+ this.retryable = retryable;
9
+ this.status = status;
10
+ }
11
+ }
12
+ export const paths = {
13
+ traffic: '/similarweb/website/traffic-engagement',
14
+ similar: '/similarweb/website/similar-sites',
15
+ keywords: '/similarweb/search/website-keywords',
16
+ geography: '/similarweb/website-top-geographies',
17
+ };
18
+ export function safeError(error) {
19
+ return error instanceof ApiError ? error : new ApiError('internal_error', 'The operation could not be completed.');
20
+ }
21
+ export class ApiClient {
22
+ key;
23
+ fetcher;
24
+ constructor(key = process.env.AISA_API_KEY, fetcher = fetch) {
25
+ this.key = key;
26
+ this.fetcher = fetcher;
27
+ }
28
+ async get(path, query, cap, signal) {
29
+ if (!this.key?.trim())
30
+ throw new ApiError('missing_credentials', 'Set AISA_API_KEY in the MCP server environment and restart the server.');
31
+ if (!Object.values(paths).includes(path))
32
+ throw new ApiError('invalid_input', 'Unsupported API operation.');
33
+ const url = new URL('https://api.aisa.one/apis/v1' + path);
34
+ for (const [k, v] of Object.entries(query))
35
+ if (v !== undefined)
36
+ url.searchParams.set(k, String(v));
37
+ const headers = { Authorization: `Bearer ${this.key.trim()}`, Accept: 'application/json' };
38
+ if (cap !== undefined)
39
+ headers['X-AISA-Max-Price-USD'] = String(cap);
40
+ try {
41
+ // No automatic retries: a timed-out billable request may already have succeeded.
42
+ const response = await this.fetcher(url, { headers, redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000) });
43
+ if (!response.ok) {
44
+ // Inspect only a bounded error body for known gateway identifiers.
45
+ // Never forward arbitrary provider messages (they may contain secrets).
46
+ let gatewayError;
47
+ let errorBody;
48
+ const errorReader = response.body?.getReader();
49
+ if (errorReader) {
50
+ const chunks = [];
51
+ let bytes = 0;
52
+ try {
53
+ while (true) {
54
+ const { done, value } = await errorReader.read();
55
+ if (done)
56
+ break;
57
+ bytes += value.byteLength;
58
+ if (bytes > 16_384) {
59
+ await errorReader.cancel();
60
+ break;
61
+ }
62
+ chunks.push(value);
63
+ }
64
+ if (bytes <= 16_384) {
65
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
66
+ errorBody = body;
67
+ gatewayError = typeof body?.error === 'string' ? body.error : body?.error?.code;
68
+ }
69
+ }
70
+ catch { /* Keep HTTP classification when error JSON cannot be read. */ }
71
+ finally {
72
+ errorReader.releaseLock();
73
+ }
74
+ }
75
+ // Similarweb uses HTTP 404 + provider error_code 401 for an absent
76
+ // dataset. Provider code 401 is NOT HTTP authentication failure.
77
+ if (response.status === 404 && errorBody?.meta?.status === 'error'
78
+ && errorBody.meta.error_code === 401 && errorBody.meta.error_message === 'Data not found'
79
+ && errorBody.data === null) {
80
+ return {
81
+ meta: { ...errorBody.meta, status: 'success', empty_reason: 'provider_no_data' },
82
+ data: path === paths.geography ? { countries: [] } : [],
83
+ };
84
+ }
85
+ if (response.status === 402 && gatewayError === 'estimated_price_exceeds_max_price') {
86
+ throw new ApiError('cost_limit_exceeded', 'The estimated request price exceeds max_price_usd. Increase the authorized budget or reduce scope; this error does not establish insufficient account credit.', false, 402);
87
+ }
88
+ if (response.status === 404 && gatewayError === 'api endpoint not found') {
89
+ throw new ApiError('unavailable_operation', 'AIsa gateway could not resolve an enabled route for this API operation. Check endpoint and metering configuration; this does not establish an account-permission failure.', false, 404);
90
+ }
91
+ const codes = {
92
+ 400: ['invalid_input', 'AIsa rejected the query scope or parameters.', false],
93
+ 401: ['unauthorized', 'AIsa rejected the API key. Check AISA_API_KEY.', false],
94
+ 402: ['payment_required', 'AIsa requires sufficient credit and the appropriate subscription. Check your AIsa account.', false],
95
+ 403: ['forbidden', 'The account cannot access this data scope.', false],
96
+ 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],
97
+ 429: ['upstream_rate_limited', 'AIsa rate limited this request. Retry later.', true],
98
+ };
99
+ const [code, message, retryable] = codes[response.status] ?? ['upstream_unavailable', 'AIsa could not complete this request.', response.status >= 500];
100
+ throw new ApiError(code, message, retryable, response.status);
101
+ }
102
+ const reader = response.body?.getReader();
103
+ if (!reader)
104
+ throw new ApiError('invalid_response', 'AIsa returned no response body.');
105
+ let size = 0;
106
+ const chunks = [];
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ break;
111
+ size += value.byteLength;
112
+ if (size > 2_000_000) {
113
+ await reader.cancel();
114
+ throw new ApiError('response_too_large', 'AIsa response exceeded the 2 MB limit.');
115
+ }
116
+ chunks.push(value);
117
+ }
118
+ let body;
119
+ try {
120
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
121
+ }
122
+ catch {
123
+ throw new ApiError('invalid_response', 'AIsa returned invalid JSON.');
124
+ }
125
+ if (!body || typeof body !== 'object' || Array.isArray(body) || !('data' in body))
126
+ throw new ApiError('invalid_response', 'AIsa returned an unexpected data envelope.');
127
+ if (body.error || (body.meta?.status && body.meta.status !== 'success'))
128
+ throw new ApiError('upstream_error', 'The data source reported an error; no data was inferred.');
129
+ return body;
130
+ }
131
+ catch (e) {
132
+ if (e instanceof ApiError)
133
+ throw e;
134
+ throw new ApiError(signal?.aborted ? 'cancelled' : 'network_error', 'Request interrupted or unavailable. It may have been billed; it was not automatically retried.', true);
135
+ }
136
+ }
137
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { createServer } from './server.js';
5
+ import { install } from './install.js';
6
+ async function main() {
7
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: { client: { type: 'string' }, home: { type: 'string' }, 'install-skills': { type: 'boolean' }, help: { type: 'boolean' } } });
8
+ const command = positionals[0] ?? 'serve';
9
+ if (values.help) {
10
+ console.log('aisa-web-market serve [--install-skills --client codex|claude-code|hermes]\naisa-web-market setup|uninstall --client codex|claude-code|hermes [--home directory]\naisa-web-market status\nSet AISA_API_KEY in the server environment. setup never stores the token.');
11
+ return;
12
+ }
13
+ if (positionals.length > 1)
14
+ throw Error('Unexpected positional argument.');
15
+ if (command === 'status') {
16
+ console.log(JSON.stringify({ credential_configured: Boolean(process.env.AISA_API_KEY?.trim()), authentication: 'bearer', version: '0.1.0' }));
17
+ return;
18
+ }
19
+ if (command === 'setup' || command === 'uninstall') {
20
+ if (!values.client)
21
+ throw Error('--client is required.');
22
+ console.log(JSON.stringify(await install(values.client, { home: values.home, remove: command === 'uninstall' })));
23
+ console.log('Client configuration preserves existing values; serialization may reformat comments. Ensure AISA_API_KEY reaches the client process. Restart or refresh the client to load skills.');
24
+ return;
25
+ }
26
+ if (command !== 'serve')
27
+ throw Error('Unknown command. Use --help.');
28
+ if (values['install-skills']) {
29
+ if (!values.client)
30
+ throw Error('--install-skills requires --client.');
31
+ console.error(JSON.stringify(await install(values.client, { home: values.home, skillsOnly: true })));
32
+ }
33
+ const server = createServer();
34
+ await server.connect(new StdioServerTransport());
35
+ for (const sig of ['SIGINT', 'SIGTERM'])
36
+ process.once(sig, () => { void server.close().finally(() => process.exit(0)); });
37
+ }
38
+ main().catch(e => { console.error(e instanceof Error ? e.message : 'Startup failed.'); process.exitCode = 1; });
@@ -0,0 +1,17 @@
1
+ export type Client = 'codex' | 'claude-code' | 'hermes';
2
+ export declare const packageRoot: string;
3
+ export declare function launchEntry(client: Client, root?: string): {
4
+ env_vars?: string[] | undefined;
5
+ command: string;
6
+ args: string[];
7
+ };
8
+ export declare function install(client: Client, options?: {
9
+ home?: string;
10
+ remove?: boolean;
11
+ skillsOnly?: boolean;
12
+ }): Promise<{
13
+ client: Client;
14
+ skill: string;
15
+ config: string | null;
16
+ action: string;
17
+ }>;
@@ -0,0 +1,176 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { homedir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import TOML from '@iarna/toml';
7
+ import YAML from 'yaml';
8
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const skillName = 'aisa-web-market';
10
+ // npm/npx installations live in node_modules. Persist a version-pinned registry
11
+ // command rather than a path into npx's disposable cache. Checkouts keep the
12
+ // local Node entry so development changes remain immediately testable.
13
+ export function launchEntry(client, root = packageRoot) {
14
+ const command = root.split(path.sep).includes('node_modules')
15
+ ? { command: process.platform === 'win32' ? 'npx.cmd' : 'npx', args: ['-y', '@hzlmy2002/web-market@0.1.0', 'serve'] }
16
+ : { command: process.execPath, args: [path.join(root, 'dist/cli.js'), 'serve'] };
17
+ return { ...command, ...(client === 'codex' ? { env_vars: ['AISA_API_KEY'] } : {}) };
18
+ }
19
+ const hash = (x) => createHash('sha256').update(x).digest('hex');
20
+ async function read(file) { try {
21
+ return await fs.readFile(file, 'utf8');
22
+ }
23
+ catch (e) {
24
+ if (e.code === 'ENOENT')
25
+ return undefined;
26
+ throw e;
27
+ } }
28
+ async function noSymlinks(target) {
29
+ const full = path.resolve(target);
30
+ let cursor = path.parse(full).root;
31
+ for (const part of full.slice(cursor.length).split(path.sep)) {
32
+ cursor = path.join(cursor, part);
33
+ try {
34
+ if ((await fs.lstat(cursor)).isSymbolicLink())
35
+ throw Error(`Refusing symbolic link: ${cursor}`);
36
+ }
37
+ catch (e) {
38
+ if (e.code !== 'ENOENT')
39
+ throw e;
40
+ }
41
+ }
42
+ }
43
+ async function atomic(file, value) {
44
+ await noSymlinks(file);
45
+ await fs.mkdir(path.dirname(file), { recursive: true });
46
+ const tmp = `${file}.${randomUUID()}.tmp`;
47
+ try {
48
+ await fs.writeFile(tmp, value, { mode: 0o600, flag: 'wx' });
49
+ await fs.rename(tmp, file);
50
+ }
51
+ finally {
52
+ await fs.rm(tmp, { force: true });
53
+ }
54
+ }
55
+ function configFor(client, home) {
56
+ if (client === 'codex')
57
+ return { file: path.join(home, '.codex/config.toml'), key: 'mcp_servers', parse: (x) => TOML.parse(x), stringify: (x) => TOML.stringify(x) };
58
+ if (client === 'hermes')
59
+ return { file: path.join(home, '.hermes/config.yaml'), key: 'mcp_servers', parse: (x) => YAML.parse(x), stringify: (x) => YAML.stringify(x) };
60
+ return { file: path.join(home, '.claude.json'), key: 'mcpServers', parse: (x) => JSON.parse(x), stringify: (x) => JSON.stringify(x, null, 2) + '\n' };
61
+ }
62
+ function skillRoot(client, home) { return path.join(home, client === 'codex' ? '.agents/skills' : client === 'hermes' ? '.hermes/skills' : '.claude/skills', skillName); }
63
+ async function skillFiles(root, prefix = '') {
64
+ const out = {};
65
+ for (const item of await fs.readdir(path.join(root, prefix), { withFileTypes: true })) {
66
+ const relative = path.join(prefix, item.name);
67
+ if (item.isSymbolicLink())
68
+ throw Error('Bundled skills must not contain symlinks.');
69
+ if (item.isDirectory())
70
+ Object.assign(out, await skillFiles(root, relative));
71
+ else
72
+ out[relative] = await fs.readFile(path.join(root, relative), 'utf8');
73
+ }
74
+ return out;
75
+ }
76
+ export async function install(client, options = {}) {
77
+ if (!['codex', 'claude-code', 'hermes'].includes(client))
78
+ throw Error('Choose --client codex, claude-code or hermes.');
79
+ const home = path.resolve(options.home ?? homedir());
80
+ const stateDir = path.join(home, '.aisa/web-market');
81
+ await noSymlinks(stateDir);
82
+ await fs.mkdir(stateDir, { recursive: true });
83
+ const lock = path.join(stateDir, 'install.lock');
84
+ let fd;
85
+ try {
86
+ fd = await fs.open(lock, 'wx', 0o600);
87
+ }
88
+ catch {
89
+ throw Error('Another setup is running. If it crashed, remove .aisa/web-market/install.lock after confirming no setup is active.');
90
+ }
91
+ try {
92
+ const stateFile = path.join(stateDir, `${client}.json`);
93
+ await noSymlinks(stateFile);
94
+ const previous = JSON.parse(await read(stateFile) ?? '{"files":{}}');
95
+ const cfg = configFor(client, home);
96
+ const root = skillRoot(client, home);
97
+ const entry = launchEntry(client);
98
+ const writes = new Map();
99
+ const next = { version: '0.1.0', files: {}, config: previous.config };
100
+ const bundled = await skillFiles(path.join(packageRoot, 'skills', skillName));
101
+ for (const relative of new Set([...Object.keys(bundled), ...Object.keys(previous.files)])) {
102
+ if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..'))
103
+ throw Error('Invalid installer state path.');
104
+ const content = bundled[relative];
105
+ const file = path.join(root, relative);
106
+ await noSymlinks(file);
107
+ const current = await read(file);
108
+ if (options.remove || content === undefined) {
109
+ if (current !== undefined && previous.files[relative] === hash(current))
110
+ writes.set(file, undefined);
111
+ else if (current !== undefined)
112
+ throw Error(`Preserving modified or unowned skill: ${file}`);
113
+ }
114
+ else {
115
+ if (current !== undefined && previous.files[relative] !== hash(current))
116
+ throw Error(`Preserving modified or unowned skill: ${file}`);
117
+ writes.set(file, content);
118
+ next.files[relative] = hash(content);
119
+ }
120
+ }
121
+ if (!options.skillsOnly && (!options.remove || previous.config)) {
122
+ await noSymlinks(cfg.file);
123
+ const current = await read(cfg.file);
124
+ let config;
125
+ try {
126
+ config = current?.trim() ? cfg.parse(current) : {};
127
+ }
128
+ catch {
129
+ throw Error('Client configuration could not be parsed; no files were changed.');
130
+ }
131
+ if (typeof config !== 'object' || Array.isArray(config))
132
+ throw Error('Invalid client configuration.');
133
+ config[cfg.key] ??= {};
134
+ if (typeof config[cfg.key] !== 'object' || Array.isArray(config[cfg.key]))
135
+ throw Error('Invalid MCP configuration section.');
136
+ const existing = config[cfg.key][skillName];
137
+ if (options.remove) {
138
+ if (existing && JSON.stringify(existing) !== JSON.stringify(previous.config))
139
+ throw Error('MCP entry has been modified; preserving it.');
140
+ delete config[cfg.key][skillName];
141
+ }
142
+ else {
143
+ if (existing && JSON.stringify(existing) !== JSON.stringify(previous.config) && JSON.stringify(existing) !== JSON.stringify(entry))
144
+ throw Error('An unowned aisa-web-market MCP entry already exists; preserving it.');
145
+ config[cfg.key][skillName] = entry;
146
+ next.config = entry;
147
+ }
148
+ writes.set(cfg.file, cfg.stringify(config));
149
+ }
150
+ writes.set(stateFile, options.remove ? undefined : JSON.stringify(next, null, 2));
151
+ const backups = new Map();
152
+ try {
153
+ for (const [file, content] of writes) {
154
+ backups.set(file, await read(file));
155
+ if (content === undefined)
156
+ await fs.rm(file, { force: true });
157
+ else
158
+ await atomic(file, content);
159
+ }
160
+ }
161
+ catch (e) {
162
+ for (const [file, old] of [...backups].reverse()) {
163
+ if (old === undefined)
164
+ await fs.rm(file, { force: true });
165
+ else
166
+ await atomic(file, old);
167
+ }
168
+ throw e;
169
+ }
170
+ return { client, skill: root, config: options.skillsOnly ? null : cfg.file, action: options.remove ? 'removed' : 'installed' };
171
+ }
172
+ finally {
173
+ await fd.close();
174
+ await fs.rm(lock, { force: true });
175
+ }
176
+ }