@kiyeonjeon21/ncli 0.1.1

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.
@@ -0,0 +1,175 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../core/config.js";
3
+ import { NaverClient } from "../core/client.js";
4
+ import { getOutputFormat, getFieldMask, printOutput, printJson } from "../core/output.js";
5
+ import { validateAgentInput, sanitizeResponse } from "../core/validate.js";
6
+ import { getSchema } from "../schemas/index.js";
7
+ import { readJsonArg } from "../core/stdin.js";
8
+ const SEARCH_TYPES = [
9
+ "blog",
10
+ "news",
11
+ "web",
12
+ "image",
13
+ "book",
14
+ "cafe",
15
+ "kin",
16
+ "encyclopedia",
17
+ "shop",
18
+ "local",
19
+ "errata",
20
+ "adult",
21
+ "doc",
22
+ ];
23
+ const SEARCH_BASE_URL = "https://openapi.naver.com/v1/search";
24
+ const SEARCH_EXAMPLES = {
25
+ blog: ` ncli search blog "AI" --fields "title,link,description"
26
+ ncli search blog --json '{"query":"AI","display":5,"sort":"date"}'`,
27
+ news: ` ncli search news "반도체" --fields "title,link,pubDate"`,
28
+ web: ` ncli search web "TypeScript" --fields "title,link,description"`,
29
+ image: ` ncli search image "제주도" --display 5`,
30
+ book: ` ncli search book "인공지능" --fields "title,author,publisher,isbn"`,
31
+ cafe: ` ncli search cafe "맛집" --fields "title,link,cafename"`,
32
+ kin: ` ncli search kin "프로그래밍" --sort count`,
33
+ encyclopedia: ` ncli search encyclopedia "양자역학" --fields "title,link,description"`,
34
+ shop: ` ncli search shop "노트북" --fields "title,lprice,mallName" --sort dsc`,
35
+ local: ` ncli search local "강남 맛집" --fields "title,address,telephone"`,
36
+ errata: ` ncli search errata "오타확인"`,
37
+ adult: ` ncli search adult "검색어"`,
38
+ doc: ` ncli search doc "인공지능 논문" --fields "title,link,description"`,
39
+ };
40
+ const API_TYPE_MAP = {
41
+ encyclopedia: "encyc",
42
+ cafe: "cafearticle",
43
+ };
44
+ export const searchCommand = new Command("search").description("Search Naver services (blog, news, web, image, book, cafe, kin, encyclopedia, shop, local, errata, adult, doc). Rate limit: 25,000/day");
45
+ for (const type of SEARCH_TYPES) {
46
+ const apiType = API_TYPE_MAP[type] || type;
47
+ searchCommand
48
+ .command(type)
49
+ .description(`Search Naver ${type} (25,000/day)`)
50
+ .argument("[query]", "search query")
51
+ .option("--json <payload>", "raw JSON payload (API params 1:1 mapping)")
52
+ .option("--display <n>", "number of results (1-100)", "10")
53
+ .option("--start <n>", "start position (1-1000)", "1")
54
+ .option("--sort <sort>", "sort: sim (relevance) or date", "sim")
55
+ .option("--page-all", "auto-paginate through all results")
56
+ .option("--max-results <n>", "max total results when using --page-all")
57
+ .option("--describe", "show API schema for this command (no execution)")
58
+ .addHelpText("after", `\nExamples:\n${SEARCH_EXAMPLES[type]}\n\nSchema: ncli schema search.${type}`)
59
+ .action(async (query, opts) => {
60
+ try {
61
+ // --describe: print schema and exit
62
+ if (opts.describe) {
63
+ const schema = getSchema(`search.${type}`);
64
+ printJson(schema);
65
+ return;
66
+ }
67
+ const config = loadConfig();
68
+ const client = new NaverClient(config);
69
+ const cmd = searchCommand.parent;
70
+ const format = getOutputFormat(cmd);
71
+ const fields = getFieldMask(cmd);
72
+ const isDryRun = cmd.optsWithGlobals().dryRun;
73
+ const shouldSanitize = cmd.optsWithGlobals().sanitize;
74
+ // Validate query input
75
+ if (query)
76
+ validateAgentInput(query, "query");
77
+ // Build params from --json or individual flags
78
+ let params;
79
+ if (opts.json) {
80
+ const jsonInput = readJsonArg(opts.json);
81
+ const parsed = JSON.parse(jsonInput);
82
+ params = { query: query || parsed.query, ...parsed };
83
+ }
84
+ else {
85
+ if (!query) {
86
+ process.stderr.write(JSON.stringify({
87
+ error: {
88
+ code: "MISSING_QUERY",
89
+ message: "Provide a search query as argument or via --json",
90
+ },
91
+ }) + "\n");
92
+ process.exit(2);
93
+ }
94
+ params = {
95
+ query,
96
+ display: opts.display,
97
+ start: opts.start,
98
+ sort: opts.sort,
99
+ };
100
+ }
101
+ if (isDryRun) {
102
+ printOutput({
103
+ dryRun: true,
104
+ method: "GET",
105
+ url: `${SEARCH_BASE_URL}/${apiType}.json`,
106
+ params,
107
+ validation: { status: "VALID" },
108
+ }, "json");
109
+ return;
110
+ }
111
+ const pageAll = !!opts.pageAll;
112
+ const maxResults = opts.maxResults ? parseInt(opts.maxResults, 10) : undefined;
113
+ if (pageAll) {
114
+ // Auto-paginate: fetch pages until no more results or maxResults reached
115
+ const allItems = [];
116
+ const pageSize = parseInt(params.display || "100", 10);
117
+ params.display = String(Math.min(pageSize, 100));
118
+ let start = parseInt(params.start || "1", 10);
119
+ while (true) {
120
+ params.start = String(start);
121
+ const page = await client.get(`${SEARCH_BASE_URL}/${apiType}.json`, params);
122
+ const pageItems = page.items;
123
+ if (!pageItems || pageItems.length === 0)
124
+ break;
125
+ const sanitized = shouldSanitize
126
+ ? sanitizeResponse(pageItems)
127
+ : pageItems;
128
+ if (format === "ndjson") {
129
+ // Stream immediately
130
+ for (const item of sanitized) {
131
+ if (maxResults && allItems.length >= maxResults)
132
+ break;
133
+ allItems.push(item);
134
+ printOutput([item], "ndjson", fields);
135
+ }
136
+ }
137
+ else {
138
+ allItems.push(...sanitized);
139
+ }
140
+ if (maxResults && allItems.length >= maxResults)
141
+ break;
142
+ if (start + pageItems.length > 1000)
143
+ break; // Naver API limit
144
+ start += pageItems.length;
145
+ }
146
+ if (format !== "ndjson") {
147
+ const trimmed = maxResults ? allItems.slice(0, maxResults) : allItems;
148
+ printOutput(trimmed, format, fields);
149
+ }
150
+ }
151
+ else {
152
+ // Single page
153
+ const data = await client.get(`${SEARCH_BASE_URL}/${apiType}.json`, params);
154
+ const output = shouldSanitize ? sanitizeResponse(data) : data;
155
+ const response = output;
156
+ const items = response.items;
157
+ if (Array.isArray(items) && (fields || format === "ndjson")) {
158
+ printOutput(items, format, fields);
159
+ }
160
+ else {
161
+ printOutput(output, format);
162
+ }
163
+ }
164
+ }
165
+ catch (err) {
166
+ process.stderr.write(JSON.stringify({
167
+ error: {
168
+ code: "COMMAND_FAILED",
169
+ message: err instanceof Error ? err.message : String(err),
170
+ },
171
+ }) + "\n");
172
+ process.exit(1);
173
+ }
174
+ });
175
+ }
@@ -0,0 +1,15 @@
1
+ import { NaverConfig } from "./config.js";
2
+ export interface ApiError {
3
+ error: {
4
+ code: string;
5
+ message: string;
6
+ status?: number;
7
+ naverCode?: string;
8
+ };
9
+ }
10
+ export declare class NaverClient {
11
+ private config;
12
+ constructor(config: NaverConfig);
13
+ get<T>(url: string, params?: Record<string, string>): Promise<T>;
14
+ post<T>(url: string, body: unknown): Promise<T>;
15
+ }
@@ -0,0 +1,73 @@
1
+ const NAVER_ERROR_MESSAGES = {
2
+ SE01: "Incorrect query request — check URL protocol and parameters",
3
+ SE02: "Invalid display value — must be 1~100",
4
+ SE03: "Invalid start value — must be 1~1000",
5
+ SE04: "Invalid sort value — check allowed sort options via schema",
6
+ SE05: "Invalid search API — endpoint not found",
7
+ SE06: "Malformed encoding — ensure UTF-8 encoding",
8
+ SE99: "Naver system error — retry later",
9
+ "024": "Authentication failed — check NAVER_CLIENT_ID and NAVER_CLIENT_SECRET",
10
+ "010": "Rate limit exceeded — 25,000/day for search, 1,000/day for others",
11
+ };
12
+ function parseNaverError(status, body) {
13
+ try {
14
+ const parsed = JSON.parse(body);
15
+ const errorCode = parsed.errorCode || parsed.error_code || "";
16
+ const errorMessage = parsed.errorMessage || parsed.error_message || parsed.message || "";
17
+ const hint = NAVER_ERROR_MESSAGES[errorCode] || "";
18
+ return {
19
+ error: {
20
+ code: `HTTP_${status}`,
21
+ message: hint ? `${errorMessage} (${hint})` : errorMessage,
22
+ status,
23
+ naverCode: errorCode || undefined,
24
+ },
25
+ };
26
+ }
27
+ catch {
28
+ return {
29
+ error: {
30
+ code: `HTTP_${status}`,
31
+ message: body || `HTTP ${status}`,
32
+ status,
33
+ },
34
+ };
35
+ }
36
+ }
37
+ export class NaverClient {
38
+ config;
39
+ constructor(config) {
40
+ this.config = config;
41
+ }
42
+ async get(url, params) {
43
+ const searchParams = new URLSearchParams(params);
44
+ const fullUrl = params ? `${url}?${searchParams}` : url;
45
+ const res = await fetch(fullUrl, {
46
+ headers: {
47
+ "X-Naver-Client-Id": this.config.clientId,
48
+ "X-Naver-Client-Secret": this.config.clientSecret,
49
+ },
50
+ });
51
+ if (!res.ok) {
52
+ const body = await res.text();
53
+ throw new Error(JSON.stringify(parseNaverError(res.status, body)));
54
+ }
55
+ return res.json();
56
+ }
57
+ async post(url, body) {
58
+ const res = await fetch(url, {
59
+ method: "POST",
60
+ headers: {
61
+ "X-Naver-Client-Id": this.config.clientId,
62
+ "X-Naver-Client-Secret": this.config.clientSecret,
63
+ "Content-Type": "application/json",
64
+ },
65
+ body: JSON.stringify(body),
66
+ });
67
+ if (!res.ok) {
68
+ const text = await res.text();
69
+ throw new Error(JSON.stringify(parseNaverError(res.status, text)));
70
+ }
71
+ return res.json();
72
+ }
73
+ }
@@ -0,0 +1,8 @@
1
+ export declare const NCLI_DIR: string;
2
+ export declare const NCLI_ENV_PATH: string;
3
+ export interface NaverConfig {
4
+ clientId: string;
5
+ clientSecret: string;
6
+ accessToken?: string;
7
+ }
8
+ export declare function loadConfig(): NaverConfig;
@@ -0,0 +1,40 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ export const NCLI_DIR = join(homedir(), ".ncli");
5
+ export const NCLI_ENV_PATH = join(NCLI_DIR, ".env");
6
+ function loadEnvFile(filePath) {
7
+ if (!existsSync(filePath))
8
+ return {};
9
+ const vars = {};
10
+ const content = readFileSync(filePath, "utf-8");
11
+ for (const line of content.split("\n")) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#"))
14
+ continue;
15
+ const eqIdx = trimmed.indexOf("=");
16
+ if (eqIdx === -1)
17
+ continue;
18
+ const key = trimmed.slice(0, eqIdx).trim();
19
+ const val = trimmed.slice(eqIdx + 1).trim();
20
+ vars[key] = val;
21
+ }
22
+ return vars;
23
+ }
24
+ export function loadConfig() {
25
+ // Priority: env vars > project .env (loaded by dotenv) > ~/.ncli/.env
26
+ const globalEnv = loadEnvFile(NCLI_ENV_PATH);
27
+ const clientId = process.env.NAVER_CLIENT_ID || globalEnv.NAVER_CLIENT_ID;
28
+ const clientSecret = process.env.NAVER_CLIENT_SECRET || globalEnv.NAVER_CLIENT_SECRET;
29
+ const accessToken = process.env.NAVER_ACCESS_TOKEN || globalEnv.NAVER_ACCESS_TOKEN;
30
+ if (!clientId || !clientSecret) {
31
+ throw new Error(JSON.stringify({
32
+ error: {
33
+ code: "AUTH_REQUIRED",
34
+ message: "Set NAVER_CLIENT_ID and NAVER_CLIENT_SECRET. " +
35
+ "Run 'ncli init' to set up credentials, or register at https://developers.naver.com/apps/",
36
+ },
37
+ }));
38
+ }
39
+ return { clientId, clientSecret, accessToken };
40
+ }
@@ -0,0 +1,7 @@
1
+ import { Command } from "commander";
2
+ export declare function getOutputFormat(cmd: Command): string;
3
+ export declare function getFieldMask(cmd: Command): string[] | undefined;
4
+ export declare function applyFieldMask(data: Record<string, unknown>, fields: string[] | undefined): Record<string, unknown>;
5
+ export declare function printJson(data: unknown): void;
6
+ export declare function printNdjson(items: Record<string, unknown>[]): void;
7
+ export declare function printOutput(data: unknown, format: string, fields?: string[]): void;
@@ -0,0 +1,57 @@
1
+ export function getOutputFormat(cmd) {
2
+ const opts = cmd.optsWithGlobals();
3
+ if (opts.output)
4
+ return opts.output;
5
+ // Auto JSON when stdout is not a TTY
6
+ if (!process.stdout.isTTY)
7
+ return "json";
8
+ return "table";
9
+ }
10
+ export function getFieldMask(cmd) {
11
+ const opts = cmd.optsWithGlobals();
12
+ if (!opts.fields)
13
+ return undefined;
14
+ return opts.fields.split(",").map((f) => f.trim());
15
+ }
16
+ export function applyFieldMask(data, fields) {
17
+ if (!fields || fields.length === 0)
18
+ return data;
19
+ const result = {};
20
+ for (const field of fields) {
21
+ if (field in data) {
22
+ result[field] = data[field];
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+ export function printJson(data) {
28
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
29
+ }
30
+ export function printNdjson(items) {
31
+ for (const item of items) {
32
+ process.stdout.write(JSON.stringify(item) + "\n");
33
+ }
34
+ }
35
+ export function printOutput(data, format, fields) {
36
+ if (format === "ndjson" && Array.isArray(data)) {
37
+ const masked = fields
38
+ ? data.map((item) => applyFieldMask(item, fields))
39
+ : data;
40
+ printNdjson(masked);
41
+ }
42
+ else if (format === "json") {
43
+ if (Array.isArray(data) && fields) {
44
+ printJson(data.map((item) => applyFieldMask(item, fields)));
45
+ }
46
+ else if (typeof data === "object" && data !== null && fields) {
47
+ printJson(applyFieldMask(data, fields));
48
+ }
49
+ else {
50
+ printJson(data);
51
+ }
52
+ }
53
+ else {
54
+ // table fallback
55
+ printJson(data);
56
+ }
57
+ }
@@ -0,0 +1 @@
1
+ export declare function readJsonArg(value: string): string;
@@ -0,0 +1,10 @@
1
+ import { readFileSync } from "fs";
2
+ export function readJsonArg(value) {
3
+ if (value === "-") {
4
+ return readFileSync(0, "utf-8").trim();
5
+ }
6
+ if (value.startsWith("@")) {
7
+ return readFileSync(value.slice(1), "utf-8").trim();
8
+ }
9
+ return value;
10
+ }
@@ -0,0 +1,8 @@
1
+ export declare function rejectControlChars(value: string, fieldName?: string): string;
2
+ export declare function validateResourceId(resourceId: string, fieldName?: string): string;
3
+ export declare function rejectPathTraversal(path: string, fieldName?: string): string;
4
+ export declare function validateAgentInput(value: string, fieldName: string, opts?: {
5
+ isPath?: boolean;
6
+ isResourceId?: boolean;
7
+ }): string;
8
+ export declare function sanitizeResponse(data: unknown): unknown;
@@ -0,0 +1,73 @@
1
+ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f]/;
2
+ const RESOURCE_ID_FORBIDDEN = new Set(["?", "#", "%", "&", "="]);
3
+ const INJECTION_PATTERNS = [
4
+ /ignore\s+(all\s+)?previous\s+instructions/i,
5
+ /forget\s+(all\s+)?previous/i,
6
+ /you\s+are\s+now\s+a/i,
7
+ /system\s*:\s*/i,
8
+ /<\s*system\s*>/i,
9
+ /IMPORTANT\s*:\s*/i,
10
+ ];
11
+ export function rejectControlChars(value, fieldName = "input") {
12
+ if (CONTROL_CHAR_PATTERN.test(value)) {
13
+ throw new Error(JSON.stringify({
14
+ error: {
15
+ code: "INVALID_INPUT",
16
+ message: `Control character detected in ${fieldName}. Input contains non-printable characters.`,
17
+ },
18
+ }));
19
+ }
20
+ return value;
21
+ }
22
+ export function validateResourceId(resourceId, fieldName = "id") {
23
+ const found = [...resourceId].filter((c) => RESOURCE_ID_FORBIDDEN.has(c));
24
+ if (found.length > 0) {
25
+ throw new Error(JSON.stringify({
26
+ error: {
27
+ code: "INVALID_RESOURCE_ID",
28
+ message: `Invalid characters [${found.join(",")}] in ${fieldName}: '${resourceId}'. Resource IDs must not contain URL meta-characters.`,
29
+ },
30
+ }));
31
+ }
32
+ return resourceId;
33
+ }
34
+ export function rejectPathTraversal(path, fieldName = "path") {
35
+ if (path.includes("..") || path.startsWith("/") || path.includes("~")) {
36
+ throw new Error(JSON.stringify({
37
+ error: {
38
+ code: "PATH_TRAVERSAL",
39
+ message: `Path traversal detected in ${fieldName}: '${path}'. Use relative paths without '..' or '~'.`,
40
+ },
41
+ }));
42
+ }
43
+ return path;
44
+ }
45
+ export function validateAgentInput(value, fieldName, opts = {}) {
46
+ rejectControlChars(value, fieldName);
47
+ if (opts.isPath)
48
+ rejectPathTraversal(value, fieldName);
49
+ if (opts.isResourceId)
50
+ validateResourceId(value, fieldName);
51
+ return value;
52
+ }
53
+ export function sanitizeResponse(data) {
54
+ if (typeof data === "string") {
55
+ for (const pattern of INJECTION_PATTERNS) {
56
+ if (pattern.test(data)) {
57
+ return "[SANITIZED: potential prompt injection detected]";
58
+ }
59
+ }
60
+ return data;
61
+ }
62
+ if (Array.isArray(data)) {
63
+ return data.map((item) => sanitizeResponse(item));
64
+ }
65
+ if (typeof data === "object" && data !== null) {
66
+ const result = {};
67
+ for (const [key, value] of Object.entries(data)) {
68
+ result[key] = sanitizeResponse(value);
69
+ }
70
+ return result;
71
+ }
72
+ return data;
73
+ }
@@ -0,0 +1,12 @@
1
+ export declare const SCHEMAS: Record<string, unknown>;
2
+ export declare function listServices(): {
3
+ services: {
4
+ name: string;
5
+ description: string;
6
+ }[];
7
+ };
8
+ export declare function listMethods(service: string): {
9
+ service: string;
10
+ methods: string[];
11
+ } | null;
12
+ export declare function getSchema(method: string): unknown | null;