Skip to content

Commit 1255d2f

Browse files
l2yshoclaudeszaganek
authored
feat(create): machine-readable --json output and hidden --origin flag (#1280)
Closes #1238. Stacked on #1278 — rebase onto master after that merges. Makes `apify create` machine-readable for agents and the Console "Clone locally" handoff. ## What changed - **`--json` is strictly non-interactive.** A missing name or `--template` fails before `mkdir`, naming the flag to pass. Gated on the flag rather than `isTTY`, so it errors deterministically instead of hanging on a prompt under a pty. - **Hidden `--origin console|cli`** (default `cli`) → recorded in `create` telemetry for the funnel. - **stdout hygiene.** `apify create --json | jq` used to fail on any call that didn't pass both `--skip-git-init` and `--skip-dependency-install` — i.e. the bare call agents and Console actually make. Two causes: - child processes inherited our stdout, putting `git init` and the whole installer transcript ahead of the payload. `keepStdoutClean()` routes child stdout to stderr, latched by the command framework for every command with `enableJsonFlag`. - `@inquirer/core` writes to `process.stdout` unless handed a context. All five prompt wrappers now render on stderr — correct independent of `--json`. - **`--template` accepts the manifest `id` as well as `name`.** 19 of 43 templates have `id !== name`, and the `--json` payload reports `id`, so the value the contract emitted was rejected on round-trip. The `--help` example was broken for the same reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Edyta <142720610+szaganek@users.noreply.github.com>
1 parent b5bbc08 commit 1255d2f

17 files changed

Lines changed: 275 additions & 54 deletions

File tree

docs/reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ DESCRIPTION
277277
directory.
278278
279279
USAGE
280-
$ apify create [actorName]
280+
$ apify create [actorName] [--json]
281281
[-l javascript|js|typescript|ts|python|py]
282282
[--omit-optional-deps] [--skip-dependency-install]
283283
[--skip-git-init] [-t <value>]
@@ -287,6 +287,8 @@ ARGUMENTS
287287
actorName Name of the Actor and its directory.
288288
289289
FLAGS
290+
--json Format the command
291+
output as JSON.
290292
-l, --language=<option> Filter templates by
291293
programming language. Ignored when --template is
292294
provided.

skills/apify/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ See https://apify.com/auth.md for how to authenticate. Do not assume `APIFY_TOKE
2929
## Structured output
3030

3131
- `--json` is supported on most list/info commands (`apify actors ls --json`, `apify actors info <id> --json`, `apify datasets info <id> --json`, `apify runs ls --json`, etc.). Use it and parse with `jq`; don't scrape the human table.
32+
- `apify create <name> --template <template> --json` prints `{ dir, actorJsonPath, template, source, nextSteps, postCreate, gitRepositoryInitialized }` on stdout. Everything else goes to stderr, so stdout is safe to pipe into `jq`. `postCreate` is non-null when the template needs extra setup before `apify run` works.
3233
- List commands paginate — control with `--limit` / `--offset` (and `--desc`).
3334
- Dataset items: `apify datasets get-items <datasetId> --format json`. Use `--limit` / `--offset`.
3435

src/commands/create.ts

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
SUPPORTED_NODEJS_VERSION,
1919
} from '../lib/consts.js';
2020
import {
21+
buildNextSteps,
2122
enhanceReadmeWithLocalSuffix,
2223
ensureValidActorName,
2324
formatCreateSuccessMessage,
@@ -36,6 +37,7 @@ import {
3637
getJsonFileContent,
3738
isNodeVersionSupported,
3839
isPythonVersionSupported,
40+
printJsonToStdout,
3941
setLocalConfig,
4042
setLocalEnv,
4143
} from '../lib/utils.js';
@@ -111,6 +113,13 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
111113
description: 'Skip initializing a git repository in the Actor directory.',
112114
required: false,
113115
}),
116+
origin: Flags.string({
117+
description: 'Where the command was invoked from. Used for funnel telemetry.',
118+
choices: ['console', 'cli'],
119+
default: 'cli',
120+
required: false,
121+
hidden: true,
122+
}),
114123
};
115124

116125
static override args = {
@@ -120,15 +129,29 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
120129
}),
121130
};
122131

132+
static override enableJsonFlag = true;
133+
123134
async run() {
124135
let { actorName } = this.args;
125-
const { template: templateName, useCase, language, skipDependencyInstall, skipGitInit } = this.flags;
136+
const { template: templateName, useCase, language, skipDependencyInstall, skipGitInit, origin, json } = this.flags;
126137

127138
// --template-archive-url is an internal, undocumented flag that's used
128139
// for testing of templates that are not yet published in the manifest
129140
let { templateArchiveUrl } = this.flags;
130141
let skipOptionalDeps = false;
131142

143+
// `--json` implies non-interactive: a caller parsing stdout cannot answer a prompt. Reject
144+
// before creating any directories so a failed run leaves nothing behind.
145+
if (json && !actorName) {
146+
throw new Error('--json runs non-interactively. Pass the Actor name as an argument.');
147+
}
148+
149+
if (json && !templateName && !templateArchiveUrl) {
150+
throw new Error(
151+
'--json runs non-interactively. Pass --template <name>; run `apify templates ls` to list values.',
152+
);
153+
}
154+
132155
// Start fetching manifest immediately to prevent
133156
// annoying delays that sometimes happen on CLI startup.
134157
const manifestPromise = fetchManifest().catch((err) => {
@@ -149,11 +172,15 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
149172
.catch(() => false));
150173

151174
if (folderExists?.isDirectory() && folderHasFiles) {
152-
error({
153-
message:
154-
`Cannot create new Actor, directory '${actorName}' already exists. Please provide a different name.` +
155-
' You can use "apify init" to create a local Actor environment inside an existing directory.',
156-
});
175+
const message =
176+
`Cannot create new Actor, directory '${actorName}' already exists. Provide a different name.` +
177+
' To create a local Actor environment inside an existing directory, use "apify init".';
178+
179+
if (json) {
180+
throw new Error(message);
181+
}
182+
183+
error({ message });
157184

158185
actorName = await ensureValidActorName();
159186
actFolderDir = join(cwd, actorName);
@@ -169,14 +196,17 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
169196
}
170197

171198
let messages = null;
199+
let templateId: string | null = null;
172200

173201
this.telemetryData.create = {
174202
fromArchiveUrl: !!templateArchiveUrl,
203+
origin,
175204
};
176205

177206
if (!templateArchiveUrl) {
178207
const templateDefinition = await getTemplateDefinition(templateName, manifestPromise, { useCase, language });
179208
({ archiveUrl: templateArchiveUrl, messages } = templateDefinition);
209+
templateId = templateDefinition.id;
180210
this.telemetryData.create.templateId = templateDefinition.id;
181211
this.telemetryData.create.templateName = templateDefinition.name;
182212
this.telemetryData.create.templateLanguage = templateDefinition.category;
@@ -389,8 +419,9 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
389419
// Initialize git repository before reporting success, but store result for later
390420
let gitInitResult: { success: boolean; error?: Error } = { success: true };
391421
const cwdHasGit = await stat(join(cwd, '.git')).catch(() => null);
422+
const gitInitAttempted = !skipGitInit && !cwdHasGit;
392423

393-
if (!skipGitInit && !cwdHasGit) {
424+
if (gitInitAttempted) {
394425
try {
395426
await execWithLog({
396427
cmd: 'git',
@@ -405,20 +436,33 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
405436
// Suggest install command if dependencies were not installed
406437
const installCommandSuggestion = !dependenciesInstalled ? await getInstallCommandSuggestion(actFolderDir) : null;
407438

408-
// Success message with extra empty line
409-
simpleLog({ message: '' });
410-
success({
411-
message: formatCreateSuccessMessage({
412-
actorName,
413-
dependenciesInstalled,
439+
const gitRepositoryInitialized = gitInitAttempted && gitInitResult.success;
440+
441+
if (json) {
442+
printJsonToStdout({
443+
dir: actFolderDir,
444+
actorJsonPath: join(actFolderDir, LOCAL_CONFIG_PATH),
445+
template: templateId,
446+
source: 'apify',
447+
nextSteps: buildNextSteps({ actorName, dependenciesInstalled, installCommandSuggestion }),
448+
// Some templates need extra setup (e.g. "playwright install") before "apify run" works.
414449
postCreate: messages?.postCreate ?? null,
415-
gitRepositoryInitialized: !skipGitInit && !cwdHasGit && gitInitResult.success,
416-
installCommandSuggestion,
417-
}),
418-
});
450+
gitRepositoryInitialized,
451+
});
452+
} else {
453+
simpleLog({ message: '' });
454+
success({
455+
message: formatCreateSuccessMessage({
456+
actorName,
457+
dependenciesInstalled,
458+
postCreate: messages?.postCreate ?? null,
459+
gitRepositoryInitialized,
460+
installCommandSuggestion,
461+
}),
462+
});
463+
}
419464

420-
// Report git initialization result only if it failed (success already included in success message)
421-
if (!skipGitInit && !cwdHasGit && !gitInitResult.success) {
465+
if (gitInitAttempted && !gitInitResult.success) {
422466
// Git init is not critical, so we just warn if it fails
423467
warning({ message: `Failed to initialize git repository: ${gitInitResult.error!.message}` });
424468
warning({ message: 'You can manually run "git init" in the Actor directory if needed.' });

src/commands/templates/ls.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { fetchManifest } from '@apify/actor-templates';
33
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
44
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
55
import { info, simpleLog } from '../../lib/outputs.js';
6+
import { languageLabel } from '../../lib/templates/consts.js';
67
import { printJsonToStdout } from '../../lib/utils.js';
78

89
const table = new ResponsiveTable({
@@ -50,7 +51,7 @@ export class TemplatesLsCommand extends ApifyCommand<typeof TemplatesLsCommand>
5051
table.pushRow({
5152
Template: template.name,
5253
Label: template.label,
53-
Language: template.category,
54+
Language: languageLabel(template.category),
5455
'Use cases': (template.useCases ?? []).join(', '),
5556
});
5657
}

src/lib/command-framework/apify-command.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import widestLine from 'widest-line';
1010
import wrapAnsi from 'wrap-ansi';
1111

1212
import { cachedStdinInput } from '../../entrypoints/_shared.js';
13+
import { keepStdoutClean } from '../exec.js';
1314
import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js';
1415
import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js';
1516
import { trackEvent } from '../hooks/telemetry/trackEvent.js';
@@ -338,6 +339,10 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
338339
} else {
339340
this.flags.json = false;
340341
}
342+
343+
if (this.flags.json) {
344+
keepStdoutClean();
345+
}
341346
}
342347

343348
const missingRequiredArgs = new Map<string, TaggedArgBuilder<ArgTag, unknown>>();

src/lib/create-utils.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export async function getTemplateDefinition(
4747
if (manifest instanceof Error) throw manifest;
4848

4949
if (maybeTemplateName) {
50-
const templateDefinition = manifest.templates.find((t) => t.name === maybeTemplateName);
50+
// Accept both the template name and its id — `--json` output and older docs reference the id.
51+
const templateDefinition = manifest.templates.find(
52+
(t) => t.name === maybeTemplateName || t.id === maybeTemplateName,
53+
);
5154
if (!templateDefinition) {
5255
throw new Error(`Could not find the selected template: ${maybeTemplateName} in the list of templates.`);
5356
}
@@ -77,6 +80,22 @@ export async function enhanceReadmeWithLocalSuffix(readmePath: string, manifestP
7780
}
7881
}
7982

83+
export function buildNextSteps(params: {
84+
actorName: string;
85+
dependenciesInstalled: boolean;
86+
installCommandSuggestion?: string | null;
87+
}): string[] {
88+
const { actorName, dependenciesInstalled, installCommandSuggestion } = params;
89+
90+
const steps = [`cd "${actorName}"`];
91+
if (!dependenciesInstalled) {
92+
steps.push(installCommandSuggestion || 'install dependencies with your package manager');
93+
}
94+
steps.push('apify run');
95+
96+
return steps;
97+
}
98+
8099
export function formatCreateSuccessMessage(params: {
81100
actorName: string;
82101
dependenciesInstalled: boolean;
@@ -88,12 +107,8 @@ export function formatCreateSuccessMessage(params: {
88107

89108
let message = `✅ Actor '${actorName}' created successfully!`;
90109

91-
if (dependenciesInstalled) {
92-
message += `\n\nNext steps:\n\ncd "${actorName}"\napify run`;
93-
} else {
94-
const installLine = installCommandSuggestion || 'install dependencies with your package manager';
95-
message += `\n\nNext steps:\n\ncd "${actorName}"\n${installLine}\napify run`;
96-
}
110+
const nextSteps = buildNextSteps({ actorName, dependenciesInstalled, installCommandSuggestion });
111+
message += `\n\nNext steps:\n\n${nextSteps.join('\n')}`;
97112

98113
message += `\n\n💡 Tip: Use 'apify push' to deploy your Actor to the Apify platform\n📖 Docs: https://docs.apify.com/platform/actors/development`;
99114

src/lib/exec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import process from 'node:process';
2+
13
import { Result } from '@sapphire/result';
24
import { execa, type ExecaError, type Options } from 'execa';
35

46
import { normalizeExecutablePath } from './hooks/runtimes/utils.js';
57
import { error, run } from './outputs.js';
68
import { cliDebugPrint } from './utils/cliDebugPrint.js';
79

10+
let childStdout: 'inherit' | typeof process.stderr = 'inherit';
11+
12+
/** Route child process stdout to our stderr, so it cannot corrupt a machine-readable payload. */
13+
export function keepStdoutClean() {
14+
childStdout = process.stderr;
15+
}
16+
817
interface SpawnPromisedInternalOptions {
918
/**
1019
* Signals that should be forwarded from the parent process to the spawned
@@ -31,7 +40,7 @@ const spawnPromised = async (
3140
env: opts.env,
3241
cwd: opts.cwd,
3342
// Pipe means it gets collected by the parent process, inherit means it gets collected by the parent process and printed out to the console
34-
stdout: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
43+
stdout: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', childStdout],
3544
stderr: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
3645
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
3746
});

src/lib/hooks/telemetry/trackEvent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface CliCommandEvent {
3333
templateId?: string;
3434
templateName?: string;
3535
templateLanguage?: string;
36+
origin?: 'console' | 'cli';
3637
};
3738

3839
push?: {

src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
import process from 'node:process';
2+
13
import { isCI } from 'ci-info';
24

35
import { useStdin } from '../useStdin.js';
46

7+
/**
8+
* Inquirer renders to stdout by default. Prompts are UI, not command output, so they must stay off
9+
* stdout, otherwise they corrupt the payload of commands invoked with `--json`.
10+
*/
11+
export const promptContext = { output: process.stderr };
12+
513
export interface StdinCheckWrapperInput<ReturnedType> extends StdinCheckWrapperOptions {
614
/**
715
* When set, this value will be used in environments where stdin is not available.
@@ -45,11 +53,7 @@ export function stdinCheckWrapper<Fn extends (...args: any[]) => any>(
4553

4654
if (isCI || (!isTTY && !hasData)) {
4755
if (typeof casted.providedConfirmFromStdin === 'undefined') {
48-
throw new Error(
49-
casted.errorMessageForStdin ??
50-
errorMessageForStdin ??
51-
`Please use the --${ConfirmFlag}/--${NoConfirmFlag} flags to confirm the action.`,
52-
);
56+
throw new Error(casted.errorMessageForStdin ?? errorMessageForStdin);
5357
}
5458

5559
return casted.providedConfirmFromStdin;

src/lib/hooks/user-confirmations/useInputConfirmation.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import input from '@inquirer/input';
22

3-
import { stdinCheckWrapper } from './_stdinCheckWrapper.js';
3+
import { promptContext, stdinCheckWrapper } from './_stdinCheckWrapper.js';
44

55
interface UseInputConfirmationInput {
66
message: string;
@@ -10,16 +10,19 @@ interface UseInputConfirmationInput {
1010

1111
export const useInputConfirmation = stdinCheckWrapper(
1212
async ({ message, expectedValue, failureMessage }: UseInputConfirmationInput) => {
13-
const result = await input({
14-
message,
15-
validate(value) {
16-
if (value === expectedValue) {
17-
return true;
18-
}
13+
const result = await input(
14+
{
15+
message,
16+
validate(value) {
17+
if (value === expectedValue) {
18+
return true;
19+
}
1920

20-
return failureMessage ?? 'That is not the correct input!';
21+
return failureMessage ?? 'That is not the correct input!';
22+
},
2123
},
22-
});
24+
promptContext,
25+
);
2326

2427
return result;
2528
},

0 commit comments

Comments
 (0)