@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.
@@ -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
+ }
@@ -0,0 +1 @@
1
+
@@ -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
+ }