@llmindset/hf-mcp 0.2.33 → 0.2.34

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 (76) hide show
  1. package/dist/hf-api-call.d.ts.map +1 -1
  2. package/dist/hf-api-call.js +4 -0
  3. package/dist/hf-api-call.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/jobs/api-client.d.ts +19 -0
  9. package/dist/jobs/api-client.d.ts.map +1 -0
  10. package/dist/jobs/api-client.js +104 -0
  11. package/dist/jobs/api-client.js.map +1 -0
  12. package/dist/jobs/commands/inspect.d.ts +5 -0
  13. package/dist/jobs/commands/inspect.d.ts.map +1 -0
  14. package/dist/jobs/commands/inspect.js +21 -0
  15. package/dist/jobs/commands/inspect.js.map +1 -0
  16. package/dist/jobs/commands/logs.d.ts +4 -0
  17. package/dist/jobs/commands/logs.d.ts.map +1 -0
  18. package/dist/jobs/commands/logs.js +24 -0
  19. package/dist/jobs/commands/logs.js.map +1 -0
  20. package/dist/jobs/commands/ps.d.ts +4 -0
  21. package/dist/jobs/commands/ps.d.ts.map +1 -0
  22. package/dist/jobs/commands/ps.js +23 -0
  23. package/dist/jobs/commands/ps.js.map +1 -0
  24. package/dist/jobs/commands/run.d.ts +5 -0
  25. package/dist/jobs/commands/run.d.ts.map +1 -0
  26. package/dist/jobs/commands/run.js +89 -0
  27. package/dist/jobs/commands/run.js.map +1 -0
  28. package/dist/jobs/commands/scheduled.d.ts +10 -0
  29. package/dist/jobs/commands/scheduled.d.ts.map +1 -0
  30. package/dist/jobs/commands/scheduled.js +111 -0
  31. package/dist/jobs/commands/scheduled.js.map +1 -0
  32. package/dist/jobs/commands/utils.d.ts +19 -0
  33. package/dist/jobs/commands/utils.d.ts.map +1 -0
  34. package/dist/jobs/commands/utils.js +99 -0
  35. package/dist/jobs/commands/utils.js.map +1 -0
  36. package/dist/jobs/formatters.d.ts +6 -0
  37. package/dist/jobs/formatters.d.ts.map +1 -0
  38. package/dist/jobs/formatters.js +98 -0
  39. package/dist/jobs/formatters.js.map +1 -0
  40. package/dist/jobs/sse-handler.d.ts +12 -0
  41. package/dist/jobs/sse-handler.d.ts.map +1 -0
  42. package/dist/jobs/sse-handler.js +80 -0
  43. package/dist/jobs/sse-handler.js.map +1 -0
  44. package/dist/jobs/tool.d.ts +35 -0
  45. package/dist/jobs/tool.d.ts.map +1 -0
  46. package/dist/jobs/tool.js +333 -0
  47. package/dist/jobs/tool.js.map +1 -0
  48. package/dist/jobs/types.d.ts +295 -0
  49. package/dist/jobs/types.d.ts.map +1 -0
  50. package/dist/jobs/types.js +95 -0
  51. package/dist/jobs/types.js.map +1 -0
  52. package/dist/tool-ids.d.ts +3 -2
  53. package/dist/tool-ids.d.ts.map +1 -1
  54. package/dist/tool-ids.js +10 -2
  55. package/dist/tool-ids.js.map +1 -1
  56. package/dist/types/tool-result.d.ts +1 -0
  57. package/dist/types/tool-result.d.ts.map +1 -1
  58. package/package.json +3 -1
  59. package/src/hf-api-call.ts +6 -0
  60. package/src/index.ts +1 -0
  61. package/src/jobs/api-client.ts +195 -0
  62. package/src/jobs/commands/inspect.ts +38 -0
  63. package/src/jobs/commands/logs.ts +36 -0
  64. package/src/jobs/commands/ps.ts +40 -0
  65. package/src/jobs/commands/run.ts +134 -0
  66. package/src/jobs/commands/scheduled.ts +189 -0
  67. package/src/jobs/commands/utils.ts +158 -0
  68. package/src/jobs/formatters.ts +149 -0
  69. package/src/jobs/sse-handler.ts +144 -0
  70. package/src/jobs/tool.ts +435 -0
  71. package/src/jobs/types.ts +237 -0
  72. package/src/tool-ids.ts +11 -1
  73. package/src/types/tool-result.ts +6 -0
  74. package/test/jobs/command-translation.spec.ts +277 -0
  75. package/test/jobs/formatters.spec.ts +267 -0
  76. package/test/jobs/uv-command.spec.ts +81 -0
@@ -0,0 +1,134 @@
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
+ });
20
+
21
+ // Submit job
22
+ const job = await client.runJob(jobSpec, args.namespace);
23
+
24
+ const jobUrl = `https://huggingface.co/jobs/${job.owner.name}/${job.id}`;
25
+
26
+ // If detached, return immediately
27
+ if (args.detach) {
28
+ return `Job started successfully!
29
+
30
+ **Job ID:** ${job.id}
31
+ **Status:** ${job.status.stage}
32
+ **View at:** ${jobUrl}
33
+
34
+ To check logs: \`hf_jobs("logs", {"job_id": "${job.id}"})\`
35
+ To inspect: \`hf_jobs("inspect", {"job_id": "${job.id}"})\``;
36
+ }
37
+
38
+ // Not detached - fetch logs
39
+ const logsUrl = client.getLogsUrl(job.id, job.owner.name);
40
+ const logResult = await fetchJobLogs(logsUrl, { token, maxDuration: 10000, maxLines: 20 });
41
+
42
+ let response = `Job started: ${job.id}\n\n`;
43
+
44
+ if (logResult.logs.length > 0) {
45
+ response += '**Logs (last 20 lines):**\n```\n';
46
+ response += logResult.logs.join('\n');
47
+ response += '\n```\n\n';
48
+ }
49
+
50
+ if (logResult.finished) {
51
+ response += `Job finished. Full details: ${jobUrl}`;
52
+ } else if (logResult.truncated) {
53
+ response += `Log collection stopped after 10s. Job may still be running.\n`;
54
+ response += `View full logs: ${jobUrl}`;
55
+ }
56
+
57
+ return response;
58
+ }
59
+
60
+ /**
61
+ * Execute the 'uv' command
62
+ * Creates and runs a UV-based Python job
63
+ */
64
+ export async function uvCommand(args: UvArgs, client: JobsApiClient, token?: string): Promise<string> {
65
+ // UV jobs use a standard UV image unless overridden
66
+ const image = 'ghcr.io/astral-sh/uv:latest'; // Standard UV image
67
+
68
+ // Detect script source and build command
69
+ const scriptSource = args.script;
70
+ let command: string | string[];
71
+
72
+ // Check if script is a URL
73
+ if (scriptSource.startsWith('http://') || scriptSource.startsWith('https://')) {
74
+ // URL - download and run
75
+ command = buildUvCommand(scriptSource, args);
76
+ } else if (scriptSource.includes('\n')) {
77
+ // Inline multi-line script - encode it
78
+ const encoded = Buffer.from(scriptSource).toString('base64');
79
+ const depsPart =
80
+ args.with_deps && args.with_deps.length > 0
81
+ ? args.with_deps.map(dep => `--with ${dep}`).join(' ')
82
+ : '';
83
+ const pythonPart = args.python ? `-p ${args.python}` : '';
84
+ const uvArgs = [depsPart, pythonPart].filter(Boolean).join(' ');
85
+ const shellSnippet = `echo "${encoded}" | base64 -d | uv run${uvArgs ? ` ${uvArgs}` : ''} -`;
86
+ command = ['/bin/sh', '-lc', shellSnippet];
87
+ } else {
88
+ // Assume it's a URL or path - UV will handle it
89
+ command = buildUvCommand(scriptSource, args);
90
+ }
91
+
92
+ // Convert to run args
93
+ const runArgs: RunArgs = {
94
+ image,
95
+ command,
96
+ flavor: args.flavor,
97
+ env: args.env,
98
+ secrets: args.secrets,
99
+ timeout: args.timeout,
100
+ detach: args.detach,
101
+ namespace: args.namespace,
102
+ };
103
+
104
+ return runCommand(runArgs, client, token);
105
+ }
106
+
107
+ /**
108
+ * Build UV command with options
109
+ */
110
+ function buildUvCommand(script: string, args: UvArgs): string {
111
+ const parts: string[] = ['uv', 'run'];
112
+
113
+ // Add dependencies
114
+ if (args.with_deps && args.with_deps.length > 0) {
115
+ for (const dep of args.with_deps) {
116
+ parts.push('--with', dep);
117
+ }
118
+ }
119
+
120
+ // Add Python version
121
+ if (args.python) {
122
+ parts.push('-p', args.python);
123
+ }
124
+
125
+ // Add script
126
+ parts.push(script);
127
+
128
+ // Add script arguments
129
+ if (args.script_args && args.script_args.length > 0) {
130
+ parts.push(...args.script_args);
131
+ }
132
+
133
+ return parts.join(' ');
134
+ }
@@ -0,0 +1,189 @@
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(args: ScheduledRunArgs, client: JobsApiClient): Promise<string> {
17
+ // Create job spec
18
+ const jobSpec = createJobSpec({
19
+ image: args.image,
20
+ command: args.command,
21
+ flavor: args.flavor,
22
+ env: args.env,
23
+ secrets: args.secrets,
24
+ timeout: args.timeout,
25
+ });
26
+
27
+ // Create scheduled job spec
28
+ const scheduledSpec: ScheduledJobSpec = {
29
+ schedule: args.schedule,
30
+ suspend: args.suspend,
31
+ jobSpec,
32
+ };
33
+
34
+ // Submit scheduled job
35
+ const scheduledJob = await client.createScheduledJob(scheduledSpec, args.namespace);
36
+
37
+ return `✓ Scheduled job created successfully!
38
+
39
+ **Scheduled Job ID:** ${scheduledJob.id}
40
+ **Schedule:** ${scheduledJob.schedule}
41
+ **Suspended:** ${scheduledJob.suspend ? 'Yes' : 'No'}
42
+ **Next Run:** ${scheduledJob.nextRun || 'N/A'}
43
+
44
+ To inspect: \`hf_jobs("scheduled inspect", {"scheduled_job_id": "${scheduledJob.id}"})\`
45
+ To list all: \`hf_jobs("scheduled ps")\``;
46
+ }
47
+
48
+ /**
49
+ * Execute 'scheduled uv' command
50
+ * Creates a scheduled UV job
51
+ */
52
+ export async function scheduledUvCommand(args: ScheduledUvArgs, client: JobsApiClient): Promise<string> {
53
+ // For UV, use standard UV image
54
+ const image = 'ghcr.io/astral-sh/uv:python3.12-bookworm';
55
+
56
+ // Build UV command (similar to regular uv command)
57
+ const scriptSource = args.script;
58
+ let command: string;
59
+
60
+ if (scriptSource.startsWith('http://') || scriptSource.startsWith('https://')) {
61
+ command = buildUvCommand(scriptSource, args);
62
+ } else if (scriptSource.includes('\n')) {
63
+ const encoded = Buffer.from(scriptSource).toString('base64');
64
+ const deps =
65
+ args.with_deps && args.with_deps.length > 0 ? args.with_deps.map((dep) => `--with ${dep}`).join(' ') + ' ' : '';
66
+ command = `echo "${encoded}" | base64 -d | uv run ${deps}${args.python ? `-p ${args.python}` : ''} -`;
67
+ } else {
68
+ command = buildUvCommand(scriptSource, args);
69
+ }
70
+
71
+ // Convert to scheduled run args
72
+ const scheduledRunArgs: ScheduledRunArgs = {
73
+ schedule: args.schedule,
74
+ suspend: args.suspend,
75
+ image,
76
+ command,
77
+ flavor: args.flavor,
78
+ env: args.env,
79
+ secrets: args.secrets,
80
+ timeout: args.timeout,
81
+ detach: args.detach,
82
+ namespace: args.namespace,
83
+ };
84
+
85
+ return scheduledRunCommand(scheduledRunArgs, client);
86
+ }
87
+
88
+ /**
89
+ * Build UV command with options
90
+ */
91
+ function buildUvCommand(
92
+ script: string,
93
+ args: { with_deps?: string[]; python?: string; script_args?: string[] }
94
+ ): string {
95
+ const parts: string[] = ['uv', 'run'];
96
+
97
+ if (args.with_deps && args.with_deps.length > 0) {
98
+ for (const dep of args.with_deps) {
99
+ parts.push('--with', dep);
100
+ }
101
+ }
102
+
103
+ if (args.python) {
104
+ parts.push('-p', args.python);
105
+ }
106
+
107
+ parts.push(script);
108
+
109
+ if (args.script_args && args.script_args.length > 0) {
110
+ parts.push(...args.script_args);
111
+ }
112
+
113
+ return parts.join(' ');
114
+ }
115
+
116
+ /**
117
+ * Execute 'scheduled ps' command
118
+ * Lists scheduled jobs
119
+ */
120
+ export async function scheduledPsCommand(args: ScheduledPsArgs, client: JobsApiClient): Promise<string> {
121
+ // Fetch all scheduled jobs
122
+ const allJobs = await client.listScheduledJobs(args.namespace);
123
+
124
+ // Filter jobs
125
+ let jobs = allJobs;
126
+
127
+ // Default: hide suspended jobs unless --all is specified
128
+ if (!args.all) {
129
+ jobs = jobs.filter((job) => !job.suspend);
130
+ }
131
+
132
+ // Format as markdown table
133
+ const table = formatScheduledJobsTable(jobs);
134
+
135
+ if (jobs.length === 0) {
136
+ if (args.all) {
137
+ return 'No scheduled jobs found.';
138
+ }
139
+ return 'No active scheduled jobs found. Use `{"all": true}` to show suspended jobs.';
140
+ }
141
+
142
+ return `**Scheduled Jobs (${jobs.length} of ${allJobs.length} total):**
143
+
144
+ ${table}`;
145
+ }
146
+
147
+ /**
148
+ * Execute 'scheduled inspect' command
149
+ * Gets details of a scheduled job
150
+ */
151
+ export async function scheduledInspectCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
152
+ const job = await client.getScheduledJob(args.scheduled_job_id, args.namespace);
153
+ const formattedDetails = formatScheduledJobDetails(job);
154
+ return `**Scheduled Job Details:**\n\n${formattedDetails}`;
155
+ }
156
+
157
+ /**
158
+ * Execute 'scheduled delete' command
159
+ * Deletes a scheduled job
160
+ */
161
+ export async function scheduledDeleteCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
162
+ await client.deleteScheduledJob(args.scheduled_job_id, args.namespace);
163
+
164
+ return `✓ Scheduled job ${args.scheduled_job_id} has been deleted.`;
165
+ }
166
+
167
+ /**
168
+ * Execute 'scheduled suspend' command
169
+ * Suspends a scheduled job
170
+ */
171
+ export async function scheduledSuspendCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
172
+ await client.suspendScheduledJob(args.scheduled_job_id, args.namespace);
173
+
174
+ return `✓ Scheduled job ${args.scheduled_job_id} has been suspended.
175
+
176
+ To resume: \`hf_jobs("scheduled resume", {"scheduled_job_id": "${args.scheduled_job_id}"})\``;
177
+ }
178
+
179
+ /**
180
+ * Execute 'scheduled resume' command
181
+ * Resumes a suspended scheduled job
182
+ */
183
+ export async function scheduledResumeCommand(args: ScheduledJobArgs, client: JobsApiClient): Promise<string> {
184
+ await client.resumeScheduledJob(args.scheduled_job_id, args.namespace);
185
+
186
+ return `✓ Scheduled job ${args.scheduled_job_id} has been resumed.
187
+
188
+ To inspect: \`hf_jobs("scheduled inspect", {"scheduled_job_id": "${args.scheduled_job_id}"})\``;
189
+ }
@@ -0,0 +1,158 @@
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
+ * Create a JobSpec from run command arguments
126
+ */
127
+ export function createJobSpec(args: {
128
+ image: string;
129
+ command: string | string[];
130
+ flavor?: string;
131
+ env?: Record<string, string>;
132
+ secrets?: Record<string, string>;
133
+ timeout?: string;
134
+ }): JobSpec {
135
+ // Validate required fields
136
+ if (!args.image) {
137
+ throw new Error('image parameter is required. Provide a Docker image (e.g., "python:3.12") or Space URL.');
138
+ }
139
+ if (!args.command) {
140
+ throw new Error('command parameter is required. Provide a command as string or array.');
141
+ }
142
+
143
+ const imageSource = parseImageSource(args.image);
144
+ const { command, arguments: cmdArgs } = parseCommand(args.command);
145
+ const timeoutSeconds = args.timeout ? parseTimeout(args.timeout) : undefined;
146
+
147
+ const spec: JobSpec = {
148
+ ...imageSource,
149
+ command,
150
+ arguments: cmdArgs,
151
+ flavor: args.flavor || 'cpu-basic',
152
+ environment: args.env || {}, // API requires object, not undefined
153
+ secrets: args.secrets || {}, // Same for secrets
154
+ timeoutSeconds,
155
+ };
156
+
157
+ return spec;
158
+ }
@@ -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
+ }