@mkterswingman/5mghost-twinkler 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 +13 -0
- package/dist/auth.d.ts +30 -0
- package/dist/auth.js +137 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +215 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +23 -0
- package/dist/skillInstall.d.ts +11 -0
- package/dist/skillInstall.js +58 -0
- package/dist/twinklerClient.d.ts +11 -0
- package/dist/twinklerClient.js +71 -0
- package/install/installer.manifest.json +11 -0
- package/package.json +44 -0
- package/skills/use-5mghost-twinkler/SKILL.md +134 -0
- package/skills.manifest.json +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 5mghost Twinkler Helper
|
|
2
|
+
|
|
3
|
+
Lightweight AI runtime for the hosted Twinkler API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @mkterswingman/5mghost-twinkler
|
|
7
|
+
twinkler setup
|
|
8
|
+
twinkler call GET /api/v1/channel/ibai/summary --query days=30
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Auth uses the shared mkterswingman PAT. Do not commit `.env`, `auth.json`,
|
|
12
|
+
PATs, bearer tokens, or any generated secret file to a remote repository. Enter
|
|
13
|
+
PATs through `twinkler auth login --pat-stdin`; do not paste PATs into AI chat.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const MKTERSWINGMAN_PAT_ENV = "MKTERSWINGMAN_PAT";
|
|
2
|
+
export declare const PAT_LOGIN_URL = "https://mkterswingman.com/pat/login";
|
|
3
|
+
export type SharedAuthData = {
|
|
4
|
+
type: "pat";
|
|
5
|
+
pat: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: "jwt";
|
|
8
|
+
access_token?: string;
|
|
9
|
+
refresh_token?: string;
|
|
10
|
+
expires_at?: number;
|
|
11
|
+
client_id?: string;
|
|
12
|
+
};
|
|
13
|
+
export interface AuthStatus {
|
|
14
|
+
authenticated: boolean;
|
|
15
|
+
type: "pat" | "jwt" | null;
|
|
16
|
+
source: "env" | "shared_auth" | null;
|
|
17
|
+
authJsonPath: string;
|
|
18
|
+
}
|
|
19
|
+
export interface TokenProviderOptions {
|
|
20
|
+
authJsonPath: string;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
authUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function getAuthStatus(options: TokenProviderOptions): AuthStatus;
|
|
26
|
+
export declare function getValidToken(options: TokenProviderOptions): Promise<string | null>;
|
|
27
|
+
export declare function savePat(authJsonPath: string, pat: string): void;
|
|
28
|
+
export declare function logout(authJsonPath: string): void;
|
|
29
|
+
export declare function readSharedAuth(authJsonPath: string): SharedAuthData | null;
|
|
30
|
+
export declare function writeSharedAuth(authJsonPath: string, data: SharedAuthData): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
export const MKTERSWINGMAN_PAT_ENV = "MKTERSWINGMAN_PAT";
|
|
4
|
+
export const PAT_LOGIN_URL = "https://mkterswingman.com/pat/login";
|
|
5
|
+
const DEFAULT_AUTH_URL = "https://mkterswingman.com";
|
|
6
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
7
|
+
export function getAuthStatus(options) {
|
|
8
|
+
if (options.env?.[MKTERSWINGMAN_PAT_ENV]) {
|
|
9
|
+
return {
|
|
10
|
+
authenticated: true,
|
|
11
|
+
type: "pat",
|
|
12
|
+
source: "env",
|
|
13
|
+
authJsonPath: options.authJsonPath
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const auth = readSharedAuth(options.authJsonPath);
|
|
17
|
+
if (!auth) {
|
|
18
|
+
return {
|
|
19
|
+
authenticated: false,
|
|
20
|
+
type: null,
|
|
21
|
+
source: null,
|
|
22
|
+
authJsonPath: options.authJsonPath
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (auth.type === "pat" && auth.pat) {
|
|
26
|
+
return {
|
|
27
|
+
authenticated: true,
|
|
28
|
+
type: "pat",
|
|
29
|
+
source: "shared_auth",
|
|
30
|
+
authJsonPath: options.authJsonPath
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (auth.type === "jwt" && (auth.access_token || auth.refresh_token)) {
|
|
34
|
+
return {
|
|
35
|
+
authenticated: true,
|
|
36
|
+
type: "jwt",
|
|
37
|
+
source: "shared_auth",
|
|
38
|
+
authJsonPath: options.authJsonPath
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
authenticated: false,
|
|
43
|
+
type: null,
|
|
44
|
+
source: null,
|
|
45
|
+
authJsonPath: options.authJsonPath
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function getValidToken(options) {
|
|
49
|
+
const envPat = options.env?.[MKTERSWINGMAN_PAT_ENV]?.trim();
|
|
50
|
+
if (envPat)
|
|
51
|
+
return envPat;
|
|
52
|
+
const auth = readSharedAuth(options.authJsonPath);
|
|
53
|
+
if (!auth)
|
|
54
|
+
return null;
|
|
55
|
+
if (auth.type === "pat")
|
|
56
|
+
return auth.pat || null;
|
|
57
|
+
if (auth.access_token && auth.expires_at && Date.now() + REFRESH_SKEW_MS < auth.expires_at) {
|
|
58
|
+
return auth.access_token;
|
|
59
|
+
}
|
|
60
|
+
if (!auth.refresh_token)
|
|
61
|
+
return null;
|
|
62
|
+
const refreshed = await refreshJwt(auth.refresh_token, {
|
|
63
|
+
clientId: auth.client_id,
|
|
64
|
+
authUrl: options.authUrl,
|
|
65
|
+
fetchImpl: options.fetchImpl
|
|
66
|
+
});
|
|
67
|
+
if (!refreshed)
|
|
68
|
+
return null;
|
|
69
|
+
writeSharedAuth(options.authJsonPath, {
|
|
70
|
+
type: "jwt",
|
|
71
|
+
access_token: refreshed.access_token,
|
|
72
|
+
refresh_token: refreshed.refresh_token,
|
|
73
|
+
expires_at: Date.now() + refreshed.expires_in * 1000,
|
|
74
|
+
client_id: auth.client_id
|
|
75
|
+
});
|
|
76
|
+
return refreshed.access_token;
|
|
77
|
+
}
|
|
78
|
+
export function savePat(authJsonPath, pat) {
|
|
79
|
+
const normalized = pat.trim();
|
|
80
|
+
if (!normalized)
|
|
81
|
+
throw new Error("mkterswingman PAT is empty");
|
|
82
|
+
writeSharedAuth(authJsonPath, { type: "pat", pat: normalized });
|
|
83
|
+
}
|
|
84
|
+
export function logout(authJsonPath) {
|
|
85
|
+
rmSync(authJsonPath, { force: true });
|
|
86
|
+
}
|
|
87
|
+
export function readSharedAuth(authJsonPath) {
|
|
88
|
+
if (!existsSync(authJsonPath))
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(readFileSync(authJsonPath, "utf8"));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function writeSharedAuth(authJsonPath, data) {
|
|
98
|
+
mkdirSync(dirname(authJsonPath), { recursive: true });
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(dirname(authJsonPath), 0o700);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Windows may ignore POSIX modes.
|
|
104
|
+
}
|
|
105
|
+
const tmpPath = `${authJsonPath}.tmp`;
|
|
106
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
107
|
+
renameSync(tmpPath, authJsonPath);
|
|
108
|
+
try {
|
|
109
|
+
chmodSync(authJsonPath, 0o600);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Windows may ignore POSIX modes.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function refreshJwt(refreshToken, options) {
|
|
116
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
117
|
+
const response = await fetchImpl(`${options.authUrl ?? DEFAULT_AUTH_URL}/oauth/token`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
grant_type: "refresh_token",
|
|
122
|
+
refresh_token: refreshToken,
|
|
123
|
+
...(options.clientId ? { client_id: options.clientId } : {})
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
if (!response.ok)
|
|
127
|
+
return null;
|
|
128
|
+
const body = (await response.json());
|
|
129
|
+
if (!body.access_token || !body.refresh_token || typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
access_token: body.access_token,
|
|
134
|
+
refresh_token: body.refresh_token,
|
|
135
|
+
expires_in: body.expires_in
|
|
136
|
+
};
|
|
137
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface CliContext {
|
|
3
|
+
argv?: string[];
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
homeDir?: string;
|
|
6
|
+
stdout?: (message: string) => void;
|
|
7
|
+
stderr?: (message: string) => void;
|
|
8
|
+
stdin?: AsyncIterable<Buffer | string>;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
export declare function runCli(context?: CliContext): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getAuthStatus, logout, PAT_LOGIN_URL, savePat } from "./auth.js";
|
|
6
|
+
import { resolveTwinklerPaths } from "./paths.js";
|
|
7
|
+
import { installBundledSkills } from "./skillInstall.js";
|
|
8
|
+
import { callTwinkler } from "./twinklerClient.js";
|
|
9
|
+
const HELP = [
|
|
10
|
+
"twinkler",
|
|
11
|
+
"",
|
|
12
|
+
"Usage:",
|
|
13
|
+
" twinkler setup",
|
|
14
|
+
" twinkler install-skills",
|
|
15
|
+
" twinkler doctor",
|
|
16
|
+
" twinkler auth status",
|
|
17
|
+
" twinkler auth login --pat <TOKEN>",
|
|
18
|
+
" twinkler auth login --pat-stdin",
|
|
19
|
+
" twinkler call <GET|POST|DELETE> /api/v1/... [--query k=v] [--json '{...}']",
|
|
20
|
+
" twinkler version",
|
|
21
|
+
"",
|
|
22
|
+
"mkterswingman PAT:",
|
|
23
|
+
` ${PAT_LOGIN_URL}`
|
|
24
|
+
].join("\n");
|
|
25
|
+
export async function runCli(context = {}) {
|
|
26
|
+
const argv = context.argv ?? process.argv.slice(2);
|
|
27
|
+
const env = context.env ?? process.env;
|
|
28
|
+
const out = context.stdout ?? ((message) => process.stdout.write(`${message}\n`));
|
|
29
|
+
const err = context.stderr ?? ((message) => process.stderr.write(`${message}\n`));
|
|
30
|
+
const paths = resolveTwinklerPaths({ homeDir: context.homeDir });
|
|
31
|
+
const [command, ...args] = argv;
|
|
32
|
+
try {
|
|
33
|
+
switch (command) {
|
|
34
|
+
case undefined:
|
|
35
|
+
case "help":
|
|
36
|
+
case "--help":
|
|
37
|
+
case "-h":
|
|
38
|
+
out(HELP);
|
|
39
|
+
return 0;
|
|
40
|
+
case "version":
|
|
41
|
+
case "--version":
|
|
42
|
+
case "-v":
|
|
43
|
+
out(readPackageVersion());
|
|
44
|
+
return 0;
|
|
45
|
+
case "auth":
|
|
46
|
+
return await runAuthCommand(args, { paths, env, out, stdin: context.stdin ?? process.stdin });
|
|
47
|
+
case "call": {
|
|
48
|
+
const parsed = parseCallArgs(args);
|
|
49
|
+
const result = await callTwinkler({
|
|
50
|
+
...parsed,
|
|
51
|
+
authJsonPath: paths.authJsonPath,
|
|
52
|
+
env,
|
|
53
|
+
fetchImpl: context.fetchImpl
|
|
54
|
+
});
|
|
55
|
+
out(JSON.stringify(result, null, 2));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
case "install-skills": {
|
|
59
|
+
const results = installBundledSkills({ homeDir: paths.homeDir });
|
|
60
|
+
out(renderSkillInstallResults(results));
|
|
61
|
+
return results.some((result) => result.status === "error") ? 1 : 0;
|
|
62
|
+
}
|
|
63
|
+
case "setup": {
|
|
64
|
+
const results = installBundledSkills({ homeDir: paths.homeDir });
|
|
65
|
+
const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
|
|
66
|
+
out([
|
|
67
|
+
"Twinkler setup",
|
|
68
|
+
renderSkillInstallResults(results),
|
|
69
|
+
auth.authenticated
|
|
70
|
+
? `Auth: ready (${auth.type} via ${auth.source})`
|
|
71
|
+
: `Auth: missing. Ask the user to open ${PAT_LOGIN_URL}, then enter the mkterswingman PAT into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
|
|
72
|
+
].join("\n"));
|
|
73
|
+
return results.some((result) => result.status === "error") ? 1 : 0;
|
|
74
|
+
}
|
|
75
|
+
case "doctor": {
|
|
76
|
+
const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
|
|
77
|
+
out([
|
|
78
|
+
"Twinkler doctor",
|
|
79
|
+
`Node: ${process.version}`,
|
|
80
|
+
`Auth: ${auth.authenticated ? `ready (${auth.type} via ${auth.source})` : "missing"}`,
|
|
81
|
+
`Auth file: ${auth.authJsonPath}`,
|
|
82
|
+
`PAT page: ${PAT_LOGIN_URL}`,
|
|
83
|
+
"Secret policy: do not commit .env files, auth.json, PATs, bearer tokens, or generated secret files to remote repositories."
|
|
84
|
+
].join("\n"));
|
|
85
|
+
return auth.authenticated ? 0 : 1;
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
err(`Unknown command: ${command}`);
|
|
89
|
+
out(HELP);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
err(error instanceof Error ? error.message : String(error));
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function runAuthCommand(args, context) {
|
|
99
|
+
const [subcommand] = args;
|
|
100
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
101
|
+
context.out([
|
|
102
|
+
"Usage:",
|
|
103
|
+
" twinkler auth status",
|
|
104
|
+
" twinkler auth login --pat <TOKEN>",
|
|
105
|
+
" twinkler auth login --pat-stdin",
|
|
106
|
+
" twinkler auth logout",
|
|
107
|
+
"",
|
|
108
|
+
`PAT page: ${PAT_LOGIN_URL}`
|
|
109
|
+
].join("\n"));
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
if (subcommand === "status") {
|
|
113
|
+
const status = getAuthStatus({ authJsonPath: context.paths.authJsonPath, env: context.env });
|
|
114
|
+
if (!status.authenticated) {
|
|
115
|
+
context.out([
|
|
116
|
+
"Not logged in.",
|
|
117
|
+
`Open ${PAT_LOGIN_URL}, copy the mkterswingman PAT, and enter it into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
|
|
118
|
+
].join("\n"));
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
context.out([
|
|
122
|
+
"Logged in.",
|
|
123
|
+
`Type: ${status.type}`,
|
|
124
|
+
`Source: ${status.source}`,
|
|
125
|
+
`Auth file: ${status.authJsonPath}`
|
|
126
|
+
].join("\n"));
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (subcommand === "logout") {
|
|
130
|
+
logout(context.paths.authJsonPath);
|
|
131
|
+
context.out("Logged out.");
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (subcommand === "login") {
|
|
135
|
+
const patFlagIndex = args.indexOf("--pat");
|
|
136
|
+
const patStdin = args.includes("--pat-stdin");
|
|
137
|
+
const token = patStdin ? await readAllStdin(context.stdin) : args[patFlagIndex + 1];
|
|
138
|
+
if (patFlagIndex === -1 && !patStdin) {
|
|
139
|
+
context.out([
|
|
140
|
+
"mkterswingman PAT is required.",
|
|
141
|
+
`Open ${PAT_LOGIN_URL}, copy the PAT, then enter it into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
|
|
142
|
+
].join("\n"));
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
savePat(context.paths.authJsonPath, token ?? "");
|
|
146
|
+
context.out("mkterswingman PAT saved.");
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
context.out(`Unknown auth subcommand: ${subcommand}`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
function parseCallArgs(args) {
|
|
153
|
+
const [method, path, ...rest] = args;
|
|
154
|
+
if (!method || !path) {
|
|
155
|
+
throw new Error("Usage: twinkler call <GET|POST|DELETE> /api/v1/... [--query k=v] [--json '{...}']");
|
|
156
|
+
}
|
|
157
|
+
const query = {};
|
|
158
|
+
let jsonBody;
|
|
159
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
160
|
+
const arg = rest[index];
|
|
161
|
+
if (arg === "--query") {
|
|
162
|
+
const pair = rest[++index];
|
|
163
|
+
if (!pair || !pair.includes("="))
|
|
164
|
+
throw new Error("--query expects k=v");
|
|
165
|
+
const [key, ...valueParts] = pair.split("=");
|
|
166
|
+
const value = valueParts.join("=");
|
|
167
|
+
query[key] ??= [];
|
|
168
|
+
query[key].push(value);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (arg === "--json") {
|
|
172
|
+
const raw = rest[++index];
|
|
173
|
+
if (!raw)
|
|
174
|
+
throw new Error("--json expects a JSON string or @file");
|
|
175
|
+
jsonBody = JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Unknown call option: ${arg}`);
|
|
179
|
+
}
|
|
180
|
+
return { method, path, query, jsonBody };
|
|
181
|
+
}
|
|
182
|
+
function renderSkillInstallResults(results) {
|
|
183
|
+
return [
|
|
184
|
+
"Skills:",
|
|
185
|
+
...results.map((result) => {
|
|
186
|
+
const suffix = result.reason ? ` (${result.reason})` : "";
|
|
187
|
+
return `- ${result.agent}:${result.skill}: ${result.status}${suffix}`;
|
|
188
|
+
})
|
|
189
|
+
].join("\n");
|
|
190
|
+
}
|
|
191
|
+
async function readAllStdin(stdin) {
|
|
192
|
+
const chunks = [];
|
|
193
|
+
for await (const chunk of stdin) {
|
|
194
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
195
|
+
}
|
|
196
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
197
|
+
}
|
|
198
|
+
function readPackageVersion() {
|
|
199
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
200
|
+
const candidates = [join(dir, "..", "package.json"), join(dir, "..", "..", "package.json")];
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(readFileSync(candidate, "utf8")).version;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Continue checking source and packaged layouts.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return "0.0.0";
|
|
210
|
+
}
|
|
211
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
212
|
+
runCli().then((code) => {
|
|
213
|
+
process.exitCode = code;
|
|
214
|
+
});
|
|
215
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface PathOptions {
|
|
2
|
+
homeDir?: string;
|
|
3
|
+
}
|
|
4
|
+
export interface TwinklerPaths {
|
|
5
|
+
homeDir: string;
|
|
6
|
+
mkterswingmanDir: string;
|
|
7
|
+
authJsonPath: string;
|
|
8
|
+
skillsManifestPath: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function resolveTwinklerPaths(options?: PathOptions): TwinklerPaths;
|
|
11
|
+
export declare function resolveBundledAssetPath(fileName: string): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
export function resolveTwinklerPaths(options = {}) {
|
|
6
|
+
const homeDir = options.homeDir ?? homedir();
|
|
7
|
+
return {
|
|
8
|
+
homeDir,
|
|
9
|
+
mkterswingmanDir: join(homeDir, ".mkterswingman"),
|
|
10
|
+
authJsonPath: join(homeDir, ".mkterswingman", "auth.json"),
|
|
11
|
+
skillsManifestPath: resolveBundledAssetPath("skills.manifest.json")
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function resolveBundledAssetPath(fileName) {
|
|
15
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const sourceLayout = join(moduleDir, "..", fileName);
|
|
17
|
+
if (existsSync(sourceLayout))
|
|
18
|
+
return sourceLayout;
|
|
19
|
+
const devLayout = join(moduleDir, "..", "..", fileName);
|
|
20
|
+
if (existsSync(devLayout))
|
|
21
|
+
return devLayout;
|
|
22
|
+
return sourceLayout;
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface InstallSkillsOptions {
|
|
2
|
+
homeDir: string;
|
|
3
|
+
}
|
|
4
|
+
export interface SkillInstallResult {
|
|
5
|
+
agent: string;
|
|
6
|
+
skill: string;
|
|
7
|
+
status: "installed" | "skipped" | "error";
|
|
8
|
+
targetDir: string;
|
|
9
|
+
reason?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function installBundledSkills(options: InstallSkillsOptions): SkillInstallResult[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { resolveBundledAssetPath } from "./paths.js";
|
|
4
|
+
const TARGETS = [
|
|
5
|
+
{ agent: "codex", skillsDir: ".codex/skills" },
|
|
6
|
+
{ agent: "agents", skillsDir: ".agents/skills" }
|
|
7
|
+
];
|
|
8
|
+
export function installBundledSkills(options) {
|
|
9
|
+
const manifestPath = resolveBundledAssetPath("skills.manifest.json");
|
|
10
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
11
|
+
const packageRoot = dirname(manifestPath);
|
|
12
|
+
const results = [];
|
|
13
|
+
for (const skill of manifest.skills) {
|
|
14
|
+
const sourceDir = join(packageRoot, skill.source);
|
|
15
|
+
for (const target of TARGETS) {
|
|
16
|
+
const rootDir = join(options.homeDir, target.skillsDir);
|
|
17
|
+
const targetDir = join(rootDir, skill.name);
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(sourceDir)) {
|
|
20
|
+
results.push({
|
|
21
|
+
agent: target.agent,
|
|
22
|
+
skill: skill.name,
|
|
23
|
+
status: "error",
|
|
24
|
+
targetDir,
|
|
25
|
+
reason: `missing bundled skill source ${sourceDir}`
|
|
26
|
+
});
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
mkdirSync(rootDir, { recursive: true });
|
|
30
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
31
|
+
cpSync(sourceDir, targetDir, { recursive: true });
|
|
32
|
+
writeFileSync(join(targetDir, ".install-receipt.json"), JSON.stringify({
|
|
33
|
+
product: manifest.product,
|
|
34
|
+
skill: skill.name,
|
|
35
|
+
agent: target.agent,
|
|
36
|
+
source: basename(sourceDir),
|
|
37
|
+
installed_at: new Date().toISOString()
|
|
38
|
+
}, null, 2));
|
|
39
|
+
results.push({
|
|
40
|
+
agent: target.agent,
|
|
41
|
+
skill: skill.name,
|
|
42
|
+
status: "installed",
|
|
43
|
+
targetDir
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
results.push({
|
|
48
|
+
agent: target.agent,
|
|
49
|
+
skill: skill.name,
|
|
50
|
+
status: "error",
|
|
51
|
+
targetDir,
|
|
52
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const DEFAULT_TWINKLER_BASE_URL = "https://mkterswingman.com/5mghost/twinkler";
|
|
2
|
+
export interface TwinklerCallOptions {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
query?: Record<string, string[]>;
|
|
6
|
+
jsonBody?: unknown;
|
|
7
|
+
authJsonPath: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
export declare function callTwinkler(options: TwinklerCallOptions): Promise<unknown>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getValidToken, MKTERSWINGMAN_PAT_ENV } from "./auth.js";
|
|
2
|
+
export const DEFAULT_TWINKLER_BASE_URL = "https://mkterswingman.com/5mghost/twinkler";
|
|
3
|
+
export async function callTwinkler(options) {
|
|
4
|
+
const method = normalizeMethod(options.method);
|
|
5
|
+
const path = normalizePath(options.path);
|
|
6
|
+
const token = await getValidToken({
|
|
7
|
+
authJsonPath: options.authJsonPath,
|
|
8
|
+
env: options.env ?? process.env,
|
|
9
|
+
fetchImpl: options.fetchImpl
|
|
10
|
+
});
|
|
11
|
+
if (!token) {
|
|
12
|
+
throw new Error(`Missing mkterswingman auth. Ask the user to open https://mkterswingman.com/pat/login, then save the PAT with 'twinkler auth login --pat-stdin' or set ${MKTERSWINGMAN_PAT_ENV}.`);
|
|
13
|
+
}
|
|
14
|
+
const url = buildUrl(path, options.query ?? {});
|
|
15
|
+
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
16
|
+
method,
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${token}`,
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
...(options.jsonBody === undefined ? {} : { "Content-Type": "application/json" })
|
|
21
|
+
},
|
|
22
|
+
body: options.jsonBody === undefined ? undefined : JSON.stringify(options.jsonBody)
|
|
23
|
+
});
|
|
24
|
+
const text = await response.text();
|
|
25
|
+
const body = parseResponseBody(text);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const code = typeof body === "object" && body !== null && "error" in body
|
|
28
|
+
? JSON.stringify(body.error)
|
|
29
|
+
: text.slice(0, 500);
|
|
30
|
+
throw new Error(`Twinkler API request failed: HTTP ${response.status} ${code}`);
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
}
|
|
34
|
+
function normalizeMethod(method) {
|
|
35
|
+
const normalized = method.trim().toUpperCase();
|
|
36
|
+
if (!["GET", "POST", "DELETE"].includes(normalized)) {
|
|
37
|
+
throw new Error("Unsupported method. Use GET, POST, or DELETE.");
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
function normalizePath(path) {
|
|
42
|
+
if (/^https?:\/\//i.test(path)) {
|
|
43
|
+
throw new Error("Pass only a Twinkler API path, not a full URL.");
|
|
44
|
+
}
|
|
45
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
46
|
+
if (!normalized.startsWith("/api/v1/")) {
|
|
47
|
+
throw new Error("Only Twinkler /api/v1/* paths are allowed.");
|
|
48
|
+
}
|
|
49
|
+
if (normalized.includes("..")) {
|
|
50
|
+
throw new Error("Path traversal is not allowed.");
|
|
51
|
+
}
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
function buildUrl(path, query) {
|
|
55
|
+
const url = new URL(`${DEFAULT_TWINKLER_BASE_URL}${path}`);
|
|
56
|
+
for (const [key, values] of Object.entries(query)) {
|
|
57
|
+
for (const value of values)
|
|
58
|
+
url.searchParams.append(key, value);
|
|
59
|
+
}
|
|
60
|
+
return url.toString();
|
|
61
|
+
}
|
|
62
|
+
function parseResponseBody(text) {
|
|
63
|
+
if (!text)
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"product": "5mghost-twinkler",
|
|
3
|
+
"runtime": "node",
|
|
4
|
+
"packageName": "@mkterswingman/5mghost-twinkler",
|
|
5
|
+
"binName": "twinkler",
|
|
6
|
+
"installCommand": "npm install -g @mkterswingman/5mghost-twinkler@latest",
|
|
7
|
+
"updateCommand": "npm install -g @mkterswingman/5mghost-twinkler@latest",
|
|
8
|
+
"uninstallCommand": "npm uninstall -g @mkterswingman/5mghost-twinkler",
|
|
9
|
+
"verifyCommand": "twinkler version",
|
|
10
|
+
"setupCommand": "twinkler setup"
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mkterswingman/5mghost-twinkler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight AI helper for the 5mghost Twinkler API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5.0"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"twinkler": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"skills",
|
|
24
|
+
"skills.manifest.json",
|
|
25
|
+
"install",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"pack:dry": "npm pack --dry-run"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"twinkler",
|
|
36
|
+
"twitch",
|
|
37
|
+
"ai",
|
|
38
|
+
"mkterswingman"
|
|
39
|
+
],
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.3.2",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-5mghost-twinkler
|
|
3
|
+
preamble-tier: 3
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
description: |
|
|
6
|
+
Use when the user wants Twitch streamer, game, ranking, stream-session, CCV,
|
|
7
|
+
or chat-watch data from the mkterswingman Twinkler API.
|
|
8
|
+
Keywords: Twinkler, Twitch, streamer, CCV, chat, watch job, SullyGnome, Arc Raiders.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Use 5mghost Twinkler
|
|
12
|
+
|
|
13
|
+
Use the local `twinkler` helper. It adds auth, refreshes short-lived
|
|
14
|
+
mkterswingman access tokens when available, and calls the hosted Twinkler REST
|
|
15
|
+
API. Prefer the helper over hand-written `curl`.
|
|
16
|
+
|
|
17
|
+
## First Check
|
|
18
|
+
|
|
19
|
+
Run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
twinkler doctor
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If the helper is missing, ask the user to install it:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @mkterswingman/5mghost-twinkler
|
|
29
|
+
twinkler setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Auth
|
|
33
|
+
|
|
34
|
+
Twinkler uses the shared mkterswingman PAT. Do not call it a Twinkler PAT.
|
|
35
|
+
|
|
36
|
+
If auth is missing:
|
|
37
|
+
|
|
38
|
+
1. Give the user this URL: <https://mkterswingman.com/pat/login>
|
|
39
|
+
2. Ask the user to copy the mkterswingman PAT.
|
|
40
|
+
3. Start the helper login command and have the user paste the PAT into the
|
|
41
|
+
helper input, not into the AI chat.
|
|
42
|
+
|
|
43
|
+
Preferred save path when stdin is available:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
twinkler auth login --pat-stdin
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then pass the PAT through stdin. Do not put the PAT in command-line arguments
|
|
50
|
+
unless no stdin-capable tool is available.
|
|
51
|
+
|
|
52
|
+
Fallback:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
twinkler auth login --pat <TOKEN>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Never echo the PAT back to the user. Never put the PAT in source files, docs,
|
|
59
|
+
logs, URLs, screenshots, PR text, or committed `.env` files. Do not commit
|
|
60
|
+
`~/.mkterswingman/auth.json` or any generated secret/config file to a remote
|
|
61
|
+
repository. Do not ask non-technical users to paste PATs into chat; use the
|
|
62
|
+
helper's stdin path or a secure secret-entry UI.
|
|
63
|
+
|
|
64
|
+
For AI-managed cloud/server environments, store the PAT as a secret named
|
|
65
|
+
`MKTERSWINGMAN_PAT`. Do not explain environment variables to non-technical
|
|
66
|
+
users unless they ask; configure the target environment yourself when you have
|
|
67
|
+
tool access.
|
|
68
|
+
|
|
69
|
+
## Preferred Calls
|
|
70
|
+
|
|
71
|
+
Use:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
twinkler call GET /api/v1/channel/ibai/summary --query days=30
|
|
75
|
+
twinkler call GET /api/v1/rankings/channels --query sort_by=mostfollowers --query days=30 --query page_size=5
|
|
76
|
+
twinkler call POST /api/v1/twitch/watch --json '{"logins":["theburntpeanut"],"collect":["ccv","chat"],"start":{"type":"time","at":"now"},"stop":{"type":"game","game_name":"ARC Raiders"}}'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`twinkler call` only allows Twinkler `/api/v1/*` paths. It returns JSON on
|
|
80
|
+
stdout.
|
|
81
|
+
|
|
82
|
+
## Calling The Helper From Scripts
|
|
83
|
+
|
|
84
|
+
When writing a local automation script for a non-technical user, prefer calling
|
|
85
|
+
the helper instead of re-implementing auth:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
import { execFile } from "node:child_process";
|
|
89
|
+
import { promisify } from "node:util";
|
|
90
|
+
|
|
91
|
+
const execFileAsync = promisify(execFile);
|
|
92
|
+
|
|
93
|
+
const { stdout } = await execFileAsync("twinkler", [
|
|
94
|
+
"call",
|
|
95
|
+
"GET",
|
|
96
|
+
"/api/v1/channel/ibai/summary",
|
|
97
|
+
"--query",
|
|
98
|
+
"days=30",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const payload = JSON.parse(stdout);
|
|
102
|
+
console.log(payload);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Do not pass PATs as script arguments. Let `twinkler auth login --pat-stdin`
|
|
106
|
+
store the mkterswingman PAT once, then scripts can call `twinkler call`.
|
|
107
|
+
|
|
108
|
+
## Direct REST Fallback
|
|
109
|
+
|
|
110
|
+
Use direct REST only when the helper cannot be installed or the runtime is
|
|
111
|
+
non-Node, such as a Python-only cloud job. Direct REST still requires auth.
|
|
112
|
+
|
|
113
|
+
Python example:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import os
|
|
117
|
+
import requests
|
|
118
|
+
|
|
119
|
+
token = os.environ["MKTERSWINGMAN_PAT"]
|
|
120
|
+
|
|
121
|
+
response = requests.get(
|
|
122
|
+
"https://mkterswingman.com/5mghost/twinkler/api/v1/channel/ibai/summary",
|
|
123
|
+
params={"days": "30"},
|
|
124
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
125
|
+
timeout=30,
|
|
126
|
+
)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
print(response.json())
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If `MKTERSWINGMAN_PAT` is missing in a non-helper environment, send the user to
|
|
132
|
+
<https://mkterswingman.com/pat/login>, then store the PAT in the target
|
|
133
|
+
runtime's secret store yourself when tool access provides a safe secret-entry
|
|
134
|
+
mechanism. Do not ask the user to paste the PAT into chat.
|