@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 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
+ }
@@ -0,0 +1,2 @@
1
+ export { callTwinkler, DEFAULT_TWINKLER_BASE_URL, type TwinklerCallOptions } from "./twinklerClient.js";
2
+ export { getAuthStatus, MKTERSWINGMAN_PAT_ENV, PAT_LOGIN_URL, savePat } from "./auth.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { callTwinkler, DEFAULT_TWINKLER_BASE_URL } from "./twinklerClient.js";
2
+ export { getAuthStatus, MKTERSWINGMAN_PAT_ENV, PAT_LOGIN_URL, savePat } from "./auth.js";
@@ -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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "product": "5mghost-twinkler",
3
+ "skills": [
4
+ {
5
+ "name": "use-5mghost-twinkler",
6
+ "source": "skills/use-5mghost-twinkler",
7
+ "description": "Use the Twinkler helper to call the mkterswingman Twinkler API from AI workflows."
8
+ }
9
+ ]
10
+ }