openhome-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/README.md +470 -0
- package/bin/openhome.js +2 -0
- package/dist/chunk-Q4UKUXDB.js +164 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3184 -0
- package/dist/store-DR7EKQ5T.js +16 -0
- package/package.json +44 -0
- package/src/api/client.ts +231 -0
- package/src/api/contracts.ts +103 -0
- package/src/api/endpoints.ts +19 -0
- package/src/api/mock-client.ts +145 -0
- package/src/cli.ts +339 -0
- package/src/commands/agents.ts +88 -0
- package/src/commands/assign.ts +123 -0
- package/src/commands/chat.ts +265 -0
- package/src/commands/config-edit.ts +163 -0
- package/src/commands/delete.ts +107 -0
- package/src/commands/deploy.ts +430 -0
- package/src/commands/init.ts +895 -0
- package/src/commands/list.ts +78 -0
- package/src/commands/login.ts +54 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/logs.ts +174 -0
- package/src/commands/status.ts +174 -0
- package/src/commands/toggle.ts +118 -0
- package/src/commands/trigger.ts +193 -0
- package/src/commands/validate.ts +53 -0
- package/src/commands/whoami.ts +54 -0
- package/src/config/keychain.ts +62 -0
- package/src/config/store.ts +137 -0
- package/src/ui/format.ts +95 -0
- package/src/util/zip.ts +74 -0
- package/src/validation/rules.ts +71 -0
- package/src/validation/validator.ts +204 -0
- package/tasks/feature-request-sdk-api.md +246 -0
- package/tasks/prd-openhome-cli.md +605 -0
- package/templates/api/README.md.tmpl +11 -0
- package/templates/api/__init__.py.tmpl +0 -0
- package/templates/api/config.json.tmpl +4 -0
- package/templates/api/main.py.tmpl +30 -0
- package/templates/basic/README.md.tmpl +7 -0
- package/templates/basic/__init__.py.tmpl +0 -0
- package/templates/basic/config.json.tmpl +4 -0
- package/templates/basic/main.py.tmpl +22 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { getApiKey, getConfig } from "../config/store.js";
|
|
3
|
+
import { ApiClient } from "../api/client.js";
|
|
4
|
+
import { WS_BASE, ENDPOINTS } from "../api/endpoints.js";
|
|
5
|
+
import { error, success, info, p, handleCancel } from "../ui/format.js";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
|
|
8
|
+
const PING_INTERVAL = 30_000;
|
|
9
|
+
const RESPONSE_TIMEOUT = 30_000;
|
|
10
|
+
|
|
11
|
+
export async function triggerCommand(
|
|
12
|
+
phraseArg?: string,
|
|
13
|
+
opts: { agent?: string } = {},
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
p.intro("⚡ Trigger an ability");
|
|
16
|
+
|
|
17
|
+
const apiKey = getApiKey();
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
error("Not authenticated. Run: openhome login");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve phrase
|
|
24
|
+
let phrase = phraseArg;
|
|
25
|
+
if (!phrase) {
|
|
26
|
+
const input = await p.text({
|
|
27
|
+
message: "Trigger phrase (e.g. 'play aquaprime')",
|
|
28
|
+
validate: (val) => {
|
|
29
|
+
if (!val || !val.trim()) return "A trigger phrase is required";
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
handleCancel(input);
|
|
33
|
+
phrase = (input as string).trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolve agent ID
|
|
37
|
+
let agentId = opts.agent ?? getConfig().default_personality_id;
|
|
38
|
+
|
|
39
|
+
if (!agentId) {
|
|
40
|
+
const s = p.spinner();
|
|
41
|
+
s.start("Fetching agents...");
|
|
42
|
+
try {
|
|
43
|
+
const client = new ApiClient(apiKey);
|
|
44
|
+
const agents = await client.getPersonalities();
|
|
45
|
+
s.stop(`Found ${agents.length} agent(s).`);
|
|
46
|
+
|
|
47
|
+
if (agents.length === 0) {
|
|
48
|
+
error("No agents found.");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const selected = await p.select({
|
|
53
|
+
message: "Which agent?",
|
|
54
|
+
options: agents.map((a) => ({
|
|
55
|
+
value: a.id,
|
|
56
|
+
label: a.name,
|
|
57
|
+
hint: a.id,
|
|
58
|
+
})),
|
|
59
|
+
});
|
|
60
|
+
handleCancel(selected);
|
|
61
|
+
agentId = selected as string;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
s.stop("Failed.");
|
|
64
|
+
error(
|
|
65
|
+
`Could not fetch agents: ${err instanceof Error ? err.message : String(err)}`,
|
|
66
|
+
);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Connect, send trigger, wait for response, disconnect
|
|
72
|
+
const wsUrl = `${WS_BASE}${ENDPOINTS.voiceStream(apiKey, agentId)}`;
|
|
73
|
+
info(`Sending "${chalk.bold(phrase)}" to agent ${chalk.bold(agentId)}...`);
|
|
74
|
+
|
|
75
|
+
const s = p.spinner();
|
|
76
|
+
s.start("Waiting for response...");
|
|
77
|
+
|
|
78
|
+
await new Promise<void>((resolve) => {
|
|
79
|
+
const ws = new WebSocket(wsUrl, {
|
|
80
|
+
perMessageDeflate: false,
|
|
81
|
+
headers: {
|
|
82
|
+
Origin: "https://app.openhome.com",
|
|
83
|
+
"User-Agent":
|
|
84
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let fullResponse = "";
|
|
89
|
+
let responseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
90
|
+
let pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
91
|
+
|
|
92
|
+
function cleanup(): void {
|
|
93
|
+
if (pingInterval) clearInterval(pingInterval);
|
|
94
|
+
if (responseTimer) clearTimeout(responseTimer);
|
|
95
|
+
if (ws.readyState === WebSocket.OPEN) ws.close(1000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ws.on("open", () => {
|
|
99
|
+
// Send trigger phrase immediately
|
|
100
|
+
ws.send(JSON.stringify({ type: "transcribed", data: phrase }));
|
|
101
|
+
|
|
102
|
+
// Keepalive
|
|
103
|
+
pingInterval = setInterval(() => {
|
|
104
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
105
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
106
|
+
}
|
|
107
|
+
}, PING_INTERVAL);
|
|
108
|
+
|
|
109
|
+
// Timeout if no response
|
|
110
|
+
responseTimer = setTimeout(() => {
|
|
111
|
+
s.stop("Timed out waiting for response.");
|
|
112
|
+
if (fullResponse) {
|
|
113
|
+
console.log(`\n${chalk.cyan("Agent:")} ${fullResponse}`);
|
|
114
|
+
}
|
|
115
|
+
cleanup();
|
|
116
|
+
resolve();
|
|
117
|
+
}, RESPONSE_TIMEOUT);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
ws.on("message", (raw: WebSocket.Data) => {
|
|
121
|
+
try {
|
|
122
|
+
const msg = JSON.parse(raw.toString()) as {
|
|
123
|
+
type: string;
|
|
124
|
+
data: unknown;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
switch (msg.type) {
|
|
128
|
+
case "message": {
|
|
129
|
+
const data = msg.data as {
|
|
130
|
+
content?: string;
|
|
131
|
+
role?: string;
|
|
132
|
+
live?: boolean;
|
|
133
|
+
final?: boolean;
|
|
134
|
+
};
|
|
135
|
+
if (data.content && data.role === "assistant") {
|
|
136
|
+
fullResponse += data.content;
|
|
137
|
+
if (!data.live || data.final) {
|
|
138
|
+
// Got final response
|
|
139
|
+
s.stop("Response received.");
|
|
140
|
+
console.log(`\n${chalk.cyan("Agent:")} ${fullResponse}\n`);
|
|
141
|
+
cleanup();
|
|
142
|
+
resolve();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "text": {
|
|
148
|
+
const textData = msg.data as string;
|
|
149
|
+
if (textData === "audio-init") {
|
|
150
|
+
ws.send(JSON.stringify({ type: "text", data: "bot-speaking" }));
|
|
151
|
+
} else if (textData === "audio-end") {
|
|
152
|
+
ws.send(JSON.stringify({ type: "text", data: "bot-speak-end" }));
|
|
153
|
+
if (fullResponse) {
|
|
154
|
+
s.stop("Response received.");
|
|
155
|
+
console.log(`\n${chalk.cyan("Agent:")} ${fullResponse}\n`);
|
|
156
|
+
cleanup();
|
|
157
|
+
resolve();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "audio":
|
|
163
|
+
ws.send(JSON.stringify({ type: "ack", data: "audio-received" }));
|
|
164
|
+
break;
|
|
165
|
+
case "error-event": {
|
|
166
|
+
const errData = msg.data as { message?: string; title?: string };
|
|
167
|
+
s.stop("Error.");
|
|
168
|
+
error(
|
|
169
|
+
`Server error: ${errData?.message || errData?.title || "Unknown"}`,
|
|
170
|
+
);
|
|
171
|
+
cleanup();
|
|
172
|
+
resolve();
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
ws.on("error", (err: Error) => {
|
|
182
|
+
s.stop("Connection error.");
|
|
183
|
+
error(err.message);
|
|
184
|
+
resolve();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
ws.on("close", () => {
|
|
188
|
+
if (pingInterval) clearInterval(pingInterval);
|
|
189
|
+
if (responseTimer) clearTimeout(responseTimer);
|
|
190
|
+
resolve();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { validateAbility } from "../validation/validator.js";
|
|
3
|
+
import { success, error, warn, p } from "../ui/format.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
|
|
6
|
+
export async function validateCommand(pathArg: string = "."): Promise<void> {
|
|
7
|
+
const targetDir = resolve(pathArg);
|
|
8
|
+
p.intro(`🔎 Validate ability`);
|
|
9
|
+
|
|
10
|
+
const s = p.spinner();
|
|
11
|
+
s.start("Running checks...");
|
|
12
|
+
|
|
13
|
+
const result = validateAbility(targetDir);
|
|
14
|
+
|
|
15
|
+
if (result.errors.length === 0 && result.warnings.length === 0) {
|
|
16
|
+
s.stop("All checks passed.");
|
|
17
|
+
p.outro("Ability is ready to deploy! 🎉");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
s.stop("Checks complete.");
|
|
22
|
+
|
|
23
|
+
if (result.errors.length > 0) {
|
|
24
|
+
p.note(
|
|
25
|
+
result.errors
|
|
26
|
+
.map(
|
|
27
|
+
(issue) =>
|
|
28
|
+
`${chalk.red("✗")} ${issue.file ? chalk.bold(`[${issue.file}]`) + " " : ""}${issue.message}`,
|
|
29
|
+
)
|
|
30
|
+
.join("\n"),
|
|
31
|
+
`${result.errors.length} Error(s)`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (result.warnings.length > 0) {
|
|
36
|
+
p.note(
|
|
37
|
+
result.warnings
|
|
38
|
+
.map(
|
|
39
|
+
(w) =>
|
|
40
|
+
`${chalk.yellow("⚠")} ${w.file ? chalk.bold(`[${w.file}]`) + " " : ""}${w.message}`,
|
|
41
|
+
)
|
|
42
|
+
.join("\n"),
|
|
43
|
+
`${result.warnings.length} Warning(s)`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (result.passed) {
|
|
48
|
+
p.outro("Validation passed (with warnings).");
|
|
49
|
+
} else {
|
|
50
|
+
error("Fix errors before deploying.");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getApiKey, getConfig, getTrackedAbilities } from "../config/store.js";
|
|
2
|
+
import { p, info } from "../ui/format.js";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export async function whoamiCommand(): Promise<void> {
|
|
7
|
+
p.intro("👤 OpenHome CLI Status");
|
|
8
|
+
|
|
9
|
+
const apiKey = getApiKey();
|
|
10
|
+
const config = getConfig();
|
|
11
|
+
const tracked = getTrackedAbilities();
|
|
12
|
+
const home = homedir();
|
|
13
|
+
|
|
14
|
+
// Auth status
|
|
15
|
+
if (apiKey) {
|
|
16
|
+
const masked = apiKey.slice(0, 6) + "..." + apiKey.slice(-4);
|
|
17
|
+
info(`Authenticated: ${chalk.green("yes")} (key: ${chalk.gray(masked)})`);
|
|
18
|
+
} else {
|
|
19
|
+
info(
|
|
20
|
+
`Authenticated: ${chalk.red("no")} — run ${chalk.bold("openhome login")}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default agent
|
|
25
|
+
if (config.default_personality_id) {
|
|
26
|
+
info(`Default agent: ${chalk.bold(config.default_personality_id)}`);
|
|
27
|
+
} else {
|
|
28
|
+
info(
|
|
29
|
+
`Default agent: ${chalk.gray("not set")} — run ${chalk.bold("openhome agents")}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// API base
|
|
34
|
+
if (config.api_base_url) {
|
|
35
|
+
info(`API base: ${config.api_base_url}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tracked abilities
|
|
39
|
+
if (tracked.length > 0) {
|
|
40
|
+
const lines = tracked.map((a) => {
|
|
41
|
+
const shortPath = a.path.startsWith(home)
|
|
42
|
+
? `~${a.path.slice(home.length)}`
|
|
43
|
+
: a.path;
|
|
44
|
+
return ` ${chalk.bold(a.name)} ${chalk.gray(shortPath)}`;
|
|
45
|
+
});
|
|
46
|
+
p.note(lines.join("\n"), `${tracked.length} tracked ability(s)`);
|
|
47
|
+
} else {
|
|
48
|
+
info(
|
|
49
|
+
`Tracked abilities: ${chalk.gray("none")} — run ${chalk.bold("openhome init")}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
p.outro("Done.");
|
|
54
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const SERVICE = "openhome-cli";
|
|
4
|
+
const ACCOUNT = "api-key";
|
|
5
|
+
|
|
6
|
+
export function keychainGet(
|
|
7
|
+
service: string = SERVICE,
|
|
8
|
+
account: string = ACCOUNT,
|
|
9
|
+
): string | null {
|
|
10
|
+
try {
|
|
11
|
+
const result = execFileSync(
|
|
12
|
+
"security",
|
|
13
|
+
["find-generic-password", "-a", account, "-s", service, "-w"],
|
|
14
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
15
|
+
);
|
|
16
|
+
return result.trim() || null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function keychainSet(
|
|
23
|
+
password: string,
|
|
24
|
+
service: string = SERVICE,
|
|
25
|
+
account: string = ACCOUNT,
|
|
26
|
+
): boolean {
|
|
27
|
+
try {
|
|
28
|
+
execFileSync(
|
|
29
|
+
"security",
|
|
30
|
+
[
|
|
31
|
+
"add-generic-password",
|
|
32
|
+
"-a",
|
|
33
|
+
account,
|
|
34
|
+
"-s",
|
|
35
|
+
service,
|
|
36
|
+
"-w",
|
|
37
|
+
password,
|
|
38
|
+
"-U",
|
|
39
|
+
],
|
|
40
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
41
|
+
);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function keychainDelete(
|
|
49
|
+
service: string = SERVICE,
|
|
50
|
+
account: string = ACCOUNT,
|
|
51
|
+
): boolean {
|
|
52
|
+
try {
|
|
53
|
+
execFileSync(
|
|
54
|
+
"security",
|
|
55
|
+
["delete-generic-password", "-a", account, "-s", service],
|
|
56
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
|
57
|
+
);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { keychainGet, keychainSet } from "./keychain.js";
|
|
11
|
+
|
|
12
|
+
export interface TrackedAbility {
|
|
13
|
+
name: string;
|
|
14
|
+
path: string;
|
|
15
|
+
created_at: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CliConfig {
|
|
19
|
+
api_base_url?: string;
|
|
20
|
+
default_personality_id?: string;
|
|
21
|
+
api_key?: string;
|
|
22
|
+
abilities?: TrackedAbility[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = join(homedir(), ".openhome");
|
|
26
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
27
|
+
|
|
28
|
+
function ensureConfigDir(): void {
|
|
29
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
30
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getConfig(): CliConfig {
|
|
35
|
+
ensureConfigDir();
|
|
36
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(CONFIG_FILE, "utf8");
|
|
41
|
+
return JSON.parse(raw) as CliConfig;
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saveConfig(config: CliConfig): void {
|
|
48
|
+
ensureConfigDir();
|
|
49
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
mode: 0o600,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getApiKey(): string | null {
|
|
56
|
+
// Try keychain first
|
|
57
|
+
const fromKeychain = keychainGet();
|
|
58
|
+
if (fromKeychain) return fromKeychain;
|
|
59
|
+
|
|
60
|
+
// Fallback to config file
|
|
61
|
+
const config = getConfig();
|
|
62
|
+
return config.api_key ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerAbility(name: string, absPath: string): void {
|
|
66
|
+
const config = getConfig();
|
|
67
|
+
const abilities = config.abilities ?? [];
|
|
68
|
+
|
|
69
|
+
// Update existing entry or add new one
|
|
70
|
+
const idx = abilities.findIndex((a) => a.path === absPath);
|
|
71
|
+
if (idx >= 0) {
|
|
72
|
+
abilities[idx].name = name;
|
|
73
|
+
} else {
|
|
74
|
+
abilities.push({
|
|
75
|
+
name,
|
|
76
|
+
path: absPath,
|
|
77
|
+
created_at: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
config.abilities = abilities;
|
|
82
|
+
saveConfig(config);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getTrackedAbilities(): TrackedAbility[] {
|
|
86
|
+
const config = getConfig();
|
|
87
|
+
const tracked = (config.abilities ?? []).filter((a) => {
|
|
88
|
+
try {
|
|
89
|
+
return existsSync(join(a.path, "config.json"));
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Auto-discover abilities in ./abilities/ that aren't tracked yet
|
|
96
|
+
const abilitiesDir = join(process.cwd(), "abilities");
|
|
97
|
+
if (existsSync(abilitiesDir)) {
|
|
98
|
+
try {
|
|
99
|
+
const dirs = readdirSync(abilitiesDir, { withFileTypes: true });
|
|
100
|
+
for (const d of dirs) {
|
|
101
|
+
if (!d.isDirectory()) continue;
|
|
102
|
+
const dirPath = join(abilitiesDir, d.name);
|
|
103
|
+
const configPath = join(dirPath, "config.json");
|
|
104
|
+
if (!existsSync(configPath)) continue;
|
|
105
|
+
if (tracked.some((a) => a.path === dirPath)) continue;
|
|
106
|
+
|
|
107
|
+
// Read name from config.json
|
|
108
|
+
try {
|
|
109
|
+
const abilityConfig = JSON.parse(
|
|
110
|
+
readFileSync(configPath, "utf8"),
|
|
111
|
+
) as { unique_name?: string };
|
|
112
|
+
tracked.push({
|
|
113
|
+
name: abilityConfig.unique_name ?? d.name,
|
|
114
|
+
path: dirPath,
|
|
115
|
+
created_at: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// skip unreadable configs
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// skip if abilities/ can't be read
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return tracked;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function saveApiKey(key: string): void {
|
|
130
|
+
const saved = keychainSet(key);
|
|
131
|
+
if (!saved) {
|
|
132
|
+
// Fallback: save in config file (less secure)
|
|
133
|
+
const config = getConfig();
|
|
134
|
+
config.api_key = key;
|
|
135
|
+
saveConfig(config);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
|
|
4
|
+
// Re-export clack for direct use in commands
|
|
5
|
+
export { p };
|
|
6
|
+
|
|
7
|
+
export function success(msg: string): void {
|
|
8
|
+
console.log(chalk.green(`✓ ${msg}`));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function error(msg: string): void {
|
|
12
|
+
console.error(chalk.red(`✗ ${msg}`));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function warn(msg: string): void {
|
|
16
|
+
console.warn(chalk.yellow(`⚠ ${msg}`));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function info(msg: string): void {
|
|
20
|
+
console.log(chalk.cyan(`ℹ ${msg}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function header(msg: string): void {
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log(chalk.bold(msg));
|
|
26
|
+
console.log(chalk.bold("─".repeat(msg.length)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TableRow {
|
|
30
|
+
[key: string]: string | number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function table(rows: TableRow[]): void {
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
info("No items to display.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const keys = Object.keys(rows[0]);
|
|
40
|
+
|
|
41
|
+
// Calculate column widths
|
|
42
|
+
const widths: Record<string, number> = {};
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
widths[key] = key.length;
|
|
45
|
+
}
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const val = String(row[key] ?? "");
|
|
49
|
+
if (val.length > widths[key]) {
|
|
50
|
+
widths[key] = val.length;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Header row
|
|
56
|
+
const headerLine = keys
|
|
57
|
+
.map((k) => chalk.bold(k.padEnd(widths[k])))
|
|
58
|
+
.join(" ");
|
|
59
|
+
console.log(headerLine);
|
|
60
|
+
console.log(keys.map((k) => "─".repeat(widths[k])).join(" "));
|
|
61
|
+
|
|
62
|
+
// Data rows
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const line = keys
|
|
65
|
+
.map((k) => String(row[k] ?? "").padEnd(widths[k]))
|
|
66
|
+
.join(" ");
|
|
67
|
+
console.log(line);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function spinner(msg: string): { stop: (finalMsg?: string) => void } {
|
|
72
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
73
|
+
let i = 0;
|
|
74
|
+
const interval = setInterval(() => {
|
|
75
|
+
process.stdout.write(`\r${chalk.cyan(frames[i++ % frames.length])} ${msg}`);
|
|
76
|
+
}, 80);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
stop(finalMsg?: string) {
|
|
80
|
+
clearInterval(interval);
|
|
81
|
+
process.stdout.write("\r" + " ".repeat(msg.length + 4) + "\r");
|
|
82
|
+
if (finalMsg) {
|
|
83
|
+
console.log(finalMsg);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Check if user cancelled a clack prompt (Ctrl+C) */
|
|
90
|
+
export function handleCancel(value: unknown): void {
|
|
91
|
+
if (p.isCancel(value)) {
|
|
92
|
+
p.cancel("Operation cancelled.");
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/util/zip.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import archiver from "archiver";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
export async function createAbilityZip(dirPath: string): Promise<Buffer> {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const chunks: Buffer[] = [];
|
|
8
|
+
|
|
9
|
+
const writable = new Writable({
|
|
10
|
+
write(chunk: Buffer, _encoding, callback) {
|
|
11
|
+
chunks.push(chunk);
|
|
12
|
+
callback();
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
writable.on("finish", () => {
|
|
17
|
+
resolve(Buffer.concat(chunks));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
writable.on("error", reject);
|
|
21
|
+
|
|
22
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
23
|
+
|
|
24
|
+
archive.on("error", reject);
|
|
25
|
+
archive.pipe(writable);
|
|
26
|
+
|
|
27
|
+
archive.glob("**/*", {
|
|
28
|
+
cwd: dirPath,
|
|
29
|
+
ignore: [
|
|
30
|
+
"**/__pycache__/**",
|
|
31
|
+
"**/*.pyc",
|
|
32
|
+
"**/.git/**",
|
|
33
|
+
"**/.env",
|
|
34
|
+
"**/.env.*",
|
|
35
|
+
"**/secrets.*",
|
|
36
|
+
"**/*.key",
|
|
37
|
+
"**/*.pem",
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
archive.finalize().catch(reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Convenience: write zip to a file path
|
|
46
|
+
export async function writeAbilityZip(
|
|
47
|
+
dirPath: string,
|
|
48
|
+
outPath: string,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const output = createWriteStream(outPath);
|
|
52
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
53
|
+
|
|
54
|
+
output.on("close", resolve);
|
|
55
|
+
archive.on("error", reject);
|
|
56
|
+
archive.pipe(output);
|
|
57
|
+
|
|
58
|
+
archive.glob("**/*", {
|
|
59
|
+
cwd: dirPath,
|
|
60
|
+
ignore: [
|
|
61
|
+
"**/__pycache__/**",
|
|
62
|
+
"**/*.pyc",
|
|
63
|
+
"**/.git/**",
|
|
64
|
+
"**/.env",
|
|
65
|
+
"**/.env.*",
|
|
66
|
+
"**/secrets.*",
|
|
67
|
+
"**/*.key",
|
|
68
|
+
"**/*.pem",
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
archive.finalize().catch(reject);
|
|
73
|
+
});
|
|
74
|
+
}
|