@starhui/huly-cli 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/.env.example ADDED
@@ -0,0 +1,12 @@
1
+ HULY_URL=https://huly.app
2
+ HULY_WORKSPACE=your-workspace
3
+
4
+ # Prefer token auth when available.
5
+ HULY_TOKEN=your-huly-token
6
+
7
+ # Or use email/password auth instead of HULY_TOKEN.
8
+ # HULY_EMAIL=your@email.com
9
+ # HULY_PASSWORD=your-password
10
+
11
+ # Optional.
12
+ # HULY_CONNECTION_TIMEOUT=30000
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 starhui-dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # huly-cli
2
+
3
+ 基于 [`@firfi/huly-mcp`](https://github.com/dearlordylord/huly-mcp) 的
4
+ [Huly](https://huly.io/) 命令行客户端。
5
+
6
+ ## 使用方式
7
+
8
+ 无需安装,直接通过 `npx` 运行:
9
+
10
+ ```bash
11
+ npx -y @starhui/huly-cli@latest --help
12
+ ```
13
+
14
+ 也可以全局安装后使用:
15
+
16
+ ```bash
17
+ npm install -g @starhui/huly-cli
18
+ huly --help
19
+ ```
20
+
21
+ ## 配置
22
+
23
+ 可以通过环境变量配置 Huly,也可以用 `--config` 指定 dotenv 格式的配置文件。
24
+
25
+ ```bash
26
+ cp .env.example .env
27
+ ```
28
+
29
+ 必填配置:
30
+
31
+ - `HULY_URL`
32
+ - `HULY_WORKSPACE`
33
+ - `HULY_TOKEN`,或 `HULY_EMAIL` 加 `HULY_PASSWORD`
34
+
35
+ 可选配置:
36
+
37
+ - `HULY_DEFAULT_PROJECT`:`issue` 命令未传 `--project` 时使用的默认项目。
38
+ - `HULY_CONNECTION_TIMEOUT`:透传给 `@firfi/huly-mcp` 的连接超时时间。
39
+
40
+ 下面这些诊断命令不需要 Huly 凭据:
41
+
42
+ ```bash
43
+ npx -y @starhui/huly-cli@latest context --json
44
+ npx -y @starhui/huly-cli@latest tools --filter issue --json
45
+ ```
46
+
47
+ ## 示例
48
+
49
+ ```bash
50
+ npx -y @starhui/huly-cli@latest --config .env project list --json
51
+ npx -y @starhui/huly-cli@latest --config .env project statuses HULY --json
52
+ npx -y @starhui/huly-cli@latest --config .env issue list --project HULY --status-category active --limit 20 --json
53
+ npx -y @starhui/huly-cli@latest --config .env issue get HULY-123 --project HULY --json
54
+ npx -y @starhui/huly-cli@latest --config .env issue create --project HULY --title "Fix login redirect"
55
+ npx -y @starhui/huly-cli@latest --config .env issue update HULY-123 --project HULY --status Done
56
+ npx -y @starhui/huly-cli@latest --config .env search "login redirect" --json
57
+ ```
58
+
59
+ 对于没有封装成一级命令的能力,可以直接调用上游 MCP 工具:
60
+
61
+ ```bash
62
+ npx -y @starhui/huly-cli@latest --config .env call list_projects --data '{"limit":10}' --json
63
+ npx -y @starhui/huly-cli@latest --config .env call get_issue --field project=HULY --field identifier=HULY-123 --json
64
+ ```
65
+
66
+ ## 命令
67
+
68
+ - `context`:输出脱敏后的 MCP/Huly 运行上下文。
69
+ - `version-remote`:输出底层 `@firfi/huly-mcp` 版本信息。
70
+ - `tools`:列出上游 MCP 暴露的工具。
71
+ - `project list|get|statuses|create|update|delete`:项目相关操作。
72
+ - `issue list|get|create|update|delete|label|unlabel|move`:常用 Issue 操作。
73
+ - `search`:全文搜索。
74
+ - `call`:直接调用 `@firfi/huly-mcp` 暴露的任意工具。
75
+
76
+ 删除、更新等破坏性命令默认会要求确认;传入 `--yes` 后跳过确认。
77
+
78
+ ## Agent Skill
79
+
80
+ 这个包内置 Codex/OpenAI skill,路径为 `skills/huly-cli/SKILL.md`。
81
+ skill 示例使用 `npx -y @starhui/huly-cli@latest`,方便 agent 在没有本地安装 CLI 的情况下直接运行。
82
+
83
+ 使用 `skills` CLI 全局安装:
84
+
85
+ ```bash
86
+ npx -y skills add starhui-dev/huly-cli --skill huly-cli --global
87
+ ```
88
+
89
+ 安装到当前项目时去掉 `--global`:
90
+
91
+ ```bash
92
+ npx -y skills add starhui-dev/huly-cli --skill huly-cli
93
+ ```
94
+
95
+ 安装前查看仓库中可用的 skills:
96
+
97
+ ```bash
98
+ npx -y skills add starhui-dev/huly-cli --list --full-depth
99
+ ```
100
+
101
+ ## 开发
102
+
103
+ ```bash
104
+ pnpm install
105
+ pnpm check
106
+ pnpm pack --dry-run
107
+ ```
108
+
109
+ `pnpm check` 会依次运行类型检查、单元测试、构建、本地 CLI 冒烟测试和上游 MCP 启动冒烟测试。
110
+ 真实的 Huly 读写操作需要有效的 Huly 凭据和网络连接。
package/dist/index.js ADDED
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/config.ts
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ var ENV_KEYS = /* @__PURE__ */ new Set([
10
+ "HULY_URL",
11
+ "HULY_EMAIL",
12
+ "HULY_PASSWORD",
13
+ "HULY_TOKEN",
14
+ "HULY_WORKSPACE",
15
+ "HULY_CONNECTION_TIMEOUT"
16
+ ]);
17
+ var parseEnvFile = (content) => {
18
+ const parsed = {};
19
+ for (const rawLine of content.split(/\r?\n/)) {
20
+ const line = rawLine.trim();
21
+ if (line === "" || line.startsWith("#")) continue;
22
+ const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
23
+ const separatorIndex = normalized.indexOf("=");
24
+ if (separatorIndex <= 0) continue;
25
+ const key = normalized.slice(0, separatorIndex).trim();
26
+ if (!ENV_KEYS.has(key)) continue;
27
+ parsed[key] = parseEnvValue(normalized.slice(separatorIndex + 1).trim());
28
+ }
29
+ return parsed;
30
+ };
31
+ var parseEnvValue = (value) => {
32
+ if (value.length >= 2) {
33
+ const first = value[0];
34
+ const last = value[value.length - 1];
35
+ if (first === '"' && last === '"' || first === "'" && last === "'") {
36
+ return value.slice(1, -1);
37
+ }
38
+ }
39
+ const hashIndex = value.indexOf(" #");
40
+ return hashIndex === -1 ? value : value.slice(0, hashIndex).trimEnd();
41
+ };
42
+ var loadConfig = (options) => {
43
+ const configPath = options.config === void 0 ? void 0 : resolve(options.config);
44
+ const envFromFile = configPath === void 0 ? {} : existsSync(configPath) ? parseEnvFile(readFileSync(configPath, "utf8")) : fail(`Config file not found: ${configPath}`);
45
+ return {
46
+ env: {
47
+ ...process.env,
48
+ ...envFromFile
49
+ },
50
+ ...configPath === void 0 ? {} : { configPath }
51
+ };
52
+ };
53
+ var validateConfig = (config) => {
54
+ const env = config.env;
55
+ const missing = [];
56
+ if (!hasText(env.HULY_URL)) missing.push("HULY_URL");
57
+ if (!hasText(env.HULY_WORKSPACE)) missing.push("HULY_WORKSPACE");
58
+ const hasToken = hasText(env.HULY_TOKEN);
59
+ const hasPasswordAuth = hasText(env.HULY_EMAIL) && hasText(env.HULY_PASSWORD);
60
+ if (!hasToken && !hasPasswordAuth) {
61
+ missing.push("HULY_TOKEN or HULY_EMAIL+HULY_PASSWORD");
62
+ }
63
+ if (missing.length > 0) {
64
+ throw new Error(
65
+ `Missing Huly configuration: ${missing.join(", ")}. Set environment variables or pass --config .env.`
66
+ );
67
+ }
68
+ };
69
+ var hasText = (value) => value !== void 0 && value.trim() !== "";
70
+ var fail = (message) => {
71
+ throw new Error(message);
72
+ };
73
+
74
+ // src/mcp-client.ts
75
+ import { spawn } from "node:child_process";
76
+ import { createRequire } from "node:module";
77
+
78
+ // src/json.ts
79
+ import { readFileSync as readFileSync2 } from "node:fs";
80
+ var parseJsonObject = (value, label) => {
81
+ const parsed = parseJson(value, label);
82
+ if (!isRecord(parsed)) {
83
+ throw new Error(`${label} must be a JSON object`);
84
+ }
85
+ return parsed;
86
+ };
87
+ var parseJson = (value, label) => {
88
+ try {
89
+ return JSON.parse(value);
90
+ } catch (error) {
91
+ throw new Error(`${label} is not valid JSON: ${String(error)}`);
92
+ }
93
+ };
94
+ var readJsonObjectFile = (path) => parseJsonObject(readFileSync2(path, "utf8"), path);
95
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
96
+
97
+ // src/mcp-client.ts
98
+ var require2 = createRequire(import.meta.url);
99
+ var createHulyMcpClient = async (config) => {
100
+ const serverPath = config.env.HULY_CLI_MCP_SERVER_PATH ?? require2.resolve("@firfi/huly-mcp");
101
+ const transport = new JsonRpcStdioClient(
102
+ process.execPath,
103
+ [serverPath],
104
+ {
105
+ ...sanitizeEnv(config.env),
106
+ LAZY_ENVS: "true",
107
+ MCP_TRANSPORT: "stdio"
108
+ }
109
+ );
110
+ await transport.start();
111
+ await transport.request("initialize", {
112
+ protocolVersion: "2025-06-18",
113
+ capabilities: {},
114
+ clientInfo: {
115
+ name: "huly-cli",
116
+ version: "0.1.0"
117
+ }
118
+ });
119
+ await transport.notify("notifications/initialized", {});
120
+ return {
121
+ callTool: async (name, args) => {
122
+ const response = await transport.request("tools/call", { name, arguments: args });
123
+ return extractToolResult(response);
124
+ },
125
+ listTools: async () => {
126
+ const response = await transport.request("tools/list", {});
127
+ return extractTools(response);
128
+ },
129
+ close: async () => {
130
+ await transport.close();
131
+ }
132
+ };
133
+ };
134
+ var JsonRpcStdioClient = class {
135
+ constructor(command, args, env) {
136
+ this.command = command;
137
+ this.args = args;
138
+ this.env = env;
139
+ }
140
+ command;
141
+ args;
142
+ env;
143
+ child;
144
+ nextId = 1;
145
+ buffer = "";
146
+ pending = /* @__PURE__ */ new Map();
147
+ start() {
148
+ this.child = spawn(this.command, [...this.args], {
149
+ env: {
150
+ ...safeInheritedEnv(),
151
+ ...this.env
152
+ },
153
+ stdio: ["pipe", "pipe", "pipe"],
154
+ windowsHide: true
155
+ });
156
+ this.child.stdout.setEncoding("utf8");
157
+ this.child.stdout.on("data", (chunk) => {
158
+ this.buffer += chunk;
159
+ this.drainBuffer();
160
+ });
161
+ this.child.stderr.setEncoding("utf8");
162
+ this.child.stderr.on("data", () => {
163
+ });
164
+ this.child.on("error", (error) => {
165
+ this.rejectAll(error);
166
+ });
167
+ this.child.on("close", (code) => {
168
+ if (this.pending.size > 0) {
169
+ this.rejectAll(new Error(`Huly MCP server exited with code ${code ?? "unknown"}`));
170
+ }
171
+ });
172
+ return Promise.resolve();
173
+ }
174
+ async request(method, params) {
175
+ const id = this.nextId++;
176
+ const message = {
177
+ jsonrpc: "2.0",
178
+ id,
179
+ method,
180
+ params
181
+ };
182
+ const response = new Promise((resolve2, reject) => {
183
+ this.pending.set(id, { resolve: resolve2, reject });
184
+ });
185
+ await this.write(message);
186
+ return response;
187
+ }
188
+ async notify(method, params) {
189
+ await this.write({
190
+ jsonrpc: "2.0",
191
+ method,
192
+ params
193
+ });
194
+ }
195
+ async close() {
196
+ if (this.child === void 0) return;
197
+ const child = this.child;
198
+ this.child = void 0;
199
+ const closed = new Promise((resolve2) => {
200
+ child.once("close", () => resolve2());
201
+ });
202
+ child.stdin.end();
203
+ await Promise.race([closed, delay(500)]);
204
+ if (child.exitCode === null) child.kill("SIGTERM");
205
+ await Promise.race([closed, delay(500)]);
206
+ if (child.exitCode === null) child.kill("SIGKILL");
207
+ }
208
+ write(message) {
209
+ if (this.child === void 0) throw new Error("Huly MCP server is not running");
210
+ return new Promise((resolve2, reject) => {
211
+ this.child?.stdin.write(`${JSON.stringify(message)}
212
+ `, (error) => {
213
+ if (error !== null && error !== void 0) {
214
+ reject(error);
215
+ } else {
216
+ resolve2();
217
+ }
218
+ });
219
+ });
220
+ }
221
+ drainBuffer() {
222
+ while (true) {
223
+ const newline = this.buffer.indexOf("\n");
224
+ if (newline === -1) return;
225
+ const rawLine = this.buffer.slice(0, newline).replace(/\r$/, "");
226
+ this.buffer = this.buffer.slice(newline + 1);
227
+ if (rawLine.trim() === "") continue;
228
+ this.handleMessage(parseJsonRpcLine(rawLine));
229
+ }
230
+ }
231
+ handleMessage(message) {
232
+ if (!isRecord(message) || typeof message.id !== "number") return;
233
+ const pending = this.pending.get(message.id);
234
+ if (pending === void 0) return;
235
+ this.pending.delete(message.id);
236
+ if (isJsonRpcFailure(message)) {
237
+ const code = typeof message.error.code === "number" ? message.error.code : "";
238
+ const fallback = `JSON-RPC error ${code}`.trim();
239
+ pending.reject(new Error(typeof message.error.message === "string" ? message.error.message : fallback));
240
+ return;
241
+ }
242
+ if (isJsonRpcSuccess(message)) {
243
+ pending.resolve(message.result);
244
+ return;
245
+ }
246
+ pending.reject(new Error("Invalid JSON-RPC response from Huly MCP server"));
247
+ }
248
+ rejectAll(error) {
249
+ for (const pending of this.pending.values()) {
250
+ pending.reject(error);
251
+ }
252
+ this.pending.clear();
253
+ }
254
+ };
255
+ var extractToolResult = (response) => {
256
+ if (isRecord(response) && response.isError === true) {
257
+ throw new Error(extractTextContent(response) ?? "Huly MCP tool failed");
258
+ }
259
+ if (isRecord(response) && isRecord(response.structuredContent) && "result" in response.structuredContent) {
260
+ return response.structuredContent.result;
261
+ }
262
+ const text = isRecord(response) ? extractTextContent(response) : void 0;
263
+ if (text === void 0) return response;
264
+ if (text.trim() === "") return response;
265
+ try {
266
+ return JSON.parse(text);
267
+ } catch {
268
+ return text;
269
+ }
270
+ };
271
+ var extractTools = (response) => {
272
+ if (!isRecord(response) || !Array.isArray(response.tools)) {
273
+ throw new Error("Invalid tools/list response from Huly MCP server");
274
+ }
275
+ return response.tools.filter(isRecord).map((tool) => ({
276
+ name: String(tool.name),
277
+ ...typeof tool.description === "string" ? { description: tool.description } : {}
278
+ }));
279
+ };
280
+ var extractTextContent = (response) => {
281
+ const content = response.content;
282
+ if (!Array.isArray(content)) return void 0;
283
+ return content.filter(isRecord).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n");
284
+ };
285
+ var sanitizeEnv = (env) => {
286
+ const result = {};
287
+ for (const [key, value] of Object.entries(env)) {
288
+ if (value !== void 0) result[key] = value;
289
+ }
290
+ return result;
291
+ };
292
+ var safeInheritedEnv = () => {
293
+ const keys = process.platform === "win32" ? [
294
+ "APPDATA",
295
+ "HOMEDRIVE",
296
+ "HOMEPATH",
297
+ "LOCALAPPDATA",
298
+ "PATH",
299
+ "PROCESSOR_ARCHITECTURE",
300
+ "SYSTEMDRIVE",
301
+ "SYSTEMROOT",
302
+ "TEMP",
303
+ "USERNAME",
304
+ "USERPROFILE",
305
+ "PROGRAMFILES"
306
+ ] : ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
307
+ const env = {};
308
+ for (const key of keys) {
309
+ const value = process.env[key];
310
+ if (value !== void 0 && !value.startsWith("()")) env[key] = value;
311
+ }
312
+ return env;
313
+ };
314
+ var parseJsonRpcLine = (line) => {
315
+ try {
316
+ return JSON.parse(line);
317
+ } catch {
318
+ return void 0;
319
+ }
320
+ };
321
+ var isJsonRpcSuccess = (message) => message.jsonrpc === "2.0" && typeof message.id === "number" && "result" in message;
322
+ var isJsonRpcFailure = (message) => message.jsonrpc === "2.0" && typeof message.id === "number" && isRecord(message.error);
323
+ var delay = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
324
+
325
+ // src/options.ts
326
+ var collectKeyValue = (value, previous) => {
327
+ const separatorIndex = value.indexOf("=");
328
+ if (separatorIndex <= 0) {
329
+ throw new Error(`Expected key=value, got "${value}"`);
330
+ }
331
+ const key = value.slice(0, separatorIndex);
332
+ const raw = value.slice(separatorIndex + 1);
333
+ return {
334
+ ...previous,
335
+ [key]: coerceScalar(raw)
336
+ };
337
+ };
338
+ var coerceScalar = (value) => {
339
+ if (value === "true") return true;
340
+ if (value === "false") return false;
341
+ if (value === "null") return null;
342
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
343
+ return value;
344
+ };
345
+
346
+ // src/output.ts
347
+ var printResult = (value, options) => {
348
+ if (options.format === "json") {
349
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
350
+ `);
351
+ return;
352
+ }
353
+ process.stdout.write(`${formatPretty(value)}
354
+ `);
355
+ };
356
+ var formatPretty = (value) => {
357
+ if (Array.isArray(value)) return value.map(formatPretty).join("\n");
358
+ if (isRecord2(value)) {
359
+ if (Array.isArray(value.projects)) return formatTable(value.projects);
360
+ if (Array.isArray(value.issues)) return formatTable(value.issues);
361
+ if (Array.isArray(value.statuses)) return formatTable(value.statuses);
362
+ if (Array.isArray(value.items)) return formatTable(value.items);
363
+ return formatObject(value);
364
+ }
365
+ return String(value);
366
+ };
367
+ var formatObject = (record) => {
368
+ const lines = [];
369
+ for (const [key, value] of Object.entries(record)) {
370
+ if (Array.isArray(value)) {
371
+ lines.push(`${key}:`);
372
+ lines.push(value.map((item) => ` ${formatInline(item)}`).join("\n"));
373
+ } else if (isRecord2(value)) {
374
+ lines.push(`${key}: ${JSON.stringify(value, null, 2)}`);
375
+ } else {
376
+ lines.push(`${key}: ${formatInline(value)}`);
377
+ }
378
+ }
379
+ return lines.join("\n");
380
+ };
381
+ var formatTable = (rows) => {
382
+ if (rows.length === 0) return "(no results)";
383
+ if (!rows.every(isRecord2)) return rows.map(formatInline).join("\n");
384
+ const records = rows;
385
+ const columns = selectColumns(records);
386
+ const widths = columns.map(
387
+ (column) => Math.max(column.length, ...records.map((row) => formatInline(row[column]).length))
388
+ );
389
+ const header = columns.map((column, index) => column.padEnd(widths[index] ?? column.length)).join(" ");
390
+ const divider = widths.map((width) => "-".repeat(width)).join(" ");
391
+ const body = records.map(
392
+ (row) => columns.map((column, index) => formatInline(row[column]).padEnd(widths[index] ?? column.length)).join(" ")
393
+ );
394
+ return [header, divider, ...body].join("\n");
395
+ };
396
+ var selectColumns = (rows) => {
397
+ const preferred = [
398
+ "identifier",
399
+ "project",
400
+ "title",
401
+ "name",
402
+ "status",
403
+ "category",
404
+ "priority",
405
+ "assignee",
406
+ "archived",
407
+ "modifiedOn",
408
+ "id",
409
+ "class",
410
+ "score"
411
+ ];
412
+ const available = new Set(rows.flatMap((row) => Object.keys(row)));
413
+ const selected = preferred.filter((column) => available.has(column));
414
+ return selected.length > 0 ? selected : Object.keys(rows[0] ?? {});
415
+ };
416
+ var formatInline = (value) => {
417
+ if (value === void 0 || value === null) return "";
418
+ if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
419
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
420
+ return JSON.stringify(value);
421
+ };
422
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
423
+
424
+ // src/prompts.ts
425
+ import { createInterface } from "node:readline/promises";
426
+ import { stdin, stdout } from "node:process";
427
+ var confirmDestructiveAction = async (prompt, options) => {
428
+ if (options.yes === true) return;
429
+ const rl = createInterface({ input: stdin, output: stdout });
430
+ try {
431
+ const answer = await rl.question(`${prompt} Type "yes" to continue: `);
432
+ if (answer !== "yes") {
433
+ throw new Error("Aborted");
434
+ }
435
+ } finally {
436
+ rl.close();
437
+ }
438
+ };
439
+
440
+ // src/commands.ts
441
+ var registerCommands = (program2) => {
442
+ program2.command("context").description("Print sanitized Huly MCP runtime context").action(async (_options, command) => {
443
+ await callAndPrint("get_huly_context", {}, normalizeOptions(command), { validate: false });
444
+ });
445
+ program2.command("version-remote").description("Print the underlying huly-mcp version information").action(async (_options, command) => {
446
+ await callAndPrint("get_version", {}, normalizeOptions(command), { validate: false });
447
+ });
448
+ program2.command("tools").description("List available Huly MCP tools").option("--filter <text>", "Filter by tool name or description").action(async (options, command) => {
449
+ const mergedOptions = normalizeOptions(command, options);
450
+ const client = await createClient(mergedOptions, { validate: false });
451
+ try {
452
+ const tools = await client.listTools();
453
+ const filter = mergedOptions.filter?.toLowerCase();
454
+ const visible = filter === void 0 ? tools : tools.filter(
455
+ (tool) => tool.name.toLowerCase().includes(filter) || tool.description?.toLowerCase().includes(filter) === true
456
+ );
457
+ printResult(visible, { format: outputFormat(mergedOptions) });
458
+ } finally {
459
+ await client.close();
460
+ }
461
+ });
462
+ const projects = program2.command("project").description("Manage Huly projects");
463
+ projects.command("list").description("List projects").option("--archived", "Include archived projects").option("--limit <number>", "Maximum number of projects").action(async (options, command) => {
464
+ const mergedOptions = normalizeOptions(command, options);
465
+ await callAndPrint("list_projects", compact({
466
+ includeArchived: mergedOptions.archived,
467
+ limit: parseOptionalNumber(mergedOptions.limit, "limit")
468
+ }), mergedOptions);
469
+ });
470
+ projects.command("get <project>").description("Get project details").action(async (project, _options, command) => {
471
+ await callAndPrint("get_project", { project }, normalizeOptions(command));
472
+ });
473
+ projects.command("statuses <project>").description("List issue statuses for a project").action(async (project, _options, command) => {
474
+ await callAndPrint("list_statuses", { project }, normalizeOptions(command));
475
+ });
476
+ projects.command("create").description("Create a tracker project").requiredOption("--name <name>", "Project name").requiredOption("--identifier <identifier>", "Project identifier, e.g. HULY").option("--description <text>", "Project description").option("--private", "Create private project").action(async (options, command) => {
477
+ const mergedOptions = normalizeOptions(command, options);
478
+ await callAndPrint("create_project", compact({
479
+ name: mergedOptions.name,
480
+ identifier: mergedOptions.identifier,
481
+ description: mergedOptions.description,
482
+ private: mergedOptions.private
483
+ }), mergedOptions);
484
+ });
485
+ projects.command("update <project>").description("Update a project").option("--name <name>", "New project name").option("--description <text>", "New project description").option("--clear-description", "Clear project description").action(async (project, options, command) => {
486
+ const mergedOptions = normalizeOptions(command, options);
487
+ await callAndPrint("update_project", compact({
488
+ project,
489
+ name: mergedOptions.name,
490
+ description: mergedOptions.clearDescription === true ? null : mergedOptions.description
491
+ }), mergedOptions);
492
+ });
493
+ projects.command("delete <project>").description("Delete a project").option("-y, --yes", "Skip confirmation").action(async (project, options, command) => {
494
+ const mergedOptions = normalizeOptions(command, options);
495
+ await confirmDestructiveAction(`Delete project ${project}?`, mergedOptions);
496
+ await callAndPrint("delete_project", { project }, mergedOptions);
497
+ });
498
+ const issues = program2.command("issue").description("Manage Huly issues");
499
+ issues.command("list").description("List issues").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").option("--status <status>", "Exact workflow status").option("--status-category <category>", "Status category").option("--assignee <person>", "Assignee email or display name").option("--component <component>", "Component id or label").option("--parent-issue <issue>", "List children of an issue").option("--title <text>", "Case-insensitive title search").option("--description <text>", "Description fulltext search").option("--top-level", "Only top-level issues").option("--limit <number>", "Maximum number of issues").action(async (options, command) => {
500
+ const mergedOptions = normalizeOptions(command, options);
501
+ await callAndPrint("list_issues", compact({
502
+ project: requireProject(mergedOptions),
503
+ status: mergedOptions.status,
504
+ statusCategory: mergedOptions.statusCategory,
505
+ assignee: mergedOptions.assignee,
506
+ component: mergedOptions.component,
507
+ parentIssue: mergedOptions.parentIssue,
508
+ titleSearch: mergedOptions.title,
509
+ descriptionSearch: mergedOptions.description,
510
+ isTopLevel: mergedOptions.topLevel,
511
+ limit: parseOptionalNumber(mergedOptions.limit, "limit")
512
+ }), mergedOptions);
513
+ });
514
+ issues.command("get <identifier>").description("Get issue details").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").action(async (identifier, options, command) => {
515
+ const mergedOptions = normalizeOptions(command, options);
516
+ await callAndPrint("get_issue", { project: requireProject(mergedOptions), identifier }, mergedOptions);
517
+ });
518
+ issues.command("create").description("Create an issue").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").requiredOption("--title <title>", "Issue title").option("--description <markdown>", "Issue description").option("--priority <priority>", "urgent, high, medium, low, no-priority").option("--assignee <person>", "Assignee email or display name").option("--status <status>", "Initial status").option("--task-type <taskType>", "Task type id or display name").option("--parent-issue <issue>", "Create as sub-issue").option("--due-date <timestamp>", "Due date as Unix timestamp in milliseconds").option("--estimation <minutes>", "Time estimation in minutes").action(async (options, command) => {
519
+ const mergedOptions = normalizeOptions(command, options);
520
+ await callAndPrint("create_issue", compact({
521
+ project: requireProject(mergedOptions),
522
+ title: mergedOptions.title,
523
+ description: mergedOptions.description,
524
+ priority: mergedOptions.priority,
525
+ assignee: mergedOptions.assignee,
526
+ status: mergedOptions.status,
527
+ taskType: mergedOptions.taskType,
528
+ parentIssue: mergedOptions.parentIssue,
529
+ dueDate: parseOptionalNumber(mergedOptions.dueDate, "due-date"),
530
+ estimation: parseOptionalNumber(mergedOptions.estimation, "estimation")
531
+ }), mergedOptions);
532
+ });
533
+ issues.command("update <identifier>").description("Update an issue").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").option("--title <title>", "New title").option("--description <markdown>", "New description").option("--priority <priority>", "New priority").option("--assignee <person>", "New assignee").option("--unassign", "Clear assignee").option("--status <status>", "New status").option("--task-type <taskType>", "New task type").option("--due-date <timestamp>", "Due date as Unix timestamp in milliseconds").option("--clear-due-date", "Clear due date").option("--estimation <minutes>", "Time estimation in minutes").option("--clear-estimation", "Clear estimation").action(async (identifier, options, command) => {
534
+ const mergedOptions = normalizeOptions(command, options);
535
+ await callAndPrint("update_issue", compact({
536
+ project: requireProject(mergedOptions),
537
+ identifier,
538
+ title: mergedOptions.title,
539
+ description: mergedOptions.description,
540
+ priority: mergedOptions.priority,
541
+ assignee: mergedOptions.unassign === true ? null : mergedOptions.assignee,
542
+ status: mergedOptions.status,
543
+ taskType: mergedOptions.taskType,
544
+ dueDate: mergedOptions.clearDueDate === true ? null : parseOptionalNumber(mergedOptions.dueDate, "due-date"),
545
+ estimation: mergedOptions.clearEstimation === true ? null : parseOptionalNumber(mergedOptions.estimation, "estimation")
546
+ }), mergedOptions);
547
+ });
548
+ issues.command("delete <identifier>").description("Delete an issue").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").option("-y, --yes", "Skip confirmation").action(async (identifier, options, command) => {
549
+ const mergedOptions = normalizeOptions(command, options);
550
+ const project = requireProject(mergedOptions);
551
+ await confirmDestructiveAction(`Delete issue ${identifier} in ${project}?`, mergedOptions);
552
+ await callAndPrint("delete_issue", { project, identifier }, mergedOptions);
553
+ });
554
+ issues.command("label <identifier> <label>").description("Add a label to an issue").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").option("--color <index>", "Huly color index").action(async (identifier, label, options, command) => {
555
+ const mergedOptions = normalizeOptions(command, options);
556
+ await callAndPrint("add_issue_label", compact({
557
+ project: requireProject(mergedOptions),
558
+ identifier,
559
+ label,
560
+ color: parseOptionalNumber(mergedOptions.color, "color")
561
+ }), mergedOptions);
562
+ });
563
+ issues.command("unlabel <identifier> <label>").description("Remove a label from an issue").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").action(async (identifier, label, options, command) => {
564
+ const mergedOptions = normalizeOptions(command, options);
565
+ await callAndPrint("remove_issue_label", {
566
+ project: requireProject(mergedOptions),
567
+ identifier,
568
+ label
569
+ }, mergedOptions);
570
+ });
571
+ issues.command("move <identifier>").description("Move an issue under a new parent or to top-level").option("-p, --project <project>", "Project identifier; defaults to HULY_DEFAULT_PROJECT").option("--parent <identifier>", "New parent issue").option("--top-level", "Move to top-level").action(async (identifier, options, command) => {
572
+ const mergedOptions = normalizeOptions(command, options);
573
+ await callAndPrint("move_issue", {
574
+ project: requireProject(mergedOptions),
575
+ identifier,
576
+ newParent: mergedOptions.topLevel === true ? null : mergedOptions.parent
577
+ }, mergedOptions);
578
+ });
579
+ program2.command("search <query>").description("Run Huly fulltext search").option("--limit <number>", "Maximum number of results").action(async (query, options, command) => {
580
+ const mergedOptions = normalizeOptions(command, options);
581
+ await callAndPrint("fulltext_search", compact({
582
+ query,
583
+ limit: parseOptionalNumber(mergedOptions.limit, "limit")
584
+ }), mergedOptions);
585
+ });
586
+ program2.command("call <tool>").description("Call any raw huly-mcp tool by name").option("--data <json>", "JSON object arguments").option("--file <path>", "JSON file containing arguments").option("--field <key=value>", "Add a scalar argument; can be repeated", collectKeyValue, {}).action(async (tool, options, command) => {
587
+ const mergedOptions = normalizeOptions(command, options);
588
+ const args = mergeRawArgs(mergedOptions);
589
+ await callAndPrint(tool, args, mergedOptions);
590
+ });
591
+ };
592
+ var callAndPrint = async (toolName, args, options, clientOptions = {}) => {
593
+ const client = await createClient(options, clientOptions);
594
+ try {
595
+ const result = await client.callTool(toolName, args);
596
+ printResult(result, { format: outputFormat(options) });
597
+ } finally {
598
+ await client.close();
599
+ }
600
+ };
601
+ var createClient = async (options, clientOptions = {}) => {
602
+ const config = loadConfig(options);
603
+ if (clientOptions.validate !== false) validateConfig(config);
604
+ return createHulyMcpClient(config);
605
+ };
606
+ var mergeRawArgs = (options) => {
607
+ const fromData = options.data === void 0 ? {} : parseJsonObject(options.data, "--data");
608
+ const fromFile = options.file === void 0 ? {} : readJsonObjectFile(options.file);
609
+ return {
610
+ ...fromFile,
611
+ ...fromData,
612
+ ...options.field
613
+ };
614
+ };
615
+ var compact = (record) => Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
616
+ var outputFormat = (options) => options.json === true ? "json" : "pretty";
617
+ var normalizeOptions = (command, options) => ({
618
+ ...command.optsWithGlobals(),
619
+ ...options
620
+ });
621
+ var requireProject = (options) => {
622
+ const project = options.project ?? process.env.HULY_DEFAULT_PROJECT;
623
+ if (project === void 0 || project.trim() === "") {
624
+ throw new Error("Project is required. Pass --project or set HULY_DEFAULT_PROJECT.");
625
+ }
626
+ return project;
627
+ };
628
+ var parseOptionalNumber = (value, label) => {
629
+ if (value === void 0) return void 0;
630
+ const parsed = Number(value);
631
+ if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number`);
632
+ return parsed;
633
+ };
634
+
635
+ // src/index.ts
636
+ var program = new Command();
637
+ program.name("huly").description("Huly command-line client powered by @firfi/huly-mcp").version("0.1.0").option("--config <path>", "Load Huly env vars from a dotenv-style file").option("--json", "Print JSON output").showHelpAfterError();
638
+ registerCommands(program);
639
+ program.parseAsync(process.argv).catch((error) => {
640
+ const message = error instanceof Error ? error.message : String(error);
641
+ process.stderr.write(`Error: ${message}
642
+ `);
643
+ process.exitCode = 1;
644
+ });
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@starhui/huly-cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line client for Huly built on top of huly-mcp.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.12.0"
8
+ },
9
+ "bin": {
10
+ "huly": "./dist/index.js",
11
+ "huly-cli": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "skills/huly-cli",
16
+ "README.md",
17
+ ".env.example"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "huly",
24
+ "cli",
25
+ "mcp",
26
+ "issue-tracker",
27
+ "project-management"
28
+ ],
29
+ "homepage": "https://github.com/starhui-dev/huly-cli#readme",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/starhui-dev/huly-cli.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/starhui-dev/huly-cli/issues"
36
+ },
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "@firfi/huly-mcp": "0.29.0",
40
+ "commander": "15.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "25.1.0",
44
+ "esbuild": "0.28.0",
45
+ "typescript": "6.0.3",
46
+ "vitest": "4.1.8"
47
+ },
48
+ "scripts": {
49
+ "build": "pnpm typecheck && rm -rf dist && esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/index.js",
50
+ "test": "vitest run",
51
+ "smoke": "node scripts/smoke-cli.mjs",
52
+ "smoke:upstream": "node scripts/smoke-upstream.mjs",
53
+ "pack:dry": "pnpm pack --dry-run",
54
+ "typecheck": "tsc -p tsconfig.json --noEmit",
55
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke && pnpm smoke:upstream"
56
+ }
57
+ }
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: huly-cli
3
+ description: Use this skill when the user wants to inspect or change Huly data from the terminal using huly-cli through npx, including projects, issues, statuses, labels, fulltext search, or raw huly-mcp tool calls. Use for Huly task triage, issue updates, project lookup, and agent-safe Huly automation.
4
+ ---
5
+
6
+ # Huly CLI
7
+
8
+ Use `npx -y @starhui/huly-cli@latest` to work with Huly through `@firfi/huly-mcp`.
9
+
10
+ For local development inside this repository, `huly` is also acceptable after `pnpm build && pnpm link --global`.
11
+
12
+ ## Quick Checks
13
+
14
+ 1. Confirm the CLI is available:
15
+ ```bash
16
+ npx -y @starhui/huly-cli@latest --version
17
+ ```
18
+ 2. Prefer explicit config:
19
+ ```bash
20
+ npx -y @starhui/huly-cli@latest --config .env context
21
+ ```
22
+ 3. If project is omitted for issue commands, set `HULY_DEFAULT_PROJECT` or pass `--project`.
23
+
24
+ Never print real tokens, passwords, or full `.env` contents. `npx -y @starhui/huly-cli@latest context` is safe because it returns sanitized config.
25
+
26
+ ## Common Commands
27
+
28
+ List projects:
29
+ ```bash
30
+ npx -y @starhui/huly-cli@latest --config .env project list --json
31
+ ```
32
+
33
+ Discover statuses before creating or updating issues:
34
+ ```bash
35
+ npx -y @starhui/huly-cli@latest --config .env project statuses HULY --json
36
+ ```
37
+
38
+ List issues:
39
+ ```bash
40
+ npx -y @starhui/huly-cli@latest --config .env issue list --project HULY --status-category active --limit 20 --json
41
+ ```
42
+
43
+ Get an issue:
44
+ ```bash
45
+ npx -y @starhui/huly-cli@latest --config .env issue get HULY-123 --project HULY --json
46
+ ```
47
+
48
+ Create an issue:
49
+ ```bash
50
+ npx -y @starhui/huly-cli@latest --config .env issue create --project HULY --title "Short imperative title" --description "Markdown body"
51
+ ```
52
+
53
+ Update an issue:
54
+ ```bash
55
+ npx -y @starhui/huly-cli@latest --config .env issue update HULY-123 --project HULY --status Done
56
+ ```
57
+
58
+ Search:
59
+ ```bash
60
+ npx -y @starhui/huly-cli@latest --config .env search "query text" --limit 10 --json
61
+ ```
62
+
63
+ ## Raw Tool Access
64
+
65
+ Use raw calls when the first-class CLI does not expose an upstream MCP tool:
66
+
67
+ ```bash
68
+ npx -y @starhui/huly-cli@latest --config .env tools --filter document
69
+ npx -y @starhui/huly-cli@latest --config .env call get_issue --field project=HULY --field identifier=HULY-123 --json
70
+ npx -y @starhui/huly-cli@latest --config .env call list_projects --data '{"limit":10}' --json
71
+ ```
72
+
73
+ `--field key=value` coerces `true`, `false`, `null`, and numbers. Use `--data` or `--file` for nested JSON.
74
+
75
+ ## Safety
76
+
77
+ - Use `--json` for agent parsing.
78
+ - Read before write: inspect the project/status/issue before mutating it unless the user gave exact values.
79
+ - Destructive commands require confirmation; only pass `--yes` when the user explicitly requested deletion.
80
+ - Prefer status names from `project statuses`; do not invent workflow statuses.
81
+ - If a command fails due to auth/config, report the missing sanitized fields from `npx -y @starhui/huly-cli@latest --config .env context`; do not expose secrets.
82
+
83
+ ## Useful Mapping
84
+
85
+ - Projects: `project list`, `project get`, `project statuses`, `project create`, `project update`, `project delete`.
86
+ - Issues: `issue list`, `issue get`, `issue create`, `issue update`, `issue delete`, `issue label`, `issue unlabel`, `issue move`.
87
+ - Global search: `search`.
88
+ - All upstream MCP tools: `tools` and `call`.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "Huly CLI"
3
+ short_description: "Operate Huly projects and issues via npx"
4
+ default_prompt: "Use $huly-cli with npx to inspect and update Huly issues safely from the terminal."
5
+ policy:
6
+ allow_implicit_invocation: true