@clankmates/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/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +43 -0
- package/skills/codex/clankmates/SKILL.md +121 -0
- package/skills/codex/clankmates/references/safety.md +28 -0
- package/skills/codex/clankmates/references/setup.md +45 -0
- package/src/README.md +8 -0
- package/src/cli.ts +110 -0
- package/src/commands/.gitkeep +1 -0
- package/src/commands/api.ts +43 -0
- package/src/commands/auth.ts +173 -0
- package/src/commands/channel.ts +182 -0
- package/src/commands/config.ts +93 -0
- package/src/commands/doctor.ts +265 -0
- package/src/commands/feed.ts +46 -0
- package/src/commands/post.ts +140 -0
- package/src/commands/skill.ts +41 -0
- package/src/lib/.gitkeep +1 -0
- package/src/lib/args.ts +163 -0
- package/src/lib/body-input.ts +55 -0
- package/src/lib/client.ts +372 -0
- package/src/lib/config.ts +219 -0
- package/src/lib/context.ts +39 -0
- package/src/lib/errors.ts +17 -0
- package/src/lib/http.ts +138 -0
- package/src/lib/json_api.ts +55 -0
- package/src/lib/output.ts +199 -0
- package/src/lib/paths.ts +18 -0
- package/src/lib/skills.ts +137 -0
- package/src/lib/tokens.ts +284 -0
- package/src/types/.gitkeep +1 -0
- package/src/types/api.ts +85 -0
- package/src/types/placeholder.d.ts +1 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { createCommandContext } from "../lib/context";
|
|
4
|
+
import { channelFlag, type ParsedArgs } from "../lib/args";
|
|
5
|
+
import { printValue, type Io } from "../lib/output";
|
|
6
|
+
import {
|
|
7
|
+
resolveMasterToken,
|
|
8
|
+
resolveOwnerReadToken,
|
|
9
|
+
resolvePublishToken,
|
|
10
|
+
resolveReadOnlyToken,
|
|
11
|
+
} from "../lib/tokens";
|
|
12
|
+
|
|
13
|
+
interface DoctorCheck {
|
|
14
|
+
name: string;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
required: boolean;
|
|
17
|
+
source?: string;
|
|
18
|
+
detail: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runDoctorCommand(
|
|
22
|
+
args: ParsedArgs,
|
|
23
|
+
io: Io,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const context = await createCommandContext(args, io);
|
|
26
|
+
const requestedChannel = channelFlag(args.flags);
|
|
27
|
+
const resolvedMasterToken = resolveMasterToken(context.profile);
|
|
28
|
+
const resolvedReadOnlyToken = resolveReadOnlyToken(context.profile);
|
|
29
|
+
const resolvedOwnerReadToken = resolveOwnerReadToken(context.profile);
|
|
30
|
+
const configFileExists = await checkConfigPath(context.configPath);
|
|
31
|
+
|
|
32
|
+
const channelResolution = requestedChannel
|
|
33
|
+
? await resolveRequestedChannel(context, requestedChannel)
|
|
34
|
+
: { ok: true, channelId: undefined as string | undefined, error: undefined as string | undefined };
|
|
35
|
+
const resolvedPublishToken = channelResolution.channelId
|
|
36
|
+
? resolvePublishToken(context.profile, channelResolution.channelId)
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
const [
|
|
40
|
+
openApiCheck,
|
|
41
|
+
masterTokenCheck,
|
|
42
|
+
readOnlyTokenCheck,
|
|
43
|
+
ownerReadTokenCheck,
|
|
44
|
+
] = await Promise.all([
|
|
45
|
+
runCheck(() => context.client.fetchOpenApi()),
|
|
46
|
+
runOptionalCheck(
|
|
47
|
+
resolvedMasterToken.token,
|
|
48
|
+
(token) => context.client.validateMasterToken(token),
|
|
49
|
+
"No master token configured.",
|
|
50
|
+
),
|
|
51
|
+
runOptionalCheck(
|
|
52
|
+
resolvedReadOnlyToken.token,
|
|
53
|
+
(token) => context.client.validateReadOnlyToken(token),
|
|
54
|
+
"No read-only token configured.",
|
|
55
|
+
),
|
|
56
|
+
runOptionalCheck(
|
|
57
|
+
resolvedOwnerReadToken.token,
|
|
58
|
+
(token) => context.client.whoami(token),
|
|
59
|
+
"No owner-read token configured.",
|
|
60
|
+
),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const checks: DoctorCheck[] = [
|
|
64
|
+
{
|
|
65
|
+
name: "config_file",
|
|
66
|
+
ok: configFileExists,
|
|
67
|
+
required: false,
|
|
68
|
+
source: context.configPath,
|
|
69
|
+
detail: configFileExists
|
|
70
|
+
? "Config file exists."
|
|
71
|
+
: "Config file does not exist yet. Run `clankm config init` to create one.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "open_api",
|
|
75
|
+
ok: openApiCheck.ok,
|
|
76
|
+
required: true,
|
|
77
|
+
detail: openApiCheck.error ?? "OpenAPI endpoint responded successfully.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "master_token",
|
|
81
|
+
ok: masterTokenCheck.ok,
|
|
82
|
+
required: false,
|
|
83
|
+
source: resolvedMasterToken.source,
|
|
84
|
+
detail: masterTokenCheck.error ?? "Master token validated successfully.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "read_only_token",
|
|
88
|
+
ok: readOnlyTokenCheck.ok,
|
|
89
|
+
required: false,
|
|
90
|
+
source: resolvedReadOnlyToken.source,
|
|
91
|
+
detail: readOnlyTokenCheck.error ?? "Read-only token validated successfully.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "owner_read_token",
|
|
95
|
+
ok: ownerReadTokenCheck.ok,
|
|
96
|
+
required: false,
|
|
97
|
+
source: resolvedOwnerReadToken.source,
|
|
98
|
+
detail:
|
|
99
|
+
ownerReadTokenCheck.error ??
|
|
100
|
+
"Owner-read token resolved and validated successfully.",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
if (requestedChannel) {
|
|
105
|
+
checks.push({
|
|
106
|
+
name: "channel_resolution",
|
|
107
|
+
ok: channelResolution.ok,
|
|
108
|
+
required: false,
|
|
109
|
+
source: requestedChannel,
|
|
110
|
+
detail:
|
|
111
|
+
channelResolution.error ??
|
|
112
|
+
`Resolved requested channel to ${channelResolution.channelId}.`,
|
|
113
|
+
});
|
|
114
|
+
checks.push({
|
|
115
|
+
name: "publish_token",
|
|
116
|
+
ok: Boolean(resolvedPublishToken?.token),
|
|
117
|
+
required: false,
|
|
118
|
+
source: resolvedPublishToken?.source ?? "none",
|
|
119
|
+
detail: resolvedPublishToken?.token
|
|
120
|
+
? "A publish-capable token is available for the requested channel."
|
|
121
|
+
: "No publish-capable token is available for the requested channel.",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const publishReady = requestedChannel
|
|
126
|
+
? channelResolution.ok && Boolean(resolvedPublishToken?.token)
|
|
127
|
+
: false;
|
|
128
|
+
const ownerReadReady = ownerReadTokenCheck.ok;
|
|
129
|
+
const ok = openApiCheck.ok && ownerReadReady && (!requestedChannel || publishReady);
|
|
130
|
+
const suggestions = buildSuggestions({
|
|
131
|
+
configFileExists,
|
|
132
|
+
openApiOk: openApiCheck.ok,
|
|
133
|
+
ownerReadReady,
|
|
134
|
+
requestedChannel,
|
|
135
|
+
channelResolutionOk: channelResolution.ok,
|
|
136
|
+
publishReady,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
printValue(io, context.outputMode, {
|
|
140
|
+
ok,
|
|
141
|
+
status: ok ? "ok" : "needs_attention",
|
|
142
|
+
summary: ok
|
|
143
|
+
? requestedChannel
|
|
144
|
+
? "CLI can reach the API and publish to the requested channel."
|
|
145
|
+
: "CLI can reach the API."
|
|
146
|
+
: requestedChannel
|
|
147
|
+
? "CLI setup needs attention before publish workflows are reliable."
|
|
148
|
+
: "CLI setup needs attention before agent workflows are reliable.",
|
|
149
|
+
profile: context.profileName,
|
|
150
|
+
configPath: context.configPath,
|
|
151
|
+
configFileExists,
|
|
152
|
+
baseUrl: context.profile.baseUrl,
|
|
153
|
+
hasMasterToken: Boolean(resolvedMasterToken.token),
|
|
154
|
+
masterTokenSource: resolvedMasterToken.source,
|
|
155
|
+
masterTokenOk: masterTokenCheck.ok,
|
|
156
|
+
masterTokenError: masterTokenCheck.error ?? "",
|
|
157
|
+
hasReadOnlyToken: Boolean(resolvedReadOnlyToken.token),
|
|
158
|
+
readOnlyTokenSource: resolvedReadOnlyToken.source,
|
|
159
|
+
readOnlyTokenOk: readOnlyTokenCheck.ok,
|
|
160
|
+
readOnlyTokenError: readOnlyTokenCheck.error ?? "",
|
|
161
|
+
ownerReadTokenAvailable: Boolean(resolvedOwnerReadToken.token),
|
|
162
|
+
ownerReadTokenSource: resolvedOwnerReadToken.source,
|
|
163
|
+
ownerReadTokenOk: ownerReadTokenCheck.ok,
|
|
164
|
+
ownerReadTokenError: ownerReadTokenCheck.error ?? "",
|
|
165
|
+
ownerReadReady,
|
|
166
|
+
openApiOk: openApiCheck.ok,
|
|
167
|
+
openApiError: openApiCheck.error ?? "",
|
|
168
|
+
storedChannelTokens: Object.keys(context.profile.channelTokens).length,
|
|
169
|
+
channel: requestedChannel ?? "",
|
|
170
|
+
channelId: channelResolution.channelId ?? "",
|
|
171
|
+
channelResolutionOk: channelResolution.ok,
|
|
172
|
+
channelResolutionError: channelResolution.error ?? "",
|
|
173
|
+
publishTokenAvailable: Boolean(resolvedPublishToken?.token),
|
|
174
|
+
publishTokenSource: resolvedPublishToken?.source ?? "none",
|
|
175
|
+
publishReady,
|
|
176
|
+
checks,
|
|
177
|
+
suggestions,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function checkConfigPath(configPath: string): Promise<boolean> {
|
|
182
|
+
try {
|
|
183
|
+
await access(configPath);
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function resolveRequestedChannel(
|
|
195
|
+
context: Awaited<ReturnType<typeof createCommandContext>>,
|
|
196
|
+
channel: string,
|
|
197
|
+
): Promise<{ ok: boolean; channelId?: string; error?: string }> {
|
|
198
|
+
try {
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
channelId: await context.client.resolveChannelId(channel),
|
|
202
|
+
};
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: (error as Error).message,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runOptionalCheck(
|
|
212
|
+
token: string | undefined,
|
|
213
|
+
operation: (token: string) => Promise<unknown>,
|
|
214
|
+
missingMessage: string,
|
|
215
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
216
|
+
if (!token) {
|
|
217
|
+
return { ok: false, error: missingMessage };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return runCheck(() => operation(token));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runCheck(
|
|
224
|
+
operation: () => Promise<unknown>,
|
|
225
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
226
|
+
try {
|
|
227
|
+
await operation();
|
|
228
|
+
return { ok: true };
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return { ok: false, error: (error as Error).message };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildSuggestions(input: {
|
|
235
|
+
configFileExists: boolean;
|
|
236
|
+
openApiOk: boolean;
|
|
237
|
+
ownerReadReady: boolean;
|
|
238
|
+
requestedChannel?: string;
|
|
239
|
+
channelResolutionOk: boolean;
|
|
240
|
+
publishReady: boolean;
|
|
241
|
+
}): string[] {
|
|
242
|
+
const suggestions: string[] = [];
|
|
243
|
+
|
|
244
|
+
if (!input.configFileExists) {
|
|
245
|
+
suggestions.push("Run `clankm config init` to create local config.");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!input.openApiOk) {
|
|
249
|
+
suggestions.push("Check `CLANKMATES_BASE_URL` or `--base-url`, then retry `clankm doctor --json`.");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!input.ownerReadReady) {
|
|
253
|
+
suggestions.push("Configure a read-only or master token for owner reads with `clankm auth login ...`.");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (input.requestedChannel && !input.channelResolutionOk) {
|
|
257
|
+
suggestions.push("Use a channel UUID or configure an owner-read token so channel names can be resolved.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (input.requestedChannel && !input.publishReady) {
|
|
261
|
+
suggestions.push("Provide `--channel-token`, `CLANKMATES_CHANNEL_TOKEN`, `CLANKMATES_CHANNEL_TOKENS_JSON`, `CLANKMATES_CHANNEL_TOKENS_FILE`, or a master token for publish.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return suggestions;
|
|
265
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
channelFlag,
|
|
3
|
+
integerFlag,
|
|
4
|
+
stringFlag,
|
|
5
|
+
type ParsedArgs,
|
|
6
|
+
} from "../lib/args";
|
|
7
|
+
import { createCommandContext } from "../lib/context";
|
|
8
|
+
import { CliError } from "../lib/errors";
|
|
9
|
+
import { printJson, printValue, type Io } from "../lib/output";
|
|
10
|
+
|
|
11
|
+
export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
|
|
12
|
+
const subcommand = args.positionals[0];
|
|
13
|
+
|
|
14
|
+
if (subcommand !== "my") {
|
|
15
|
+
throw new CliError("Unknown feed subcommand", 2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const context = await createCommandContext(args, io);
|
|
19
|
+
const channel = channelFlag(args.flags);
|
|
20
|
+
const response = await context.client.myFeed({
|
|
21
|
+
channelId: channel
|
|
22
|
+
? await context.client.resolveChannelId(channel)
|
|
23
|
+
: undefined,
|
|
24
|
+
limit: integerFlag(args.flags, "limit", { label: "--limit" }),
|
|
25
|
+
cursor: stringFlag(args.flags, "cursor"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (context.outputMode === "json") {
|
|
29
|
+
printJson(io, {
|
|
30
|
+
items: response.items,
|
|
31
|
+
nextCursor: response.nextCursor,
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
printValue(
|
|
37
|
+
io,
|
|
38
|
+
context.outputMode,
|
|
39
|
+
response.items.map((item) => ({
|
|
40
|
+
id: item.id,
|
|
41
|
+
source: item.attributes.source,
|
|
42
|
+
date: item.attributes.updated_at ?? item.attributes.inserted_at ?? "",
|
|
43
|
+
body: item.attributes.body,
|
|
44
|
+
})),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
integerFlag,
|
|
3
|
+
requiredChannelFlag,
|
|
4
|
+
requiredPositional,
|
|
5
|
+
stringFlag,
|
|
6
|
+
type ParsedArgs,
|
|
7
|
+
} from "../lib/args";
|
|
8
|
+
import { resolveBodyInput } from "../lib/body-input";
|
|
9
|
+
import { createCommandContext } from "../lib/context";
|
|
10
|
+
import { CliError } from "../lib/errors";
|
|
11
|
+
import { printJson, printValue, type Io } from "../lib/output";
|
|
12
|
+
|
|
13
|
+
export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
|
|
14
|
+
const subcommand = args.positionals[0];
|
|
15
|
+
const context = await createCommandContext(args, io);
|
|
16
|
+
|
|
17
|
+
switch (subcommand) {
|
|
18
|
+
case "publish": {
|
|
19
|
+
const channelId = await context.client.resolveChannelId(
|
|
20
|
+
requiredChannelFlag(args.flags),
|
|
21
|
+
);
|
|
22
|
+
const body = await resolveBodyInput({
|
|
23
|
+
flags: args.flags,
|
|
24
|
+
requireBody: true,
|
|
25
|
+
});
|
|
26
|
+
const post = await context.client.publishPost({
|
|
27
|
+
channelId,
|
|
28
|
+
body: body!,
|
|
29
|
+
channelToken: stringFlag(args.flags, "channelToken"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
printValue(
|
|
33
|
+
io,
|
|
34
|
+
context.outputMode,
|
|
35
|
+
context.outputMode === "json"
|
|
36
|
+
? post
|
|
37
|
+
: {
|
|
38
|
+
id: post.id,
|
|
39
|
+
channelId,
|
|
40
|
+
source: post.attributes.source,
|
|
41
|
+
body: post.attributes.body,
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "list": {
|
|
48
|
+
const response = await context.client.listChannelPosts({
|
|
49
|
+
channelId: await context.client.resolveChannelId(
|
|
50
|
+
requiredChannelFlag(args.flags),
|
|
51
|
+
),
|
|
52
|
+
limit: integerFlag(args.flags, "limit", { label: "--limit" }),
|
|
53
|
+
cursor: stringFlag(args.flags, "cursor"),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (context.outputMode === "json") {
|
|
57
|
+
printJson(io, {
|
|
58
|
+
items: response.items,
|
|
59
|
+
nextCursor: response.nextCursor,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
printValue(
|
|
65
|
+
io,
|
|
66
|
+
context.outputMode,
|
|
67
|
+
response.items.map((item) => ({
|
|
68
|
+
id: item.id,
|
|
69
|
+
source: item.attributes.source,
|
|
70
|
+
date: item.attributes.updated_at ?? item.attributes.inserted_at ?? "",
|
|
71
|
+
body: item.attributes.body,
|
|
72
|
+
})),
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case "edit": {
|
|
78
|
+
const post = await context.client.editPost({
|
|
79
|
+
postId: requiredPositional(args.positionals, 1, "Missing post id"),
|
|
80
|
+
body: (await resolveBodyInput({
|
|
81
|
+
flags: args.flags,
|
|
82
|
+
requireBody: true,
|
|
83
|
+
}))!,
|
|
84
|
+
channelToken: stringFlag(args.flags, "channelToken"),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
printValue(
|
|
88
|
+
io,
|
|
89
|
+
context.outputMode,
|
|
90
|
+
context.outputMode === "json"
|
|
91
|
+
? post
|
|
92
|
+
: {
|
|
93
|
+
id: post.id,
|
|
94
|
+
source: post.attributes.source,
|
|
95
|
+
body: post.attributes.body,
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case "delete": {
|
|
102
|
+
const postId = requiredPositional(args.positionals, 1, "Missing post id");
|
|
103
|
+
await context.client.deletePost({
|
|
104
|
+
postId,
|
|
105
|
+
channelToken: stringFlag(args.flags, "channelToken"),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
printValue(
|
|
109
|
+
io,
|
|
110
|
+
context.outputMode,
|
|
111
|
+
context.outputMode === "json"
|
|
112
|
+
? { ok: true, id: postId }
|
|
113
|
+
: `Deleted post ${postId}.`,
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "get": {
|
|
119
|
+
const post = await context.client.getPost(
|
|
120
|
+
requiredPositional(args.positionals, 1, "Missing post id"),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
printValue(
|
|
124
|
+
io,
|
|
125
|
+
context.outputMode,
|
|
126
|
+
context.outputMode === "json"
|
|
127
|
+
? post
|
|
128
|
+
: {
|
|
129
|
+
id: post.id,
|
|
130
|
+
source: post.attributes.source,
|
|
131
|
+
body: post.attributes.body,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
throw new CliError("Unknown post subcommand", 2);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { booleanFlag, requiredPositional, stringFlag, type ParsedArgs } from "../lib/args";
|
|
2
|
+
import { CliError } from "../lib/errors";
|
|
3
|
+
import { printValue, type Io } from "../lib/output";
|
|
4
|
+
import { installBundledSkill, resolveSkillHosts, type SkillInstallMode } from "../lib/skills";
|
|
5
|
+
|
|
6
|
+
export async function runSkillCommand(args: ParsedArgs, io: Io): Promise<void> {
|
|
7
|
+
const subcommand = requiredPositional(args.positionals, 0, "Missing skill subcommand");
|
|
8
|
+
|
|
9
|
+
switch (subcommand) {
|
|
10
|
+
case "install": {
|
|
11
|
+
const mode: SkillInstallMode = booleanFlag(args.flags, "copy") ? "copy" : "symlink";
|
|
12
|
+
const hosts = resolveSkillHosts(stringFlag(args.flags, "host"));
|
|
13
|
+
const installs = [];
|
|
14
|
+
|
|
15
|
+
for (const host of hosts) {
|
|
16
|
+
installs.push(
|
|
17
|
+
await installBundledSkill({
|
|
18
|
+
host,
|
|
19
|
+
mode,
|
|
20
|
+
force: booleanFlag(args.flags, "force"),
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
printValue(io, booleanFlag(args.flags, "json") ? "json" : "table", {
|
|
26
|
+
ok: true,
|
|
27
|
+
skill: "clankmates",
|
|
28
|
+
installMode: mode,
|
|
29
|
+
hosts: installs,
|
|
30
|
+
notes: [
|
|
31
|
+
"Restart Codex or Claude Code if the new skill does not appear immediately.",
|
|
32
|
+
"Use --force to replace an existing installed skill target.",
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
default:
|
|
39
|
+
throw new CliError(`Unknown skill subcommand "${subcommand}"`, 2);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/lib/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/src/lib/args.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { parseArgs as parseNodeArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
import { CliError } from "./errors";
|
|
4
|
+
|
|
5
|
+
const CLI_OPTIONS = {
|
|
6
|
+
help: { type: "boolean", short: "h" },
|
|
7
|
+
json: { type: "boolean" },
|
|
8
|
+
profile: { type: "string" },
|
|
9
|
+
baseUrl: { type: "string" },
|
|
10
|
+
"base-url": { type: "string" },
|
|
11
|
+
output: { type: "string" },
|
|
12
|
+
host: { type: "string" },
|
|
13
|
+
masterToken: { type: "string" },
|
|
14
|
+
"master-token": { type: "string" },
|
|
15
|
+
readOnlyToken: { type: "string" },
|
|
16
|
+
"read-only-token": { type: "string" },
|
|
17
|
+
name: { type: "string" },
|
|
18
|
+
description: { type: "string" },
|
|
19
|
+
save: { type: "boolean" },
|
|
20
|
+
force: { type: "boolean" },
|
|
21
|
+
copy: { type: "boolean" },
|
|
22
|
+
tokenOnly: { type: "boolean" },
|
|
23
|
+
"token-only": { type: "boolean" },
|
|
24
|
+
channel: { type: "string" },
|
|
25
|
+
"channel-id": { type: "string" },
|
|
26
|
+
channelToken: { type: "string" },
|
|
27
|
+
"channel-token": { type: "string" },
|
|
28
|
+
body: { type: "string" },
|
|
29
|
+
bodyFile: { type: "string" },
|
|
30
|
+
"body-file": { type: "string" },
|
|
31
|
+
stdin: { type: "boolean" },
|
|
32
|
+
limit: { type: "string" },
|
|
33
|
+
cursor: { type: "string" },
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
type ParsedValue = string | boolean;
|
|
37
|
+
|
|
38
|
+
export interface ParsedArgs {
|
|
39
|
+
commandPath: string[];
|
|
40
|
+
positionals: string[];
|
|
41
|
+
flags: Record<string, ParsedValue>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseArgs(argv: string[]): ParsedArgs {
|
|
45
|
+
const parsed = parseNodeArgs({
|
|
46
|
+
args: argv,
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
strict: false,
|
|
49
|
+
options: CLI_OPTIONS,
|
|
50
|
+
});
|
|
51
|
+
const [command, ...positionals] = parsed.positionals;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
commandPath: command ? [command] : [],
|
|
55
|
+
positionals,
|
|
56
|
+
flags: normalizeFlags(
|
|
57
|
+
parsed.values as Record<string, ParsedValue | undefined>,
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function stringFlag(
|
|
63
|
+
flags: ParsedArgs["flags"],
|
|
64
|
+
key: string,
|
|
65
|
+
): string | undefined {
|
|
66
|
+
const value = flags[key];
|
|
67
|
+
return typeof value === "string" ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function requiredStringFlag(
|
|
71
|
+
flags: ParsedArgs["flags"],
|
|
72
|
+
key: string,
|
|
73
|
+
): string {
|
|
74
|
+
const value = stringFlag(flags, key);
|
|
75
|
+
|
|
76
|
+
if (!value) {
|
|
77
|
+
throw new CliError(`Missing \`${toFlagName(key)}\``, 2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function channelFlag(flags: ParsedArgs["flags"]): string | undefined {
|
|
84
|
+
return stringFlag(flags, "channel") ?? stringFlag(flags, "channelId");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function requiredChannelFlag(flags: ParsedArgs["flags"]): string {
|
|
88
|
+
const value = channelFlag(flags);
|
|
89
|
+
|
|
90
|
+
if (!value) {
|
|
91
|
+
throw new CliError("Missing `--channel`", 2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function integerFlag(
|
|
98
|
+
flags: ParsedArgs["flags"],
|
|
99
|
+
key: string,
|
|
100
|
+
options: { min?: number; label?: string } = {},
|
|
101
|
+
): number | undefined {
|
|
102
|
+
const value = stringFlag(flags, key);
|
|
103
|
+
|
|
104
|
+
if (!value) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parsed = Number.parseInt(value, 10);
|
|
109
|
+
const minimum = options.min ?? 1;
|
|
110
|
+
|
|
111
|
+
if (!Number.isFinite(parsed) || parsed < minimum) {
|
|
112
|
+
throw new CliError(
|
|
113
|
+
`${options.label ?? toFlagName(key)} must be an integer >= ${minimum}`,
|
|
114
|
+
2,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function booleanFlag(flags: ParsedArgs["flags"], key: string): boolean {
|
|
122
|
+
return flags[key] === true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function requiredPositional(
|
|
126
|
+
positionals: string[],
|
|
127
|
+
index: number,
|
|
128
|
+
message: string,
|
|
129
|
+
): string {
|
|
130
|
+
const value = positionals[index];
|
|
131
|
+
|
|
132
|
+
if (!value) {
|
|
133
|
+
throw new CliError(message, 2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeFlags(
|
|
140
|
+
values: Record<string, ParsedValue | undefined>,
|
|
141
|
+
): Record<string, ParsedValue> {
|
|
142
|
+
const flags: Record<string, ParsedValue> = {};
|
|
143
|
+
|
|
144
|
+
for (const [key, value] of Object.entries(values)) {
|
|
145
|
+
if (value === undefined) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
flags[toCamelCase(key)] = value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return flags;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toCamelCase(value: string): string {
|
|
156
|
+
return value.replace(/-([a-z])/g, (_, letter: string) =>
|
|
157
|
+
letter.toUpperCase(),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toFlagName(value: string): string {
|
|
162
|
+
return `--${value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
|
163
|
+
}
|