requestshield 0.1.6 → 0.1.8

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 (36) hide show
  1. package/README.md +177 -68
  2. package/config/.env.prod +1 -0
  3. package/package.json +1 -1
  4. package/skills/requestshield/SKILL.md +20 -17
  5. package/skills/requestshield/assets/AGENTS.codex.md +5 -3
  6. package/skills/requestshield/references/backend-java-core.md +3 -3
  7. package/skills/requestshield/references/backend-spring-boot.md +3 -3
  8. package/skills/requestshield/references/browser-manual.md +1 -1
  9. package/skills/requestshield/references/cli.md +92 -28
  10. package/skills/requestshield/references/integration-planning.md +10 -8
  11. package/skills/requestshield/references/troubleshooting.md +2 -2
  12. package/src/api-client.mjs +1 -1
  13. package/src/args.mjs +98 -106
  14. package/src/cli.mjs +69 -243
  15. package/src/command-registry.mjs +97 -0
  16. package/src/commands/agent-setup.mjs +24 -17
  17. package/src/commands/agent-status.mjs +60 -0
  18. package/src/commands/application-mutations.mjs +8 -4
  19. package/src/commands/apps-get.mjs +12 -4
  20. package/src/commands/apps-list.mjs +20 -13
  21. package/src/commands/contract.mjs +25 -0
  22. package/src/commands/keys-create.mjs +5 -2
  23. package/src/commands/mutation-support.mjs +26 -12
  24. package/src/commands/output.mjs +21 -0
  25. package/src/commands/secret-commands.mjs +13 -6
  26. package/src/commands/signin.mjs +17 -8
  27. package/src/commands/signout.mjs +7 -3
  28. package/src/commands/update-check.mjs +97 -43
  29. package/src/config.mjs +29 -3
  30. package/src/entrypoint.mjs +20 -13
  31. package/src/integration-contract-client.mjs +81 -0
  32. package/src/integration-contract.mjs +104 -0
  33. package/src/oauth-client.mjs +2 -2
  34. package/src/oauth-loopback.mjs +1 -1
  35. package/src/session-files.mjs +4 -4
  36. package/src/session-store.mjs +2 -2
package/src/cli.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
-
4
3
  import { parseArgs } from "./args.mjs";
4
+ import { getCommandDefinition } from "./command-registry.mjs";
5
5
  import { ManagementApiClient } from "./api-client.mjs";
6
6
  import { SessionStore } from "./session-store.mjs";
7
7
  import { OAuthClient } from "./oauth-client.mjs";
8
-
9
8
  import { signin } from "./commands/signin.mjs";
10
9
  import { createKeys } from "./commands/keys-create.mjs";
11
10
  import { renameApp, setAppEnabled } from "./commands/application-mutations.mjs";
@@ -15,263 +14,90 @@ import { signout } from "./commands/signout.mjs";
15
14
  import { listApps } from "./commands/apps-list.mjs";
16
15
  import { getApp } from "./commands/apps-get.mjs";
17
16
  import { setupAgent } from "./commands/agent-setup.mjs";
18
- import { checkForUpdate } from "./commands/update-check.mjs";
19
-
17
+ import { showAgentStatus } from "./commands/agent-status.mjs";
18
+ import { checkForUpdate, applyUpdate } from "./commands/update-check.mjs";
19
+ import { showContract } from "./commands/contract.mjs";
20
20
  import packageJson from "../package.json" with { type: "json" };
21
21
  import { getApiUrl, getAuthorizationIssuer, getCommandInvocation, getCommandName, getOAuthConfig } from "./config.mjs";
22
22
 
23
- /**
24
- * @typedef {{
25
- * log: (message: string) => void,
26
- * warn?: (message: string) => void,
27
- * api?: ManagementApiClient,
28
- * sessions?: SessionStore,
29
- * oauth?: OAuthClient,
30
- * authorizationIssuer?: string,
31
- * openBrowser?: typeof import('./browser-opener.mjs').openBrowser,
32
- * signal?: AbortSignal,
33
- * env?: NodeJS.ProcessEnv,
34
- * profile?: import('./config.mjs').Profile,
35
- * homeDir?: string,
36
- * sourceDir?: string,
37
- * executablePath?: string,
38
- * detectAgents?: () => Promise<Array<"codex" | "claude">>,
39
- * selectAgent?: (
40
- * detected: Array<"codex" | "claude">
41
- * ) => Promise<"codex" | "claude">,
42
- * wait?: (milliseconds: number) => Promise<unknown>,
43
- * confirm?: () => Promise<boolean>,
44
- * fetchImpl?: typeof fetch,
45
- * confirmUpdate?: () => Promise<boolean>,
46
- * installLatest?: (
47
- * packageName: string,
48
- * version: string
49
- * ) => Promise<void>
50
- * }} CommandContext
51
- */
52
-
53
- /**
54
- * @typedef {CommandContext & {
55
- * api: ManagementApiClient,
56
- * sessions: SessionStore
57
- * }} AuthenticatedCommandContext
58
- */
59
-
60
- /**
61
- * @typedef {{
62
- * requiresApiSession: boolean,
63
- * handler: (
64
- * parsed: any,
65
- * context: CommandContext
66
- * ) => unknown | Promise<unknown>
67
- * }} CommandHandler
68
- */
69
-
70
- /**
71
- * Put every CLI command handler here.
72
- *
73
- * @type {Record<string, CommandHandler>}
74
- */
75
- const COMMAND_HANDLERS = {
76
- "help": {
77
- requiresApiSession: false,
78
- handler: (parsed, context) =>
79
- context.log(parsed.help.replace(/^ requestshield /gm, ` ${getCommandInvocation(context.profile)} `)),
80
- },
81
-
82
- "version": {
83
- requiresApiSession: false,
84
- handler: (_parsed, context) =>
85
- context.log(`${getCommandName(context.profile)} ${packageJson.version}`),
86
- },
87
-
88
- "update-check": {
89
- requiresApiSession: false,
90
- handler: (_parsed, context) =>
91
- checkForUpdate({
92
- profile: context.profile,
93
- currentVersion: packageJson.version,
94
- packageName: packageJson.name,
95
- log: context.log,
96
- fetchImpl: context.fetchImpl,
97
- confirm: context.confirmUpdate,
98
- install: context.installLatest,
99
- }),
100
- },
101
-
102
- "agent-setup": {
103
- requiresApiSession: false,
104
- handler: (parsed, context) =>
105
- setupAgent(parsed, context),
106
- },
107
-
108
- "signin": {
109
- requiresApiSession: false,
110
- handler: (parsed, context) => {
111
- const env = context.env ?? process.env;
112
- const oauth = context.oauth ?? new OAuthClient({config: getOAuthConfig(context.profile), fetchImpl: context.fetchImpl});
113
- const authorizationIssuer = context.authorizationIssuer ?? (context.oauth
114
- ? oauth.config.issuer : getAuthorizationIssuer(context.profile, oauth.config.issuer));
115
- const sessions = context.sessions ?? new SessionStore({env, homeDir: context.homeDir, oauth, config: oauth.config, profile: context.profile});
116
- return signin(parsed, {...context, oauth, sessions, authorizationIssuer});
117
- },
118
- },
119
-
120
- "keys-create": {
121
- requiresApiSession: true,
122
- handler: (parsed, context) =>
123
- createKeys(parsed, requireApiSession(context)),
124
- },
125
-
126
- "keys-rotate": {requiresApiSession: true, handler: (parsed, context) => rotateSecret(parsed, requireApiSession(context))},
127
- "keys-reveal": {requiresApiSession: true, handler: (parsed, context) => revealSecret(parsed, requireApiSession(context))},
128
- "keys-revoke": {requiresApiSession: true, handler: (parsed, context) => revokeSecret(parsed, requireApiSession(context))},
129
- "apps-rename": {requiresApiSession: true, handler: (parsed, context) => renameApp(parsed, requireApiSession(context))},
130
- "apps-enable": {requiresApiSession: true, handler: (parsed, context) => setAppEnabled(parsed, requireApiSession(context))},
131
- "apps-disable": {requiresApiSession: true, handler: (parsed, context) => setAppEnabled(parsed, requireApiSession(context))},
132
- "auth-status": {requiresApiSession: false, handler: (parsed, context) => showAuthStatus(parsed, {...context, sessions: localSessions(context)})},
133
- "signout": {requiresApiSession: false, handler: (parsed, context) => signout(parsed, {...context, sessions: localSessions(context)})},
134
-
135
- "apps-list": {
136
- requiresApiSession: true,
137
- handler: (parsed, context) =>
138
- listApps(parsed, requireApiSession(context)),
139
- },
23
+ /** @typedef {{
24
+ * log: (message: string) => void, warn?: (message: string) => void,
25
+ * api?: ManagementApiClient, sessions?: SessionStore, oauth?: OAuthClient,
26
+ * authorizationIssuer?: string, openBrowser?: typeof import('./browser-opener.mjs').openBrowser,
27
+ * signal?: AbortSignal, env?: NodeJS.ProcessEnv, profile?: import('./config.mjs').Profile,
28
+ * homeDir?: string, sourceDir?: string, executablePath?: string,
29
+ * detectAgents?: () => Promise<Array<'codex' | 'claude'>>,
30
+ * selectAgent?: (detected: Array<'codex' | 'claude'>) => Promise<'codex' | 'claude'>,
31
+ * confirm?: () => Promise<boolean>, fetchImpl?: typeof fetch,
32
+ * confirmUpdate?: () => Promise<boolean>, installLatest?: (packageName: string, version: string) => Promise<void>
33
+ * }} CommandContext */
34
+
35
+ /** Runtime bindings only. Syntax, help and dependency policy live in the registry.
36
+ * @type {Record<string, (parsed: any, context: CommandContext) => unknown | Promise<unknown>>} */
37
+ export const COMMAND_HANDLERS = {
38
+ help: (parsed, context) => context.log(parsed.help.replace(/^ requestshield /gm, ` ${getCommandInvocation(context.profile)} `)),
39
+ version: (_parsed, context) => context.log(`${getCommandName(context.profile)} ${packageJson.version}`),
40
+ login: login,
41
+ logout: (parsed, context) => signout(parsed, {...context, sessions: localSessions(context)}),
42
+ "auth-status": (parsed, context) => showAuthStatus(parsed, {...context, sessions: localSessions(context)}),
43
+ contract: (parsed, context) => showContract(parsed, context),
44
+ "app-create": (parsed, context) => createKeys(parsed, requireApiSession(context)),
45
+ "app-list": (parsed, context) => listApps(parsed, requireApiSession(context)),
46
+ "app-get": (parsed, context) => getApp(parsed, requireApiSession(context)),
47
+ "app-rename": (parsed, context) => renameApp(parsed, requireApiSession(context)),
48
+ "app-enable": (parsed, context) => setAppEnabled(parsed, requireApiSession(context)),
49
+ "app-disable": (parsed, context) => setAppEnabled(parsed, requireApiSession(context)),
50
+ "secret-rotate": (parsed, context) => rotateSecret(parsed, requireApiSession(context)),
51
+ "secret-reveal": (parsed, context) => revealSecret(parsed, requireApiSession(context)),
52
+ "secret-revoke": (parsed, context) => revokeSecret(parsed, requireApiSession(context)),
53
+ "agent-setup": (parsed, context) => setupAgent(parsed, context),
54
+ "agent-status": (parsed, context) => showAgentStatus(parsed, context),
55
+ "update-check": (parsed, context) => checkForUpdate(updateOptions(parsed, context)),
56
+ "update-apply": (parsed, context) => applyUpdate(updateOptions(parsed, context)),
57
+ };
140
58
 
141
- "apps-get": {
142
- requiresApiSession: true,
143
- handler: (parsed, context) =>
144
- getApp(parsed, requireApiSession(context)),
145
- },
59
+ /** @param {{json?: boolean, yes?: boolean}} parsed @param {CommandContext} context */
60
+ function updateOptions(parsed, context) {
61
+ return {...parsed, profile: context.profile, currentVersion: packageJson.version, packageName: packageJson.name,
62
+ log: context.log, warn: context.warn, fetchImpl: context.fetchImpl, confirm: context.confirmUpdate, install: context.installLatest};
63
+ }
146
64
 
147
- };
65
+ /** @param {{noOpen?: boolean, json?: boolean}} parsed @param {CommandContext} context */
66
+ function login(parsed, context) {
67
+ const env = context.env ?? process.env;
68
+ const oauth = context.oauth ?? new OAuthClient({config: getOAuthConfig(context.profile), fetchImpl: context.fetchImpl});
69
+ const authorizationIssuer = context.authorizationIssuer ?? (context.oauth
70
+ ? oauth.config.issuer : getAuthorizationIssuer(context.profile, oauth.config.issuer));
71
+ const sessions = context.sessions ?? new SessionStore({env, homeDir: context.homeDir, oauth, config: oauth.config, profile: context.profile});
72
+ return signin(parsed, {...context, oauth, sessions, authorizationIssuer});
73
+ }
148
74
 
149
75
  /** @param {CommandContext} context */
150
76
  function localSessions(context) {
151
77
  return context.sessions ?? new SessionStore({env: context.env, homeDir: context.homeDir, profile: context.profile});
152
78
  }
153
79
 
154
- /**
155
- * Narrow the shared context before an authenticated handler can use it.
156
- *
157
- * @param {CommandContext} context
158
- * @returns {AuthenticatedCommandContext}
159
- */
80
+ /** @param {CommandContext} context */
160
81
  function requireApiSession(context) {
161
- if (!context.api || !context.sessions) {
162
- throw new Error("Authenticated command context was not initialized");
163
- }
164
-
165
- return {
166
- ...context,
167
- api: context.api,
168
- sessions: context.sessions,
169
- };
82
+ if (!context.api || !context.sessions) throw new Error("Authenticated command context was not initialized");
83
+ return {...context, api: context.api, sessions: context.sessions};
170
84
  }
171
85
 
172
- /**
173
- * @param {string[]} argv
174
- * @param {{
175
- * log?: (message: string) => void,
176
- * warn?: (message: string) => void,
177
- * api?: ManagementApiClient,
178
- * sessions?: SessionStore,
179
- * oauth?: OAuthClient,
180
- * authorizationIssuer?: string,
181
- * openBrowser?: typeof import('./browser-opener.mjs').openBrowser,
182
- * signal?: AbortSignal,
183
- * env?: NodeJS.ProcessEnv,
184
- * profile?: import('./config.mjs').Profile,
185
- * homeDir?: string,
186
- * sourceDir?: string,
187
- * executablePath?: string,
188
- * detectAgents?: () => Promise<Array<"codex" | "claude">>,
189
- * selectAgent?: (
190
- * detected: Array<"codex" | "claude">
191
- * ) => Promise<"codex" | "claude">,
192
- * wait?: (milliseconds: number) => Promise<unknown>,
193
- * confirm?: () => Promise<boolean>,
194
- * fetchImpl?: typeof fetch,
195
- * confirmUpdate?: () => Promise<boolean>,
196
- * installLatest?: (
197
- * packageName: string,
198
- * version: string
199
- * ) => Promise<void>
200
- * }} [deps]
201
- */
86
+ /** @param {string[]} argv @param {Partial<CommandContext>} [deps] */
202
87
  export async function run(argv, deps = {}) {
88
+ // Parse and reject unavailable commands before reading configuration or sessions.
89
+ const parsed = parseArgs(argv);
203
90
  const profile = deps.profile ?? "prod";
204
91
  getCommandName(profile);
205
- const parsed = parseArgs(argv);
206
-
207
- const log =
208
- deps.log ??
209
- ((message) => console.log(message));
210
-
211
- /*
212
- * Find the requested command.
213
- */
214
- const command = COMMAND_HANDLERS[parsed.command];
215
-
216
- if (!command) {
217
- throw new Error(
218
- `Unsupported command: ${String(parsed.command)}`
219
- );
92
+ const definition = getCommandDefinition(parsed.command);
93
+ const handler = definition?.handler ? COMMAND_HANDLERS[definition.handler] : undefined;
94
+ if (!handler) throw new Error("Unsupported command handler");
95
+ const context = {...deps, profile, log: deps.log ?? console.log, warn: deps.warn ?? console.error};
96
+ if (definition?.requiresApiSession) {
97
+ return handler(parsed, {...context,
98
+ api: deps.api ?? new ManagementApiClient({baseUrl: getApiUrl(profile), fetchImpl: deps.fetchImpl}),
99
+ sessions: localSessions(context),
100
+ });
220
101
  }
221
-
222
- /*
223
- * Dependencies available to every command.
224
- */
225
- /** @type {CommandContext} */
226
- const context = {
227
- ...deps,
228
- profile,
229
- log,
230
- warn: deps.warn ?? ((message) => console.error(message)),
231
- };
232
-
233
- /*
234
- * Only initialize the Management API and session
235
- * for commands that require them.
236
- */
237
- if (command.requiresApiSession) {
238
- const env =
239
- deps.env ??
240
- process.env;
241
-
242
- const api =
243
- deps.api ??
244
- new ManagementApiClient({
245
- baseUrl: getApiUrl(profile),
246
- fetchImpl: deps.fetchImpl,
247
- });
248
-
249
- const sessions =
250
- deps.sessions ??
251
- new SessionStore({
252
- env,
253
- homeDir: deps.homeDir,
254
- profile,
255
- });
256
-
257
- const authenticatedContext = {
258
- ...context,
259
- env,
260
- api,
261
- sessions,
262
- };
263
-
264
- return command.handler(
265
- parsed,
266
- authenticatedContext
267
- );
268
- }
269
-
270
- /*
271
- * Execute commands that do not need an API session.
272
- */
273
- return command.handler(
274
- parsed,
275
- context
276
- );
102
+ return handler(parsed, context);
277
103
  }
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {"help"|"version"|"login"|"logout"|"auth-status"|"app-create"|"app-list"|"app-get"|"app-rename"|"app-enable"|"app-disable"|"secret-rotate"|"secret-reveal"|"secret-revoke"|"agent-setup"|"agent-status"|"update-check"|"update-apply"|"contract"|"service-status"|"secret-status"|"usage-challenges"|"billing-get"} CommandId
5
+ * @typedef {{name:string,field:string,required:boolean,kind:"app-name"|"app-key",description:string}} ArgumentDefinition
6
+ * @typedef {{name:string,kind:"boolean"|"value",description:string,value?:string,choices?:string[]}} FlagDefinition
7
+ * @typedef {{id:CommandId,path:string[],handler:CommandId|null,requiresApiSession:boolean,availability:"implemented"|"placeholder",arguments:ArgumentDefinition[],flags:FlagDefinition[],summary:string,examples:string[],notes?:string[],shortcuts?:string[]}} CommandDefinition
8
+ */
9
+
10
+ /** @type {FlagDefinition} */
11
+ const json = {name:"--json",kind:"boolean",description:"Write one JSON result to stdout."};
12
+ /** @type {FlagDefinition} */
13
+ const yes = {name:"--yes",kind:"boolean",description:"Confirm this operation without prompting."};
14
+ /** @type {FlagDefinition} */
15
+ const idempotency = {name:"--idempotency-key",kind:"value",value:"key",description:"Reuse the same key when retrying the identical request within seven days."};
16
+ /** @type {FlagDefinition} */
17
+ const noOpen = {name:"--no-open",kind:"boolean",description:"Print the login URL without opening a browser."};
18
+ /** @type {FlagDefinition[]} */
19
+ const agents = [
20
+ {name:"--codex",kind:"boolean",description:"Use the Codex RequestShield Skill."},
21
+ {name:"--claude",kind:"boolean",description:"Use the Claude RequestShield Skill."},
22
+ ];
23
+ /** @type {ArgumentDefinition} */
24
+ const appKey = {name:"app-key",field:"appKey",required:true,kind:"app-key",description:"The exact App Key, not the application name."};
25
+ /** @param {string} field @param {boolean} [required] @returns {ArgumentDefinition} */
26
+ const appName = (field, required = true) => ({name:"name",field,required,kind:"app-name",description:"Application name (1–100 characters); quote names containing spaces."});
27
+ const propagation = "Accepted changes propagate asynchronously. Acceptance does not mean the change is globally active.";
28
+
29
+ /**
30
+ * The complete public surface. Parsing, help and dispatch share this registry.
31
+ * @type {CommandDefinition[]}
32
+ */
33
+ export const COMMAND_REGISTRY = [
34
+ {id:"help",path:["--help"],shortcuts:["-h"],handler:"help",requiresApiSession:false,availability:"implemented",arguments:[],flags:[],summary:"Show help for the CLI, a group or a command.",examples:["--help","app create --help"]},
35
+ {id:"version",path:["--version"],shortcuts:["-v"],handler:"version",requiresApiSession:false,availability:"implemented",arguments:[],flags:[],summary:"Print the installed CLI version.",examples:["--version"]},
36
+ {id:"login",path:["login"],handler:"login",requiresApiSession:false,availability:"implemented",arguments:[],flags:[noOpen,json],summary:"Sign in through the browser using OAuth.",examples:["login","login --no-open"]},
37
+ {id:"logout",path:["logout"],handler:"logout",requiresApiSession:false,availability:"implemented",arguments:[],flags:[json],summary:"Remove the saved session for this profile.",examples:["logout"],notes:["Does not end your browser session or revoke the provider grant."]},
38
+ {id:"auth-status",path:["auth","status"],handler:"auth-status",requiresApiSession:false,availability:"implemented",arguments:[],flags:[json],summary:"Inspect saved local session metadata.",examples:["auth status --json"],notes:["Does not refresh the session or verify that the provider accepts it."]},
39
+ {id:"app-create",path:["app","create"],handler:"app-create",requiresApiSession:true,availability:"implemented",arguments:[appName("appName")],flags:[idempotency,json],summary:"Create an app and its first secret.",examples:["app create \"Checkout API\""],notes:["Copy the returned secret into protected backend configuration; the CLI does not save it.","Receipt replay can return apiSecret: null and never automatically reveals a secret.",propagation]},
40
+ {id:"app-list",path:["app","list"],handler:"app-list",requiresApiSession:true,availability:"implemented",arguments:[],flags:[{name:"--limit",kind:"value",value:"1-100",description:"Number of applications per page."},{name:"--cursor",kind:"value",value:"cursor",description:"Continue from an opaque page cursor."},{name:"--all",kind:"boolean",description:"Read every page, up to 100 pages."},json],summary:"List your apps.",examples:["app list","app list --all --json"],notes:["Defaults to one page. --cursor and --all are mutually exclusive.","App status is configuration state, not evidence of traffic or a working integration."]},
41
+ {id:"app-get",path:["app","get"],handler:"app-get",requiresApiSession:true,availability:"implemented",arguments:[appKey],flags:[json],summary:"Show an app's information.",examples:["app get pk_example --json"]},
42
+ {id:"app-rename",path:["app","rename"],handler:"app-rename",requiresApiSession:true,availability:"implemented",arguments:[appKey,appName("name")],flags:[idempotency,json],summary:"Change an app's display name.",examples:["app rename pk_example \"New name\""]},
43
+ {id:"app-enable",path:["app","enable"],handler:"app-enable",requiresApiSession:true,availability:"implemented",arguments:[appKey],flags:[idempotency,json],summary:"Enable an app.",examples:["app enable pk_example"],notes:[propagation]},
44
+ {id:"app-disable",path:["app","disable"],handler:"app-disable",requiresApiSession:true,availability:"implemented",arguments:[appKey],flags:[idempotency,yes,json],summary:"Disable an app while retaining its secret.",examples:["app disable pk_example --yes"],notes:[propagation]},
45
+ {id:"secret-rotate",path:["secret","rotate"],handler:"secret-rotate",requiresApiSession:true,availability:"implemented",arguments:[appKey],flags:[idempotency,yes,json],summary:"Replace the current secret, keeping the App Key and enabled state.",examples:["secret rotate pk_example --yes"],notes:["Copy the returned secret into protected backend configuration. A receipt replay can return apiSecret: null.",propagation]},
46
+ {id:"secret-reveal",path:["secret","reveal"],handler:"secret-reveal",requiresApiSession:true,availability:"implemented",arguments:[appKey],flags:[yes,json],summary:"Explicitly retrieve the current active secret.",examples:["secret reveal pk_example --yes"],notes:["This command discloses sensitive backend credentials and records the retrieval in the audit log."]},
47
+ {id:"secret-revoke",path:["secret","revoke"],handler:"secret-revoke",requiresApiSession:true,availability:"implemented",arguments:[appKey],flags:[idempotency,yes,json],summary:"Revoke the current secret.",examples:["secret revoke pk_example --yes"],notes:[propagation]},
48
+ {id:"agent-setup",path:["agent","setup"],handler:"agent-setup",requiresApiSession:false,availability:"implemented",arguments:[],flags:[...agents,{name:"--force",kind:"boolean",description:"Replace the selected existing Skill installation."},json],summary:"Install the RequestShield Skill for Codex or Claude.",examples:["agent setup --codex","agent setup --claude --force"],notes:["--codex and --claude are mutually exclusive."]},
49
+ {id:"agent-status",path:["agent","status"],handler:"agent-status",requiresApiSession:false,availability:"implemented",arguments:[],flags:[...agents,json],summary:"Check whether Agent Skill files are installed, missing or invalid.",examples:["agent status","agent status --codex --json"],notes:["Checks both agents by default. File presence does not prove an agent has loaded the Skill."]},
50
+ {id:"update-check",path:["update","check"],handler:"update-check",requiresApiSession:false,availability:"implemented",arguments:[],flags:[json],summary:"Check for a newer CLI version without installing it.",examples:["update check"],notes:["QAT/STG runners return local source-update guidance without querying npm."]},
51
+ {id:"update-apply",path:["update","apply"],handler:"update-apply",requiresApiSession:false,availability:"implemented",arguments:[],flags:[yes,json],summary:"Check and install the available version into the global npm installation.",examples:["update apply","update apply --yes"],notes:["Requires confirmation unless --yes is supplied. QAT/STG runners only provide source-update guidance."]},
52
+ {id:"contract",path:["contract"],handler:"contract",requiresApiSession:false,availability:"implemented",arguments:[],flags:[json],summary:"Get the public integration contract and SDK versions.",examples:["contract","contract --json"],notes:["Reads the selected profile's public documentation without signing in or accessing a saved session.","Returns published integration metadata; it does not install SDKs, modify project files or verify your application integration."]},
53
+ {id:"service-status",path:["service","status"],handler:null,requiresApiSession:false,availability:"placeholder",arguments:[],flags:[json],summary:"Check service availability.",examples:["service status"]},
54
+ {id:"secret-status",path:["secret","status"],handler:null,requiresApiSession:false,availability:"placeholder",arguments:[appKey],flags:[json],summary:"Get secret status metadata without revealing the secret.",examples:["secret status pk_example"]},
55
+ {id:"usage-challenges",path:["usage","challenges"],handler:null,requiresApiSession:false,availability:"placeholder",arguments:[appKey],flags:[{name:"--from",kind:"value",value:"time",description:"Start of the requested time range."},{name:"--to",kind:"value",value:"time",description:"End of the requested time range."},{name:"--granularity",kind:"value",value:"hour|day",choices:["hour","day"],description:"Requested aggregation period."},json],summary:"Get challenge usage for an app.",examples:["usage challenges pk_example --granularity day"]},
56
+ {id:"billing-get",path:["billing","get"],handler:null,requiresApiSession:false,availability:"placeholder",arguments:[appKey],flags:[json],summary:"Get an app's plan and billing information.",examples:["billing get pk_example"]},
57
+ ];
58
+
59
+ /** @param {string} id */
60
+ export function getCommandDefinition(id) { return COMMAND_REGISTRY.find((command) => command.id === id); }
61
+
62
+ /** @param {CommandDefinition} command */
63
+ export function commandUsage(command) {
64
+ return [command.path.join(" "),...command.arguments.map((argument) => argument.required ? `<${argument.name}>` : `[${argument.name}]`),
65
+ ...command.flags.map((flag) => `[${flag.name}${flag.value ? ` <${flag.value}>` : ""}]`)].join(" ");
66
+ }
67
+
68
+ /** @param {CommandDefinition|string} [scope] */
69
+ export function renderHelp(scope) {
70
+ if (typeof scope === "object") {
71
+ const unavailable = scope.availability === "placeholder";
72
+ return [
73
+ `RequestShield CLI — ${scope.path.join(" ")}${unavailable ? " [coming soon]" : ""}`,
74
+ "",scope.summary,"","Usage:",` requestshield ${commandUsage(scope)}`,
75
+ ...(scope.arguments.length ? ["","Arguments:",...scope.arguments.map((arg) => ` ${arg.required ? `<${arg.name}>` : `[${arg.name}]`} ${arg.description}`)] : []),
76
+ "","Options:",...scope.flags.map((flag) => ` ${flag.name}${flag.value ? ` <${flag.value}>` : ""} ${flag.description}`),
77
+ " --help, -h Show help for this command.",
78
+ "","Examples:",...scope.examples.map((example) => ` requestshield ${example}`),
79
+ ...(scope.notes?.length ? ["",...scope.notes] : []),
80
+ ...(unavailable ? ["","This command is not available yet. It returns COMMAND_UNAVAILABLE (exit code 2) without configuration, authentication or network access."] : []),
81
+ ].join("\n");
82
+ }
83
+ const commands = scope ? COMMAND_REGISTRY.filter((command) => command.path[0] === scope) : COMMAND_REGISTRY;
84
+ const groups = scope ? [scope] : ["General",...new Set(commands.filter((command) => command.path.length > 1).map((command) => command.path[0]))];
85
+ const lines = [`RequestShield CLI${scope ? ` — ${scope}` : ""}`,"","Usage:",` requestshield ${scope ? `${scope} <command>` : "<command>"} [options]`];
86
+ for (const group of groups) {
87
+ const members = commands.filter((command) => group === "General" ? command.path.length === 1 : command.path[0] === group);
88
+ if (!members.length) continue;
89
+ lines.push("",`${group === "General" ? group : `${group[0].toUpperCase()}${group.slice(1)}`} commands:`);
90
+ for (const command of members) {
91
+ const syntax = commandUsage(command);
92
+ lines.push(` requestshield ${syntax}${command.shortcuts?.length ? ` (${command.shortcuts.join(", ")})` : ""}${command.availability === "placeholder" ? " [coming soon]" : ""}`,` ${command.summary}`);
93
+ }
94
+ }
95
+ lines.push("","Use --help after a group or command for detailed usage and examples.","Commands marked [coming soon] return COMMAND_UNAVAILABLE and perform no work.");
96
+ return lines.join("\n");
97
+ }
@@ -1,27 +1,22 @@
1
1
  // @ts-check
2
2
 
3
3
  import { cp, lstat, mkdir, readFile, realpath, rename, rm } from "node:fs/promises";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
5
  import { randomUUID } from "node:crypto";
7
6
  import { createInterface } from "node:readline/promises";
8
7
  import { fileURLToPath } from "node:url";
9
8
  import { CliError } from "../errors.mjs";
10
9
  import { detectAgents } from "../agent-detector.mjs";
10
+ import { agentSkillPath } from "./agent-status.mjs";
11
11
 
12
12
  /**
13
- * @param {{ agent?: "codex" | "claude", force: boolean }} options
13
+ * @param {{ agent?: "codex" | "claude", force: boolean, json?: boolean }} options
14
14
  * @param {{ env?: NodeJS.ProcessEnv, homeDir?: string, log: (message: string) => void, sourceDir?: string, executablePath?: string, detectAgents?: () => Promise<Array<"codex" | "claude">>, selectAgent?: (detected: Array<"codex" | "claude">) => Promise<"codex" | "claude"> }} deps
15
15
  */
16
16
  export async function setupAgent(options, deps) {
17
- const agent = await resolveAgent(options.agent, deps);
18
- const homeDir = deps.homeDir ?? os.homedir();
19
-
20
- // Codex and Claude discover personal skills from different directories.
21
- const skillsRoot = agent === "codex"
22
- ? path.join(homeDir, ".agents", "skills")
23
- : path.join(homeDir, ".claude", "skills");
24
- const destination = path.join(skillsRoot, "requestshield");
17
+ const agent = await resolveAgent(options.agent, deps, !options.json);
18
+ const destination = agentSkillPath(agent, deps);
19
+ const skillsRoot = path.dirname(destination);
25
20
  await mkdir(skillsRoot, { recursive: true });
26
21
 
27
22
  const exists = await pathExists(destination);
@@ -56,8 +51,13 @@ export async function setupAgent(options, deps) {
56
51
  throw error;
57
52
  }
58
53
  const agentLabel = agent === "codex" ? "Codex" : "Claude";
59
- deps.log(`Installed RequestShield skill for ${agentLabel} at ${destination}`);
60
- deps.log(`Restart ${agentLabel} or start a new task before using the skill.`);
54
+ const result = { agent, path: destination, status: /** @type {const} */ ("installed") };
55
+ if (options.json) deps.log(JSON.stringify({ data: result }, null, 2));
56
+ else {
57
+ deps.log(`Installed RequestShield skill for ${agentLabel} at ${destination}`);
58
+ deps.log(`Restart ${agentLabel} or start a new task before using the skill.`);
59
+ }
60
+ return result;
61
61
  }
62
62
 
63
63
  /** @param {string} [executablePath] */
@@ -93,9 +93,10 @@ async function resolveSkillSource(executablePath) {
93
93
  *
94
94
  * @param {"codex" | "claude" | undefined} requested
95
95
  * @param {{ env?: NodeJS.ProcessEnv, homeDir?: string, detectAgents?: () => Promise<Array<"codex" | "claude">>, selectAgent?: (detected: Array<"codex" | "claude">) => Promise<"codex" | "claude"> }} deps
96
+ * @param {boolean} allowPrompt
96
97
  * @returns {Promise<"codex" | "claude">}
97
98
  */
98
- async function resolveAgent(requested, deps) {
99
+ async function resolveAgent(requested, deps, allowPrompt) {
99
100
  // A command-line choice is authoritative and avoids probing the machine.
100
101
  if (requested) return requested;
101
102
 
@@ -119,6 +120,12 @@ async function resolveAgent(requested, deps) {
119
120
 
120
121
  // Never choose silently when both agents are present. Interactive users get
121
122
  // a prompt; non-interactive callers receive a stable conflict error.
123
+ if (!allowPrompt) {
124
+ throw new CliError("Multiple coding agents detected. Choose --codex or --claude explicitly when using --json.", {
125
+ code: "INSTALL_CONFLICT",
126
+ exitCode: 2,
127
+ });
128
+ }
122
129
  const selectAgent = deps.selectAgent ?? promptForAgent;
123
130
  const selected = await selectAgent(detected);
124
131
  if (!detected.includes(selected)) {
@@ -136,7 +143,7 @@ async function resolveAgent(requested, deps) {
136
143
  * @returns {Promise<"codex" | "claude">}
137
144
  */
138
145
  async function promptForAgent() {
139
- if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
146
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
140
147
  throw new CliError(
141
148
  [
142
149
  "Multiple coding agents detected.",
@@ -151,10 +158,10 @@ async function promptForAgent() {
151
158
 
152
159
  const prompt = createInterface({
153
160
  input: process.stdin,
154
- output: process.stdout,
161
+ output: process.stderr,
155
162
  });
156
163
  try {
157
- process.stdout.write(
164
+ process.stderr.write(
158
165
  "Multiple coding agents detected.\n\n 1. Codex\n 2. Claude\n\n",
159
166
  );
160
167
  while (true) {
@@ -163,7 +170,7 @@ async function promptForAgent() {
163
170
  .toLowerCase();
164
171
  if (answer === "1" || answer === "codex") return "codex";
165
172
  if (answer === "2" || answer === "claude") return "claude";
166
- process.stdout.write("Enter 1 for Codex or 2 for Claude.\n");
173
+ process.stderr.write("Enter 1 for Codex or 2 for Claude.\n");
167
174
  }
168
175
  } finally {
169
176
  prompt.close();
@@ -0,0 +1,60 @@
1
+ // @ts-check
2
+
3
+ import { lstat, readFile, stat } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /** @typedef {"codex" | "claude"} Agent */
8
+ /** @typedef {{ agent: Agent, path: string, status: "installed" | "missing" | "invalid" }} AgentInstallation */
9
+
10
+ /** @param {Agent} agent @param {{ homeDir?: string }} [deps] */
11
+ export function agentSkillPath(agent, deps = {}) {
12
+ return path.join(deps.homeDir ?? os.homedir(), agent === "codex" ? ".agents" : ".claude", "skills", "requestshield");
13
+ }
14
+
15
+ /**
16
+ * Inspect the personal Skill installation only; this does not prove an agent
17
+ * has loaded the Skill, and never probes executables or creates directories.
18
+ * @param {Agent} agent
19
+ * @param {{ homeDir?: string }} [deps]
20
+ * @returns {Promise<AgentInstallation>}
21
+ */
22
+ export async function inspectAgent(agent, deps = {}) {
23
+ const destination = agentSkillPath(agent, deps);
24
+ try {
25
+ await lstat(destination);
26
+ } catch (error) {
27
+ return { agent, path: destination, status: /** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT" ? "missing" : "invalid" };
28
+ }
29
+
30
+ try {
31
+ // stat follows an intentional directory symlink; broken links are invalid.
32
+ if (!(await stat(destination)).isDirectory()) return { agent, path: destination, status: "invalid" };
33
+ const contents = await readFile(path.join(destination, "SKILL.md"), "utf8");
34
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(contents);
35
+ const valid = frontmatter !== null
36
+ && /^name:\s*(?:requestshield|"requestshield"|'requestshield')\s*$/m.test(frontmatter[1])
37
+ && frontmatter[2].trim().length > 0;
38
+ return { agent, path: destination, status: valid ? "installed" : "invalid" };
39
+ } catch {
40
+ return { agent, path: destination, status: "invalid" };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {{ agent?: Agent, json?: boolean }} options
46
+ * @param {{ homeDir?: string, log: (message: string) => void }} deps
47
+ */
48
+ export async function showAgentStatus(options, deps) {
49
+ const agents = options.agent ? [options.agent] : /** @type {Agent[]} */ (["codex", "claude"]);
50
+ const result = await Promise.all(agents.map((agent) => inspectAgent(agent, deps)));
51
+ if (options.json) {
52
+ deps.log(JSON.stringify({ data: result }, null, 2));
53
+ } else {
54
+ for (const installation of result) {
55
+ deps.log(`${installation.agent === "codex" ? "Codex" : "Claude"}: ${installation.status} (${installation.path})`);
56
+ }
57
+ deps.log("This checks installed Skill files, not whether an agent has loaded them.");
58
+ }
59
+ return result;
60
+ }