@llmindset/hf-mcp 0.2.33 → 0.2.35
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/dist/docs-search/docs-semantic-search.js +1 -1
- package/dist/docs-search/docs-semantic-search.js.map +1 -1
- package/dist/hf-api-call.d.ts.map +1 -1
- package/dist/hf-api-call.js +4 -0
- package/dist/hf-api-call.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jobs/api-client.d.ts +19 -0
- package/dist/jobs/api-client.d.ts.map +1 -0
- package/dist/jobs/api-client.js +95 -0
- package/dist/jobs/api-client.js.map +1 -0
- package/dist/jobs/commands/inspect.d.ts +5 -0
- package/dist/jobs/commands/inspect.d.ts.map +1 -0
- package/dist/jobs/commands/inspect.js +21 -0
- package/dist/jobs/commands/inspect.js.map +1 -0
- package/dist/jobs/commands/logs.d.ts +4 -0
- package/dist/jobs/commands/logs.d.ts.map +1 -0
- package/dist/jobs/commands/logs.js +24 -0
- package/dist/jobs/commands/logs.js.map +1 -0
- package/dist/jobs/commands/ps.d.ts +4 -0
- package/dist/jobs/commands/ps.d.ts.map +1 -0
- package/dist/jobs/commands/ps.js +23 -0
- package/dist/jobs/commands/ps.js.map +1 -0
- package/dist/jobs/commands/run.d.ts +5 -0
- package/dist/jobs/commands/run.d.ts.map +1 -0
- package/dist/jobs/commands/run.js +90 -0
- package/dist/jobs/commands/run.js.map +1 -0
- package/dist/jobs/commands/scheduled.d.ts +10 -0
- package/dist/jobs/commands/scheduled.d.ts.map +1 -0
- package/dist/jobs/commands/scheduled.js +112 -0
- package/dist/jobs/commands/scheduled.js.map +1 -0
- package/dist/jobs/commands/utils.d.ts +20 -0
- package/dist/jobs/commands/utils.d.ts.map +1 -0
- package/dist/jobs/commands/utils.js +120 -0
- package/dist/jobs/commands/utils.js.map +1 -0
- package/dist/jobs/formatters.d.ts +6 -0
- package/dist/jobs/formatters.d.ts.map +1 -0
- package/dist/jobs/formatters.js +98 -0
- package/dist/jobs/formatters.js.map +1 -0
- package/dist/jobs/sse-handler.d.ts +12 -0
- package/dist/jobs/sse-handler.d.ts.map +1 -0
- package/dist/jobs/sse-handler.js +80 -0
- package/dist/jobs/sse-handler.js.map +1 -0
- package/dist/jobs/tool.d.ts +35 -0
- package/dist/jobs/tool.d.ts.map +1 -0
- package/dist/jobs/tool.js +333 -0
- package/dist/jobs/tool.js.map +1 -0
- package/dist/jobs/types.d.ts +295 -0
- package/dist/jobs/types.d.ts.map +1 -0
- package/dist/jobs/types.js +95 -0
- package/dist/jobs/types.js.map +1 -0
- package/dist/tool-ids.d.ts +3 -2
- package/dist/tool-ids.d.ts.map +1 -1
- package/dist/tool-ids.js +10 -2
- package/dist/tool-ids.js.map +1 -1
- package/dist/types/tool-result.d.ts +1 -0
- package/dist/types/tool-result.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/docs-search/docs-semantic-search.ts +1 -1
- package/src/hf-api-call.ts +6 -0
- package/src/index.ts +1 -0
- package/src/jobs/api-client.ts +187 -0
- package/src/jobs/commands/inspect.ts +38 -0
- package/src/jobs/commands/logs.ts +36 -0
- package/src/jobs/commands/ps.ts +40 -0
- package/src/jobs/commands/run.ts +135 -0
- package/src/jobs/commands/scheduled.ts +198 -0
- package/src/jobs/commands/utils.ts +191 -0
- package/src/jobs/formatters.ts +149 -0
- package/src/jobs/sse-handler.ts +144 -0
- package/src/jobs/tool.ts +435 -0
- package/src/jobs/types.ts +237 -0
- package/src/tool-ids.ts +11 -1
- package/src/types/tool-result.ts +6 -0
- package/test/jobs/command-translation.spec.ts +331 -0
- package/test/jobs/formatters.spec.ts +267 -0
- package/test/jobs/uv-command.spec.ts +81 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { RunArgs, UvArgs } from '../types.js';
|
|
2
|
+
import type { JobsApiClient } from '../api-client.js';
|
|
3
|
+
import { createJobSpec } from './utils.js';
|
|
4
|
+
import { fetchJobLogs } from '../sse-handler.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute the 'run' command
|
|
8
|
+
* Creates and runs a job, optionally waiting for logs
|
|
9
|
+
*/
|
|
10
|
+
export async function runCommand(args: RunArgs, client: JobsApiClient, token?: string): Promise<string> {
|
|
11
|
+
// Create job spec from args
|
|
12
|
+
const jobSpec = createJobSpec({
|
|
13
|
+
image: args.image,
|
|
14
|
+
command: args.command,
|
|
15
|
+
flavor: args.flavor,
|
|
16
|
+
env: args.env,
|
|
17
|
+
secrets: args.secrets,
|
|
18
|
+
timeout: args.timeout,
|
|
19
|
+
hfToken: token,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Submit job
|
|
23
|
+
const job = await client.runJob(jobSpec, args.namespace);
|
|
24
|
+
|
|
25
|
+
const jobUrl = `https://huggingface.co/jobs/${job.owner.name}/${job.id}`;
|
|
26
|
+
|
|
27
|
+
// If detached, return immediately
|
|
28
|
+
if (args.detach) {
|
|
29
|
+
return `Job started successfully!
|
|
30
|
+
|
|
31
|
+
**Job ID:** ${job.id}
|
|
32
|
+
**Status:** ${job.status.stage}
|
|
33
|
+
**View at:** ${jobUrl}
|
|
34
|
+
|
|
35
|
+
To check logs: \`hf_jobs("logs", {"job_id": "${job.id}"})\`
|
|
36
|
+
To inspect: \`hf_jobs("inspect", {"job_id": "${job.id}"})\``;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Not detached - fetch logs
|
|
40
|
+
const logsUrl = client.getLogsUrl(job.id, job.owner.name);
|
|
41
|
+
const logResult = await fetchJobLogs(logsUrl, { token, maxDuration: 10000, maxLines: 20 });
|
|
42
|
+
|
|
43
|
+
let response = `Job started: ${job.id}\n\n`;
|
|
44
|
+
|
|
45
|
+
if (logResult.logs.length > 0) {
|
|
46
|
+
response += '**Logs (last 20 lines):**\n```\n';
|
|
47
|
+
response += logResult.logs.join('\n');
|
|
48
|
+
response += '\n```\n\n';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (logResult.finished) {
|
|
52
|
+
response += `Job finished. Full details: ${jobUrl}`;
|
|
53
|
+
} else if (logResult.truncated) {
|
|
54
|
+
response += `Log collection stopped after 10s. Job may still be running.\n`;
|
|
55
|
+
response += `View full logs: ${jobUrl}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Execute the 'uv' command
|
|
63
|
+
* Creates and runs a UV-based Python job
|
|
64
|
+
*/
|
|
65
|
+
export async function uvCommand(args: UvArgs, client: JobsApiClient, token?: string): Promise<string> {
|
|
66
|
+
// UV jobs use a standard UV image unless overridden
|
|
67
|
+
const image = 'ghcr.io/astral-sh/uv:latest'; // Standard UV image
|
|
68
|
+
|
|
69
|
+
// Detect script source and build command
|
|
70
|
+
const scriptSource = args.script;
|
|
71
|
+
let command: string | string[];
|
|
72
|
+
|
|
73
|
+
// Check if script is a URL
|
|
74
|
+
if (scriptSource.startsWith('http://') || scriptSource.startsWith('https://')) {
|
|
75
|
+
// URL - download and run
|
|
76
|
+
command = buildUvCommand(scriptSource, args);
|
|
77
|
+
} else if (scriptSource.includes('\n')) {
|
|
78
|
+
// Inline multi-line script - encode it
|
|
79
|
+
const encoded = Buffer.from(scriptSource).toString('base64');
|
|
80
|
+
const depsPart =
|
|
81
|
+
args.with_deps && args.with_deps.length > 0
|
|
82
|
+
? args.with_deps.map(dep => `--with ${dep}`).join(' ')
|
|
83
|
+
: '';
|
|
84
|
+
const pythonPart = args.python ? `-p ${args.python}` : '';
|
|
85
|
+
const uvArgs = [depsPart, pythonPart].filter(Boolean).join(' ');
|
|
86
|
+
const shellSnippet = `echo "${encoded}" | base64 -d | uv run${uvArgs ? ` ${uvArgs}` : ''} -`;
|
|
87
|
+
command = ['/bin/sh', '-lc', shellSnippet];
|
|
88
|
+
} else {
|
|
89
|
+
// Assume it's a URL or path - UV will handle it
|
|
90
|
+
command = buildUvCommand(scriptSource, args);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Convert to run args
|
|
94
|
+
const runArgs: RunArgs = {
|
|
95
|
+
image,
|
|
96
|
+
command,
|
|
97
|
+
flavor: args.flavor,
|
|
98
|
+
env: args.env,
|
|
99
|
+
secrets: args.secrets,
|
|
100
|
+
timeout: args.timeout,
|
|
101
|
+
detach: args.detach,
|
|
102
|
+
namespace: args.namespace,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return runCommand(runArgs, client, token);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build UV command with options
|
|
110
|
+
*/
|
|
111
|
+
function buildUvCommand(script: string, args: UvArgs): string {
|
|
112
|
+
const parts: string[] = ['uv', 'run'];
|
|
113
|
+
|
|
114
|
+
// Add dependencies
|
|
115
|
+
if (args.with_deps && args.with_deps.length > 0) {
|
|
116
|
+
for (const dep of args.with_deps) {
|
|
117
|
+
parts.push('--with', dep);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add Python version
|
|
122
|
+
if (args.python) {
|
|
123
|
+
parts.push('-p', args.python);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Add script
|
|
127
|
+
parts.push(script);
|
|
128
|
+
|
|
129
|
+
// Add script arguments
|
|
130
|
+
if (args.script_args && args.script_args.length > 0) {
|
|
131
|
+
parts.push(...args.script_args);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return parts.join(' ');
|
|
135
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ScheduledRunArgs,
|
|
3
|
+
ScheduledUvArgs,
|
|
4
|
+
ScheduledPsArgs,
|
|
5
|
+
ScheduledJobArgs,
|
|
6
|
+
ScheduledJobSpec,
|
|
7
|
+
} from '../types.js';
|
|
8
|
+
import type { JobsApiClient } from '../api-client.js';
|
|
9
|
+
import { formatScheduledJobsTable, formatScheduledJobDetails } from '../formatters.js';
|
|
10
|
+
import { createJobSpec } from './utils.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Execute 'scheduled run' command
|
|
14
|
+
* Creates a scheduled job
|
|
15
|
+
*/
|
|
16
|
+
export async function scheduledRunCommand(
|
|
17
|
+
args: ScheduledRunArgs,
|
|
18
|
+
client: JobsApiClient,
|
|
19
|
+
token?: string
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
// Create job spec
|
|
22
|
+
const jobSpec = createJobSpec({
|
|
23
|
+
image: args.image,
|
|
24
|
+
command: args.command,
|
|
25
|
+
flavor: args.flavor,
|
|
26
|
+
env: args.env,
|
|
27
|
+
secrets: args.secrets,
|
|
28
|
+
timeout: args.timeout,
|
|
29
|
+
hfToken: token,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Create scheduled job spec
|
|
33
|
+
const scheduledSpec: ScheduledJobSpec = {
|
|
34
|
+
schedule: args.schedule,
|
|
35
|
+
suspend: args.suspend,
|
|
36
|
+
jobSpec,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Submit scheduled job
|
|
40
|
+
const scheduledJob = await client.createScheduledJob(scheduledSpec, args.namespace);
|
|
41
|
+
|
|
42
|
+
return `✓ Scheduled job created successfully!
|
|
43
|
+
|
|
44
|
+
**Scheduled Job ID:** ${scheduledJob.id}
|
|
45
|
+
**Schedule:** ${scheduledJob.schedule}
|
|
46
|
+
**Suspended:** ${scheduledJob.suspend ? 'Yes' : 'No'}
|
|
47
|
+
**Next Run:** ${scheduledJob.nextRun || 'N/A'}
|
|
48
|
+
|
|
49
|
+
To inspect: \`hf_jobs("scheduled inspect", {"scheduled_job_id": "${scheduledJob.id}"})\`
|
|
50
|
+
To list all: \`hf_jobs("scheduled ps")\``;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Execute 'scheduled uv' command
|
|
55
|
+
* Creates a scheduled UV job
|
|
56
|
+
*/
|
|
57
|
+
export async function scheduledUvCommand(
|
|
58
|
+
args: ScheduledUvArgs,
|
|
59
|
+
client: JobsApiClient,
|
|
60
|
+
token?: string
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
// For UV, use standard UV image
|
|
63
|
+
const image = 'ghcr.io/astral-sh/uv:python3.12-bookworm';
|
|
64
|
+
|
|
65
|
+
// Build UV command (similar to regular uv command)
|
|
66
|
+
const scriptSource = args.script;
|
|
67
|
+
let command: string;
|
|
68
|
+
|
|
69
|
+
if (scriptSource.startsWith('http://') || scriptSource.startsWith('https://')) {
|
|
70
|
+
command = buildUvCommand(scriptSource, args);
|
|
71
|
+
} else if (scriptSource.includes('\n')) {
|
|
72
|
+
const encoded = Buffer.from(scriptSource).toString('base64');
|
|
73
|
+
const deps =
|
|
74
|
+
args.with_deps && args.with_deps.length > 0 ? args.with_deps.map((dep) => `--with ${dep}`).join(' ') + ' ' : '';
|
|
75
|
+
command = `echo "${encoded}" | base64 -d | uv run ${deps}${args.python ? `-p ${args.python}` : ''} -`;
|
|
76
|
+
} else {
|
|
77
|
+
command = buildUvCommand(scriptSource, args);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Convert to scheduled run args
|
|
81
|
+
const scheduledRunArgs: ScheduledRunArgs = {
|
|
82
|
+
schedule: args.schedule,
|
|
83
|
+
suspend: args.suspend,
|
|
84
|
+
image,
|
|
85
|
+
command,
|
|
86
|
+
flavor: args.flavor,
|
|
87
|
+
env: args.env,
|
|
88
|
+
secrets: args.secrets,
|
|
89
|
+
timeout: args.timeout,
|
|
90
|
+
detach: args.detach,
|
|
91
|
+
namespace: args.namespace,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return scheduledRunCommand(scheduledRunArgs, client, token);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build UV command with options
|
|
99
|
+
*/
|
|
100
|
+
function buildUvCommand(
|
|
101
|
+
script: string,
|
|
102
|
+
args: { with_deps?: string[]; python?: string; script_args?: string[] }
|
|
103
|
+
): string {
|
|
104
|
+
const parts: string[] = ['uv', 'run'];
|
|
105
|
+
|
|
106
|
+
if (args.with_deps && args.with_deps.length > 0) {
|
|
107
|
+
for (const dep of args.with_deps) {
|
|
108
|
+
parts.push('--with', dep);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (args.python) {
|
|
113
|
+
parts.push('-p', args.python);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
parts.push(script);
|
|
117
|
+
|
|
118
|
+
if (args.script_args && args.script_args.length > 0) {
|
|
119
|
+
parts.push(...args.script_args);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return parts.join(' ');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Execute 'scheduled ps' command
|
|
127
|
+
* Lists scheduled jobs
|
|
128
|
+
*/
|
|
129
|
+
export async function scheduledPsCommand(args: ScheduledPsArgs, client: JobsApiClient): Promise<string> {
|
|
130
|
+
// Fetch all scheduled jobs
|
|
131
|
+
const allJobs = await client.listScheduledJobs(args.namespace);
|
|
132
|
+
|
|
133
|
+
// Filter jobs
|
|
134
|
+
let jobs = allJobs;
|
|
135
|
+
|
|
136
|
+
// Default: hide suspended jobs unless --all is specified
|
|
137
|
+
if (!args.all) {
|
|
138
|
+
jobs = jobs.filter((job) => !job.suspend);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Format as markdown table
|
|
142
|
+
const table = formatScheduledJobsTable(jobs);
|
|
143
|
+
|
|
144
|
+
if (jobs.length === 0) {
|
|
145
|
+
if (args.all) {
|
|
146
|
+
return 'No scheduled jobs found.';
|
|
147
|
+
}
|
|
148
|
+
return 'No active scheduled jobs found. Use `{"all": true}` to show suspended jobs.';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `**Scheduled Jobs (${jobs.length} of ${allJobs.length} total):**
|
|
152
|
+
|
|
153
|
+
${table}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Execute 'scheduled inspect' command
|
|
158
|
+
* Gets details of a scheduled job
|
|
159
|
+
*/
|
|
160
|
+
export async function scheduledInspectCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
|
|
161
|
+
const job = await client.getScheduledJob(args.scheduled_job_id, args.namespace);
|
|
162
|
+
const formattedDetails = formatScheduledJobDetails(job);
|
|
163
|
+
return `**Scheduled Job Details:**\n\n${formattedDetails}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Execute 'scheduled delete' command
|
|
168
|
+
* Deletes a scheduled job
|
|
169
|
+
*/
|
|
170
|
+
export async function scheduledDeleteCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
|
|
171
|
+
await client.deleteScheduledJob(args.scheduled_job_id, args.namespace);
|
|
172
|
+
|
|
173
|
+
return `✓ Scheduled job ${args.scheduled_job_id} has been deleted.`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Execute 'scheduled suspend' command
|
|
178
|
+
* Suspends a scheduled job
|
|
179
|
+
*/
|
|
180
|
+
export async function scheduledSuspendCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
|
|
181
|
+
await client.suspendScheduledJob(args.scheduled_job_id, args.namespace);
|
|
182
|
+
|
|
183
|
+
return `✓ Scheduled job ${args.scheduled_job_id} has been suspended.
|
|
184
|
+
|
|
185
|
+
To resume: \`hf_jobs("scheduled resume", {"scheduled_job_id": "${args.scheduled_job_id}"})\``;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Execute 'scheduled resume' command
|
|
190
|
+
* Resumes a suspended scheduled job
|
|
191
|
+
*/
|
|
192
|
+
export async function scheduledResumeCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
|
|
193
|
+
await client.resumeScheduledJob(args.scheduled_job_id, args.namespace);
|
|
194
|
+
|
|
195
|
+
return `✓ Scheduled job ${args.scheduled_job_id} has been resumed.
|
|
196
|
+
|
|
197
|
+
To inspect: \`hf_jobs("scheduled inspect", {"scheduled_job_id": "${args.scheduled_job_id}"})\``;
|
|
198
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { JobSpec } from '../types.js';
|
|
2
|
+
import { parse as parseShellArgs } from 'shell-quote';
|
|
3
|
+
|
|
4
|
+
interface EnvToken {
|
|
5
|
+
type: 'env';
|
|
6
|
+
key: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SPECIAL_PARAMS = new Set(['*', '@', '#', '?', '!', '-', '_']);
|
|
10
|
+
|
|
11
|
+
function isEnvToken(entry: unknown): entry is EnvToken {
|
|
12
|
+
return Boolean(entry && typeof entry === 'object' && (entry as EnvToken).type === 'env');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatEnvReference(key: string): string {
|
|
16
|
+
if (key === '') {
|
|
17
|
+
return '$';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (key === '$') {
|
|
21
|
+
return '$$';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (/^[A-Za-z0-9_]+$/.test(key)) {
|
|
25
|
+
return `$${key}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (SPECIAL_PARAMS.has(key)) {
|
|
29
|
+
return `$${key}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `\${${key}}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse timeout string (e.g., "5m", "2h", "30s") to seconds
|
|
37
|
+
*/
|
|
38
|
+
export function parseTimeout(timeout: string): number {
|
|
39
|
+
const timeUnits: Record<'s' | 'm' | 'h' | 'd', number> = {
|
|
40
|
+
s: 1,
|
|
41
|
+
m: 60,
|
|
42
|
+
h: 3600,
|
|
43
|
+
d: 86400,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const match = timeout.match(/^(\d+(?:\.\d+)?)(s|m|h|d)$/);
|
|
47
|
+
if (!match || !match[1] || !match[2]) {
|
|
48
|
+
// Try to parse as plain number (seconds)
|
|
49
|
+
const seconds = parseInt(timeout, 10);
|
|
50
|
+
if (!isNaN(seconds)) {
|
|
51
|
+
return seconds;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid timeout format: "${timeout}". Use format like "5m", "2h", "30s", or plain seconds.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const value = parseFloat(match[1]);
|
|
59
|
+
const unit = match[2] as 's' | 'm' | 'h' | 'd';
|
|
60
|
+
return Math.floor(value * timeUnits[unit]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect if image is a Space URL and extract spaceId
|
|
65
|
+
* Returns { dockerImage } or { spaceId }
|
|
66
|
+
*/
|
|
67
|
+
export function parseImageSource(image: string): { dockerImage?: string; spaceId?: string } {
|
|
68
|
+
const spacePrefixes = [
|
|
69
|
+
'https://huggingface.co/spaces/',
|
|
70
|
+
'https://hf.co/spaces/',
|
|
71
|
+
'huggingface.co/spaces/',
|
|
72
|
+
'hf.co/spaces/',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const prefix of spacePrefixes) {
|
|
76
|
+
if (image.startsWith(prefix)) {
|
|
77
|
+
return { spaceId: image.substring(prefix.length) };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Not a space, treat as docker image
|
|
82
|
+
return { dockerImage: image };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse command string or array into command array
|
|
87
|
+
* Uses shell-quote library for proper POSIX-compliant parsing
|
|
88
|
+
*/
|
|
89
|
+
export function parseCommand(command: string | string[]): { command: string[]; arguments?: string[] } {
|
|
90
|
+
// If already an array, return as-is
|
|
91
|
+
if (Array.isArray(command)) {
|
|
92
|
+
return { command, arguments: [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse the command string using shell-quote for POSIX-compliant parsing
|
|
96
|
+
const parsed = parseShellArgs<EnvToken>(command, key => ({ type: 'env', key }));
|
|
97
|
+
|
|
98
|
+
// Convert parsed result to string array
|
|
99
|
+
// shell-quote can return various types (strings, objects for operators, etc.)
|
|
100
|
+
// We filter to only keep string arguments
|
|
101
|
+
const stringArgs: string[] = [];
|
|
102
|
+
for (const arg of parsed) {
|
|
103
|
+
if (typeof arg === 'string') {
|
|
104
|
+
stringArgs.push(arg);
|
|
105
|
+
} else if (isEnvToken(arg)) {
|
|
106
|
+
stringArgs.push(formatEnvReference(arg.key));
|
|
107
|
+
} else {
|
|
108
|
+
// If we encounter a non-string (like operators), throw an error
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Unsupported shell syntax in command: "${command}". ` +
|
|
111
|
+
`Please use an array format for commands with complex shell operators, ` +
|
|
112
|
+
`or use simple quoted strings.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (stringArgs.length === 0) {
|
|
118
|
+
throw new Error(`Invalid command: "${command}". Command cannot be empty.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { command: stringArgs, arguments: [] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Replace HF token placeholder with actual token if available
|
|
126
|
+
*/
|
|
127
|
+
function replaceTokenPlaceholder(value: string, hfToken?: string): string {
|
|
128
|
+
if (!hfToken) {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (value === '$HF_TOKEN' || value === '${HF_TOKEN}') {
|
|
133
|
+
return hfToken;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function transformEnvMap(
|
|
140
|
+
map: Record<string, string> | undefined,
|
|
141
|
+
hfToken?: string
|
|
142
|
+
): Record<string, string> | undefined {
|
|
143
|
+
if (!map) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const transformedEntries = Object.entries(map).map<[string, string]>(([key, value]) => [
|
|
148
|
+
key,
|
|
149
|
+
replaceTokenPlaceholder(value, hfToken),
|
|
150
|
+
]);
|
|
151
|
+
return Object.fromEntries(transformedEntries) as Record<string, string>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create a JobSpec from run command arguments
|
|
156
|
+
*/
|
|
157
|
+
export function createJobSpec(args: {
|
|
158
|
+
image: string;
|
|
159
|
+
command: string | string[];
|
|
160
|
+
flavor?: string;
|
|
161
|
+
env?: Record<string, string>;
|
|
162
|
+
secrets?: Record<string, string>;
|
|
163
|
+
timeout?: string;
|
|
164
|
+
hfToken?: string;
|
|
165
|
+
}): JobSpec {
|
|
166
|
+
// Validate required fields
|
|
167
|
+
if (!args.image) {
|
|
168
|
+
throw new Error('image parameter is required. Provide a Docker image (e.g., "python:3.12") or Space URL.');
|
|
169
|
+
}
|
|
170
|
+
if (!args.command) {
|
|
171
|
+
throw new Error('command parameter is required. Provide a command as string or array.');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const imageSource = parseImageSource(args.image);
|
|
175
|
+
const { command, arguments: cmdArgs } = parseCommand(args.command);
|
|
176
|
+
const timeoutSeconds = args.timeout ? parseTimeout(args.timeout) : undefined;
|
|
177
|
+
const environment = transformEnvMap(args.env, args.hfToken) || {};
|
|
178
|
+
const secrets = transformEnvMap(args.secrets, args.hfToken) || {};
|
|
179
|
+
|
|
180
|
+
const spec: JobSpec = {
|
|
181
|
+
...imageSource,
|
|
182
|
+
command,
|
|
183
|
+
arguments: cmdArgs,
|
|
184
|
+
flavor: args.flavor || 'cpu-basic',
|
|
185
|
+
environment,
|
|
186
|
+
secrets,
|
|
187
|
+
timeoutSeconds,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return spec;
|
|
191
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { JobInfo, ScheduledJobInfo } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Truncate a string to a maximum length with ellipsis
|
|
5
|
+
*/
|
|
6
|
+
function truncate(str: string, maxLength: number): string {
|
|
7
|
+
if (str.length <= maxLength) {
|
|
8
|
+
return str;
|
|
9
|
+
}
|
|
10
|
+
return str.substring(0, maxLength - 3) + '...';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format a date string to a readable format
|
|
15
|
+
*/
|
|
16
|
+
function formatDate(dateStr: string | undefined): string {
|
|
17
|
+
if (!dateStr) {
|
|
18
|
+
return 'N/A';
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const date = new Date(dateStr);
|
|
22
|
+
return date.toISOString().replace('T', ' ').substring(0, 19);
|
|
23
|
+
} catch {
|
|
24
|
+
return dateStr;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format command array as a single string
|
|
30
|
+
*/
|
|
31
|
+
function formatCommand(command?: string[]): string {
|
|
32
|
+
if (!command || command.length === 0) {
|
|
33
|
+
return 'N/A';
|
|
34
|
+
}
|
|
35
|
+
return command.join(' ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get image/space identifier from job
|
|
40
|
+
*/
|
|
41
|
+
function getImageOrSpace(job: JobInfo | { dockerImage?: string; spaceId?: string }): string {
|
|
42
|
+
if (job.spaceId) {
|
|
43
|
+
return job.spaceId;
|
|
44
|
+
}
|
|
45
|
+
if (job.dockerImage) {
|
|
46
|
+
return job.dockerImage;
|
|
47
|
+
}
|
|
48
|
+
return 'N/A';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format jobs as a markdown table
|
|
53
|
+
*/
|
|
54
|
+
export function formatJobsTable(jobs: JobInfo[]): string {
|
|
55
|
+
if (jobs.length === 0) {
|
|
56
|
+
return 'No jobs found.';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Calculate dynamic ID column width - never truncate IDs!
|
|
60
|
+
const longestIdLength = Math.max(...jobs.map((job) => job.id.length));
|
|
61
|
+
const idColumnWidth = Math.max(longestIdLength, 'JOB ID'.length);
|
|
62
|
+
|
|
63
|
+
// Define column widths
|
|
64
|
+
const colWidths = {
|
|
65
|
+
id: idColumnWidth,
|
|
66
|
+
image: 20,
|
|
67
|
+
command: 30,
|
|
68
|
+
created: 19,
|
|
69
|
+
status: 12,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Build header
|
|
73
|
+
const header = `| ${'JOB ID'.padEnd(colWidths.id)} | ${'IMAGE/SPACE'.padEnd(colWidths.image)} | ${'COMMAND'.padEnd(colWidths.command)} | ${'CREATED'.padEnd(colWidths.created)} | ${'STATUS'.padEnd(colWidths.status)} |`;
|
|
74
|
+
const separator = `|${'-'.repeat(colWidths.id + 2)}|${'-'.repeat(colWidths.image + 2)}|${'-'.repeat(colWidths.command + 2)}|${'-'.repeat(colWidths.created + 2)}|${'-'.repeat(colWidths.status + 2)}|`;
|
|
75
|
+
|
|
76
|
+
// Build rows
|
|
77
|
+
const rows = jobs.map((job) => {
|
|
78
|
+
const id = job.id; // Never truncate IDs!
|
|
79
|
+
const image = truncate(getImageOrSpace(job), colWidths.image);
|
|
80
|
+
const command = truncate(formatCommand(job.command), colWidths.command);
|
|
81
|
+
const created = truncate(formatDate(job.createdAt), colWidths.created);
|
|
82
|
+
const status = truncate(job.status.stage, colWidths.status);
|
|
83
|
+
|
|
84
|
+
return `| ${id.padEnd(colWidths.id)} | ${image.padEnd(colWidths.image)} | ${command.padEnd(colWidths.command)} | ${created.padEnd(colWidths.created)} | ${status.padEnd(colWidths.status)} |`;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return [header, separator, ...rows].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format scheduled jobs as a markdown table
|
|
92
|
+
*/
|
|
93
|
+
export function formatScheduledJobsTable(jobs: ScheduledJobInfo[]): string {
|
|
94
|
+
if (jobs.length === 0) {
|
|
95
|
+
return 'No scheduled jobs found.';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Calculate dynamic ID column width - never truncate IDs!
|
|
99
|
+
const longestIdLength = Math.max(...jobs.map((job) => job.id.length));
|
|
100
|
+
const idColumnWidth = Math.max(longestIdLength, 'ID'.length);
|
|
101
|
+
|
|
102
|
+
// Define column widths
|
|
103
|
+
const colWidths = {
|
|
104
|
+
id: idColumnWidth,
|
|
105
|
+
schedule: 12,
|
|
106
|
+
image: 18,
|
|
107
|
+
command: 25,
|
|
108
|
+
lastRun: 19,
|
|
109
|
+
nextRun: 19,
|
|
110
|
+
suspend: 9,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Build header
|
|
114
|
+
const header = `| ${'ID'.padEnd(colWidths.id)} | ${'SCHEDULE'.padEnd(colWidths.schedule)} | ${'IMAGE/SPACE'.padEnd(colWidths.image)} | ${'COMMAND'.padEnd(colWidths.command)} | ${'LAST RUN'.padEnd(colWidths.lastRun)} | ${'NEXT RUN'.padEnd(colWidths.nextRun)} | ${'SUSPENDED'.padEnd(colWidths.suspend)} |`;
|
|
115
|
+
const separator = `|${'-'.repeat(colWidths.id + 2)}|${'-'.repeat(colWidths.schedule + 2)}|${'-'.repeat(colWidths.image + 2)}|${'-'.repeat(colWidths.command + 2)}|${'-'.repeat(colWidths.lastRun + 2)}|${'-'.repeat(colWidths.nextRun + 2)}|${'-'.repeat(colWidths.suspend + 2)}|`;
|
|
116
|
+
|
|
117
|
+
// Build rows
|
|
118
|
+
const rows = jobs.map((job) => {
|
|
119
|
+
const id = job.id; // Never truncate IDs!
|
|
120
|
+
const schedule = truncate(job.schedule, colWidths.schedule);
|
|
121
|
+
const image = truncate(getImageOrSpace(job.jobSpec), colWidths.image);
|
|
122
|
+
const command = truncate(formatCommand(job.jobSpec.command), colWidths.command);
|
|
123
|
+
const lastRun = truncate(formatDate(job.lastRun), colWidths.lastRun);
|
|
124
|
+
const nextRun = truncate(formatDate(job.nextRun), colWidths.nextRun);
|
|
125
|
+
const suspend = job.suspend ? 'Yes' : 'No';
|
|
126
|
+
|
|
127
|
+
return `| ${id.padEnd(colWidths.id)} | ${schedule.padEnd(colWidths.schedule)} | ${image.padEnd(colWidths.image)} | ${command.padEnd(colWidths.command)} | ${lastRun.padEnd(colWidths.lastRun)} | ${nextRun.padEnd(colWidths.nextRun)} | ${suspend.padEnd(colWidths.suspend)} |`;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return [header, separator, ...rows].join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format job details as JSON in a markdown code block
|
|
135
|
+
*/
|
|
136
|
+
export function formatJobDetails(jobs: JobInfo | JobInfo[]): string {
|
|
137
|
+
const jobArray = Array.isArray(jobs) ? jobs : [jobs];
|
|
138
|
+
const json = JSON.stringify(jobArray, null, 2);
|
|
139
|
+
return `\`\`\`json\n${json}\n\`\`\``;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Format scheduled job details as JSON in a markdown code block
|
|
144
|
+
*/
|
|
145
|
+
export function formatScheduledJobDetails(jobs: ScheduledJobInfo | ScheduledJobInfo[]): string {
|
|
146
|
+
const jobArray = Array.isArray(jobs) ? jobs : [jobs];
|
|
147
|
+
const json = JSON.stringify(jobArray, null, 2);
|
|
148
|
+
return `\`\`\`json\n${json}\n\`\`\``;
|
|
149
|
+
}
|