@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
package/src/lib/http.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { CliError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export interface RequestOptions {
|
|
4
|
+
method?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
rawBody?: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
accept?: string;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HttpResponse<T = unknown> {
|
|
14
|
+
status: number;
|
|
15
|
+
data: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function requestJson<T = unknown>(
|
|
19
|
+
baseUrl: string,
|
|
20
|
+
path: string,
|
|
21
|
+
options: RequestOptions = {}
|
|
22
|
+
): Promise<HttpResponse<T>> {
|
|
23
|
+
const url = new URL(path, ensureTrailingSlash(baseUrl));
|
|
24
|
+
const headers = new Headers(options.headers);
|
|
25
|
+
headers.set("accept", options.accept ?? "application/json");
|
|
26
|
+
|
|
27
|
+
let body: string | undefined;
|
|
28
|
+
|
|
29
|
+
if (options.body !== undefined && options.rawBody !== undefined) {
|
|
30
|
+
throw new CliError("Request body cannot set both structured and raw payloads.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.rawBody !== undefined) {
|
|
34
|
+
headers.set("content-type", options.contentType ?? "application/json");
|
|
35
|
+
body = options.rawBody;
|
|
36
|
+
} else if (options.body !== undefined) {
|
|
37
|
+
headers.set("content-type", options.contentType ?? "application/json");
|
|
38
|
+
body = JSON.stringify(options.body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.token) {
|
|
42
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
method: options.method ?? "GET",
|
|
47
|
+
headers,
|
|
48
|
+
body
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const rawPayload = await response.text();
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw toCliError(response.status, parsePayload(response, rawPayload, "lenient"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = parsePayload(response, rawPayload, "strict");
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
status: response.status,
|
|
61
|
+
data: payload as T
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function requestJsonApi<T = unknown>(
|
|
66
|
+
baseUrl: string,
|
|
67
|
+
path: string,
|
|
68
|
+
options: RequestOptions = {}
|
|
69
|
+
): Promise<HttpResponse<T>> {
|
|
70
|
+
return requestJson<T>(baseUrl, path, {
|
|
71
|
+
...options,
|
|
72
|
+
accept: options.accept ?? "application/vnd.api+json",
|
|
73
|
+
contentType: options.contentType ?? "application/vnd.api+json"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toCliError(status: number, payload: unknown): CliError {
|
|
78
|
+
const message = extractErrorMessage(payload) ?? `Request failed with status ${status}`;
|
|
79
|
+
return new CliError(message, 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parsePayload(
|
|
83
|
+
response: Response,
|
|
84
|
+
rawPayload: string,
|
|
85
|
+
mode: "lenient" | "strict"
|
|
86
|
+
): unknown {
|
|
87
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
88
|
+
const isJson = contentType.includes("json");
|
|
89
|
+
|
|
90
|
+
if (!isJson || rawPayload.length === 0) {
|
|
91
|
+
return rawPayload;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(rawPayload);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (mode === "lenient") {
|
|
98
|
+
return rawPayload;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new CliError(
|
|
102
|
+
`Received invalid JSON from server: ${(error as Error).message}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractErrorMessage(payload: unknown): string | undefined {
|
|
108
|
+
if (!payload) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof payload === "string") {
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof payload !== "object") {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record = payload as Record<string, unknown>;
|
|
121
|
+
const errors = record.errors;
|
|
122
|
+
|
|
123
|
+
if (Array.isArray(errors) && errors.length > 0 && typeof errors[0] === "object" && errors[0] !== null) {
|
|
124
|
+
const first = errors[0] as Record<string, unknown>;
|
|
125
|
+
const title = typeof first.title === "string" ? first.title : undefined;
|
|
126
|
+
const detail = typeof first.detail === "string" ? first.detail : undefined;
|
|
127
|
+
const code = typeof first.code === "string" ? first.code : undefined;
|
|
128
|
+
|
|
129
|
+
return [title, detail, code ? `code=${code}` : undefined].filter(Boolean).join(": ");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const message = typeof record.message === "string" ? record.message : undefined;
|
|
133
|
+
return message;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ensureTrailingSlash(baseUrl: string): string {
|
|
137
|
+
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CliError } from "./errors";
|
|
2
|
+
import type { JsonApiDocument, JsonApiResource } from "../types/api";
|
|
3
|
+
|
|
4
|
+
export interface JsonApiCollectionResult<TAttributes extends object> {
|
|
5
|
+
items: Array<JsonApiResource<TAttributes>>;
|
|
6
|
+
nextCursor?: string;
|
|
7
|
+
prevCursor?: string;
|
|
8
|
+
meta?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function expectResource<TAttributes extends object>(
|
|
12
|
+
payload: unknown
|
|
13
|
+
): JsonApiResource<TAttributes> {
|
|
14
|
+
const document = payload as JsonApiDocument<TAttributes>;
|
|
15
|
+
|
|
16
|
+
if (!document || Array.isArray(document.data) || typeof document.data !== "object") {
|
|
17
|
+
throw new CliError("Expected a JSON:API resource object in response");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return document.data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function expectCollection<TAttributes extends object>(
|
|
24
|
+
payload: unknown
|
|
25
|
+
): JsonApiCollectionResult<TAttributes> {
|
|
26
|
+
const document = payload as JsonApiDocument<TAttributes>;
|
|
27
|
+
|
|
28
|
+
if (!document || !Array.isArray(document.data)) {
|
|
29
|
+
throw new CliError("Expected a JSON:API collection in response");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
items: document.data,
|
|
34
|
+
nextCursor: extractCursor(document.links?.next),
|
|
35
|
+
prevCursor: extractCursor(document.links?.prev),
|
|
36
|
+
meta: document.meta
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractCursor(link: string | null | undefined): string | undefined {
|
|
41
|
+
if (!link) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(link, "http://placeholder.local");
|
|
47
|
+
return (
|
|
48
|
+
url.searchParams.get("page[after]") ??
|
|
49
|
+
url.searchParams.get("page[cursor]") ??
|
|
50
|
+
undefined
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { OutputMode } from "../types/api";
|
|
2
|
+
|
|
3
|
+
export interface Io {
|
|
4
|
+
stdout: (message: string) => void;
|
|
5
|
+
stderr: (message: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function defaultIo(): Io {
|
|
9
|
+
return {
|
|
10
|
+
stdout: (message) => process.stdout.write(`${message}\n`),
|
|
11
|
+
stderr: (message) => process.stderr.write(`${message}\n`)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function printJson(io: Io, value: unknown): void {
|
|
16
|
+
io.stdout(JSON.stringify(value, null, 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function printValue(io: Io, outputMode: OutputMode, value: unknown): void {
|
|
20
|
+
if (outputMode === "json") {
|
|
21
|
+
printJson(io, value);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof value === "string") {
|
|
26
|
+
io.stdout(value);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
if (value.length === 0) {
|
|
32
|
+
io.stdout("No results.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isRecordArray(value)) {
|
|
37
|
+
io.stdout(renderTable(value));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
printJson(io, value);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof value === "object" && value !== null) {
|
|
46
|
+
io.stdout(renderRecord(value as Record<string, unknown>));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
io.stdout(formatCell(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderTable(rows: Record<string, unknown>[]): string {
|
|
54
|
+
const columns = collectColumns(rows);
|
|
55
|
+
|
|
56
|
+
if (columns.length === 0) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const renderedRows = rows.map((row) =>
|
|
61
|
+
columns.map((column) => formatTableCell(column, row[column])),
|
|
62
|
+
);
|
|
63
|
+
const widths = columns.map((column, index) =>
|
|
64
|
+
Math.max(
|
|
65
|
+
column.length,
|
|
66
|
+
...renderedRows.map((row) => row[index]!.length),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
renderTableLine(columns, widths),
|
|
72
|
+
renderTableLine(widths.map((width) => "-".repeat(width)), widths),
|
|
73
|
+
...renderedRows.map((row) => renderTableLine(row, widths)),
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderRecord(value: Record<string, unknown>): string {
|
|
78
|
+
return Object.entries(value)
|
|
79
|
+
.map(([key, entry]) => `${key}: ${formatCell(entry)}`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRecordArray(value: unknown[]): value is Record<string, unknown>[] {
|
|
84
|
+
return value.every(isRecord);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
88
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatCell(value: unknown): string {
|
|
92
|
+
if (value === null || value === undefined) {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof value === "string") {
|
|
97
|
+
return value.replace(/\s+/g, " ").trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof value === "object") {
|
|
101
|
+
return JSON.stringify(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return String(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collectColumns(rows: Record<string, unknown>[]): string[] {
|
|
108
|
+
const columns: string[] = [];
|
|
109
|
+
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
for (const key of Object.keys(row)) {
|
|
112
|
+
if (!columns.includes(key)) {
|
|
113
|
+
columns.push(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return columns;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatTableCell(column: string, value: unknown): string {
|
|
122
|
+
if (value === null || value === undefined) {
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof value === "string") {
|
|
127
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
128
|
+
|
|
129
|
+
if (isDateColumn(column)) {
|
|
130
|
+
return formatDateValue(normalized);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (column === "body") {
|
|
134
|
+
return truncate(normalized, 30);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return normalized;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof value === "object") {
|
|
141
|
+
return JSON.stringify(value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return String(value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderTableLine(cells: string[], widths: number[]): string {
|
|
148
|
+
return cells
|
|
149
|
+
.map((cell, index) => cell.padEnd(widths[index]!))
|
|
150
|
+
.join(" ")
|
|
151
|
+
.trimEnd();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isDateColumn(column: string): boolean {
|
|
155
|
+
return column === "date" || column.endsWith("_at") || /At$/.test(column);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatDateValue(value: string): string {
|
|
159
|
+
const timestamp = Date.parse(value);
|
|
160
|
+
|
|
161
|
+
if (Number.isNaN(timestamp)) {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return formatLocalTimestamp(new Date(timestamp));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function truncate(value: string, maxLength: number): string {
|
|
169
|
+
if (value.length <= maxLength) {
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatLocalTimestamp(value: Date): string {
|
|
177
|
+
const year = value.getFullYear();
|
|
178
|
+
const month = padDatePart(value.getMonth() + 1);
|
|
179
|
+
const day = padDatePart(value.getDate());
|
|
180
|
+
const hours = padDatePart(value.getHours());
|
|
181
|
+
const minutes = padDatePart(value.getMinutes());
|
|
182
|
+
const seconds = padDatePart(value.getSeconds());
|
|
183
|
+
|
|
184
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${formatTimezoneOffset(value)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatTimezoneOffset(value: Date): string {
|
|
188
|
+
const offsetMinutes = -value.getTimezoneOffset();
|
|
189
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
190
|
+
const absoluteMinutes = Math.abs(offsetMinutes);
|
|
191
|
+
const hours = padDatePart(Math.floor(absoluteMinutes / 60));
|
|
192
|
+
const minutes = padDatePart(absoluteMinutes % 60);
|
|
193
|
+
|
|
194
|
+
return `${sign}${hours}:${minutes}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function padDatePart(value: number): string {
|
|
198
|
+
return String(value).padStart(2, "0");
|
|
199
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function getConfigPath(): string {
|
|
5
|
+
const explicit = process.env.CLANKMATES_CONFIG_PATH;
|
|
6
|
+
|
|
7
|
+
if (explicit) {
|
|
8
|
+
return explicit;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
12
|
+
|
|
13
|
+
if (xdg) {
|
|
14
|
+
return path.join(xdg, "clankmates", "config.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return path.join(os.homedir(), ".config", "clankmates", "config.json");
|
|
18
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { CliError } from "./errors";
|
|
7
|
+
|
|
8
|
+
export type SkillHost = "codex" | "claude" | "both";
|
|
9
|
+
export type SkillInstallMode = "symlink" | "copy";
|
|
10
|
+
|
|
11
|
+
export interface InstalledSkillTarget {
|
|
12
|
+
host: Exclude<SkillHost, "both">;
|
|
13
|
+
mode: SkillInstallMode;
|
|
14
|
+
sourcePath: string;
|
|
15
|
+
targetPath: string;
|
|
16
|
+
status: "installed" | "updated" | "already_installed";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SKILL_NAME = "clankmates";
|
|
20
|
+
|
|
21
|
+
export function bundledSkillPath(): string {
|
|
22
|
+
return fileURLToPath(
|
|
23
|
+
new URL("../../skills/codex/clankmates", import.meta.url),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveSkillHosts(host: string | undefined): Exclude<SkillHost, "both">[] {
|
|
28
|
+
switch (host ?? "both") {
|
|
29
|
+
case "codex":
|
|
30
|
+
return ["codex"];
|
|
31
|
+
case "claude":
|
|
32
|
+
case "claude-code":
|
|
33
|
+
return ["claude"];
|
|
34
|
+
case "both":
|
|
35
|
+
return ["codex", "claude"];
|
|
36
|
+
default:
|
|
37
|
+
throw new CliError(
|
|
38
|
+
"Unsupported `--host`. Use `codex`, `claude`, or `both`.",
|
|
39
|
+
2,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveSkillInstallTarget(host: Exclude<SkillHost, "both">): string {
|
|
45
|
+
if (host === "codex") {
|
|
46
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
47
|
+
return path.join(codexHome, "skills", SKILL_NAME);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const claudeHome = process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
|
|
51
|
+
return path.join(claudeHome, "skills", SKILL_NAME);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function installBundledSkill(input: {
|
|
55
|
+
host: Exclude<SkillHost, "both">;
|
|
56
|
+
mode: SkillInstallMode;
|
|
57
|
+
force: boolean;
|
|
58
|
+
}): Promise<InstalledSkillTarget> {
|
|
59
|
+
const sourcePath = bundledSkillPath();
|
|
60
|
+
const targetPath = resolveSkillInstallTarget(input.host);
|
|
61
|
+
const existing = await readInstallState(targetPath);
|
|
62
|
+
|
|
63
|
+
if (existing.exists) {
|
|
64
|
+
if (input.mode === "symlink" && existing.kind === "symlink" && existing.linkTarget === sourcePath) {
|
|
65
|
+
return {
|
|
66
|
+
host: input.host,
|
|
67
|
+
mode: input.mode,
|
|
68
|
+
sourcePath,
|
|
69
|
+
targetPath,
|
|
70
|
+
status: "already_installed",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!input.force) {
|
|
75
|
+
throw new CliError(
|
|
76
|
+
`Skill target already exists at ${targetPath}. Re-run with \`--force\` to replace it.`,
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
85
|
+
|
|
86
|
+
if (input.mode === "copy") {
|
|
87
|
+
await cp(sourcePath, targetPath, { recursive: true });
|
|
88
|
+
} else {
|
|
89
|
+
await symlink(sourcePath, targetPath, "dir");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
host: input.host,
|
|
94
|
+
mode: input.mode,
|
|
95
|
+
sourcePath,
|
|
96
|
+
targetPath,
|
|
97
|
+
status: existing.exists ? "updated" : "installed",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readInstallState(targetPath: string): Promise<{
|
|
102
|
+
exists: boolean;
|
|
103
|
+
kind?: "directory" | "symlink" | "file";
|
|
104
|
+
linkTarget?: string;
|
|
105
|
+
}> {
|
|
106
|
+
try {
|
|
107
|
+
const stats = await lstat(targetPath);
|
|
108
|
+
|
|
109
|
+
if (stats.isSymbolicLink()) {
|
|
110
|
+
return {
|
|
111
|
+
exists: true,
|
|
112
|
+
kind: "symlink",
|
|
113
|
+
linkTarget: await readlink(targetPath),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (stats.isDirectory()) {
|
|
118
|
+
return {
|
|
119
|
+
exists: true,
|
|
120
|
+
kind: "directory",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
exists: true,
|
|
126
|
+
kind: "file",
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
130
|
+
return { exists: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw new CliError(
|
|
134
|
+
`Failed to inspect skill target ${targetPath}: ${(error as Error).message}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|