diff --git a/README.md b/README.md
index 061ab9f..f85b5e6 100644
--- a/README.md
+++ b/README.md
@@ -114,7 +114,7 @@ CGV mcp를 사용해서 강남 CGV 영화랑 시간표 확인해줘
> Claude Code CLI에서 MCP 서버 추가
```bash
-claude mcp add daiso-mcp https://mcp.aka.page --transport sse
+claude mcp add daiso-mcp https://mcp.aka.page --transport http
```
diff --git a/package-lock.json b/package-lock.json
index 9d790d6..6630988 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,8 +31,8 @@
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"tsx": "^4.20.6",
- "typescript": "^5.7.2",
- "typescript-eslint": "^8.57.0",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.59.3",
"vitest": "^4.1.0",
"wrangler": "^4.73.0"
},
@@ -1953,20 +1953,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
- "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
+ "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/type-utils": "8.57.2",
- "@typescript-eslint/utils": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/type-utils": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1976,9 +1976,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.57.2",
+ "@typescript-eslint/parser": "^8.59.3",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -1992,16 +1992,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
- "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
+ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
"debug": "^4.4.3"
},
"engines": {
@@ -2013,18 +2013,18 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
- "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
+ "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.57.2",
- "@typescript-eslint/types": "^8.57.2",
+ "@typescript-eslint/tsconfig-utils": "^8.59.3",
+ "@typescript-eslint/types": "^8.59.3",
"debug": "^4.4.3"
},
"engines": {
@@ -2035,18 +2035,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
- "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
+ "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2"
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2057,9 +2057,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
- "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
+ "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2070,21 +2070,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
- "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
+ "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/utils": "8.57.2",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3",
"debug": "^4.4.3",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2095,13 +2095,13 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
- "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
+ "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2113,21 +2113,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
- "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
+ "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.57.2",
- "@typescript-eslint/tsconfig-utils": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/project-service": "8.59.3",
+ "@typescript-eslint/tsconfig-utils": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/visitor-keys": "8.59.3",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2137,20 +2137,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
- "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
+ "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2"
+ "@typescript-eslint/scope-manager": "8.59.3",
+ "@typescript-eslint/types": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2161,17 +2161,17 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
- "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
+ "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/types": "8.59.3",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -5956,9 +5956,9 @@
}
},
"node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5970,16 +5970,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
- "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
+ "version": "8.59.3",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
+ "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.57.2",
- "@typescript-eslint/parser": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/utils": "8.57.2"
+ "@typescript-eslint/eslint-plugin": "8.59.3",
+ "@typescript-eslint/parser": "8.59.3",
+ "@typescript-eslint/typescript-estree": "8.59.3",
+ "@typescript-eslint/utils": "8.59.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -5990,7 +5990,7 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uc.micro": {
diff --git a/package.json b/package.json
index 2a3f687..3df3dc4 100644
--- a/package.json
+++ b/package.json
@@ -73,8 +73,8 @@
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"tsx": "^4.20.6",
- "typescript": "^5.7.2",
- "typescript-eslint": "^8.57.0",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.59.3",
"vitest": "^4.1.0",
"wrangler": "^4.73.0"
},
diff --git a/src/cli.ts b/src/cli.ts
index fbfc9d9..182dd51 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -6,252 +6,42 @@
* npx daiso 명령으로 원격 MCP 서버 정보를 확인하고 상태를 점검합니다.
*/
-import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { spawn } from 'node:child_process';
-import { printCommandHelp, printHelp } from './cliHelp.js';
-import { runInteractiveCli, type InteractiveCliDeps } from './cliInteractive.js';
-import { renderApiEnvelope } from './cliRenderer.js';
-import { buildDaisoStoreKeywordVariants } from './utils/daisoKeyword.js';
-
-const DEFAULT_BASE_URL = 'https://mcp.aka.page';
-const DEFAULT_MCP_URL = `${DEFAULT_BASE_URL}/mcp`;
-
-type FetchLike = typeof fetch;
-
-type WriteFn = (message: string) => void;
-
-interface CliDeps {
- fetchImpl: FetchLike;
- writeOut: WriteFn;
- writeErr: WriteFn;
- getVersion: () => string;
- nowIso: () => string;
- runCommand: (command: string, args: string[]) => Promise;
- isInteractiveTerminal: () => boolean;
- runInteractive: (deps: InteractiveCliDeps) => Promise;
-}
-
-function loadVersion(): string {
- const cliPath = fileURLToPath(import.meta.url);
- const packagePath = path.resolve(path.dirname(cliPath), '../package.json');
-
- if (!existsSync(packagePath)) {
- return '0.0.0';
- }
-
- const raw = readFileSync(packagePath, 'utf8');
- const parsed = JSON.parse(raw) as { version?: string };
- return parsed.version ?? '0.0.0';
-}
-
-async function execCommand(command: string, args: string[]): Promise {
- return await new Promise((resolve, reject) => {
- const child = spawn(command, args, {
- stdio: 'inherit',
- shell: false,
- });
-
- child.on('error', (error) => {
- reject(error);
- });
-
- child.on('close', (code) => {
- resolve(code ?? 1);
- });
- });
-}
-
-function createDefaultDeps(): CliDeps {
- return {
- fetchImpl: fetch,
- writeOut: (message: string) => {
- process.stdout.write(`${message}\n`);
- },
- writeErr: (message: string) => {
- process.stderr.write(`${message}\n`);
- },
- getVersion: loadVersion,
- nowIso: () => new Date().toISOString(),
- runCommand: execCommand,
- isInteractiveTerminal: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
- runInteractive: runInteractiveCli,
- };
-}
-
-function parseCliArgs(args: string[]): { positionals: string[]; options: Record } {
- const positionals: string[] = [];
- const options: Record = {};
-
- for (let index = 0; index < args.length; index += 1) {
- const token = args[index];
- if (!token.startsWith('--')) {
- positionals.push(token);
- continue;
- }
-
- const withoutPrefix = token.slice(2);
- const equalIndex = withoutPrefix.indexOf('=');
-
- if (equalIndex >= 0) {
- const key = withoutPrefix.slice(0, equalIndex);
- const value = withoutPrefix.slice(equalIndex + 1);
- options[key] = value;
- continue;
- }
-
- const key = withoutPrefix;
- const nextValue = args[index + 1];
- if (!nextValue || nextValue.startsWith('--')) {
- options[key] = 'true';
- continue;
- }
-
- options[key] = nextValue;
- index += 1;
- }
-
- return { positionals, options };
-}
-
-function toUrl(pathOrUrl: string): URL {
- if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
- return new URL(pathOrUrl);
- }
-
- const normalizedPath = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
- return new URL(normalizedPath, DEFAULT_BASE_URL);
-}
-
-function applyOptionsToQuery(url: URL, options: Record): void {
- for (const [key, value] of Object.entries(options)) {
- url.searchParams.set(key, value);
- }
-}
-
-function toQueryOptions(options: Record): Record {
- const queryOptions: Record = {};
- for (const [key, value] of Object.entries(options)) {
- if (key === 'help' || key === 'json') {
- continue;
- }
- queryOptions[key] = value;
- }
- return queryOptions;
-}
-
-function toStoreCount(payload: unknown): number {
- if (typeof payload !== 'object' || payload === null) {
- return 0;
- }
- const record = payload as { success?: unknown; data?: unknown };
- if (record.success !== true || typeof record.data !== 'object' || record.data === null) {
- return 0;
- }
- const stores = (record.data as { stores?: unknown }).stores;
- return Array.isArray(stores) ? stores.length : 0;
-}
-
-async function requestAndPrintResponse(
- fetchImpl: FetchLike,
- writeOut: WriteFn,
- writeErr: WriteFn,
- url: URL,
- command: string,
- asJson: boolean,
-): Promise {
- try {
- const response = await fetchImpl(url.toString());
-
- if (!response.ok) {
- const bodyText = await response.text();
- writeErr(`요청 실패: HTTP ${response.status}`);
- if (bodyText) {
- writeErr(bodyText);
- }
- return 1;
- }
-
- const payload = (await response.json()) as unknown;
- if (asJson) {
- writeOut(JSON.stringify(payload, null, 2));
- } else {
- writeOut(renderApiEnvelope(command, url, payload));
- }
- return 0;
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- writeErr(`요청 중 오류 발생: ${message}`);
- return 1;
- }
-}
-
-async function requestAndPrintStoresWithKeywordFallback(
- fetchImpl: FetchLike,
- writeOut: WriteFn,
- writeErr: WriteFn,
- options: Record,
- asJson: boolean,
-): Promise {
- const originalKeyword = options.keyword;
- if (!originalKeyword) {
- const url = toUrl('/api/daiso/stores');
- applyOptionsToQuery(url, options);
- return await requestAndPrintResponse(fetchImpl, writeOut, writeErr, url, 'stores', asJson);
- }
-
- const keywords = buildDaisoStoreKeywordVariants(originalKeyword);
- const candidates = keywords.length > 0 ? keywords : [originalKeyword];
-
- try {
- let lastUrl = toUrl('/api/daiso/stores');
- let lastPayload: unknown = null;
-
- for (const keyword of candidates) {
- const targetUrl = toUrl('/api/daiso/stores');
- applyOptionsToQuery(targetUrl, { ...options, keyword });
- lastUrl = targetUrl;
-
- const response = await fetchImpl(targetUrl.toString());
- if (!response.ok) {
- const bodyText = await response.text();
- writeErr(`요청 실패: HTTP ${response.status}`);
- if (bodyText) {
- writeErr(bodyText);
- }
- return 1;
- }
-
- const payload = (await response.json()) as unknown;
- lastPayload = payload;
-
- if (toStoreCount(payload) > 0) {
- if (keyword !== originalKeyword) {
- writeOut(`입력 키워드 "${originalKeyword}" 대신 "${keyword}"로 매장을 찾았습니다.`);
- }
- if (asJson) {
- writeOut(JSON.stringify(payload, null, 2));
- } else {
- writeOut(renderApiEnvelope('stores', targetUrl, payload));
- }
- return 0;
- }
- }
-
- if (asJson) {
- writeOut(JSON.stringify(lastPayload, null, 2));
- } else {
- writeOut(renderApiEnvelope('stores', lastUrl, lastPayload));
- }
- writeOut('힌트: "안산 중앙역" 대신 "안산중앙" 또는 "고잔"으로 검색해보세요.');
- return 0;
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- writeErr(`요청 중 오류 발생: ${message}`);
- return 1;
- }
-}
+import { printHelp, printCommandHelp } from './cliHelp.js';
+import { createDefaultDeps } from './cli/deps.js';
+import type { CliDeps } from './cli/types.js';
+import { DEFAULT_BASE_URL, DEFAULT_MCP_URL } from './cli/constants.js';
+import {
+ handleGet,
+ handleProducts,
+ handleProduct,
+ handleStores,
+ handleInventory,
+ handleDisplayLocation,
+} from './cli/commands/daiso.js';
+import {
+ handleCuStores,
+ handleCuInventory,
+ handleEmart24Stores,
+ handleEmart24Products,
+ handleEmart24Inventory,
+ handleLotteMartStores,
+ handleLotteMartProducts,
+ handleGs25Stores,
+ handleGs25Products,
+ handleGs25Inventory,
+ handleSevenElevenProducts,
+ handleSevenElevenStores,
+ handleSevenElevenPopwords,
+ handleSevenElevenCatalog,
+ handleLottecinemaTheaters,
+ handleLottecinemaMovies,
+ handleLottecinemaSeats,
+} from './cli/commands/convenience.js';
+
+export type { CliDeps } from './cli/types.js';
+export type { InteractiveCliDeps } from './cliInteractive.js';
export async function runCli(argv: string[], deps?: Partial): Promise {
const resolvedDeps = {
@@ -341,582 +131,29 @@ export async function runCli(argv: string[], deps?: Partial): Promise } {
+ const positionals: string[] = [];
+ const options: Record = {};
+
+ for (let index = 0; index < args.length; index += 1) {
+ const token = args[index];
+ if (!token.startsWith('--')) {
+ positionals.push(token);
+ continue;
+ }
+
+ const withoutPrefix = token.slice(2);
+ const equalIndex = withoutPrefix.indexOf('=');
+
+ if (equalIndex >= 0) {
+ const key = withoutPrefix.slice(0, equalIndex);
+ const value = withoutPrefix.slice(equalIndex + 1);
+ options[key] = value;
+ continue;
+ }
+
+ const key = withoutPrefix;
+ const nextValue = args[index + 1];
+ if (!nextValue || nextValue.startsWith('--')) {
+ options[key] = 'true';
+ continue;
+ }
+
+ options[key] = nextValue;
+ index += 1;
+ }
+
+ return { positionals, options };
+}
+
+export function toUrl(pathOrUrl: string): URL {
+ if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
+ return new URL(pathOrUrl);
+ }
+
+ const normalizedPath = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
+ return new URL(normalizedPath, DEFAULT_BASE_URL);
+}
+
+export function applyOptionsToQuery(url: URL, options: Record): void {
+ for (const [key, value] of Object.entries(options)) {
+ url.searchParams.set(key, value);
+ }
+}
+
+export function toQueryOptions(options: Record): Record {
+ const queryOptions: Record = {};
+ for (const [key, value] of Object.entries(options)) {
+ if (key === 'help' || key === 'json') {
+ continue;
+ }
+ queryOptions[key] = value;
+ }
+ return queryOptions;
+}
diff --git a/src/cli/commands/convenience.ts b/src/cli/commands/convenience.ts
new file mode 100644
index 0000000..e99f4db
--- /dev/null
+++ b/src/cli/commands/convenience.ts
@@ -0,0 +1,335 @@
+/**
+ * 편의점 및 기타 서비스 CLI 명령 핸들러
+ * (CU, 이마트24, 롯데마트, GS25, 세븐일레븐, 롯데시네마)
+ */
+
+import { printCommandHelp } from '../../cliHelp.js';
+import type { CliDeps } from '../types.js';
+import { parseCliArgs, toUrl, applyOptionsToQuery, toQueryOptions } from '../args.js';
+import { requestAndPrintResponse } from '../http.js';
+
+export async function handleCuStores(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('cu-stores', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (keyword) {
+ parsed.options.keyword = keyword;
+ }
+
+ const targetUrl = toUrl('/api/cu/stores');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'cu-stores', parsed.options.json === 'true',
+ );
+}
+
+export async function handleCuInventory(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('cu-inventory', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (!keyword) {
+ deps.writeErr('cu-inventory 명령은 검색어가 필요합니다. 예: daiso cu-inventory 과자');
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/cu/inventory');
+ targetUrl.searchParams.set('keyword', keyword);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'cu-inventory', parsed.options.json === 'true',
+ );
+}
+
+export async function handleEmart24Stores(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('emart24-stores', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (keyword) {
+ parsed.options.keyword = keyword;
+ }
+
+ const targetUrl = toUrl('/api/emart24/stores');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'emart24-stores', parsed.options.json === 'true',
+ );
+}
+
+export async function handleEmart24Products(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('emart24-products', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (!keyword) {
+ deps.writeErr('emart24-products 명령은 검색어가 필요합니다. 예: daiso emart24-products 두바이');
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/emart24/products');
+ targetUrl.searchParams.set('keyword', keyword);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'emart24-products', parsed.options.json === 'true',
+ );
+}
+
+export async function handleEmart24Inventory(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('emart24-inventory', deps.writeOut, deps.writeErr);
+ }
+
+ const pluCd = parsed.positionals[0];
+ const bizNoArr = parsed.options.bizNoArr;
+ if (!pluCd || !bizNoArr) {
+ deps.writeErr(
+ 'emart24-inventory 명령은 pluCd와 --bizNoArr가 필요합니다. 예: daiso emart24-inventory 8800244010504 --bizNoArr 28339,05015',
+ );
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/emart24/inventory');
+ targetUrl.searchParams.set('pluCd', pluCd);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'emart24-inventory', parsed.options.json === 'true',
+ );
+}
+
+export async function handleLotteMartStores(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('lottemart-stores', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (keyword) {
+ parsed.options.keyword = keyword;
+ }
+
+ const targetUrl = toUrl('/api/lottemart/stores');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'lottemart-stores', parsed.options.json === 'true',
+ );
+}
+
+export async function handleLotteMartProducts(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('lottemart-products', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (!keyword) {
+ deps.writeErr('lottemart-products 명령은 검색어가 필요합니다. 예: daiso lottemart-products 콜라 --storeName 강변점');
+ return 1;
+ }
+
+ if (!parsed.options.storeCode && !parsed.options.storeName) {
+ deps.writeErr('lottemart-products 명령은 --storeCode 또는 --storeName이 필요합니다. 예: daiso lottemart-products 콜라 --storeName 강변점');
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/lottemart/products');
+ targetUrl.searchParams.set('keyword', keyword);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'lottemart-products', parsed.options.json === 'true',
+ );
+}
+
+export async function handleGs25Stores(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('gs25-stores', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (keyword) {
+ parsed.options.keyword = keyword;
+ }
+
+ const targetUrl = toUrl('/api/gs25/stores');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'gs25-stores', parsed.options.json === 'true',
+ );
+}
+
+export async function handleGs25Products(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('gs25-products', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (!keyword) {
+ deps.writeErr('gs25-products 명령은 검색어가 필요합니다. 예: daiso gs25-products 오감자');
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/gs25/products');
+ targetUrl.searchParams.set('keyword', keyword);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'gs25-products', parsed.options.json === 'true',
+ );
+}
+
+export async function handleGs25Inventory(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('gs25-inventory', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (!keyword) {
+ deps.writeErr('gs25-inventory 명령은 검색어가 필요합니다. 예: daiso gs25-inventory 오감자');
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/gs25/inventory');
+ targetUrl.searchParams.set('keyword', keyword);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'gs25-inventory', parsed.options.json === 'true',
+ );
+}
+
+export async function handleSevenElevenProducts(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('seveneleven-products', deps.writeOut, deps.writeErr);
+ }
+
+ const query = parsed.positionals[0];
+ if (!query) {
+ deps.writeErr(
+ 'seveneleven-products 명령은 검색어가 필요합니다. 예: daiso seveneleven-products 삼각김밥',
+ );
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/seveneleven/products');
+ targetUrl.searchParams.set('query', query);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'seveneleven-products', parsed.options.json === 'true',
+ );
+}
+
+export async function handleSevenElevenStores(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('seveneleven-stores', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (!keyword) {
+ deps.writeErr(
+ 'seveneleven-stores 명령은 검색어가 필요합니다. 예: daiso seveneleven-stores 안산 중앙역',
+ );
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/seveneleven/stores');
+ targetUrl.searchParams.set('keyword', keyword);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'seveneleven-stores', parsed.options.json === 'true',
+ );
+}
+
+export async function handleSevenElevenPopwords(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('seveneleven-popwords', deps.writeOut, deps.writeErr);
+ }
+
+ const targetUrl = toUrl('/api/seveneleven/popwords');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'seveneleven-popwords', parsed.options.json === 'true',
+ );
+}
+
+export async function handleSevenElevenCatalog(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('seveneleven-catalog', deps.writeOut, deps.writeErr);
+ }
+
+ const targetUrl = toUrl('/api/seveneleven/catalog');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'seveneleven-catalog', parsed.options.json === 'true',
+ );
+}
+
+export async function handleLottecinemaTheaters(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('lottecinema-theaters', deps.writeOut, deps.writeErr);
+ }
+
+ const targetUrl = toUrl('/api/lottecinema/theaters');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'lottecinema-theaters', parsed.options.json === 'true',
+ );
+}
+
+export async function handleLottecinemaMovies(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('lottecinema-movies', deps.writeOut, deps.writeErr);
+ }
+
+ const targetUrl = toUrl('/api/lottecinema/movies');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'lottecinema-movies', parsed.options.json === 'true',
+ );
+}
+
+export async function handleLottecinemaSeats(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('lottecinema-seats', deps.writeOut, deps.writeErr);
+ }
+
+ const targetUrl = toUrl('/api/lottecinema/seats');
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl, deps.writeOut, deps.writeErr,
+ targetUrl, 'lottecinema-seats', parsed.options.json === 'true',
+ );
+}
diff --git a/src/cli/commands/daiso.ts b/src/cli/commands/daiso.ts
new file mode 100644
index 0000000..64c7e13
--- /dev/null
+++ b/src/cli/commands/daiso.ts
@@ -0,0 +1,165 @@
+/**
+ * 다이소 관련 CLI 명령 핸들러
+ */
+
+import { printCommandHelp } from '../../cliHelp.js';
+import type { CliDeps } from '../types.js';
+import { parseCliArgs, toUrl, applyOptionsToQuery, toQueryOptions } from '../args.js';
+import { requestAndPrintResponse, requestAndPrintStoresWithKeywordFallback } from '../http.js';
+
+export async function handleGet(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('get', deps.writeOut, deps.writeErr);
+ }
+
+ const targetPath = parsed.positionals[0];
+ if (!targetPath) {
+ deps.writeErr('get 명령은 경로가 필요합니다. 예: daiso get /api/daiso/products --q 수납박스');
+ return 1;
+ }
+
+ const targetUrl = toUrl(targetPath);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl,
+ deps.writeOut,
+ deps.writeErr,
+ targetUrl,
+ 'get',
+ parsed.options.json === 'true',
+ );
+}
+
+export async function handleProducts(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('products', deps.writeOut, deps.writeErr);
+ }
+
+ const query = parsed.positionals[0];
+ if (!query) {
+ deps.writeErr('products 명령은 검색어가 필요합니다. 예: daiso products 수납박스');
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/daiso/products');
+ targetUrl.searchParams.set('q', query);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl,
+ deps.writeOut,
+ deps.writeErr,
+ targetUrl,
+ 'products',
+ parsed.options.json === 'true',
+ );
+}
+
+export async function handleProduct(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('product', deps.writeOut, deps.writeErr);
+ }
+
+ const productId = parsed.positionals[0];
+ if (!productId) {
+ deps.writeErr('product 명령은 제품 ID가 필요합니다. 예: daiso product 1034604');
+ return 1;
+ }
+
+ const targetUrl = toUrl(`/api/daiso/products/${productId}`);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl,
+ deps.writeOut,
+ deps.writeErr,
+ targetUrl,
+ 'product',
+ parsed.options.json === 'true',
+ );
+}
+
+export async function handleStores(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('stores', deps.writeOut, deps.writeErr);
+ }
+
+ const keyword = parsed.positionals[0];
+ if (keyword) {
+ parsed.options.keyword = keyword;
+ }
+
+ if (!parsed.options.keyword && !parsed.options.sido) {
+ deps.writeErr(
+ 'stores 명령은 keyword 또는 --sido가 필요합니다. 예: daiso stores 강남역 / daiso stores --sido 서울',
+ );
+ return 1;
+ }
+
+ return await requestAndPrintStoresWithKeywordFallback(
+ deps.fetchImpl,
+ deps.writeOut,
+ deps.writeErr,
+ toQueryOptions(parsed.options),
+ parsed.options.json === 'true',
+ );
+}
+
+export async function handleInventory(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('inventory', deps.writeOut, deps.writeErr);
+ }
+
+ const productId = parsed.positionals[0];
+ if (!productId) {
+ deps.writeErr(
+ 'inventory 명령은 제품 ID가 필요합니다. 예: daiso inventory 1034604 --keyword 강남역',
+ );
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/daiso/inventory');
+ targetUrl.searchParams.set('productId', productId);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl,
+ deps.writeOut,
+ deps.writeErr,
+ targetUrl,
+ 'inventory',
+ parsed.options.json === 'true',
+ );
+}
+
+export async function handleDisplayLocation(options: string[], deps: CliDeps): Promise {
+ const parsed = parseCliArgs(options);
+ if (parsed.options.help === 'true') {
+ return printCommandHelp('display-location', deps.writeOut, deps.writeErr);
+ }
+
+ const productId = parsed.positionals[0];
+ const storeCode = parsed.positionals[1];
+
+ if (!productId || !storeCode) {
+ deps.writeErr(
+ 'display-location 명령은 productId와 storeCode가 필요합니다. 예: daiso display-location 1034604 04515',
+ );
+ return 1;
+ }
+
+ const targetUrl = toUrl('/api/daiso/display-location');
+ targetUrl.searchParams.set('productId', productId);
+ targetUrl.searchParams.set('storeCode', storeCode);
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
+ return await requestAndPrintResponse(
+ deps.fetchImpl,
+ deps.writeOut,
+ deps.writeErr,
+ targetUrl,
+ 'display-location',
+ parsed.options.json === 'true',
+ );
+}
diff --git a/src/cli/constants.ts b/src/cli/constants.ts
new file mode 100644
index 0000000..676dc01
--- /dev/null
+++ b/src/cli/constants.ts
@@ -0,0 +1,6 @@
+/**
+ * CLI 공통 상수
+ */
+
+export const DEFAULT_BASE_URL = 'https://mcp.aka.page';
+export const DEFAULT_MCP_URL = `${DEFAULT_BASE_URL}/mcp`;
diff --git a/src/cli/deps.ts b/src/cli/deps.ts
new file mode 100644
index 0000000..c38c2de
--- /dev/null
+++ b/src/cli/deps.ts
@@ -0,0 +1,57 @@
+/**
+ * CLI 기본 의존성 생성
+ */
+
+import { existsSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { spawn } from 'node:child_process';
+import { runInteractiveCli } from '../cliInteractive.js';
+import type { CliDeps } from './types.js';
+
+function loadVersion(): string {
+ const cliPath = fileURLToPath(import.meta.url);
+ const packagePath = path.resolve(path.dirname(cliPath), '../../package.json');
+
+ if (!existsSync(packagePath)) {
+ return '0.0.0';
+ }
+
+ const raw = readFileSync(packagePath, 'utf8');
+ const parsed = JSON.parse(raw) as { version?: string };
+ return parsed.version ?? '0.0.0';
+}
+
+async function execCommand(command: string, args: string[]): Promise {
+ return await new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ stdio: 'inherit',
+ shell: false,
+ });
+
+ child.on('error', (error) => {
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ resolve(code ?? 1);
+ });
+ });
+}
+
+export function createDefaultDeps(): CliDeps {
+ return {
+ fetchImpl: fetch,
+ writeOut: (message: string) => {
+ process.stdout.write(`${message}\n`);
+ },
+ writeErr: (message: string) => {
+ process.stderr.write(`${message}\n`);
+ },
+ getVersion: loadVersion,
+ nowIso: () => new Date().toISOString(),
+ runCommand: execCommand,
+ isInteractiveTerminal: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
+ runInteractive: runInteractiveCli,
+ };
+}
diff --git a/src/cli/http.ts b/src/cli/http.ts
new file mode 100644
index 0000000..3e88407
--- /dev/null
+++ b/src/cli/http.ts
@@ -0,0 +1,120 @@
+/**
+ * HTTP 요청 및 응답 처리 유틸리티
+ */
+
+import { renderApiEnvelope } from '../cliRenderer.js';
+import { buildDaisoStoreKeywordVariants } from '../utils/daisoKeyword.js';
+import type { FetchLike, WriteFn } from './types.js';
+import { applyOptionsToQuery, toUrl } from './args.js';
+
+export async function requestAndPrintResponse(
+ fetchImpl: FetchLike,
+ writeOut: WriteFn,
+ writeErr: WriteFn,
+ url: URL,
+ command: string,
+ asJson: boolean,
+): Promise {
+ try {
+ const response = await fetchImpl(url.toString());
+
+ if (!response.ok) {
+ const bodyText = await response.text();
+ writeErr(`요청 실패: HTTP ${response.status}`);
+ if (bodyText) {
+ writeErr(bodyText);
+ }
+ return 1;
+ }
+
+ const payload = (await response.json()) as unknown;
+ if (asJson) {
+ writeOut(JSON.stringify(payload, null, 2));
+ } else {
+ writeOut(renderApiEnvelope(command, url, payload));
+ }
+ return 0;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ writeErr(`요청 중 오류 발생: ${message}`);
+ return 1;
+ }
+}
+
+function toStoreCount(payload: unknown): number {
+ if (typeof payload !== 'object' || payload === null) {
+ return 0;
+ }
+ const record = payload as { success?: unknown; data?: unknown };
+ if (record.success !== true || typeof record.data !== 'object' || record.data === null) {
+ return 0;
+ }
+ const stores = (record.data as { stores?: unknown }).stores;
+ return Array.isArray(stores) ? stores.length : 0;
+}
+
+export async function requestAndPrintStoresWithKeywordFallback(
+ fetchImpl: FetchLike,
+ writeOut: WriteFn,
+ writeErr: WriteFn,
+ options: Record,
+ asJson: boolean,
+): Promise {
+ const originalKeyword = options.keyword;
+ if (!originalKeyword) {
+ const url = toUrl('/api/daiso/stores');
+ applyOptionsToQuery(url, options);
+ return await requestAndPrintResponse(fetchImpl, writeOut, writeErr, url, 'stores', asJson);
+ }
+
+ const keywords = buildDaisoStoreKeywordVariants(originalKeyword);
+ const candidates = keywords.length > 0 ? keywords : [originalKeyword];
+
+ try {
+ let lastUrl = toUrl('/api/daiso/stores');
+ let lastPayload: unknown = null;
+
+ for (const keyword of candidates) {
+ const targetUrl = toUrl('/api/daiso/stores');
+ applyOptionsToQuery(targetUrl, { ...options, keyword });
+ lastUrl = targetUrl;
+
+ const response = await fetchImpl(targetUrl.toString());
+ if (!response.ok) {
+ const bodyText = await response.text();
+ writeErr(`요청 실패: HTTP ${response.status}`);
+ if (bodyText) {
+ writeErr(bodyText);
+ }
+ return 1;
+ }
+
+ const payload = (await response.json()) as unknown;
+ lastPayload = payload;
+
+ if (toStoreCount(payload) > 0) {
+ if (keyword !== originalKeyword) {
+ writeOut(`입력 키워드 "${originalKeyword}" 대신 "${keyword}"로 매장을 찾았습니다.`);
+ }
+ if (asJson) {
+ writeOut(JSON.stringify(payload, null, 2));
+ } else {
+ writeOut(renderApiEnvelope('stores', targetUrl, payload));
+ }
+ return 0;
+ }
+ }
+
+ if (asJson) {
+ writeOut(JSON.stringify(lastPayload, null, 2));
+ } else {
+ writeOut(renderApiEnvelope('stores', lastUrl, lastPayload));
+ }
+ writeOut('힌트: "안산 중앙역" 대신 "안산중앙" 또는 "고잔"으로 검색해보세요.');
+ return 0;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ writeErr(`요청 중 오류 발생: ${message}`);
+ return 1;
+ }
+}
diff --git a/src/cli/types.ts b/src/cli/types.ts
new file mode 100644
index 0000000..289a85a
--- /dev/null
+++ b/src/cli/types.ts
@@ -0,0 +1,20 @@
+/**
+ * CLI 공통 타입 정의
+ */
+
+import type { InteractiveCliDeps } from '../cliInteractive.js';
+
+export type FetchLike = typeof fetch;
+
+export type WriteFn = (message: string) => void;
+
+export interface CliDeps {
+ fetchImpl: FetchLike;
+ writeOut: WriteFn;
+ writeErr: WriteFn;
+ getVersion: () => string;
+ nowIso: () => string;
+ runCommand: (command: string, args: string[]) => Promise;
+ isInteractiveTerminal: () => boolean;
+ runInteractive: (deps: InteractiveCliDeps) => Promise;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index ad4f32f..178b9a3 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -47,6 +47,7 @@ export default defineConfig({
'src/pages/openapi.ts', // 엔트리 재노출 파일
'src/bin.ts', // npm bin 진입점 파일
'src/cli.ts', // 실행 진입점 파일
+ 'src/cli/**/*.ts', // CLI 오케스트레이션 분리 모듈
'src/cliInteractive.ts', // 인터랙티브 UI 오케스트레이션 파일
'src/cliRenderer.ts', // CLI 출력 렌더러 파일
],