@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.
Files changed (79) hide show
  1. package/dist/docs-search/docs-semantic-search.js +1 -1
  2. package/dist/docs-search/docs-semantic-search.js.map +1 -1
  3. package/dist/hf-api-call.d.ts.map +1 -1
  4. package/dist/hf-api-call.js +4 -0
  5. package/dist/hf-api-call.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/jobs/api-client.d.ts +19 -0
  11. package/dist/jobs/api-client.d.ts.map +1 -0
  12. package/dist/jobs/api-client.js +95 -0
  13. package/dist/jobs/api-client.js.map +1 -0
  14. package/dist/jobs/commands/inspect.d.ts +5 -0
  15. package/dist/jobs/commands/inspect.d.ts.map +1 -0
  16. package/dist/jobs/commands/inspect.js +21 -0
  17. package/dist/jobs/commands/inspect.js.map +1 -0
  18. package/dist/jobs/commands/logs.d.ts +4 -0
  19. package/dist/jobs/commands/logs.d.ts.map +1 -0
  20. package/dist/jobs/commands/logs.js +24 -0
  21. package/dist/jobs/commands/logs.js.map +1 -0
  22. package/dist/jobs/commands/ps.d.ts +4 -0
  23. package/dist/jobs/commands/ps.d.ts.map +1 -0
  24. package/dist/jobs/commands/ps.js +23 -0
  25. package/dist/jobs/commands/ps.js.map +1 -0
  26. package/dist/jobs/commands/run.d.ts +5 -0
  27. package/dist/jobs/commands/run.d.ts.map +1 -0
  28. package/dist/jobs/commands/run.js +90 -0
  29. package/dist/jobs/commands/run.js.map +1 -0
  30. package/dist/jobs/commands/scheduled.d.ts +10 -0
  31. package/dist/jobs/commands/scheduled.d.ts.map +1 -0
  32. package/dist/jobs/commands/scheduled.js +112 -0
  33. package/dist/jobs/commands/scheduled.js.map +1 -0
  34. package/dist/jobs/commands/utils.d.ts +20 -0
  35. package/dist/jobs/commands/utils.d.ts.map +1 -0
  36. package/dist/jobs/commands/utils.js +120 -0
  37. package/dist/jobs/commands/utils.js.map +1 -0
  38. package/dist/jobs/formatters.d.ts +6 -0
  39. package/dist/jobs/formatters.d.ts.map +1 -0
  40. package/dist/jobs/formatters.js +98 -0
  41. package/dist/jobs/formatters.js.map +1 -0
  42. package/dist/jobs/sse-handler.d.ts +12 -0
  43. package/dist/jobs/sse-handler.d.ts.map +1 -0
  44. package/dist/jobs/sse-handler.js +80 -0
  45. package/dist/jobs/sse-handler.js.map +1 -0
  46. package/dist/jobs/tool.d.ts +35 -0
  47. package/dist/jobs/tool.d.ts.map +1 -0
  48. package/dist/jobs/tool.js +333 -0
  49. package/dist/jobs/tool.js.map +1 -0
  50. package/dist/jobs/types.d.ts +295 -0
  51. package/dist/jobs/types.d.ts.map +1 -0
  52. package/dist/jobs/types.js +95 -0
  53. package/dist/jobs/types.js.map +1 -0
  54. package/dist/tool-ids.d.ts +3 -2
  55. package/dist/tool-ids.d.ts.map +1 -1
  56. package/dist/tool-ids.js +10 -2
  57. package/dist/tool-ids.js.map +1 -1
  58. package/dist/types/tool-result.d.ts +1 -0
  59. package/dist/types/tool-result.d.ts.map +1 -1
  60. package/package.json +4 -2
  61. package/src/docs-search/docs-semantic-search.ts +1 -1
  62. package/src/hf-api-call.ts +6 -0
  63. package/src/index.ts +1 -0
  64. package/src/jobs/api-client.ts +187 -0
  65. package/src/jobs/commands/inspect.ts +38 -0
  66. package/src/jobs/commands/logs.ts +36 -0
  67. package/src/jobs/commands/ps.ts +40 -0
  68. package/src/jobs/commands/run.ts +135 -0
  69. package/src/jobs/commands/scheduled.ts +198 -0
  70. package/src/jobs/commands/utils.ts +191 -0
  71. package/src/jobs/formatters.ts +149 -0
  72. package/src/jobs/sse-handler.ts +144 -0
  73. package/src/jobs/tool.ts +435 -0
  74. package/src/jobs/types.ts +237 -0
  75. package/src/tool-ids.ts +11 -1
  76. package/src/types/tool-result.ts +6 -0
  77. package/test/jobs/command-translation.spec.ts +331 -0
  78. package/test/jobs/formatters.spec.ts +267 -0
  79. 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
+ }